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
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The managed repository list: parsing, validation, and one atomic writer.
|
|
3
|
+
*
|
|
4
|
+
* Two callers own this file's format — the panel's HTTP routes and
|
|
5
|
+
* `scripts/configure.mjs` — and they must not drift: a list the script writes has
|
|
6
|
+
* to be a list the Host reads, byte for byte, with the same rules about what a
|
|
7
|
+
* repository name may look like. So the rules live here once.
|
|
8
|
+
*
|
|
9
|
+
* The parse is deliberately tolerant and the write is deliberately strict. The
|
|
10
|
+
* Host must never fail to start because a file is malformed (it reports the
|
|
11
|
+
* problem instead), while a write must refuse anything it would later have to
|
|
12
|
+
* quietly drop.
|
|
13
|
+
*
|
|
14
|
+
* @module dsh-plugin-cicd/lib/config-store
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
18
|
+
import { isAbsolute, dirname } from 'node:path'
|
|
19
|
+
import { normalizeBvid } from './bilibili.mjs'
|
|
20
|
+
|
|
21
|
+
/** Upper bound on registered repositories, so a runaway file cannot hurt startup. */
|
|
22
|
+
export const MAX_REPOS = 40
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A repository name is passed to `gh` as one argv element with no shell in
|
|
26
|
+
* between, so this shape check is not cosmetic: it is what keeps `--help` or a
|
|
27
|
+
* flag-looking string from being read as an option.
|
|
28
|
+
*/
|
|
29
|
+
export const SLUG = /^[A-Za-z0-9][A-Za-z0-9._-]*$/
|
|
30
|
+
export const SLUG_WITH_OWNER = /^[A-Za-z0-9][A-Za-z0-9._-]*\/[A-Za-z0-9][A-Za-z0-9._-]*$/
|
|
31
|
+
|
|
32
|
+
/** Trim a value that should be a non-empty string, or return the fallback. */
|
|
33
|
+
function text(value, fallback = '') {
|
|
34
|
+
return typeof value === 'string' && value.trim() !== '' ? value.trim() : fallback
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Normalize one entry's Bilibili binding.
|
|
39
|
+
*
|
|
40
|
+
* The binding says which video's comment section carries this repository's
|
|
41
|
+
* update notes. Its shape is checked here — where the file is parsed — for the
|
|
42
|
+
* same reason a repository name is: a value this plugin cannot act on must not
|
|
43
|
+
* travel further as if it could. A malformed BV id becomes "no binding", which
|
|
44
|
+
* the panel shows as 未绑定 rather than as a comment posted to the wrong video.
|
|
45
|
+
*
|
|
46
|
+
* @param {unknown} value - `entry.bilibili`.
|
|
47
|
+
* @returns {{bvid: string, auto: boolean}|null} the normalized binding, or null.
|
|
48
|
+
*/
|
|
49
|
+
export function normalizeBinding(value) {
|
|
50
|
+
if (value === null || typeof value !== 'object') return null
|
|
51
|
+
const bvid = normalizeBvid(value.bvid ?? value.bv)
|
|
52
|
+
if (bvid === null) return null
|
|
53
|
+
return { bvid, auto: value.auto !== false }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Normalize one entry, accepting both shapes a person would naturally write:
|
|
58
|
+
* `dsh-plugin-restart` or `{ repo, localPath?, label? }`.
|
|
59
|
+
*
|
|
60
|
+
* @param {unknown} entry - one element of `repos`.
|
|
61
|
+
* @returns {{repo: string, localPath: string, label: string, bilibili: object|null}|null} null when unusable.
|
|
62
|
+
*/
|
|
63
|
+
export function normalizeEntry(entry) {
|
|
64
|
+
if (typeof entry === 'string') {
|
|
65
|
+
const repo = entry.trim()
|
|
66
|
+
return SLUG.test(repo) || SLUG_WITH_OWNER.test(repo) ? { repo, localPath: '', label: '', bilibili: null } : null
|
|
67
|
+
}
|
|
68
|
+
if (entry === null || typeof entry !== 'object') return null
|
|
69
|
+
const repo = text(entry.repo ?? entry.name)
|
|
70
|
+
if (!SLUG.test(repo) && !SLUG_WITH_OWNER.test(repo)) return null
|
|
71
|
+
const localPath = text(entry.localPath ?? entry.path)
|
|
72
|
+
return {
|
|
73
|
+
repo,
|
|
74
|
+
// Absolute only: a relative path would be resolved against the Host's own
|
|
75
|
+
// working directory, which is not the user's shell directory.
|
|
76
|
+
localPath: localPath !== '' && isAbsolute(localPath) ? localPath : '',
|
|
77
|
+
label: text(entry.label),
|
|
78
|
+
bilibili: normalizeBinding(entry.bilibili),
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Parse the file tolerantly and report what had to be dropped.
|
|
84
|
+
*
|
|
85
|
+
* @param {string} raw - file contents.
|
|
86
|
+
* @returns {{ok: true, owner: string, repos: object[], dropped: number}|{ok: false, message: string}}
|
|
87
|
+
*/
|
|
88
|
+
export function parseConfig(raw) {
|
|
89
|
+
let parsed
|
|
90
|
+
try {
|
|
91
|
+
parsed = JSON.parse(raw)
|
|
92
|
+
} catch (error) {
|
|
93
|
+
return { ok: false, message: `not valid JSON: ${error.message}` }
|
|
94
|
+
}
|
|
95
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
96
|
+
return { ok: false, message: 'expected a JSON object with "owner" and "repos"' }
|
|
97
|
+
}
|
|
98
|
+
if (parsed.repos !== undefined && !Array.isArray(parsed.repos)) {
|
|
99
|
+
return { ok: false, message: '"repos" must be an array' }
|
|
100
|
+
}
|
|
101
|
+
const declared = Array.isArray(parsed.repos) ? parsed.repos : []
|
|
102
|
+
const repos = []
|
|
103
|
+
const seen = new Set()
|
|
104
|
+
for (const entry of declared.slice(0, MAX_REPOS)) {
|
|
105
|
+
const normalized = normalizeEntry(entry)
|
|
106
|
+
if (normalized === null || seen.has(normalized.repo)) continue
|
|
107
|
+
seen.add(normalized.repo)
|
|
108
|
+
repos.push(normalized)
|
|
109
|
+
}
|
|
110
|
+
return { ok: true, owner: text(parsed.owner), repos, dropped: declared.length - repos.length }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Read the file; a missing file is an empty configuration, not an error.
|
|
115
|
+
* @param {string} file - absolute path.
|
|
116
|
+
* @returns {{exists: boolean, owner: string, repos: object[], dropped: number, problem: string|null}}
|
|
117
|
+
*/
|
|
118
|
+
export function readConfig(file) {
|
|
119
|
+
if (!existsSync(file)) return { exists: false, owner: '', repos: [], dropped: 0, problem: null }
|
|
120
|
+
const parsed = parseConfig(readFileSync(file, 'utf8'))
|
|
121
|
+
if (!parsed.ok) return { exists: true, owner: '', repos: [], dropped: 0, problem: `${file}: ${parsed.message}` }
|
|
122
|
+
return { exists: true, owner: parsed.owner, repos: parsed.repos, dropped: parsed.dropped, problem: null }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Write the file atomically.
|
|
127
|
+
*
|
|
128
|
+
* The plugin re-reads this file on every request, so a partially written file
|
|
129
|
+
* would be observed as a broken configuration and would blank the panel. The new
|
|
130
|
+
* content therefore lands via rename, and the previous revision is kept beside it.
|
|
131
|
+
*
|
|
132
|
+
* @param {string} file - absolute path.
|
|
133
|
+
* @param {{owner: string, repos: object[]}} config - what to store.
|
|
134
|
+
* @returns {string} the exact bytes written.
|
|
135
|
+
*/
|
|
136
|
+
export function writeConfig(file, config) {
|
|
137
|
+
const body = `${JSON.stringify({ owner: text(config.owner), repos: config.repos }, null, 2)}\n`
|
|
138
|
+
mkdirSync(dirname(file), { recursive: true })
|
|
139
|
+
if (existsSync(file)) {
|
|
140
|
+
try {
|
|
141
|
+
writeFileSync(`${file}.bak`, readFileSync(file))
|
|
142
|
+
} catch {
|
|
143
|
+
/* a missing backup must not block the write */
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
const temporary = `${file}.tmp`
|
|
147
|
+
writeFileSync(temporary, body, 'utf8')
|
|
148
|
+
renameSync(temporary, file)
|
|
149
|
+
return body
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Whether a name may be registered at all.
|
|
154
|
+
* @param {unknown} repo - candidate.
|
|
155
|
+
* @returns {boolean}
|
|
156
|
+
*/
|
|
157
|
+
export function isValidRepoName(repo) {
|
|
158
|
+
return typeof repo === 'string' && (SLUG.test(repo) || SLUG_WITH_OWNER.test(repo))
|
|
159
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,65 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin-cicd",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.5.0",
|
|
4
|
+
"description": "发布台(Release Console)for DeepSeek Harness: a sidebar entry and a full main-panel page that show what each repository has built, what it has released, whether the local checkout is ahead of the release, whether the copy installed in this profile is the published artifact, and which version npm holds — with buttons to trigger a build, cut a release, publish a draft, push that version to npm, install the release into the profile, and hand a restart to dsh-plugin-restart. It also carries the third channel: when a repository's release goes public, one short update note is posted under the video that introduces the plugin, with the credential obtained from a Bilibili sign-in inside the panel. Reuses the machine's authenticated gh CLI and the Host's own package manager, so the plugin stores no GitHub or npm credential of its own; the one credential it does own is the Bilibili session it created, kept in its own directory and never echoed back.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"icon": "./icon.svg",
|
|
7
|
+
"main": "index.js",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./index.js",
|
|
10
|
+
"./client": "./client.js",
|
|
11
|
+
"./package.json": "./package.json"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"index.js",
|
|
15
|
+
"client.js",
|
|
16
|
+
"lib",
|
|
17
|
+
"icon.svg",
|
|
18
|
+
"cordis.patch.yml",
|
|
19
|
+
"README.md",
|
|
20
|
+
"RELEASING.md",
|
|
21
|
+
"scripts",
|
|
22
|
+
"tests",
|
|
23
|
+
"LICENSE"
|
|
24
|
+
],
|
|
25
|
+
"meta": {
|
|
26
|
+
"title": "DSH 插件发布台",
|
|
27
|
+
"description": "面向 DSH 插件仓库的持续构建与发布(CI/CD):侧边栏图标 + 独立主面板,显示每个仓库的最近一次运行、已发布的 Release、本地是否领先于发布、本 profile 装的到底是哪一份(link: 本地检出 / Release 的 tgz),以及 npm 上是哪一版。浏览器登录、勾选仓库即可,复用本机已登录的 gh 与 Host 自己的包管理器,不另存 GitHub / npm 凭据。一个包两条渠道:发布、装 Release、升版本并发布为一个循环(装完面板接着问要不要重启,转发给 dsh-plugin-restart),推送 npm 是独立按钮(未登录时可在面板里粘贴 token 写入 npm 自己的 .npmrc,支持 2FA 一次性密码)。第三条渠道是 B 站更新播报:Release 公开后,在绑定的视频评论区自动补一条简短的更新说明——只认已公开的 Release(草稿不算),按 (仓库, tag) 幂等,绑定视频时写入基线所以不追发历史版本;凭据走面板内的 B 站登录(passport 网页二维码)或粘贴一次,落在插件自己的目录里、不回显,并且会真的去问一次账号接口——因为 biliup 默认写下的 cookies.json 是 BiliTV 登录,发评论走的却是 Web 接口。门禁与产包形状按 DSH bundle 设计(契约检查 + 整树 zip + 可安装 tgz + npm),任何带 ci.yml / release.yml 的仓库都能接。"
|
|
28
|
+
},
|
|
29
|
+
"dsh": {
|
|
30
|
+
"bundle": {
|
|
31
|
+
"patch": "./cordis.patch.yml"
|
|
32
|
+
},
|
|
33
|
+
"client": {
|
|
34
|
+
"platform": "web",
|
|
35
|
+
"immediately": true,
|
|
36
|
+
"inject": [
|
|
37
|
+
"@deepseek-ai/dsh-client-ui-layout",
|
|
38
|
+
"@deepseek-ai/dsh-client-ui-sidebar"
|
|
39
|
+
]
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
"engines": {
|
|
43
|
+
"node": ">=20"
|
|
44
|
+
},
|
|
45
|
+
"keywords": [
|
|
46
|
+
"dsh",
|
|
47
|
+
"dsh-plugin",
|
|
48
|
+
"deepseek-harness",
|
|
49
|
+
"github-actions",
|
|
50
|
+
"release",
|
|
51
|
+
"ci",
|
|
52
|
+
"cd",
|
|
53
|
+
"gh-cli"
|
|
54
|
+
],
|
|
55
|
+
"license": "MIT",
|
|
56
|
+
"author": "林冠宇 (SOH4C4759) <107021339+SOH4C4759@users.noreply.github.com>",
|
|
57
|
+
"repository": {
|
|
58
|
+
"type": "git",
|
|
59
|
+
"url": "git+https://github.com/SOH4C4759/dsh-plugin-cicd.git"
|
|
60
|
+
},
|
|
61
|
+
"homepage": "https://github.com/SOH4C4759/dsh-plugin-cicd#readme",
|
|
62
|
+
"bugs": {
|
|
63
|
+
"url": "https://github.com/SOH4C4759/dsh-plugin-cicd/issues"
|
|
64
|
+
}
|
|
6
65
|
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Configure the Release Console without editing YAML by hand.
|
|
4
|
+
*
|
|
5
|
+
* The plugin's row lives in the profile patch, which is the wrong place to keep a
|
|
6
|
+
* list that changes: hand-editing it is slow, easy to get wrong, and needs a
|
|
7
|
+
* restart to take effect. So the repository list lives in a JSON file this script
|
|
8
|
+
* owns, the row only has to be pointed at it once, and the plugin re-reads the
|
|
9
|
+
* file on every request — `add` is visible on the panel's next poll.
|
|
10
|
+
*
|
|
11
|
+
* node scripts/configure.mjs list
|
|
12
|
+
* node scripts/configure.mjs add dsh-plugin-restart --path F:\CodeProj\dsh-plugin-restart
|
|
13
|
+
* node scripts/configure.mjs add other/repo
|
|
14
|
+
* node scripts/configure.mjs remove dsh-plugin-restart
|
|
15
|
+
* node scripts/configure.mjs owner SOH4C4759
|
|
16
|
+
* node scripts/configure.mjs check # what the panel would show right now
|
|
17
|
+
*
|
|
18
|
+
* Options:
|
|
19
|
+
* --file <path> manage a different file (default: $DSH_HOME/dsh-plugin-cicd/repos.json)
|
|
20
|
+
* --json machine-readable output
|
|
21
|
+
* --url <base> Host base URL for `check` (default: $DSH_WEB_URL or http://127.0.0.1:19387)
|
|
22
|
+
*
|
|
23
|
+
* Windows paths are written with backslashes and read back the same way; nothing
|
|
24
|
+
* here rewrites a path, so what you typed is what the plugin sees.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { existsSync } from 'node:fs'
|
|
28
|
+
import { homedir } from 'node:os'
|
|
29
|
+
import { isAbsolute, join, resolve } from 'node:path'
|
|
30
|
+
import { SLUG, SLUG_WITH_OWNER, isValidRepoName, readConfig, writeConfig } from '../lib/config-store.mjs'
|
|
31
|
+
|
|
32
|
+
const DEFAULT_HOST_URL = 'http://127.0.0.1:19387'
|
|
33
|
+
|
|
34
|
+
const argv = process.argv.slice(2)
|
|
35
|
+
const options = { json: false, file: '', url: '', path: '', label: '' }
|
|
36
|
+
const positional = []
|
|
37
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
38
|
+
const token = argv[index]
|
|
39
|
+
if (token === '--json') options.json = true
|
|
40
|
+
else if (token === '--file') options.file = argv[++index] ?? ''
|
|
41
|
+
else if (token === '--url') options.url = argv[++index] ?? ''
|
|
42
|
+
else if (token === '--path') options.path = argv[++index] ?? ''
|
|
43
|
+
else if (token === '--label') options.label = argv[++index] ?? ''
|
|
44
|
+
else if (token.startsWith('--')) fail(`unknown option: ${token}`)
|
|
45
|
+
else positional.push(token)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const command = positional[0] ?? 'list'
|
|
49
|
+
|
|
50
|
+
/** Print a message to stderr and exit non-zero. */
|
|
51
|
+
function fail(message) {
|
|
52
|
+
process.stderr.write(`configure: ${message}\n`)
|
|
53
|
+
process.exit(2)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The managed file, resolved the same way the Host resolves it. */
|
|
57
|
+
function configPath() {
|
|
58
|
+
if (options.file !== '') return resolve(options.file)
|
|
59
|
+
if (typeof process.env.DSH_CICD_CONFIG === 'string' && process.env.DSH_CICD_CONFIG !== '') return resolve(process.env.DSH_CICD_CONFIG)
|
|
60
|
+
const home = typeof process.env.DSH_HOME === 'string' && process.env.DSH_HOME !== ''
|
|
61
|
+
? process.env.DSH_HOME
|
|
62
|
+
: join(process.env.USERPROFILE ?? homedir(), '.dsh')
|
|
63
|
+
return join(home, 'dsh-plugin-cicd', 'repos.json')
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Read the managed file and turn a problem into a CLI error. */
|
|
67
|
+
function loadConfig(file) {
|
|
68
|
+
const stored = readConfig(file)
|
|
69
|
+
if (stored.problem !== null) fail(`${stored.problem}. Fix or delete the file, then retry.`)
|
|
70
|
+
return { owner: stored.owner, repos: stored.repos, dropped: stored.dropped }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Validate one repository argument the way the Host will. */
|
|
74
|
+
function checkRepoSlug(repo) {
|
|
75
|
+
if (repo === undefined) fail('a repository name is required')
|
|
76
|
+
if (!isValidRepoName(repo)) {
|
|
77
|
+
fail(`unusable repository name: ${JSON.stringify(repo)} — expected "name" or "owner/name"`)
|
|
78
|
+
}
|
|
79
|
+
return repo
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Validate a local path: the Host ignores a relative one, so refuse it here. */
|
|
83
|
+
function checkLocalPath(value) {
|
|
84
|
+
if (value === '') return ''
|
|
85
|
+
if (!isAbsolute(value)) fail(`--path must be absolute, got ${JSON.stringify(value)} (a relative path resolves against the Host's own directory)`)
|
|
86
|
+
return value
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function emit(payload, human) {
|
|
90
|
+
if (options.json) process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`)
|
|
91
|
+
else process.stdout.write(`${human}\n`)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const file = configPath()
|
|
95
|
+
const config = loadConfig(file)
|
|
96
|
+
|
|
97
|
+
switch (command) {
|
|
98
|
+
case 'list': {
|
|
99
|
+
const lines = [`config file: ${file}`, `owner: ${config.owner === '' ? '(unset)' : config.owner}`, `repositories (${config.repos.length}):`]
|
|
100
|
+
for (const entry of config.repos) {
|
|
101
|
+
const bound = entry.bilibili?.bvid ? ` B站 ${entry.bilibili.bvid}${entry.bilibili.auto === false ? '(仅手动)' : ''}` : ''
|
|
102
|
+
lines.push(` - ${entry.repo}${entry.localPath === undefined ? '' : ` → ${entry.localPath}`}${entry.label === undefined ? '' : ` [${entry.label}]`}${bound}`)
|
|
103
|
+
}
|
|
104
|
+
if (config.repos.length === 0) lines.push(' (none — add one with: configure.mjs add <repo> --path <dir>)')
|
|
105
|
+
lines.push('', 'The panel re-reads this file on every poll, so changes apply without a restart.')
|
|
106
|
+
emit({ file, owner: config.owner, repos: config.repos }, lines.join('\n'))
|
|
107
|
+
break
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
case 'init': {
|
|
111
|
+
if (existsSync(file)) emit({ file, created: false }, `already exists: ${file}`)
|
|
112
|
+
else {
|
|
113
|
+
writeConfig(file, config)
|
|
114
|
+
emit({ file, created: true }, `created ${file}`)
|
|
115
|
+
}
|
|
116
|
+
break
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
case 'owner': {
|
|
120
|
+
const owner = positional[1]
|
|
121
|
+
if (owner === undefined || owner === '') fail('owner requires a GitHub login')
|
|
122
|
+
if (!SLUG.test(owner)) fail(`unusable GitHub login: ${JSON.stringify(owner)}`)
|
|
123
|
+
config.owner = owner
|
|
124
|
+
writeConfig(file, config)
|
|
125
|
+
emit({ file, owner }, `owner set to ${owner} in ${file}`)
|
|
126
|
+
break
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
case 'add': {
|
|
130
|
+
const repo = checkRepoSlug(positional[1])
|
|
131
|
+
const localPath = checkLocalPath(options.path)
|
|
132
|
+
const existing = config.repos.findIndex((entry) => entry.repo === repo)
|
|
133
|
+
/* The Bilibili binding is not something this command knows about, so an
|
|
134
|
+
`add` that overwrote it would silently unbind a video — the failure being
|
|
135
|
+
invisible until a release note never appears. It is carried over. */
|
|
136
|
+
const binding = existing === -1 ? null : config.repos[existing].bilibili ?? null
|
|
137
|
+
const entry = {
|
|
138
|
+
repo,
|
|
139
|
+
...(localPath !== '' ? { localPath } : {}),
|
|
140
|
+
...(options.label !== '' ? { label: options.label } : {}),
|
|
141
|
+
...(binding === null ? {} : { bilibili: binding }),
|
|
142
|
+
}
|
|
143
|
+
if (existing === -1) config.repos.push(entry)
|
|
144
|
+
else config.repos[existing] = entry
|
|
145
|
+
writeConfig(file, config)
|
|
146
|
+
const notes = [`${existing === -1 ? 'added' : 'updated'} ${repo} in ${file}`]
|
|
147
|
+
if (localPath !== '' && !existsSync(localPath)) notes.push(`note: ${localPath} does not exist yet — the panel will show "not a git checkout" until it does`)
|
|
148
|
+
if (localPath === '') notes.push('note: no --path given, so the panel cannot compare the local checkout against the release')
|
|
149
|
+
if (!SLUG_WITH_OWNER.test(repo) && config.owner === '') notes.push(`note: "${repo}" has no owner and the file records none — set one with: configure.mjs owner <login>`)
|
|
150
|
+
notes.push('It applies on the panel\'s next poll; no restart needed.')
|
|
151
|
+
emit({ file, entry, configSource: 'file' }, notes.join('\n'))
|
|
152
|
+
break
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
case 'remove': {
|
|
156
|
+
const repo = checkRepoSlug(positional[1])
|
|
157
|
+
const before = config.repos.length
|
|
158
|
+
config.repos = config.repos.filter((entry) => entry.repo !== repo)
|
|
159
|
+
if (config.repos.length === before) fail(`${repo} is not registered in ${file}`)
|
|
160
|
+
writeConfig(file, config)
|
|
161
|
+
emit({ file, removed: repo }, `removed ${repo} from ${file}`)
|
|
162
|
+
break
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
case 'check': {
|
|
166
|
+
const base = options.url !== '' ? options.url : (process.env.DSH_WEB_URL ?? DEFAULT_HOST_URL)
|
|
167
|
+
/** POST one route; the Host gates these to loopback, which this is. */
|
|
168
|
+
const post = async (path, body) => {
|
|
169
|
+
const response = await fetch(`${base}${path}`, {
|
|
170
|
+
method: 'POST',
|
|
171
|
+
headers: { 'content-type': 'application/json' },
|
|
172
|
+
body: JSON.stringify(body ?? {}),
|
|
173
|
+
})
|
|
174
|
+
return response.json()
|
|
175
|
+
}
|
|
176
|
+
let status
|
|
177
|
+
try {
|
|
178
|
+
status = await post('/api/dsh-cicd/status', {})
|
|
179
|
+
} catch (error) {
|
|
180
|
+
fail(`cannot reach the Host at ${base} (${error.message}). Is DSH running? Pass --url <base> if it listens elsewhere.`)
|
|
181
|
+
}
|
|
182
|
+
if (status?.ok !== true) fail(`the Host refused /status: ${status?.message ?? 'unknown error'}`)
|
|
183
|
+
const overview = await post('/api/dsh-cicd/overview', { force: true })
|
|
184
|
+
|
|
185
|
+
const value = status.value
|
|
186
|
+
// Read defensively: a Host running an older copy of this plugin answers with
|
|
187
|
+
// fewer fields, and a diagnosis tool that crashes on that is useless exactly
|
|
188
|
+
// when it is needed.
|
|
189
|
+
const gh = value.gh ?? {}
|
|
190
|
+
const scopes = Array.isArray(gh.scopes) ? gh.scopes : []
|
|
191
|
+
const missingScopes = Array.isArray(gh.missingScopes) ? gh.missingScopes : []
|
|
192
|
+
const lines = [
|
|
193
|
+
`Host: ${base}`,
|
|
194
|
+
`gh: ${gh.available ? (gh.version ?? 'present') : 'NOT FOUND'}${gh.authenticated ? ` — ${gh.account ?? 'signed in'}` : ' — not signed in'}`,
|
|
195
|
+
`scopes: ${scopes.length === 0 ? '(not reported by this Host)' : scopes.join(', ')}${missingScopes.length > 0 ? ` MISSING: ${missingScopes.join(', ')}` : ''}`,
|
|
196
|
+
`config from: ${value.configSource ?? '(older Host)'} (${value.configFile ?? 'unknown'})`,
|
|
197
|
+
]
|
|
198
|
+
if (value.configProblem) lines.push(`config problem: ${value.configProblem}`)
|
|
199
|
+
lines.push(`repositories: ${Array.isArray(value.repos) ? value.repos.length : 0}`)
|
|
200
|
+
for (const repo of overview?.value?.repos ?? []) {
|
|
201
|
+
const run = repo.latestRun === null ? 'no runs' : `${repo.latestRun.workflow}/${repo.latestRun.conclusion || repo.latestRun.status}`
|
|
202
|
+
const releases = repo.releases.length === 0 ? 'no releases' : repo.releases.map((entry) => `${entry.tag}${entry.draft ? '(draft)' : ''}`).join(',')
|
|
203
|
+
const local = repo.local?.available ? `dirty=${repo.local.dirty} ahead=${repo.local.ahead} behind=${repo.local.behind}` : `local: ${repo.local?.reason ?? 'unknown'}`
|
|
204
|
+
const problems = repo.problems.length > 0 ? ` PROBLEMS: ${repo.problems.join(' | ')}` : ''
|
|
205
|
+
lines.push(` - ${repo.label.padEnd(28)} v${repo.version ?? '?'} ${repo.published ? 'published' : 'unpublished'} ${run} ${releases} ${local}${problems}`)
|
|
206
|
+
}
|
|
207
|
+
emit({ status: value, overview: overview?.value ?? null }, lines.join('\n'))
|
|
208
|
+
break
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
default:
|
|
212
|
+
fail(`unknown command: ${command}. Try list | init | add | remove | owner | check`)
|
|
213
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# Push local commits to GitHub through the Git Data API.
|
|
2
|
+
#
|
|
3
|
+
# Why this exists: on this machine `github.com:443` is intermittently blocked
|
|
4
|
+
# while `api.github.com:443` stays reachable, so `git push` fails with
|
|
5
|
+
# "Connection was reset" / "Could not connect to server". The API can create the
|
|
6
|
+
# same objects, and because git objects are content-addressed the reconstructed
|
|
7
|
+
# commit carries the *same SHA* as the local one — so updating the ref leaves the
|
|
8
|
+
# clone and the remote identical, with no divergence to reconcile later.
|
|
9
|
+
#
|
|
10
|
+
# Safety: every reconstructed commit SHA is compared against the local commit
|
|
11
|
+
# SHA before the ref is touched. A single mismatch aborts without moving the ref.
|
|
12
|
+
param(
|
|
13
|
+
[Parameter(Mandatory = $true)][string]$RepoPath,
|
|
14
|
+
[string]$Owner = 'SOH4C4759',
|
|
15
|
+
[string]$Repo
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
$ErrorActionPreference = 'Stop'
|
|
19
|
+
if (-not $Repo) { $Repo = Split-Path $RepoPath -Leaf }
|
|
20
|
+
|
|
21
|
+
function Write-JsonFile([string]$Path, $Value) {
|
|
22
|
+
[IO.File]::WriteAllText($Path, ($Value | ConvertTo-Json -Depth 8 -Compress), (New-Object Text.UTF8Encoding($false)))
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function Get-BlobBytes([string]$BlobSha, [string]$Scratch) {
|
|
26
|
+
# cmd redirection is byte-exact; PowerShell's native-output capture is not.
|
|
27
|
+
$out = Join-Path $Scratch 'blob.bin'
|
|
28
|
+
cmd /c "git cat-file blob $BlobSha > `"$out`""
|
|
29
|
+
if ($LASTEXITCODE -ne 0) { throw "git cat-file blob $BlobSha failed" }
|
|
30
|
+
return [IO.File]::ReadAllBytes($out)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
Push-Location $RepoPath
|
|
34
|
+
try {
|
|
35
|
+
$head = (git rev-parse HEAD).Trim()
|
|
36
|
+
$remote = (gh api "repos/$Owner/$Repo/git/ref/heads/main" --jq '.object.sha').Trim()
|
|
37
|
+
Write-Host "[$Repo] remote main $($remote.Substring(0,7)) local HEAD $($head.Substring(0,7))"
|
|
38
|
+
if ($remote -eq $head) { Write-Host "[$Repo] already in sync"; return }
|
|
39
|
+
|
|
40
|
+
$chain = @()
|
|
41
|
+
$cursor = $head
|
|
42
|
+
while ($cursor -ne $remote) {
|
|
43
|
+
$chain = @($cursor) + $chain
|
|
44
|
+
$cursor = (git rev-parse "$cursor^").Trim()
|
|
45
|
+
if ($chain.Count -gt 20) { throw "[$Repo] remote main is not an ancestor of HEAD — refusing to rewrite history" }
|
|
46
|
+
}
|
|
47
|
+
Write-Host "[$Repo] $($chain.Count) commit(s) to publish"
|
|
48
|
+
|
|
49
|
+
$scratch = Join-Path $env:TEMP ("apipush-" + [guid]::NewGuid().ToString('N'))
|
|
50
|
+
New-Item -ItemType Directory -Path $scratch | Out-Null
|
|
51
|
+
$parent = $remote
|
|
52
|
+
|
|
53
|
+
foreach ($commit in $chain) {
|
|
54
|
+
$meta = (git log -1 --format='%H%n%T%n%an%n%ae%n%aI%n%cn%n%ce%n%cI' $commit) -split "`n"
|
|
55
|
+
$expectedTree = $meta[1].Trim()
|
|
56
|
+
$an = $meta[2]; $ae = $meta[3]; $ad = $meta[4].Trim()
|
|
57
|
+
$cn = $meta[5]; $ce = $meta[6]; $cd = $meta[7].Trim()
|
|
58
|
+
$message = ((git log -1 --format='%B' $commit) -join "`n")
|
|
59
|
+
|
|
60
|
+
$entries = @()
|
|
61
|
+
foreach ($line in (git show --name-status --format= $commit)) {
|
|
62
|
+
if ($line -match '^\s*$') { continue }
|
|
63
|
+
$parts = $line -split "`t"
|
|
64
|
+
$status = $parts[0]; $path = $parts[1]
|
|
65
|
+
if ($status -notmatch '^(A|M)$') { throw "[$Repo] unsupported change '$status' for $path — refusing" }
|
|
66
|
+
$blobSha = (git rev-parse "${commit}:$path").Trim()
|
|
67
|
+
$bytes = Get-BlobBytes $blobSha $scratch
|
|
68
|
+
$blobBody = Join-Path $scratch 'blob.json'
|
|
69
|
+
Write-JsonFile $blobBody @{ content = [Convert]::ToBase64String($bytes); encoding = 'base64' }
|
|
70
|
+
$uploaded = (gh api --method POST "repos/$Owner/$Repo/git/blobs" --input $blobBody --jq '.sha').Trim()
|
|
71
|
+
if ($uploaded -ne $blobSha) { throw "[$Repo] blob round-trip changed $path ($blobSha -> $uploaded)" }
|
|
72
|
+
$entries += @{ path = $path; mode = '100644'; type = 'blob'; sha = $blobSha }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
$parentTree = (gh api "repos/$Owner/$Repo/git/commits/$parent" --jq '.tree.sha').Trim()
|
|
76
|
+
$treeBody = Join-Path $scratch 'tree.json'
|
|
77
|
+
Write-JsonFile $treeBody @{ base_tree = $parentTree; tree = @($entries) }
|
|
78
|
+
$newTree = (gh api --method POST "repos/$Owner/$Repo/git/trees" --input $treeBody --jq '.sha').Trim()
|
|
79
|
+
if ($newTree -ne $expectedTree) { throw "[$Repo] tree mismatch for $($commit.Substring(0,7)): $newTree != $expectedTree" }
|
|
80
|
+
|
|
81
|
+
$commitBody = Join-Path $scratch 'commit.json'
|
|
82
|
+
Write-JsonFile $commitBody @{
|
|
83
|
+
message = $message
|
|
84
|
+
tree = $newTree
|
|
85
|
+
parents = @($parent)
|
|
86
|
+
author = @{ name = $an; email = $ae; date = $ad }
|
|
87
|
+
committer = @{ name = $cn; email = $ce; date = $cd }
|
|
88
|
+
}
|
|
89
|
+
$created = (gh api --method POST "repos/$Owner/$Repo/git/commits" --input $commitBody --jq '.sha').Trim()
|
|
90
|
+
if ($created -ne $commit) { throw "[$Repo] commit mismatch: $created != $commit — no ref was moved" }
|
|
91
|
+
Write-Host " + $($commit.Substring(0,7)) $((git log -1 --format='%s' $commit)) [sha identical]"
|
|
92
|
+
$parent = $commit
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
$refBody = Join-Path $scratch 'ref.json'
|
|
96
|
+
Write-JsonFile $refBody @{ sha = $parent; force = $false }
|
|
97
|
+
gh api --method PATCH "repos/$Owner/$Repo/git/refs/heads/main" --input $refBody --jq '.object.sha' | Out-Null
|
|
98
|
+
|
|
99
|
+
$after = (gh api "repos/$Owner/$Repo/git/ref/heads/main" --jq '.object.sha').Trim()
|
|
100
|
+
if ($after -ne $head) { throw "[$Repo] ref update did not land where expected: $after" }
|
|
101
|
+
|
|
102
|
+
# A real `git push` also moves the remote-tracking ref; going through the API does
|
|
103
|
+
# not, and a stale origin/main makes `git status` report a phantom "ahead" — which
|
|
104
|
+
# is exactly what a release panel reading `@{u}...HEAD` would then show.
|
|
105
|
+
& git update-ref "refs/remotes/origin/main" $after
|
|
106
|
+
Write-Host "[$Repo] PUSHED — remote main now $($after.Substring(0,7)), identical to local HEAD (tracking ref updated)"
|
|
107
|
+
|
|
108
|
+
Remove-Item -Recurse -Force $scratch
|
|
109
|
+
} finally {
|
|
110
|
+
Pop-Location
|
|
111
|
+
}
|