@noob-stupid/dsh-plugin-console 0.5.55 → 0.5.56
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/lib/client.js +19 -1
- package/lib/server/domain/compat-exempt.js +127 -0
- package/lib/server/routes/index.js +3 -1
- package/lib/server/routes/state.js +41 -1
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -121,6 +121,8 @@ window.__ModuleLoader__.load({
|
|
|
121
121
|
hostedNoChannel: "本实例由桌面端外壳托管:框架升级与回滚请走桌面端(此处不可用)",
|
|
122
122
|
linksTitle: "开发式链接(link):共 {n} 个,自足 {ready} 个 —— ✓ 自足 · · 缺东西(悬停看原因)",
|
|
123
123
|
healthSummary: "需要处理 {n} 类问题",
|
|
124
|
+
exemptGrantBtn: "授精确版本豁免",
|
|
125
|
+
exemptGrantTitle: "把「这个精确版本」在「当前框架版本」上授权给框架(写入 profile 的 compatibility.json,先备份、写完读回验证)",
|
|
124
126
|
linksFailed: "开发式链接清单读取失败",
|
|
125
127
|
actionReconcileLabel: "重建 pnpm-lock.yaml",
|
|
126
128
|
actionRun: "执行",
|
|
@@ -715,6 +717,8 @@ window.__ModuleLoader__.load({
|
|
|
715
717
|
hostedNoChannel: "Hosted by the desktop shell: use the desktop app for framework upgrade and rollback (unavailable here)",
|
|
716
718
|
linksTitle: "Dev links (link:): {n} total, {ready} self-sufficient - checkmark = ready, dot = something missing (hover for why)",
|
|
717
719
|
healthSummary: "{n} issue type(s) need attention",
|
|
720
|
+
exemptGrantBtn: "Grant exact-version exemption",
|
|
721
|
+
exemptGrantTitle: "Authorize this exact version on the current framework version (writes via compatibility.json in the profile, with backup and read-back verification)",
|
|
718
722
|
linksFailed: "Failed to read the dev-link inventory",
|
|
719
723
|
actionReconcileLabel: "Rebuild pnpm-lock.yaml",
|
|
720
724
|
actionRun: "Run",
|
|
@@ -4610,7 +4614,21 @@ window.__ModuleLoader__.load({
|
|
|
4610
4614
|
el("p", null,
|
|
4611
4615
|
t("skippedBundlesTitle").replace("{n}", String(state.data.skippedBundles.items.length))),
|
|
4612
4616
|
...state.data.skippedBundles.items.map((b) => el("p", { key: b.packageName, title: b.hint ?? undefined, style: { margin: "4px 0 0 12px" } },
|
|
4613
|
-
"• " + b.packageName + (b.version ? " v" + b.version : "") + " — " + b.reason
|
|
4617
|
+
"• " + b.packageName + (b.version ? " v" + b.version : "") + " — " + b.reason,
|
|
4618
|
+
// ★ 0.5.56:把提示里的"三条出路"之一真正做成可点的动作。
|
|
4619
|
+
// 过去控制台**只读** compatibility.json、从不写 ⇒ 那句提示等于把活推回用户。
|
|
4620
|
+
// 只在能确定精确版本时才给按钮(写豁免必须按精确版本,否则框架会忽略)。
|
|
4621
|
+
b.version
|
|
4622
|
+
? el("button", {
|
|
4623
|
+
type: "button",
|
|
4624
|
+
style: { marginLeft: "8px", cursor: "pointer" },
|
|
4625
|
+
title: t("exemptGrantTitle"),
|
|
4626
|
+
onClick: () => {
|
|
4627
|
+
call("/plugin-console/compat-exempt", { packageName: b.packageName, version: b.version })
|
|
4628
|
+
.then(() => refresh(), () => {});
|
|
4629
|
+
},
|
|
4630
|
+
}, t("exemptGrantBtn"))
|
|
4631
|
+
: null)),
|
|
4614
4632
|
)
|
|
4615
4633
|
: null,
|
|
4616
4634
|
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// L1 · domain —— 兼容豁免的**写入**(0.5.56 加法:把"只读"补成"可操作")
|
|
2
|
+
//
|
|
3
|
+
// 为什么需要:
|
|
4
|
+
// 0.5.53 让"被框架跳过的 bundle"可见了,提示里写着三条出路(更新 / 授精确版本豁免 / 从 bundles 删掉),
|
|
5
|
+
// 但控制台**只会读** `compatibility.json`,从不会写 —— 于是那句提示等于把活推回给用户:
|
|
6
|
+
// "你自己去编辑那个文件,格式自己查"。这就是半截活。
|
|
7
|
+
//
|
|
8
|
+
// 格式(与框架 `readProfileCompatibility` 逐字对齐,读错会静默失效):
|
|
9
|
+
// {
|
|
10
|
+
// "@scope/pkg@1.2.3": ["0.2.1-rc.2", "0.2.1-rc.1"] // 精确包版本 → 允许的精确 DSH 版本列表
|
|
11
|
+
// }
|
|
12
|
+
// · 键必须是**精确** `name@version`(框架用 isExactPluginVersion 校验,范围/前缀一律被忽略)
|
|
13
|
+
// · 值必须是精确版本字符串数组
|
|
14
|
+
// · 坏记录被框架**忽略**(不是致命)——所以写入方必须自己保证格式,否则"写了但没生效"且没人知道
|
|
15
|
+
//
|
|
16
|
+
// 纪律:
|
|
17
|
+
// · **绝不静默**:每一次写盘都先备份、写完**读回来验证**,验证不过就回滚并如实报错;
|
|
18
|
+
// · 只增删**指定那一条**,其余记录(含用户手写的)原样保留、键序不变;
|
|
19
|
+
// · 撤销 = 从该键的列表里去掉当前框架版本;列表空了才删键(不误伤同一包的其它授权)。
|
|
20
|
+
//
|
|
21
|
+
// 分层:L1 domain —— 收已解开的 profileDir,不认 ctx;不联网、不 spawn。
|
|
22
|
+
import { copyFileSync, existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
23
|
+
import { join } from 'node:path'
|
|
24
|
+
import { COMPATIBILITY_FILE, readCompatExemptions } from './bundle-veto.js'
|
|
25
|
+
|
|
26
|
+
const readJsonSafe = (file) => {
|
|
27
|
+
try { return JSON.parse(readFileSync(file, 'utf8')) } catch { return null }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** 精确版本判据(框架同义:不接受范围/前缀/空白)。 */
|
|
31
|
+
const EXACT_VERSION_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/u
|
|
32
|
+
/** 精确 `name@version` 键。 */
|
|
33
|
+
function isExactPackageVersionKey(key) {
|
|
34
|
+
if (typeof key !== 'string') return false
|
|
35
|
+
const at = key.lastIndexOf('@')
|
|
36
|
+
if (at <= 0) return false
|
|
37
|
+
return EXACT_VERSION_RE.test(key.slice(at + 1)) && /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/u.test(key.slice(0, at))
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 规划一次豁免写入(**纯函数,不写盘**)。
|
|
42
|
+
*
|
|
43
|
+
* @param {object} p
|
|
44
|
+
* @param {string} p.profileDir profile 根目录
|
|
45
|
+
* @param {string} p.packageName 包名
|
|
46
|
+
* @param {string} p.version 该包的**精确**版本
|
|
47
|
+
* @param {string} p.frameworkVersion 要授权的**精确** DSH 版本
|
|
48
|
+
* @param {'grant'|'revoke'} [p.mode] grant=加一条授权(默认),revoke=撤销当前框架版本的授权
|
|
49
|
+
* @returns {{ ok: boolean, status: number, error: string|null, file: string, next: object|null,
|
|
50
|
+
* changed: boolean, key: string|null, note: string|null }}
|
|
51
|
+
*/
|
|
52
|
+
function planExemption({ profileDir, packageName, version, frameworkVersion, mode = 'grant' } = {}) {
|
|
53
|
+
const file = typeof profileDir === 'string' && profileDir !== '' ? join(profileDir, COMPATIBILITY_FILE) : null
|
|
54
|
+
const base = { ok: false, status: 400, error: null, file, next: null, changed: false, key: null, note: null }
|
|
55
|
+
if (file === null) return { ...base, error: '拿不到 profile 目录,拒绝改动' }
|
|
56
|
+
if (typeof packageName !== 'string' || packageName === '') return { ...base, error: '缺少 packageName' }
|
|
57
|
+
if (typeof version !== 'string' || !EXACT_VERSION_RE.test(version)) {
|
|
58
|
+
return { ...base, error: `version 必须是精确版本(收到 ${JSON.stringify(version)});豁免按精确版本生效,范围没有意义` }
|
|
59
|
+
}
|
|
60
|
+
if (typeof frameworkVersion !== 'string' || !EXACT_VERSION_RE.test(frameworkVersion)) {
|
|
61
|
+
return { ...base, error: `拿不到精确的框架版本(收到 ${JSON.stringify(frameworkVersion)})—— 拒绝写一条"授权了但不知道授权给谁"的记录` }
|
|
62
|
+
}
|
|
63
|
+
const key = `${packageName}@${version}`
|
|
64
|
+
if (!isExactPackageVersionKey(key)) return { ...base, error: `键 ${key} 不是合法的精确 name@version` }
|
|
65
|
+
|
|
66
|
+
// 现有文件:坏文件不覆盖(那是用户的文件,覆盖等于丢数据)
|
|
67
|
+
let current = {}
|
|
68
|
+
if (existsSync(file)) {
|
|
69
|
+
const parsed = readJsonSafe(file)
|
|
70
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
71
|
+
return { ...base, status: 409, error: `${file} 已存在但不是合法对象;为避免覆盖你的文件,本动作已停止(请先手工修好或删掉它)` }
|
|
72
|
+
}
|
|
73
|
+
current = parsed
|
|
74
|
+
}
|
|
75
|
+
const next = { ...current }
|
|
76
|
+
const existing = Array.isArray(next[key]) ? next[key].filter((v) => typeof v === 'string') : []
|
|
77
|
+
|
|
78
|
+
if (mode === 'revoke') {
|
|
79
|
+
if (!existing.includes(frameworkVersion)) {
|
|
80
|
+
return { ...base, ok: true, status: 200, next: current, changed: false, key, note: `没有 ${key} → ${frameworkVersion} 的授权,无需撤销` }
|
|
81
|
+
}
|
|
82
|
+
const left = existing.filter((v) => v !== frameworkVersion)
|
|
83
|
+
if (left.length === 0) delete next[key]
|
|
84
|
+
else next[key] = left
|
|
85
|
+
return { ...base, ok: true, status: 200, next, changed: true, key, note: `已撤销 ${key} 对 ${frameworkVersion} 的授权` }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (existing.includes(frameworkVersion)) {
|
|
89
|
+
return { ...base, ok: true, status: 200, next: current, changed: false, key, note: `${key} 已授权 ${frameworkVersion},无需重复写入` }
|
|
90
|
+
}
|
|
91
|
+
next[key] = [...existing, frameworkVersion]
|
|
92
|
+
return { ...base, ok: true, status: 200, next, changed: true, key, note: `已授权 ${key} 在 ${frameworkVersion} 上使用` }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* 执行写入:备份 → 原子写(临时文件 + rename)→ **读回来验证** → 失败则回滚。
|
|
97
|
+
* 绝不谎报成功:验证不过一律返回 ok:false 并把原因写清。
|
|
98
|
+
*/
|
|
99
|
+
function applyExemption(input) {
|
|
100
|
+
const plan = planExemption(input)
|
|
101
|
+
if (plan.ok !== true || plan.changed !== true) return plan
|
|
102
|
+
const { file, next, key } = plan
|
|
103
|
+
const backup = `${file}.bak-${Date.now()}`
|
|
104
|
+
try {
|
|
105
|
+
if (existsSync(file)) copyFileSync(file, backup)
|
|
106
|
+
const tmp = `${file}.tmp-${Date.now()}`
|
|
107
|
+
writeFileSync(tmp, `${JSON.stringify(next, null, 2)}\n`, 'utf8')
|
|
108
|
+
renameSync(tmp, file)
|
|
109
|
+
} catch (error) {
|
|
110
|
+
// 写失败:把备份放回去(有备份时),如实报
|
|
111
|
+
try { if (existsSync(backup)) copyFileSync(backup, file) } catch {}
|
|
112
|
+
return { ...plan, ok: false, status: 500, error: `写入 ${file} 失败:${error instanceof Error ? error.message : String(error)}`, backup }
|
|
113
|
+
}
|
|
114
|
+
// 读回来验证:框架读得出来的才算真的生效("写了但没生效"是最坏的一种假绿)
|
|
115
|
+
const verify = readCompatExemptions(typeof input?.profileDir === 'string' ? input.profileDir : '')
|
|
116
|
+
const granted = Array.isArray(verify[key]) ? verify[key] : []
|
|
117
|
+
const want = input?.mode === 'revoke'
|
|
118
|
+
? !granted.includes(input?.frameworkVersion)
|
|
119
|
+
: granted.includes(input?.frameworkVersion)
|
|
120
|
+
if (!want) {
|
|
121
|
+
try { if (existsSync(backup)) copyFileSync(backup, file) } catch {}
|
|
122
|
+
return { ...plan, ok: false, status: 500, error: `写入后验证失败(${file} 读回来不符合预期),已回滚`, backup }
|
|
123
|
+
}
|
|
124
|
+
return { ...plan, ok: true, backup }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export { EXACT_VERSION_RE, isExactPackageVersionKey, planExemption, applyExemption }
|
|
@@ -30,7 +30,7 @@ import { routeEnrich, routeMarketIndex, routeRepo, routeSearch, routeSubpackages
|
|
|
30
30
|
import { routeAdaptUnlock, routeAdaptUnlockAll, routeCleanResiduals, routeSelfUpdate, routeToggle, routeUninstall } from './plugins.js'
|
|
31
31
|
import { routeSkillRemove, routeSkillToggle, routeSkillsInstalledGet } from './skills.js'
|
|
32
32
|
import { routeGiteeOauthCallbackGet, routeGiteeOauthUrlGet, routeRegistryScan, routeSources, routeSourcesGet } from './sources.js'
|
|
33
|
-
import { routeDetails, routeLinksGet, routeStateGet } from './state.js'
|
|
33
|
+
import { routeCompatExempt, routeDetails, routeLinksGet, routeStateGet } from './state.js'
|
|
34
34
|
|
|
35
35
|
const ROUTE_PREFIX = '/plugin-console'
|
|
36
36
|
|
|
@@ -38,6 +38,8 @@ const ROUTES_EARLY = [
|
|
|
38
38
|
{ methods: ['GET'], path: `${ROUTE_PREFIX}/state`, handler: routeStateGet },
|
|
39
39
|
// link 开发式安装清单(0.5.54 加法):纯只读,供面板渲染"已挂载 / 自足性 / 垫片 / 持久化"
|
|
40
40
|
{ methods: ['GET'], path: `${ROUTE_PREFIX}/links`, handler: routeLinksGet },
|
|
41
|
+
// 兼容豁免(0.5.56 加法):把"被跳过的 bundle"的三条出路之一做成真正可点的动作
|
|
42
|
+
{ methods: ['POST'], path: `${ROUTE_PREFIX}/compat-exempt`, handler: routeCompatExempt },
|
|
41
43
|
{ methods: ['GET'], path: `${ROUTE_PREFIX}/sources`, handler: routeSourcesGet },
|
|
42
44
|
{ methods: ['GET'], path: `${ROUTE_PREFIX}/gitee-oauth-url`, handler: routeGiteeOauthUrlGet },
|
|
43
45
|
{ methods: ['GET'], path: `${ROUTE_PREFIX}/gitee-oauth-callback`, handler: routeGiteeOauthCallbackGet },
|
|
@@ -11,6 +11,7 @@ import { readExtraBundleRows } from '../domain/install-job.js'
|
|
|
11
11
|
import { bundleVetoList } from '../domain/bundle-veto.js'
|
|
12
12
|
import { buildLinkInventory } from '../domain/link-inventory.js'
|
|
13
13
|
import { buildInstallHealth } from '../domain/install-health.js'
|
|
14
|
+
import { applyExemption } from '../domain/compat-exempt.js'
|
|
14
15
|
import { readBundlesSync } from '../domain/persist.js'
|
|
15
16
|
import { failedJobEvidence } from '../domain/install-verify.js'
|
|
16
17
|
import { installJobView, readGithubAuth } from '../domain/install.js'
|
|
@@ -224,4 +225,43 @@ async function routeLinksGet(req, res, rc) {
|
|
|
224
225
|
sendJson(res, 200, payload)
|
|
225
226
|
}
|
|
226
227
|
|
|
227
|
-
|
|
228
|
+
/**
|
|
229
|
+
* POST /compat-exempt —— **写**一条精确版本豁免(0.5.56 加法)。
|
|
230
|
+
*
|
|
231
|
+
* 0.5.53 让"被框架跳过的 bundle"可见,并在提示里给了三条出路;但控制台只会**读** compatibility.json,
|
|
232
|
+
* 从不写 —— 那条提示等于把活推回用户("你自己去编辑文件,格式自己查")。本路由把那半截补上。
|
|
233
|
+
*
|
|
234
|
+
* 安全与纪律(与仓库其它写动作一致):
|
|
235
|
+
* · 只接受 { packageName, version, mode },**不收任何路径** —— 文件位置由服务端按 profile 决定;
|
|
236
|
+
* · 精确版本校验(范围/前缀一律拒),框架版本必须是**当前运行时**那个;
|
|
237
|
+
* · 先备份 → 原子写 → **读回来验证**,验证不过即回滚并如实报错(绝不谎报成功);
|
|
238
|
+
* · 已有坏文件时**停止**而不是覆盖(那是用户的文件)。
|
|
239
|
+
*/
|
|
240
|
+
async function routeCompatExempt(req, res, rc) {
|
|
241
|
+
const ctx = rc.ctx
|
|
242
|
+
const body = rc.body ?? {}
|
|
243
|
+
let profileDir = null
|
|
244
|
+
try { profileDir = dirname(findPatchPath(ctx)) } catch {}
|
|
245
|
+
const frameworkVersion = typeof body.frameworkVersion === 'string' && body.frameworkVersion !== ''
|
|
246
|
+
? body.frameworkVersion
|
|
247
|
+
: (() => { try { return currentFrameworkVersion(ctx) } catch { return null } })()
|
|
248
|
+
const result = applyExemption({
|
|
249
|
+
profileDir,
|
|
250
|
+
packageName: body.packageName,
|
|
251
|
+
version: body.version,
|
|
252
|
+
frameworkVersion,
|
|
253
|
+
mode: body.mode === 'revoke' ? 'revoke' : 'grant',
|
|
254
|
+
})
|
|
255
|
+
const payload = {
|
|
256
|
+
ok: result.ok === true,
|
|
257
|
+
changed: result.changed === true,
|
|
258
|
+
key: result.key ?? null,
|
|
259
|
+
note: result.note ?? null,
|
|
260
|
+
error: result.error ?? null,
|
|
261
|
+
backup: result.backup ?? null,
|
|
262
|
+
}
|
|
263
|
+
if (result.ok === true) sendJson(res, 200, payload)
|
|
264
|
+
else sendError(res, result.status ?? 400, payload.error ?? '豁免写入失败', payload)
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export { routeStateGet, routeDetails, routeLinksGet, routeCompatExempt }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@noob-stupid/dsh-plugin-console",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.56",
|
|
4
4
|
"description": "DSH 框架升级安全与插件升级门控:一键升级、失败自动回滚、升级后回滚上版、旧插件不适配自动禁用;内置多源插件市场为发现层,插件源全部可自定义,可指向公司内网私有源 / 私有索引 / 本地 Git 仓库,纯内网离线可用 | Framework upgrade safety & plugin version gating for DSH, with a customizable multi-source plugin market: point every source at internal mirrors or a local file:// repo for intranet-only, offline installs.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|