@noob-stupid/dsh-plugin-console 0.3.67 → 0.4.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/lib/client.js +126 -31
- package/lib/index.js +60 -9687
- package/lib/server/domain/ai-run.js +479 -0
- package/lib/server/domain/ai.js +246 -0
- package/lib/server/domain/compat.js +474 -0
- package/lib/server/domain/components.js +108 -0
- package/lib/server/domain/dep-source.js +122 -0
- package/lib/server/domain/format-contract.js +265 -0
- package/lib/server/domain/format-scan.js +431 -0
- package/lib/server/domain/framework.js +393 -0
- package/lib/server/domain/install-job.js +561 -0
- package/lib/server/domain/install.js +599 -0
- package/lib/server/domain/jobs.js +28 -0
- package/lib/server/domain/market.js +409 -0
- package/lib/server/domain/patch.js +203 -0
- package/lib/server/domain/presets.js +93 -0
- package/lib/server/domain/quarantine.js +224 -0
- package/lib/server/domain/release-source.js +504 -0
- package/lib/server/domain/repoland.js +119 -0
- package/lib/server/domain/revoke.js +184 -0
- package/lib/server/domain/runtime.js +118 -0
- package/lib/server/domain/selfupdate.js +319 -0
- package/lib/server/domain/skills.js +234 -0
- package/lib/server/domain/sources.js +297 -0
- package/lib/server/domain/suite.js +220 -0
- package/lib/server/infra/exec.js +98 -0
- package/lib/server/infra/fsx.js +163 -0
- package/lib/server/infra/fw-integrity-check.js +37 -0
- package/lib/server/infra/http.js +373 -0
- package/lib/server/infra/httpd.js +51 -0
- package/lib/server/infra/mask.js +19 -0
- package/lib/server/infra/paths.js +177 -0
- package/lib/server/infra/semver.js +168 -0
- package/lib/server/routes/ai.js +172 -0
- package/lib/server/routes/components.js +254 -0
- package/lib/server/routes/framework-preflight.js +154 -0
- package/lib/server/routes/framework-upgrade.js +679 -0
- package/lib/server/routes/framework.js +544 -0
- package/lib/server/routes/github-login.js +198 -0
- package/lib/server/routes/index.js +128 -0
- package/lib/server/routes/install.js +116 -0
- package/lib/server/routes/market.js +415 -0
- package/lib/server/routes/plugins.js +562 -0
- package/lib/server/routes/skills.js +107 -0
- package/lib/server/routes/sources.js +437 -0
- package/lib/server/routes/state.js +125 -0
- package/lib/server/state.js +22 -0
- package/package.json +1 -1
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// L1 · domain —— revoke.js(按安装任务撤销「已安装但尚未生效」的安装)
|
|
2
|
+
//
|
|
3
|
+
// 动机(2026-09-20 真装真卸演练实测):面板装完插件后 /install 返回 entryId: null,
|
|
4
|
+
// 对比安装前后的 GET /state,**新增 loader 条目 = 0**(bundle 型插件要重启才被加载,
|
|
5
|
+
// 重启后条目才出现,如 include:dsh-whale-widget);而 POST /uninstall 过去只按「运行中」
|
|
6
|
+
// loader 条目查找,找不到就 404「没有名为 X 的插件条目」——于是**刚装错的插件在重启前
|
|
7
|
+
// 无法从面板卸载**,用户只能手改 patch + package.json + 删 node_modules 才能撤回。
|
|
8
|
+
//
|
|
9
|
+
// 安装任务(lib/server/state.js 的 installJobs)里其实记着撤销所需的全部信息:
|
|
10
|
+
// · job.packageName(install-job.js:294)
|
|
11
|
+
// · job.bundle(install-job.js:341,注册进 <profile>/package.json 的 dsh.profile.bundles)
|
|
12
|
+
// · job.entryId(install-job.js:356-357 deriveEntryId + appendInsert 追加的补丁行)
|
|
13
|
+
// 本模块据此撤销:删补丁行 → 退 bundles 清单 → 移除包目录 → 逐项回读核实。
|
|
14
|
+
//
|
|
15
|
+
// ★ 回读核实是硬要求:删除类操作在本机某些环境下会**静默落空**(不抛错、目录仍在,见
|
|
16
|
+
// infra/fsx.js removeDirVerified 的注释)。删完直接报成功就是对用户撒谎,所以三个子项
|
|
17
|
+
// 都要在动作之后回读判定,并把结果如实交给调用方(verified / warn)。
|
|
18
|
+
//
|
|
19
|
+
// 分层:本模块只接收路径与已注入的 pnpmRemove(形参名 ports/无 ctx),不认识 cordis ctx。
|
|
20
|
+
|
|
21
|
+
import { existsSync } from 'node:fs'
|
|
22
|
+
import { readFile } from 'node:fs/promises'
|
|
23
|
+
import { join } from 'node:path'
|
|
24
|
+
import { removeBundleFromManifest } from './install.js'
|
|
25
|
+
import { parseInsertNames, readPatchState, removeDisableBlock, removeInsertRow } from './patch.js'
|
|
26
|
+
import { packageNameOf } from '../infra/paths.js'
|
|
27
|
+
|
|
28
|
+
/** profile 内 node_modules/<pkg> 的目录(作用域包按 / 分段,与 pnpm 布局一致)。 */
|
|
29
|
+
function packageDirIn(profileDir, packageName) {
|
|
30
|
+
const name = String(packageName)
|
|
31
|
+
const segments = name.startsWith('@') ? name.split('/') : [name]
|
|
32
|
+
return join(profileDir, 'node_modules', ...segments)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** profile 的 package.json 是否还把这个包当依赖(pnpm add 会写进 dependencies)。 */
|
|
36
|
+
async function manifestRefsPackage(profileDir, packageName) {
|
|
37
|
+
try {
|
|
38
|
+
const manifest = JSON.parse(await readFile(join(profileDir, 'package.json'), 'utf8'))
|
|
39
|
+
return ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies']
|
|
40
|
+
.some((key) => manifest?.[key] !== null && typeof manifest?.[key] === 'object'
|
|
41
|
+
&& Object.prototype.hasOwnProperty.call(manifest[key], packageName))
|
|
42
|
+
} catch {
|
|
43
|
+
return false
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* 这个包是否还需要一次 pnpm remove:目录还在盘上,或 manifest 还引用它(pnpm add 会写 dependencies)。
|
|
49
|
+
* 两者都不成立时拉 pnpm 只会得到一句没有信息量的 "Command failed: corepack pnpm remove …"(本机演练实测)
|
|
50
|
+
* —— 但 manifest 仍引用时必须跑,否则 package.json 会留下指向空目录的幽灵依赖。
|
|
51
|
+
*/
|
|
52
|
+
async function needsPnpmRemove(profileDir, packageName) {
|
|
53
|
+
return existsSync(packageDirIn(profileDir, packageName)) || await manifestRefsPackage(profileDir, packageName)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* bundle 包自带 patch(node_modules/<pkg>/cordis.patch.yml)里**归属该包**的 insert 行 id。
|
|
58
|
+
* 为什么要它:这类行由 bundle 的补丁在下次启动时组合进树,profile 补丁里不会有 insert 块,
|
|
59
|
+
* 但可能留着它们的 `- id: X` + `disabled: true` 覆盖块(聚合包完整性检查自动禁用 / 用户手动停用)。
|
|
60
|
+
* 只认模块名等于该包或它的子路径的行 —— 聚合包补丁里常引用**别人家**的包(如 @deepseek-ai/dsh-root),
|
|
61
|
+
* 那些行的覆盖块不属于本次撤销,动了就是越界。
|
|
62
|
+
*/
|
|
63
|
+
async function bundleOwnRowIds(profileDir, packageName) {
|
|
64
|
+
const ids = new Set()
|
|
65
|
+
try {
|
|
66
|
+
const text = await readFile(join(packageDirIn(profileDir, packageName), 'cordis.patch.yml'), 'utf8')
|
|
67
|
+
for (const [rowId, moduleName] of parseInsertNames(text)) {
|
|
68
|
+
if (moduleName === packageName || String(moduleName).startsWith(`${packageName}/`)) ids.add(rowId)
|
|
69
|
+
}
|
|
70
|
+
} catch {}
|
|
71
|
+
return ids
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* 撤销一个「已安装但未生效」的安装任务。三个子项各自 try/catch,成败如实汇报;本函数不抛错。
|
|
76
|
+
* 返回 { packageName, bundle, rowIds, verified: { patchClean, bundlesClean, packageGone }, warn, uninstallError }
|
|
77
|
+
*/
|
|
78
|
+
async function revokePendingInstall(job, { profileDir, patchPath, pnpmRemove }) {
|
|
79
|
+
const packageName = typeof job?.packageName === 'string' ? job.packageName.trim() : ''
|
|
80
|
+
const isBundle = job?.bundle === true
|
|
81
|
+
// 兜底(路由已先拦):没有包名就什么都别碰 —— 靠 entryId 单点删除可能误伤别的包
|
|
82
|
+
if (packageName === '') {
|
|
83
|
+
return {
|
|
84
|
+
packageName, bundle: isBundle, rowIds: [],
|
|
85
|
+
verified: { patchClean: false, bundlesClean: false, packageGone: false },
|
|
86
|
+
warn: '该安装任务没有记录包名,未执行任何删除(补丁行 / bundles 清单 / 包目录都未处理)',
|
|
87
|
+
uninstallError: null,
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
// ── a) 补丁行:删掉这次安装注册的 insert 行 + 它的 disabled/forced 覆盖块 ──────────
|
|
91
|
+
const rowIds = new Set()
|
|
92
|
+
let patchClean = false
|
|
93
|
+
let patchProblem = null
|
|
94
|
+
try {
|
|
95
|
+
const before = await readPatchState(patchPath)
|
|
96
|
+
const declared = parseInsertNames(before.text) // insert 行 id → 模块名
|
|
97
|
+
if (typeof job?.entryId === 'string' && job.entryId !== '') {
|
|
98
|
+
const owner = declared.get(job.entryId)
|
|
99
|
+
// 补丁里这一行若明确属于**别的包**,绝不按 entryId 删(归属判定以补丁文本为准)
|
|
100
|
+
if (owner === undefined || owner === packageName) rowIds.add(job.entryId)
|
|
101
|
+
}
|
|
102
|
+
for (const [rowId, moduleName] of declared) if (moduleName === packageName) rowIds.add(rowId)
|
|
103
|
+
for (const rowId of await bundleOwnRowIds(profileDir, packageName)) rowIds.add(rowId)
|
|
104
|
+
for (const rowId of rowIds) {
|
|
105
|
+
if (before.inserts.includes(rowId)) await removeInsertRow(patchPath, rowId)
|
|
106
|
+
await removeDisableBlock(patchPath, rowId)
|
|
107
|
+
}
|
|
108
|
+
// 回读核实:目标行(insert / disabled / forced)都不在了,补丁文本也不该再出现这个包名
|
|
109
|
+
const after = await readPatchState(patchPath)
|
|
110
|
+
const leakedRows = [...rowIds].filter((id) => after.inserts.includes(id) || after.disables.includes(id) || after.forced.includes(id))
|
|
111
|
+
const leakedNames = [...parseInsertNames(after.text).values()].filter((name) => name === packageName)
|
|
112
|
+
patchClean = leakedRows.length === 0 && leakedNames.length === 0 && !String(after.text ?? '').includes(packageName)
|
|
113
|
+
if (!patchClean) patchProblem = `仍有残留行 ${[...new Set([...leakedRows, ...leakedNames])].join('、') || packageName}`
|
|
114
|
+
} catch (error) {
|
|
115
|
+
patchProblem = error instanceof Error ? error.message : String(error)
|
|
116
|
+
}
|
|
117
|
+
// ── b) bundles 清单:只删这一个包名(保留顺序、保留其余项)──────────────────────
|
|
118
|
+
let bundlesClean = !isBundle
|
|
119
|
+
let bundlesProblem = null
|
|
120
|
+
if (isBundle) {
|
|
121
|
+
try {
|
|
122
|
+
await removeBundleFromManifest(profileDir, packageName)
|
|
123
|
+
const manifest = JSON.parse(await readFile(join(profileDir, 'package.json'), 'utf8'))
|
|
124
|
+
bundlesClean = !(manifest?.dsh?.profile?.bundles ?? []).includes(packageName)
|
|
125
|
+
if (!bundlesClean) bundlesProblem = `dsh.profile.bundles 里仍有 ${packageName}`
|
|
126
|
+
} catch (error) {
|
|
127
|
+
bundlesProblem = error instanceof Error ? error.message : String(error)
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
// ── c) 包目录:pnpm remove(与安装同一管理器)──────────────────────────────────
|
|
131
|
+
const packageDir = packageDirIn(profileDir, packageName)
|
|
132
|
+
let uninstallError = null
|
|
133
|
+
if (await needsPnpmRemove(profileDir, packageName)) {
|
|
134
|
+
try {
|
|
135
|
+
await pnpmRemove(profileDir, packageName)
|
|
136
|
+
} catch (error) {
|
|
137
|
+
uninstallError = error instanceof Error ? error.message : String(error)
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
const packageGone = !existsSync(packageDir)
|
|
141
|
+
// ── d) 核实汇总:任一子项没清干净 → ok 仍为 true,但必须带 warn 说清是哪项、路径在哪 ──
|
|
142
|
+
const verified = { patchClean, bundlesClean, packageGone }
|
|
143
|
+
const problems = []
|
|
144
|
+
if (!patchClean) problems.push(`补丁未清干净(${patchPath}):${patchProblem ?? '仍有残留行'}`)
|
|
145
|
+
if (!bundlesClean) problems.push(`bundles 清单未清干净(${join(profileDir, 'package.json')}):${bundlesProblem ?? `仍有 ${packageName}`}`)
|
|
146
|
+
if (!packageGone) problems.push(`包目录仍在(${packageDir})${uninstallError !== null ? `,pnpm remove 失败:${uninstallError}` : ''}`)
|
|
147
|
+
const warn = problems.length > 0 ? `撤销未完全生效 —— ${problems.join(';')};请手动处理后重试` : null
|
|
148
|
+
return { packageName, bundle: isBundle, rowIds: [...rowIds], verified, warn, uninstallError }
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* 「已安装 · 重启后生效」清单:status === 'done'、未撤销、有包名,且该包**不在**运行中的
|
|
153
|
+
* loader 条目里。bundle 型插件要重启才被加载(演练实测:装完新增 loader 条目 = 0),
|
|
154
|
+
* 前端据此把这类任务渲染成带待重启徽标的行,并允许按 jobId 撤销。
|
|
155
|
+
* entries 传 listEntries(ports) 的结果(含子路径条目 → 归一到根包名再比)。
|
|
156
|
+
*/
|
|
157
|
+
function pendingRestartJobs(jobs, entries) {
|
|
158
|
+
const running = new Set()
|
|
159
|
+
for (const entry of entries ?? []) {
|
|
160
|
+
const moduleName = entry?.moduleName
|
|
161
|
+
if (typeof moduleName !== 'string' || moduleName === '' || moduleName.startsWith('cordis:')) continue
|
|
162
|
+
running.add(moduleName)
|
|
163
|
+
const root = packageNameOf(moduleName)
|
|
164
|
+
if (typeof root === 'string' && root !== '') running.add(root)
|
|
165
|
+
}
|
|
166
|
+
// 同一包名的多次安装/更新(安装表里会有多条 done 记录)只留最后一次:
|
|
167
|
+
// 否则一个插件在面板上会出现好几行「已安装·重启后生效」。
|
|
168
|
+
const latest = new Map()
|
|
169
|
+
for (const job of jobs ?? []) {
|
|
170
|
+
if (job?.status !== 'done' || job.revokedAt !== undefined) continue
|
|
171
|
+
if (typeof job.packageName !== 'string' || job.packageName.trim() === '') continue
|
|
172
|
+
if (running.has(job.packageName)) continue
|
|
173
|
+
latest.set(job.packageName, job)
|
|
174
|
+
}
|
|
175
|
+
return [...latest.values()].map((job) => ({
|
|
176
|
+
jobId: job.id,
|
|
177
|
+
repo: job.repo ?? null,
|
|
178
|
+
packageName: job.packageName,
|
|
179
|
+
bundle: job.bundle === true,
|
|
180
|
+
finishedAt: job.finishedAt ?? null,
|
|
181
|
+
}))
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export { revokePendingInstall, pendingRestartJobs, packageDirIn, needsPnpmRemove }
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// L1 · domain —— runtime.js(从宿主运行时读取信息:loader 条目 / webServer 端口)。
|
|
2
|
+
// 这两个函数原本吃 cordis 的 ctx;Step 8c 起它们的形参改名为 ports(= 调用方注入的窄接口),domain 层不再出现 ctx 标识符。
|
|
3
|
+
// 分组见 D:\dsh\dsh-plugin-hub-plan\architecture.zh.md 三
|
|
4
|
+
|
|
5
|
+
import { FIBER_PHASE } from './compat.js'
|
|
6
|
+
import { rowIdOf } from '../infra/paths.js'
|
|
7
|
+
|
|
8
|
+
/** 从加载器读取 webserver 监听端口(默认 3080)。 */
|
|
9
|
+
function webPort(ports) {
|
|
10
|
+
// 优先取运行时真实监听端口(自定义 --port / 系统分配端口也能正确校验 Host)
|
|
11
|
+
try {
|
|
12
|
+
const ws = ports.webServer
|
|
13
|
+
if (ws && typeof ws.port === 'number' && ws.port > 0) return ws.port
|
|
14
|
+
} catch {}
|
|
15
|
+
for (const entry of ports.loader.entries()) {
|
|
16
|
+
if (entry.options?.name === '@deepseek-ai/dsh-host-webserver') {
|
|
17
|
+
const port = entry.options?.config?.port
|
|
18
|
+
if (typeof port === 'number' && port > 0) return port
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return 3080
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** 组装当前插件清单(镜像 dsh-host-plugin-inventory 的读取逻辑)。 */
|
|
25
|
+
function listEntries(ports) { const entries = []
|
|
26
|
+
for (const entry of ports.loader.entries()) {
|
|
27
|
+
if (entry.options.group) continue
|
|
28
|
+
const moduleName = entry.options.name
|
|
29
|
+
const rowId = rowIdOf(ports, entry.id)
|
|
30
|
+
const protectedRow = isProtectedModule(moduleName)
|
|
31
|
+
entries.push({
|
|
32
|
+
entryId: entry.id,
|
|
33
|
+
rowId,
|
|
34
|
+
moduleName,
|
|
35
|
+
enabled: !entry.disabled,
|
|
36
|
+
fiberPhase: entry.fiber === undefined ? null : FIBER_PHASE[entry.fiber.state],
|
|
37
|
+
protected: protectedRow,
|
|
38
|
+
toggleable: rowId !== 'plugin-console'
|
|
39
|
+
&& !protectedRow
|
|
40
|
+
&& typeof moduleName === 'string'
|
|
41
|
+
&& !moduleName.startsWith('cordis:'),
|
|
42
|
+
})
|
|
43
|
+
}
|
|
44
|
+
return entries
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* 宿主基础设施行:停用会连带破坏 HMR/传输/存储/设置链(例如停用 timer
|
|
49
|
+
* 会让补丁热加载整体失效,停用 webserver 会让页面失联)。这些行禁止开关。
|
|
50
|
+
*/
|
|
51
|
+
const PROTECTED_MODULE_PATTERNS = [
|
|
52
|
+
/^cordis:/u,
|
|
53
|
+
/^@deepseek-ai\/cordis-plugin-/u,
|
|
54
|
+
/^@deepseek-ai\/dsh-host-/u,
|
|
55
|
+
/^@deepseek-ai\/dsh-client-modules$/u,
|
|
56
|
+
/^@deepseek-ai\/dsh-client-connection$/u,
|
|
57
|
+
/^@deepseek-ai\/dsh-client-hmr$/u,
|
|
58
|
+
/^@deepseek-ai\/dsh-client-runtime$/u,
|
|
59
|
+
/^@deepseek-ai\/dsh-client-locale$/u,
|
|
60
|
+
/^@deepseek-ai\/dsh-client-ui-attachment$/u,
|
|
61
|
+
/^@deepseek-ai\/dsh-client-web/u,
|
|
62
|
+
/^@deepseek-ai\/dsh-web-frontend$/u,
|
|
63
|
+
/^@deepseek-ai\/dsh-web-app$/u,
|
|
64
|
+
/^@deepseek-ai\/dsh-settings/u,
|
|
65
|
+
/^@deepseek-ai\/dsh-credentials/u,
|
|
66
|
+
/^@deepseek-ai\/dsh-session/u,
|
|
67
|
+
/^@deepseek-ai\/dsh-storage/u,
|
|
68
|
+
/^@deepseek-ai\/dsh-attachment/u,
|
|
69
|
+
/^@deepseek-ai\/dsh-typert/u,
|
|
70
|
+
/^@deepseek-ai\/dsh-api-remotes$/u,
|
|
71
|
+
/^@deepseek-ai\/dsh-tools$/u,
|
|
72
|
+
/^@deepseek-ai\/dsh-system-prompt$/u,
|
|
73
|
+
/^@deepseek-ai\/dsh-agent/u,
|
|
74
|
+
/^@deepseek-ai\/dsh-llm/u,
|
|
75
|
+
/^@deepseek-ai\/dsh-persona$/u,
|
|
76
|
+
/^@deepseek-ai\/dsh-scope$/u,
|
|
77
|
+
/^@deepseek-ai\/dsh-launch-environment$/u,
|
|
78
|
+
/^@deepseek-ai\/dsh-shell$/u,
|
|
79
|
+
/^@deepseek-ai\/dsh-subprocess/u,
|
|
80
|
+
/^@deepseek-ai\/dsh-fs/u,
|
|
81
|
+
/^@deepseek-ai\/dsh-sandbox/u,
|
|
82
|
+
/^@deepseek-ai\/dsh-jobs/u,
|
|
83
|
+
/^@deepseek-ai\/dsh-skill/u,
|
|
84
|
+
/^@deepseek-ai\/dsh-goal/u,
|
|
85
|
+
/^@deepseek-ai\/dsh-workflow/u,
|
|
86
|
+
/^@deepseek-ai\/dsh-subagent/u,
|
|
87
|
+
/^@deepseek-ai\/dsh-web$/u,
|
|
88
|
+
/^@deepseek-ai\/dsh-workspace/u,
|
|
89
|
+
/^@deepseek-ai\/dsh-user-approval$/u,
|
|
90
|
+
/^@deepseek-ai\/dsh-user-questions$/u,
|
|
91
|
+
/^@deepseek-ai\/dsh-commands$/u,
|
|
92
|
+
/^@deepseek-ai\/dsh-hook/u,
|
|
93
|
+
/^@deepseek-ai\/dsh-spill/u,
|
|
94
|
+
/^@deepseek-ai\/dsh-guard/u,
|
|
95
|
+
/^@deepseek-ai\/dsh-tool-call-timeout-policy$/u,
|
|
96
|
+
/^@deepseek-ai\/dsh-repeat-tool-reminder$/u,
|
|
97
|
+
]
|
|
98
|
+
|
|
99
|
+
function isProtectedModule(moduleName) {
|
|
100
|
+
return typeof moduleName === 'string' && PROTECTED_MODULE_PATTERNS.some((pattern) => pattern.test(moduleName))
|
|
101
|
+
}
|
|
102
|
+
/** 包名 → 稳定的 entryId(去 scope、非字母数字转 -、查重加后缀)。 */
|
|
103
|
+
function deriveEntryId(packageName, taken) {
|
|
104
|
+
const base = packageName
|
|
105
|
+
.replace(/^@/u, '')
|
|
106
|
+
.replace(/\//gu, '-')
|
|
107
|
+
.replace(/[^A-Za-z0-9_-]/gu, '-')
|
|
108
|
+
.replace(/-+/gu, '-')
|
|
109
|
+
.replace(/^-|-$/gu, '')
|
|
110
|
+
.slice(0, 40) || 'plugin'
|
|
111
|
+
if (!taken.has(base)) return base
|
|
112
|
+
for (let index = 2; index < 1000; index += 1) {
|
|
113
|
+
const candidate = `${base}-${index}`
|
|
114
|
+
if (!taken.has(candidate)) return candidate
|
|
115
|
+
}
|
|
116
|
+
throw new Error('无法为插件生成唯一的条目 id')
|
|
117
|
+
}
|
|
118
|
+
export { listEntries, webPort, PROTECTED_MODULE_PATTERNS, isProtectedModule, deriveEntryId }
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
// L1 · domain —— selfupdate.js(控制台自更新:**包管理器优先**,保证升级真的写进 pnpm-lock.yaml)
|
|
2
|
+
//
|
|
3
|
+
// 缺陷背景(2026-09-20 用户实测报告,附完整时间线):
|
|
4
|
+
// 旧的 `/self-update` 直接把 npm tarball 的文件铺进 profile 的 node_modules,**不碰 pnpm-lock.yaml**;
|
|
5
|
+
// 而 profile 的依赖是 pnpm 按 lock 管理的(`dsh plugin` 本身就是 pnpm 的薄转发器)。于是只要发生
|
|
6
|
+
// **任何一次 pnpm 操作**——开关任意插件(会改 `dsh.profile.bundles`)、`dsh plugin add/remove/install`——
|
|
7
|
+
// pnpm 就按 lock 重装,把刚"升级"上去的版本**还原**回 lock 里钉住的旧版本。
|
|
8
|
+
// 用户侧现象:UI 一直提示有新版(package.json 写 `^0.3.47` 允许 0.3.54),点更新显示成功、重启后还是旧版。
|
|
9
|
+
//
|
|
10
|
+
// 现在的顺序:① spec 是版本范围 → `pnpm update <pkg>`(spec 不变、lock 提到范围内最新)
|
|
11
|
+
// ② 仍不是 latest(超出范围 / spec 是 git·file 来源)→ `pnpm add <pkg>@<latest>`(spec 与 lock 同步改写)
|
|
12
|
+
// ③ 回读核实 readInstalledVersion / lockVersion;都不匹配才回落手铺文件,且**必须**带 lockNote 警告。
|
|
13
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
14
|
+
import { join } from 'node:path'
|
|
15
|
+
import { linkSpecFor, linkSpecIsIntact, materializePackageForLink, probeRegistryPackage } from './dep-source.js'
|
|
16
|
+
import { pnpmInstall } from './install.js'
|
|
17
|
+
import { fetchJsonUrl } from '../infra/http.js'
|
|
18
|
+
import { runPnpmWithFallback } from '../infra/exec.js'
|
|
19
|
+
|
|
20
|
+
const CONSOLE_PACKAGE = '@noob-stupid/dsh-plugin-console'
|
|
21
|
+
|
|
22
|
+
/** spec 是否是 registry 版本范围(`^1.2.3` / `~1.2.3` / `1.2.3` / `>=1` / `=1`)。
|
|
23
|
+
* 只有这种才能用 `pnpm update` 在**不改写 spec**的前提下把 lock 提到范围内最新;
|
|
24
|
+
* git / file / link / workspace 来源没有"范围"可言,只能走 `pnpm add <pkg>@<版本>`。 */
|
|
25
|
+
function isRegistryRange(spec) {
|
|
26
|
+
if (typeof spec !== 'string') return false
|
|
27
|
+
const s = spec.trim()
|
|
28
|
+
if (/^[\^~]?\d/u.test(s)) return true
|
|
29
|
+
return /^(>=|>|=)\s*\d/u.test(s)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** profile 里该包的依赖声明(spec)。读不到返回 null。 */
|
|
33
|
+
function profileSpec(profileDir, name) {
|
|
34
|
+
try {
|
|
35
|
+
const pkg = JSON.parse(readFileSync(join(profileDir, 'package.json'), 'utf8'))
|
|
36
|
+
const spec = pkg?.dependencies?.[name]
|
|
37
|
+
return typeof spec === 'string' ? spec : null
|
|
38
|
+
} catch {
|
|
39
|
+
return null
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** 已安装版本(直接读 node_modules 里那份 package.json)。 */
|
|
44
|
+
function readInstalledVersion(profileDir, name) {
|
|
45
|
+
try {
|
|
46
|
+
const pkg = JSON.parse(readFileSync(join(profileDir, 'node_modules', ...name.split('/'), 'package.json'), 'utf8'))
|
|
47
|
+
return typeof pkg?.version === 'string' ? pkg.version : null
|
|
48
|
+
} catch {
|
|
49
|
+
return null
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* 从 pnpm-lock.yaml 读出该包被**钉住**的版本。解析策略(lock 格式五花八门,只认两种确定性位置,避免子串误命中):
|
|
55
|
+
* ① importers 段:行内容恰为 `'<name>':`(可带缩进),随后几行内的 `version: X`;
|
|
56
|
+
* ② packages 段:行首(可带缩进/引号)`<name>@<版本或 git+...>:` 的键名。
|
|
57
|
+
* 读不到返回 null(调用方据此判定"没能核实",不谎报成功)。
|
|
58
|
+
*
|
|
59
|
+
* ★ 2026-09-22 修复(缺陷②连带):旧版 ① 在遇到 `specifier: …` 行时**直接 break**,而 importers 段
|
|
60
|
+
* 恒为 `specifier:` 在前、`version:` 在后 → ① 从来没生效过,全靠 ② 兜底;而 ② 的正则用 `[^':\s]+`
|
|
61
|
+
* 取值,遇到 `name@https://…tgz` / `link:…` 会在第一个冒号处截断(读出 `http`)或读不到(link 依赖
|
|
62
|
+
* 在 lock 里没有 packages 条目 → 返回 null)。后果:来源钉住的包永远被判为「漂移」→ 每次安装都白跑
|
|
63
|
+
* 一次 pnpm add,并给用户一条假的「没写进 lock」警告。现在 ① 跳过 specifier 行、② 取到行尾。
|
|
64
|
+
*/
|
|
65
|
+
function lockVersion(profileDir, name) {
|
|
66
|
+
const file = join(profileDir, 'pnpm-lock.yaml')
|
|
67
|
+
if (!existsSync(file)) return null
|
|
68
|
+
let lines = []
|
|
69
|
+
try {
|
|
70
|
+
lines = readFileSync(file, 'utf8').split(/\r?\n/u)
|
|
71
|
+
} catch {
|
|
72
|
+
return null
|
|
73
|
+
}
|
|
74
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
75
|
+
const bare = lines[i].trim().replace(/^'|':?$/gu, '').replace(/'$/u, '')
|
|
76
|
+
if (bare === name || lines[i].trim() === `'${name}':`) {
|
|
77
|
+
for (let k = i + 1; k < Math.min(i + 8, lines.length); k += 1) {
|
|
78
|
+
const m = /^\s*version:\s*(\S+)\s*$/u.exec(lines[k])
|
|
79
|
+
if (m) return m[1]
|
|
80
|
+
// 只在新包的键行(`'<name>':` 形态)处停止;`specifier: X` 有值,不是键行,必须继续往下看
|
|
81
|
+
if (/^\s*'?[^\s:]+'?:\s*$/u.test(lines[k])) break
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
const escaped = name.replace(/[/\\^$*+?.()|[\]{}]/gu, '\\$&')
|
|
86
|
+
// 取到行尾再剥尾部的引号/冒号:`'name@https://…tgz':` / `name@git+https://…#sha:` / `name@1.2.3:`
|
|
87
|
+
const keyRe = new RegExp(`^\\s*'?${escaped}@(.+?)'?:\\s*$`, 'u')
|
|
88
|
+
for (const line of lines) {
|
|
89
|
+
const m = keyRe.exec(line)
|
|
90
|
+
if (m) return m[1]
|
|
91
|
+
}
|
|
92
|
+
return null
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** 给用户的兜底建议命令(照用户报告里那条:让 lock 也变成新版本,而不是只改文件)。 */
|
|
96
|
+
function selfUpdateCommand(latest, profileName = '<你的profile>') {
|
|
97
|
+
return `dsh plugin --profile ${profileName} add ${CONSOLE_PACKAGE}@${latest}`
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* 把控制台升到 `latest` 并**确保写进 lock**。
|
|
102
|
+
* 依赖以参数注入(便于单测替换):`runPnpm`(默认 runPnpmWithFallback)、`pnpmAdd`(默认 pnpmInstall)、
|
|
103
|
+
* `curlManualInstall`(手铺文件兜底,由路由传入)。
|
|
104
|
+
*/
|
|
105
|
+
async function selfUpdateToLatest({ profileDir, latest, registries, curlManualInstall, runPnpm = runPnpmWithFallback, pnpmAdd = pnpmInstall, profileName = null, execOpts = {} }) {
|
|
106
|
+
const command = selfUpdateCommand(latest, profileName ?? '<你的profile>')
|
|
107
|
+
const spec = profileSpec(profileDir, CONSOLE_PACKAGE)
|
|
108
|
+
const errors = []
|
|
109
|
+
let method = null
|
|
110
|
+
const label = (e) => (e instanceof Error ? e.message : String(e))
|
|
111
|
+
|
|
112
|
+
// ① spec 是版本范围 → pnpm update(spec 不变,lock 提到范围内最新)
|
|
113
|
+
if (isRegistryRange(spec)) {
|
|
114
|
+
try {
|
|
115
|
+
await runPnpm(['update', CONSOLE_PACKAGE], { execOpts: { cwd: profileDir, timeout: 300000, ...execOpts } })
|
|
116
|
+
method = 'pnpm-update'
|
|
117
|
+
} catch (error) {
|
|
118
|
+
errors.push(`pnpm update 失败:${label(error)}`)
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
// ② 还没到 latest(超出 range / git·file 来源)→ pnpm add <pkg>@<latest>(spec 与 lock 一起改写)
|
|
122
|
+
if (readInstalledVersion(profileDir, CONSOLE_PACKAGE) !== latest) {
|
|
123
|
+
try {
|
|
124
|
+
await pnpmAdd(profileDir, `${CONSOLE_PACKAGE}@${latest}`, registries?.[0])
|
|
125
|
+
method = method === null ? 'pnpm-add' : `${method}+pnpm-add`
|
|
126
|
+
} catch (error) {
|
|
127
|
+
errors.push(`pnpm add 失败:${label(error)}`)
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
let installed = readInstalledVersion(profileDir, CONSOLE_PACKAGE)
|
|
132
|
+
let lock = lockVersion(profileDir, CONSOLE_PACKAGE)
|
|
133
|
+
let lockUpdated = installed === latest && lock === latest
|
|
134
|
+
let lockNote = null
|
|
135
|
+
|
|
136
|
+
// ③ 包管理器都没成 → 手铺文件兜底(**不写 lock**),并且必须明确警告,不能让人以为升级成功
|
|
137
|
+
if (!lockUpdated) {
|
|
138
|
+
try {
|
|
139
|
+
const info = await curlManualInstall(profileDir, CONSOLE_PACKAGE, registries, null, latest)
|
|
140
|
+
method = method === null ? 'manual-copy' : `${method}+manual-copy`
|
|
141
|
+
installed = info?.version ?? readInstalledVersion(profileDir, CONSOLE_PACKAGE)
|
|
142
|
+
lock = lockVersion(profileDir, CONSOLE_PACKAGE)
|
|
143
|
+
lockUpdated = installed === latest && lock === latest
|
|
144
|
+
} catch (error) {
|
|
145
|
+
errors.push(`手动铺文件失败:${label(error)}`)
|
|
146
|
+
}
|
|
147
|
+
if (!lockUpdated) {
|
|
148
|
+
lockNote = `此更新未写入 pnpm-lock.yaml(lock 里仍是 ${lock ?? '未知'}):之后任何 pnpm 操作(开关插件、dsh plugin add/remove)都会把它还原成 lock 里的版本。要真正落地请执行:${command}`
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const sourceSwitch = spec !== null && !isRegistryRange(spec) ? `安装来源已从 ${spec} 变为 registry 版本 ${latest}` : null
|
|
153
|
+
const noteParts = [lockUpdated ? '已写入 pnpm-lock.yaml,重启服务后生效' : '已铺入文件但未写入 pnpm-lock.yaml:重启后可用,但会被 pnpm 还原']
|
|
154
|
+
if (sourceSwitch !== null) noteParts.push(sourceSwitch)
|
|
155
|
+
|
|
156
|
+
return { method, spec, installedVersion: installed, lockVersion: lock, lockUpdated, lockNote, command, note: noteParts.join(';'), errors }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* 依赖**来源规格**的协议前缀:manifest 里出现这些,说明来源不是 npm registry(link:/file:/URL/git/别名)。
|
|
161
|
+
* 这类 spec 必须原样保留,**绝不能**换成版本号(换成 `<name>@<版本>` 就等于把来源丢掉 —— 缺陷②)。
|
|
162
|
+
*/
|
|
163
|
+
const SOURCE_SPEC_RE = /^(link:|file:|https?:|git\+|git:|github:|gitlab:|bitbucket:|workspace:|portal:|npm:|jsr:)/u
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* lock 里被**来源**(而不是版本号)钉住的解析:`link:../x`、`https://…tgz`、`git+https://…#sha`。
|
|
167
|
+
* 这类解析的 `version` 字段不是语义化版本,不能拿它跟已装版本做相等比较(见下面 aligned)。
|
|
168
|
+
*/
|
|
169
|
+
const SOURCE_PINNED_RE = SOURCE_SPEC_RE
|
|
170
|
+
|
|
171
|
+
/** 裸精确版本号(面板写回留下的形态):`0.3.3`。`^0.3.3` / `~0.3.3` / `>=1` 是用户手写的范围。 */
|
|
172
|
+
const EXACT_VERSION_RE = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/u
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* dist-tag 形式的 spec(`latest` / `next` / `beta`):也是 registry 来源,但要**保留标签**而不是钉成版本号。
|
|
176
|
+
* 判据:不含版本号或协议前缀的裸标识符。注意不能把它当成"非 registry 来源"原样丢给 pnpm ——
|
|
177
|
+
* `pnpm add latest` 会去装一个**名叫 latest 的包**,那是灾难性的误装。
|
|
178
|
+
*/
|
|
179
|
+
const DIST_TAG_RE = /^[A-Za-z][A-Za-z0-9._-]*$/u
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* 通用 lock 对账(**任何**安装通道装完都该调用,支持一次对账多个包):
|
|
183
|
+
* 走非 pnpm 通道(并行 curl / curl tarball / GitHub Release / git clone 装配 / 套装装配)装的包,
|
|
184
|
+
* node_modules 里的版本与 `pnpm-lock.yaml` 记的版本可能不一致——之后任何 pnpm 操作(开关插件改
|
|
185
|
+
* `dsh.profile.bundles`、`dsh plugin add/remove`)都可能按 lock 把它**还原**甚至当外来物处理。这里:
|
|
186
|
+
* ① 全部一致 → 直接返回(不跑 pnpm,零成本);
|
|
187
|
+
* ② 有漂移 → **一次** `pnpm add <spec1> <spec2> …` 把所有漂移包精确写进 lock;
|
|
188
|
+
* ③ 仍对不上 → 返回 lockNote(逐包列出)与可复制命令,由调用方如实展示,绝不假装成功。
|
|
189
|
+
* `packages` 形如 [{ name, version }];只给 `packageName` 时等价于单包对账(向后兼容)。
|
|
190
|
+
*
|
|
191
|
+
* ★ 缺陷②修复(0.4.0-beta.16 / 0.3.63):写回 spec 前必须确认来源,**绝不能把 release 来源的包
|
|
192
|
+
* 写成裸版本号**——0.3.57 起的旧实现一律 `pnpm add <name>@<installed>`,对 npm 上不存在的包会被
|
|
193
|
+
* pnpm 静默改写成 `<name>: "<版本>"`(EXIT=0,装完看不出问题),lock 一重建就 ERR_PNPM_FETCH_404。
|
|
194
|
+
* 现在的规则(详见 domain/install.js 顶部「依赖来源写回」注释与 issue 草案「缺陷②」):
|
|
195
|
+
* · manifest 已是真实来源(link: / git+ / file:)→ 原样重放,不降级成版本号;
|
|
196
|
+
* · 已是 tarball URL(或在 lock 里被 URL 钉住)→ 转成 link: 形式
|
|
197
|
+
* (pnpm 10 对 direct-URL 依赖重写 lock 会丢 integrity,实测 ERR_PNPM_MISSING_TARBALL_INTEGRITY);
|
|
198
|
+
* · 版本号 / 没有条目 → **先探 registry**:这个包的**这个版本**可解析才写 `<name>@<版本>`;
|
|
199
|
+
* 查无此包(404)→ 物化到 `<DSH_HOME>/plugin-src/<包名>` 并写 `link:<绝对路径>`,同时回 depNote;
|
|
200
|
+
* 连物化都做不到 → **跳过对齐且不碰 package.json**,只记 note。
|
|
201
|
+
*/
|
|
202
|
+
async function reconcileLockfile({ profileDir, packageName = null, packages = null, registries = [], pnpmAdd = pnpmInstall, execOpts = {}, fetchJson = fetchJsonUrl, home = null } = {}) {
|
|
203
|
+
const targets = Array.isArray(packages)
|
|
204
|
+
? packages.filter((p) => p && typeof p.name === 'string' && p.name !== '')
|
|
205
|
+
: (packageName === null ? [] : [{ name: packageName, version: null }])
|
|
206
|
+
const unique = []
|
|
207
|
+
for (const t of targets) if (!unique.some((u) => u.name === t.name)) unique.push(t)
|
|
208
|
+
const command = (name, version) => `dsh plugin --profile <你的profile> add ${name}@${version ?? '<版本>'}`
|
|
209
|
+
|
|
210
|
+
const snap = () => unique.map((t) => ({
|
|
211
|
+
name: t.name,
|
|
212
|
+
spec: profileSpec(profileDir, t.name),
|
|
213
|
+
installed: readInstalledVersion(profileDir, t.name),
|
|
214
|
+
lock: lockVersion(profileDir, t.name),
|
|
215
|
+
}))
|
|
216
|
+
const sourcePinned = (v) => typeof v === 'string' && SOURCE_PINNED_RE.test(v.trim())
|
|
217
|
+
const urlPinned = (v) => typeof v === 'string' && /^https?:/u.test(v.trim())
|
|
218
|
+
/** 缺陷②指纹:manifest 声明裸版本号、lock 却把该包解析到一个 URL —— pnpm 静默改写留下的状态。 */
|
|
219
|
+
const misrecorded = (r) => typeof r.spec === 'string' && EXACT_VERSION_RE.test(r.spec.trim()) && urlPinned(r.lock)
|
|
220
|
+
/** manifest 里仍是 tarball URL:虽然 lock 能解析,但 pnpm 10 重写 lock 会丢 integrity(实测),
|
|
221
|
+
* 必须**主动**规整成 link: 形式 —— 否则「删 lock / 清 node_modules / 换机」就装不回来。 */
|
|
222
|
+
const urlSpec = (r) => typeof r.spec === 'string' && /^https?:/u.test(r.spec.trim())
|
|
223
|
+
/** 来源钉住的包没有「版本号相等」可比:link 依赖要看链接是否**真的**还在(release 通道更新会把
|
|
224
|
+
* node_modules/<包名> 换成真实目录,此时必须重新物化 + 重放 link:,否则下次 pnpm 操作会还原版本);
|
|
225
|
+
* 其余来源(git+/file:/URL)只要有解析且包装着即视为对齐。 */
|
|
226
|
+
const aligned = (r) => {
|
|
227
|
+
if (r.installed === null) return false
|
|
228
|
+
if (typeof r.spec === 'string' && r.spec.startsWith('link:')) return linkSpecIsIntact(profileDir, r.name, r.spec)
|
|
229
|
+
return r.installed === r.lock || sourcePinned(r.lock)
|
|
230
|
+
}
|
|
231
|
+
const driftedOf = (rows) => rows.filter((r) => r.installed === null || !aligned(r) || misrecorded(r) || urlSpec(r))
|
|
232
|
+
|
|
233
|
+
const depNotes = []
|
|
234
|
+
// 物化 + link: 计划(registry 查无此包时唯一安全的写回形式)
|
|
235
|
+
const linkPlan = (row, why) => {
|
|
236
|
+
const dir = materializePackageForLink(profileDir, row.name, home === null ? {} : { home })
|
|
237
|
+
if (dir === null) {
|
|
238
|
+
return {
|
|
239
|
+
spec: null,
|
|
240
|
+
note: `${row.name}:${why},但 node_modules 里找不到可物化的已装副本 —— 已跳过 lock 对齐(**未改动 package.json**,避免留下指向不存在 npm 版本的裸版本号)`,
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
const link = linkSpecFor(dir)
|
|
244
|
+
return {
|
|
245
|
+
spec: link,
|
|
246
|
+
note: `${row.name}:${why},已按 link: 形式记录依赖(${link})—— 不经 npm registry 解析、不经 tarball 完整性校验,pnpm 重建 lock 也能装上`,
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
/** 决定单个漂移包该以什么 spec 写回。spec=null 表示跳过(不碰 manifest)。 */
|
|
250
|
+
const planWriteback = async (row) => {
|
|
251
|
+
const { name, spec, installed } = row
|
|
252
|
+
// ① manifest 里已是真实来源(协议前缀)→ 保持来源,绝不降级成裸版本号
|
|
253
|
+
if (typeof spec === 'string' && SOURCE_SPEC_RE.test(spec.trim())) {
|
|
254
|
+
if (/^https?:/u.test(spec.trim()) || misrecorded(row)) {
|
|
255
|
+
return linkPlan(row, '该包原先以 tarball URL 记录(pnpm 10 重写 lock 会丢 integrity,实测 ERR_PNPM_MISSING_TARBALL_INTEGRITY)')
|
|
256
|
+
}
|
|
257
|
+
// link: 规格:链接可能已被 release/curl 通道的"先删再铺"打断 → 先把新副本刷进 plugin-src 再重放
|
|
258
|
+
if (spec.trim().startsWith('link:')) {
|
|
259
|
+
const dir = materializePackageForLink(profileDir, name, home === null ? {} : { home })
|
|
260
|
+
return { spec: dir === null ? spec.trim() : linkSpecFor(dir), note: null }
|
|
261
|
+
}
|
|
262
|
+
return { spec: spec.trim(), note: null } // git+ / file: / 别名 原样重放(幂等,来源不变)
|
|
263
|
+
}
|
|
264
|
+
// ② dist-tag(latest/next…):registry 来源,但**保留标签**(钉成版本号会悄悄失去升级语义)
|
|
265
|
+
if (typeof spec === 'string' && DIST_TAG_RE.test(spec.trim())) {
|
|
266
|
+
const probeTag = await probeRegistryPackage(name, registries, { fetchJson })
|
|
267
|
+
if (probeTag.resolvable) return { spec: `${name}@${spec.trim()}`, note: null }
|
|
268
|
+
return linkPlan(row, 'npm registry 查无此包(该包只存在于 GitHub release)')
|
|
269
|
+
}
|
|
270
|
+
// ③ 版本号或没有条目 → 先探 registry:**这个版本**可解析才允许写版本号
|
|
271
|
+
const probe = await probeRegistryPackage(name, registries, { version: installed, fetchJson })
|
|
272
|
+
if (probe.resolvable && probe.hasVersion) return { spec: `${name}@${installed}`, note: null }
|
|
273
|
+
// ④ registry 查无此包(或查无此版本)→ 只存在于 GitHub release → 物化 + link:
|
|
274
|
+
return linkPlan(row, probe.resolvable ? `registry 上没有 ${installed} 这个版本` : 'npm registry 查无此包(该包只存在于 GitHub release)')
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
let rows = snap()
|
|
278
|
+
let drifted = driftedOf(rows)
|
|
279
|
+
const errors = []
|
|
280
|
+
let method = null
|
|
281
|
+
if (drifted.length > 0) {
|
|
282
|
+
// 只对"能读到实际版本"的包做对齐;读不到的(目录都没有)无法对账,留给 lockNote
|
|
283
|
+
const specs = []
|
|
284
|
+
for (const row of drifted) {
|
|
285
|
+
if (row.installed === null) continue
|
|
286
|
+
const plan = await planWriteback(row)
|
|
287
|
+
if (plan.note !== null) depNotes.push(plan.note)
|
|
288
|
+
if (plan.spec !== null) specs.push(plan.spec)
|
|
289
|
+
}
|
|
290
|
+
if (specs.length > 0) {
|
|
291
|
+
try {
|
|
292
|
+
await pnpmAdd(profileDir, specs.length === 1 ? specs[0] : specs, registries?.[0])
|
|
293
|
+
method = 'pnpm-add'
|
|
294
|
+
} catch (error) {
|
|
295
|
+
errors.push(`pnpm add 对齐失败:${error instanceof Error ? error.message : String(error)}`)
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
rows = snap()
|
|
299
|
+
drifted = driftedOf(rows)
|
|
300
|
+
}
|
|
301
|
+
const lockUpdated = drifted.length === 0
|
|
302
|
+
return {
|
|
303
|
+
method,
|
|
304
|
+
packages: rows.map((r) => ({ name: r.name, installedVersion: r.installed, lockVersion: r.lock, spec: r.spec, aligned: r.installed !== null && (r.installed === r.lock || sourcePinned(r.lock)) })),
|
|
305
|
+
spec: rows.length === 1 ? rows[0].spec : null,
|
|
306
|
+
installedVersion: rows.length === 1 ? rows[0].installed : null,
|
|
307
|
+
lockVersion: rows.length === 1 ? rows[0].lock : null,
|
|
308
|
+
lockUpdated,
|
|
309
|
+
lockNote: lockUpdated
|
|
310
|
+
? null
|
|
311
|
+
: `${drifted.length} 个包没写进 pnpm-lock.yaml(${drifted.map((r) => `${r.name}:装了 ${r.installed ?? '未知'}/lock 里是 ${r.lock ?? '未记录'}`).join(';')}):之后任何 pnpm 操作(开关插件、dsh plugin add/remove)都可能把它们还原。要钉住请执行:${command(drifted[0].name, drifted[0].installed)}`,
|
|
312
|
+
// 非常规来源(link:)写回时的**用户可见**说明:绝不静默(缺陷②的隐蔽性正是"装完看不出问题")
|
|
313
|
+
depNote: depNotes.length > 0 ? depNotes.join(';') : null,
|
|
314
|
+
command: drifted.length > 0 ? command(drifted[0].name, drifted[0].installed) : command(rows[0]?.name ?? '', rows[0]?.installed ?? null),
|
|
315
|
+
errors,
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
export { CONSOLE_PACKAGE, isRegistryRange, readInstalledVersion, lockVersion, profileSpec, reconcileLockfile, selfUpdateCommand, selfUpdateToLatest }
|