@noob-stupid/dsh-plugin-console 0.5.18 → 0.5.20
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 +116 -0
- package/lib/server/domain/dep-source.js +59 -2
- package/lib/server/domain/install-job.js +29 -31
- package/lib/server/domain/lockfile-health.js +6 -5
- package/lib/server/domain/manifest.js +146 -20
- package/lib/server/domain/plugin-actions.js +202 -0
- package/lib/server/domain/selfupdate.js +56 -17
- package/lib/server/domain/sources.js +12 -1
- package/lib/server/domain/suite.js +130 -4
- package/lib/server/routes/actions.js +32 -0
- package/lib/server/routes/compat.js +13 -2
- package/lib/server/routes/index.js +3 -0
- package/lib/server/routes/install.js +16 -4
- package/package.json +1 -1
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
// L1 · domain —— plugin-actions.js(**结构化建议动作的执行器**:白名单动作 + 服务端自拼 argv)
|
|
2
|
+
//
|
|
3
|
+
// 为什么单开一个模块(2026-09-27 加法 + 改错,用户明确要求「可执行的钉住/修复动作框」):
|
|
4
|
+
// · 面板过去只给一句文案("要钉住请执行:dsh plugin … add …"),用户得自己开终端抄命令,
|
|
5
|
+
// 抄错一个字符就是另一种事故;而**让面板自由执行命令**又是绝不能开的口子。
|
|
6
|
+
// · 所以接口只接受**结构化的动作类型 + 目标**(`{ action, packageName, version, profile }`),
|
|
7
|
+
// 命令行的每一个参数都由本模块的 ACTION_SPECS 自己拼 —— 客户端**没有任何位置**能传命令字符串。
|
|
8
|
+
// 出现 command/cmd/argv/args/exec/shell/script/run/spawn/bin 任一字段即 400(配套测试钉死)。
|
|
9
|
+
// · `profile` 字段**只作回显**:服务端一律按当前实例的 profile 目录施工(绝不按请求体挑目录)。
|
|
10
|
+
//
|
|
11
|
+
// 动作表(唯一真源,新增动作必须同时进这张表与测试):
|
|
12
|
+
// · pin-dependency:把清单里的该包钉成 `link:<DSH_HOME>/plugin-src/<包名>`,再跑一次
|
|
13
|
+
// `pnpm add <link:…>` 把 lock / node_modules 对齐(argv 由 infra/exec.js#pnpmAddArgs 产出);
|
|
14
|
+
// · reconcile-lock:只重写 pnpm-lock.yaml,复用 lockfile-health 的 repair(argv 由 repairArgsFor
|
|
15
|
+
// 唯一产出,**不带**任何绕过供应链闸的开关)。
|
|
16
|
+
//
|
|
17
|
+
// 供应链闸的一次性放宽(写清代价,绝不静默):pnpm 11 的 `minimumReleaseAge` 默认 24h,
|
|
18
|
+
// 本控制台自己刚发的版本 (<24h) 会让**任何** lockfile 校验失败 —— 连"把本地 link: 记进 lock"都做不成。
|
|
19
|
+
// 处理:第一次**原样跑**(不带任何放宽);只有当失败原因**确定**是 ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION
|
|
20
|
+
// 时,才为**这一条命令**追加 `--config.minimumReleaseAge=0` 重试一次,并在结果里显式回报
|
|
21
|
+
// `relaxedReleaseAge: true`(面板原样展示)。绝不写进任何配置文件、绝不用它绕过错配的包名/版本。
|
|
22
|
+
//
|
|
23
|
+
// 分层:L1 domain —— 不认识 cordis ctx,IO/执行器一律可注入(便于离线单测)。
|
|
24
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
25
|
+
import { join } from 'node:path'
|
|
26
|
+
import { NPM_NAME_RE } from './dep-source.js'
|
|
27
|
+
import { runLockfileRepair } from './lockfile-health.js'
|
|
28
|
+
import { pinProfileDependency, specOf } from './manifest.js'
|
|
29
|
+
import { lockVersion } from './selfupdate.js'
|
|
30
|
+
import { buildPnpmEnv, pnpmAddArgs, runPnpmWithFallback } from '../infra/exec.js'
|
|
31
|
+
|
|
32
|
+
const DEFAULT_REGISTRY = 'https://registry.npmmirror.com'
|
|
33
|
+
/** 动作白名单(客户端只能给这些 kind;执行分支与 argv 全在服务端)。 */
|
|
34
|
+
const ACTION_KINDS = ['pin-dependency', 'reconcile-lock']
|
|
35
|
+
/** 一旦出现这些键就整条拒绝:本接口不存在"传命令"的位置。 */
|
|
36
|
+
const COMMAND_KEYS = ['command', 'cmd', 'argv', 'args', 'exec', 'execute', 'shell', 'script', 'run', 'spawn', 'bin', 'shellCommand']
|
|
37
|
+
const OUTPUT_LIMIT = 4000
|
|
38
|
+
|
|
39
|
+
/** 校验并翻译成结构化请求。返回 { ok, error, status, action, packageName, version, profile }。 */
|
|
40
|
+
function parseActionRequest(body) {
|
|
41
|
+
const raw = body !== null && typeof body === 'object' ? body : {}
|
|
42
|
+
for (const key of COMMAND_KEYS) {
|
|
43
|
+
if (key in raw) {
|
|
44
|
+
return { ok: false, status: 400, error: `不接受客户端传来的命令字符串(字段 ${key} 非法):本接口只认结构化动作,命令行由服务端自己拼` }
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
const action = typeof raw.action === 'string' ? raw.action.trim() : ''
|
|
48
|
+
if (action === '') return { ok: false, status: 400, error: '缺少 action(只允许 pin-dependency / reconcile-lock)' }
|
|
49
|
+
if (!ACTION_KINDS.includes(action)) {
|
|
50
|
+
return { ok: false, status: 400, error: `未知动作 ${action}(只允许 ${ACTION_KINDS.join(' / ')})` }
|
|
51
|
+
}
|
|
52
|
+
const packageName = typeof raw.packageName === 'string' ? raw.packageName.trim() : ''
|
|
53
|
+
if (action === 'pin-dependency') {
|
|
54
|
+
if (packageName === '') return { ok: false, status: 400, error: 'pin-dependency 需要 packageName' }
|
|
55
|
+
if (packageName.length > 214 || !NPM_NAME_RE.test(packageName)) return { ok: false, status: 400, error: 'packageName 不是合法的 npm 包名' }
|
|
56
|
+
}
|
|
57
|
+
const version = typeof raw.version === 'string' && raw.version.length <= 120 ? raw.version : null
|
|
58
|
+
const profile = typeof raw.profile === 'string' && raw.profile.length <= 64 ? raw.profile : null
|
|
59
|
+
return { ok: true, error: null, status: 200, action, packageName: packageName === '' ? null : packageName, version, profile }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** 该包是否属于当前 profile(已声明在 dependencies / devDependencies / optionalDependencies 或
|
|
63
|
+
* `dsh.profile.bundles` 里)。**安全边界**:动作只对该 profile 自己认得的包生效,
|
|
64
|
+
* 请求体说了不算(包名由客户端给,但"这个包属不属于这个 profile"必须服务端自己读盘判定)。 */
|
|
65
|
+
function isProfilePackage(profileDir, packageName) {
|
|
66
|
+
try {
|
|
67
|
+
const manifest = JSON.parse(readFileSync(join(profileDir, 'package.json'), 'utf8'))
|
|
68
|
+
for (const section of ['dependencies', 'devDependencies', 'optionalDependencies']) {
|
|
69
|
+
if (manifest?.[section] !== null && typeof manifest?.[section] === 'object' && packageName in manifest[section]) return true
|
|
70
|
+
}
|
|
71
|
+
const bundles = manifest?.dsh?.profile?.bundles
|
|
72
|
+
return Array.isArray(bundles) && bundles.includes(packageName)
|
|
73
|
+
} catch {
|
|
74
|
+
return false
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** 目标包是否已落在该 profile 的 node_modules 里(pin 的物理前提)。 */
|
|
79
|
+
function isInstalled(profileDir, packageName) {
|
|
80
|
+
return existsSync(join(profileDir, 'node_modules', ...String(packageName).split('/'), 'package.json'))
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function tail(text, limit = OUTPUT_LIMIT) {
|
|
84
|
+
const s = typeof text === 'string' ? text : String(text ?? '')
|
|
85
|
+
return s.length <= limit ? s : s.slice(-limit)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** 供应链闸判据(只认 pnpm 自己的错误码/文案;认不出就绝不放宽)。 */
|
|
89
|
+
function isReleaseAgeBlock(message) {
|
|
90
|
+
return /ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION|minimumReleaseAge/iu.test(String(message ?? ''))
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** 跑一次 pnpm(原样),必要时**只对这一条命令**放宽供应链闸并如实标记。
|
|
94
|
+
* 返回 { ok, exitCode, stdout, stderr, relaxedReleaseAge, error }。 */
|
|
95
|
+
async function runAddWithReleaseAgeRetry({ profileDir, spec, registry, runAdd, timeoutMs = 180000 }) {
|
|
96
|
+
const argv = pnpmAddArgs(spec, registry)
|
|
97
|
+
const execOpts = { cwd: profileDir, timeout: timeoutMs, windowsHide: true, maxBuffer: 4 * 1024 * 1024, env: buildPnpmEnv(registry) }
|
|
98
|
+
const attempt = async (args) => {
|
|
99
|
+
try {
|
|
100
|
+
const out = await runAdd(args, { execOpts })
|
|
101
|
+
return { ok: true, exitCode: 0, stdout: tail(out?.stdout), stderr: tail(out?.stderr), error: null }
|
|
102
|
+
} catch (error) {
|
|
103
|
+
return {
|
|
104
|
+
ok: false,
|
|
105
|
+
exitCode: Number.isInteger(error?.code) ? error.code : 1,
|
|
106
|
+
stdout: tail(error?.stdout),
|
|
107
|
+
stderr: tail(error?.stderr ?? error?.message ?? String(error)),
|
|
108
|
+
error: error instanceof Error ? error : new Error(String(error)),
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
const first = await attempt(argv)
|
|
113
|
+
// 放宽条件**必须同时**成立:第一次真的失败了,且失败原因确定是供应链闸(文案/错误码认得出)
|
|
114
|
+
const blocked = !first.ok && (isReleaseAgeBlock(first.stderr) || isReleaseAgeBlock(String(first.error?.message ?? '')))
|
|
115
|
+
if (!blocked) return { ...first, relaxedReleaseAge: false }
|
|
116
|
+
const retried = await attempt([...argv, '--config.minimumReleaseAge=0'])
|
|
117
|
+
return { ...retried, relaxedReleaseAge: true }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* 执行一个结构化建议动作(**唯一入口**,路由只做鉴权/取 profileDir)。
|
|
122
|
+
* 返回统一形状:{ ok, status, action, packageName, exitCode, command, stdout, stderr, relaxedReleaseAge,
|
|
123
|
+
* manifest: { before, after, changed, pinned }, lock: { entry, synced }, notes, reason, hint, detail }
|
|
124
|
+
* `ok` = 目标状态**真的**达成(pin:清单是 link: **且** lock 里也有该 link 条目);只达成一半时
|
|
125
|
+
* `partial: true` 并如实说明哪一半没成 —— 绝不把"写了一半"报成成功。
|
|
126
|
+
*/
|
|
127
|
+
async function runSuggestedAction({ body, profileDir, registries = [], deps = {} } = {}) {
|
|
128
|
+
const parsed = parseActionRequest(body)
|
|
129
|
+
if (parsed.ok !== true) return { ok: false, status: parsed.status, action: null, error: parsed.error }
|
|
130
|
+
const registry = (Array.isArray(registries) ? registries : []).find((r) => typeof r === 'string' && r.trim() !== '') ?? DEFAULT_REGISTRY
|
|
131
|
+
const runAdd = typeof deps.runAdd === 'function' ? deps.runAdd : runPnpmWithFallback
|
|
132
|
+
const pin = typeof deps.pin === 'function' ? deps.pin : pinProfileDependency
|
|
133
|
+
const repair = typeof deps.repair === 'function' ? deps.repair : runLockfileRepair
|
|
134
|
+
const base = {
|
|
135
|
+
ok: false, status: 200, action: parsed.action, packageName: parsed.packageName, profile: parsed.profile,
|
|
136
|
+
registry, exitCode: null, command: null, stdout: '', stderr: '', relaxedReleaseAge: false,
|
|
137
|
+
manifest: null, lock: null, notes: [], reason: null, hint: null, detail: null,
|
|
138
|
+
}
|
|
139
|
+
if (parsed.action === 'reconcile-lock') {
|
|
140
|
+
const view = await repair({ profileDir, registries: Array.isArray(registries) && registries.length > 0 ? registries : [registry] })
|
|
141
|
+
const done = view?.action === 'repaired' || view?.action === 'noop'
|
|
142
|
+
return {
|
|
143
|
+
...base,
|
|
144
|
+
ok: done,
|
|
145
|
+
exitCode: view?.action === 'failed' ? 1 : 0,
|
|
146
|
+
command: view?.command ?? null,
|
|
147
|
+
stderr: tail(view?.stderrTail),
|
|
148
|
+
notes: [view?.reason].filter((n) => typeof n === 'string'),
|
|
149
|
+
reason: view?.reason ?? null,
|
|
150
|
+
hint: view?.hint ?? null,
|
|
151
|
+
detail: view ?? null,
|
|
152
|
+
lock: { entry: null, synced: view?.action === 'repaired' || view?.action === 'noop' },
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
// pin-dependency
|
|
156
|
+
if (!isProfilePackage(profileDir, parsed.packageName)) {
|
|
157
|
+
return { ...base, ok: false, status: 400, error: `${parsed.packageName} 不在本 profile 的清单(dependencies / dsh.profile.bundles)里,拒绝改动` }
|
|
158
|
+
}
|
|
159
|
+
if (!isInstalled(profileDir, parsed.packageName)) {
|
|
160
|
+
return { ...base, ok: false, status: 400, error: `${parsed.packageName} 没有装在当前 profile 的 node_modules 里,无法钉住(先安装)` }
|
|
161
|
+
}
|
|
162
|
+
const before = await specOf(profileDir, parsed.packageName)
|
|
163
|
+
let pinned = null
|
|
164
|
+
try {
|
|
165
|
+
pinned = await pin(profileDir, parsed.packageName, {})
|
|
166
|
+
} catch (error) {
|
|
167
|
+
return { ...base, ok: false, status: 200, manifest: { before, after: before, changed: false, pinned: false }, reason: error instanceof Error ? error.message : String(error) }
|
|
168
|
+
}
|
|
169
|
+
const argv = pnpmAddArgs(pinned.spec, registry)
|
|
170
|
+
const run = await runAddWithReleaseAgeRetry({ profileDir, spec: pinned.spec, registry, runAdd, timeoutMs: deps.timeoutMs })
|
|
171
|
+
const after = await specOf(profileDir, parsed.packageName)
|
|
172
|
+
const entry = lockVersion(profileDir, parsed.packageName)
|
|
173
|
+
const lockSynced = typeof entry === 'string' && entry.startsWith('link:')
|
|
174
|
+
// "钉住了"的判据:清单里该包**是 link: 形式**(不要求与计划的绝对路径逐字相同 —— pnpm 自己也可能把它
|
|
175
|
+
// 规范化成相对路径,那同样是"不经 registry 解析"的来源型 spec,判据见 domain/dep-source.js#linkSpecIsIntact)
|
|
176
|
+
const pinnedOk = typeof after === 'string' && after.startsWith('link:')
|
|
177
|
+
const manifests = { before, after, changed: before !== after, planned: pinned.spec, pinned: pinnedOk }
|
|
178
|
+
const notes = []
|
|
179
|
+
if (run.relaxedReleaseAge) notes.push('本机 pnpm 的 24h 供应链闸(minimumReleaseAge)拦下了这次 lock 校验,已**仅对这一条命令**追加 --config.minimumReleaseAge=0 重试一次(有安全代价,未写进任何配置文件)。')
|
|
180
|
+
if (!run.ok && !manifests.pinned) notes.push('清单与 lock 都没改成功:见 stderr(原始输出已原样回显)。')
|
|
181
|
+
// 只有"lock 里确实没有这条 link"时才说 lock 没对齐 —— 否则就是谎报(本次 pnpm 只是重复做了个空动作)
|
|
182
|
+
if (!run.ok && manifests.pinned && !lockSynced) notes.push('清单已钉住(下次 pnpm 操作不会再 404),但 lock 没能对齐:见 stderr。')
|
|
183
|
+
return {
|
|
184
|
+
...base,
|
|
185
|
+
// 只有"清单真的是 link: **且** lock 里也有这条 link"才算成功;只成一半时 partial=true 并如实说明
|
|
186
|
+
ok: manifests.pinned && lockSynced,
|
|
187
|
+
partial: manifests.pinned && !lockSynced,
|
|
188
|
+
exitCode: run.exitCode,
|
|
189
|
+
command: `pnpm ${argv.join(' ')}`,
|
|
190
|
+
stdout: run.stdout,
|
|
191
|
+
stderr: run.stderr,
|
|
192
|
+
relaxedReleaseAge: run.relaxedReleaseAge,
|
|
193
|
+
manifest: manifests,
|
|
194
|
+
lock: { entry: entry ?? null, synced: lockSynced },
|
|
195
|
+
notes,
|
|
196
|
+
reason: manifests.pinned
|
|
197
|
+
? (lockSynced ? `${parsed.packageName} 已钉住:清单与 pnpm-lock.yaml 都指向 link:${pinned.dir}` : `${parsed.packageName} 的清单已钉住,lock 未对齐`)
|
|
198
|
+
: `${parsed.packageName} 的清单写回未生效(实际是 ${after ?? '(无该条目)'})`,
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export { ACTION_KINDS, COMMAND_KEYS, DEFAULT_REGISTRY, OUTPUT_LIMIT, isProfilePackage, isReleaseAgeBlock, parseActionRequest, runAddWithReleaseAgeRetry, runSuggestedAction }
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
// ③ 回读核实 readInstalledVersion / lockVersion;都不匹配才回落手铺文件,且**必须**带 lockNote 警告。
|
|
13
13
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
|
14
14
|
import { join } from 'node:path'
|
|
15
|
-
import { linkSpecFor, linkSpecIsIntact, materializePackageForLink, probeRegistryPackage } from './dep-source.js'
|
|
15
|
+
import { linkSpecFor, linkSpecIsIntact, materializePackageForLink, probeRegistryPackage, suggestedPinAction } from './dep-source.js'
|
|
16
16
|
import { pnpmInstall } from './install.js'
|
|
17
17
|
import { fetchJsonUrl } from '../infra/http.js'
|
|
18
18
|
import { runPnpmWithFallback } from '../infra/exec.js'
|
|
@@ -239,36 +239,46 @@ async function reconcileLockfile({ profileDir, packageName = null, packages = nu
|
|
|
239
239
|
}))
|
|
240
240
|
const sourcePinned = (v) => typeof v === 'string' && SOURCE_PINNED_RE.test(v.trim())
|
|
241
241
|
const urlPinned = (v) => typeof v === 'string' && /^https?:/u.test(v.trim())
|
|
242
|
-
/** 缺陷②指纹:manifest 声明裸版本号、lock 却把该包解析到一个 URL —— pnpm 静默改写留下的状态。
|
|
243
|
-
|
|
242
|
+
/** 缺陷②指纹:manifest 声明裸版本号、lock 却把该包解析到一个 URL —— pnpm 静默改写留下的状态。
|
|
243
|
+
* 2026-09-27 扩展(真机证据,desktop profile):**不止 URL** —— `dependencies` 写 `0.0.1-rc1`
|
|
244
|
+
* 而 lock 的 importer 把同一个包钉在 `link:…`,同样是"两处对不上"的静默遗留态(清单里那个
|
|
245
|
+
* 裸版本号在 registry 上根本不存在,lock 一重建就 ERR_PNPM_FETCH_404)。来源一律算指纹。 */
|
|
246
|
+
const misrecorded = (r) => typeof r.spec === 'string' && EXACT_VERSION_RE.test(r.spec.trim()) && sourcePinned(r.lock)
|
|
244
247
|
/** manifest 里仍是 tarball URL:虽然 lock 能解析,但 pnpm 10 重写 lock 会丢 integrity(实测),
|
|
245
248
|
* 必须**主动**规整成 link: 形式 —— 否则「删 lock / 清 node_modules / 换机」就装不回来。 */
|
|
246
249
|
const urlSpec = (r) => typeof r.spec === 'string' && /^https?:/u.test(r.spec.trim())
|
|
247
250
|
/** 来源钉住的包没有「版本号相等」可比:link 依赖要看链接是否**真的**还在(release 通道更新会把
|
|
248
251
|
* node_modules/<包名> 换成真实目录,此时必须重新物化 + 重放 link:,否则下次 pnpm 操作会还原版本);
|
|
249
|
-
* 其余来源(git+/file:/URL)只要有解析且包装着即视为对齐。
|
|
252
|
+
* 其余来源(git+/file:/URL)只要有解析且包装着即视为对齐。
|
|
253
|
+
* ★ 2026-09-27 改错:link: 依赖还必须**在 lock 里有那条 link 条目**才算对齐。旧判据只看"链接还在",
|
|
254
|
+
* 于是「清单是 link:、node_modules 是链接、但 lock 里根本没有这个依赖」也被判成对齐 → `lockUpdated`
|
|
255
|
+
* 报 true(谎报"已写进 lock"),而 lock 一重建就丢依赖。真机复现路径:先手动把 node_modules 换成指向
|
|
256
|
+
* plugin-src 的链接(或上一次对账刚建好链接),再走一次声明 —— pnpm add 根本不会被调用。 */
|
|
250
257
|
const aligned = (r) => {
|
|
251
258
|
if (r.installed === null) return false
|
|
252
|
-
if (typeof r.spec === 'string' && r.spec.startsWith('link:'))
|
|
259
|
+
if (typeof r.spec === 'string' && r.spec.startsWith('link:')) {
|
|
260
|
+
return linkSpecIsIntact(profileDir, r.name, r.spec)
|
|
261
|
+
&& typeof r.lock === 'string' && r.lock.trim().startsWith('link:')
|
|
262
|
+
}
|
|
253
263
|
return r.installed === r.lock || sourcePinned(r.lock)
|
|
254
264
|
}
|
|
255
265
|
const driftedOf = (rows) => rows.filter((r) => r.installed === null || !aligned(r) || misrecorded(r) || urlSpec(r))
|
|
256
266
|
|
|
257
267
|
const depNotes = []
|
|
258
|
-
// 物化 + link: 计划(registry
|
|
268
|
+
// 物化 + link: 计划(registry 查无此包时唯一安全的写回形式)。
|
|
269
|
+
// ★ 2026-09-27 改错:这里**只产出"打算写什么"和"为什么"**,不再抢先断言"已按 link: 形式记录依赖"——
|
|
270
|
+
// 旧版在 pnpm add 之前就把这句写进 depNote,于是"写回失败/被后一步覆盖"时面板仍然报"已钉住",
|
|
271
|
+
// 而磁盘上留着的是裸版本号(真机 desktop profile 的实际状态)。最终文案一律在写回之后**读回真实值**再生成。
|
|
259
272
|
const linkPlan = (row, why) => {
|
|
260
273
|
const dir = materializePackageForLink(profileDir, row.name, home === null ? {} : { home })
|
|
261
274
|
if (dir === null) {
|
|
262
275
|
return {
|
|
263
276
|
spec: null,
|
|
277
|
+
reason: why,
|
|
264
278
|
note: `${row.name}:${why},但 node_modules 里找不到可物化的已装副本 —— 已跳过 lock 对齐(**未改动 package.json**,避免留下指向不存在 npm 版本的裸版本号)`,
|
|
265
279
|
}
|
|
266
280
|
}
|
|
267
|
-
|
|
268
|
-
return {
|
|
269
|
-
spec: link,
|
|
270
|
-
note: `${row.name}:${why},已按 link: 形式记录依赖(${link})—— 不经 npm registry 解析、不经 tarball 完整性校验,pnpm 重建 lock 也能装上`,
|
|
271
|
-
}
|
|
281
|
+
return { spec: linkSpecFor(dir), reason: why, note: null }
|
|
272
282
|
}
|
|
273
283
|
/** 决定单个漂移包该以什么 spec 写回。spec=null 表示跳过(不碰 manifest)。 */
|
|
274
284
|
const planWriteback = async (row) => {
|
|
@@ -281,19 +291,25 @@ async function reconcileLockfile({ profileDir, packageName = null, packages = nu
|
|
|
281
291
|
// link: 规格:链接可能已被 release/curl 通道的"先删再铺"打断 → 先把新副本刷进 plugin-src 再重放
|
|
282
292
|
if (spec.trim().startsWith('link:')) {
|
|
283
293
|
const dir = materializePackageForLink(profileDir, name, home === null ? {} : { home })
|
|
284
|
-
return { spec: dir === null ? spec.trim() : linkSpecFor(dir), note: null }
|
|
294
|
+
return { spec: dir === null ? spec.trim() : linkSpecFor(dir), reason: null, note: null }
|
|
285
295
|
}
|
|
286
|
-
return { spec: spec.trim(), note: null } // git+ / file: / 别名 原样重放(幂等,来源不变)
|
|
296
|
+
return { spec: spec.trim(), reason: null, note: null } // git+ / file: / 别名 原样重放(幂等,来源不变)
|
|
297
|
+
}
|
|
298
|
+
// ①′ 清单写裸版本号、lock 却把该包钉在**来源**上(URL/link/git):pnpm 静默改写的遗留态。
|
|
299
|
+
// 按 lock 的来源重放(来源绝不降级成版本号),而不是"registry 能解析就换回版本号"——
|
|
300
|
+
// 后者等于把用户/前一步刻意钉住的来源丢掉,且 lock 重建时会指向 registry 上并不存在的版本。
|
|
301
|
+
if (misrecorded(row) && typeof row.lock === 'string' && row.lock.trim().startsWith('link:')) {
|
|
302
|
+
return linkPlan(row, `pnpm-lock.yaml 里把该包钉在 link:(${row.lock.trim()})而清单里是版本号 ${spec.trim()} —— 两处对不上,按来源重放`)
|
|
287
303
|
}
|
|
288
304
|
// ② dist-tag(latest/next…):registry 来源,但**保留标签**(钉成版本号会悄悄失去升级语义)
|
|
289
305
|
if (typeof spec === 'string' && DIST_TAG_RE.test(spec.trim())) {
|
|
290
306
|
const probeTag = await probeRegistryPackage(name, registries, { fetchJson })
|
|
291
|
-
if (probeTag.resolvable) return { spec: `${name}@${spec.trim()}`, note: null }
|
|
307
|
+
if (probeTag.resolvable) return { spec: `${name}@${spec.trim()}`, reason: null, note: null }
|
|
292
308
|
return linkPlan(row, 'npm registry 查无此包(该包只存在于 GitHub release)')
|
|
293
309
|
}
|
|
294
310
|
// ③ 版本号或没有条目 → 先探 registry:**这个版本**可解析才允许写版本号
|
|
295
311
|
const probe = await probeRegistryPackage(name, registries, { version: installed, fetchJson })
|
|
296
|
-
if (probe.resolvable && probe.hasVersion) return { spec: `${name}@${installed}`, note: null }
|
|
312
|
+
if (probe.resolvable && probe.hasVersion) return { spec: `${name}@${installed}`, reason: null, note: null }
|
|
297
313
|
// ④ registry 查无此包(或查无此版本)→ 只存在于 GitHub release → 物化 + link:
|
|
298
314
|
return linkPlan(row, probe.resolvable ? `registry 上没有 ${installed} 这个版本` : 'npm registry 查无此包(该包只存在于 GitHub release)')
|
|
299
315
|
}
|
|
@@ -302,13 +318,14 @@ async function reconcileLockfile({ profileDir, packageName = null, packages = nu
|
|
|
302
318
|
let drifted = driftedOf(rows)
|
|
303
319
|
const errors = []
|
|
304
320
|
let method = null
|
|
321
|
+
const planned = []
|
|
305
322
|
if (drifted.length > 0) {
|
|
306
323
|
// 只对"能读到实际版本"的包做对齐;读不到的(目录都没有)无法对账,留给 lockNote
|
|
307
324
|
const specs = []
|
|
308
325
|
for (const row of drifted) {
|
|
309
326
|
if (row.installed === null) continue
|
|
310
327
|
const plan = await planWriteback(row)
|
|
311
|
-
|
|
328
|
+
planned.push({ name: row.name, installed: row.installed, spec: plan.spec, reason: plan.reason ?? null, note: plan.note ?? null })
|
|
312
329
|
if (plan.spec !== null) specs.push(plan.spec)
|
|
313
330
|
}
|
|
314
331
|
if (specs.length > 0) {
|
|
@@ -322,6 +339,25 @@ async function reconcileLockfile({ profileDir, packageName = null, packages = nu
|
|
|
322
339
|
rows = snap()
|
|
323
340
|
drifted = driftedOf(rows)
|
|
324
341
|
}
|
|
342
|
+
// ★ 文案一律按**实际写入形态**生成(读回清单再描述)——"计划写 link:"与"清单里真是 link:"
|
|
343
|
+
// 是两件事:pnpm add 失败、或后续某一步又把 spec 覆盖成版本号时,绝不能报"已钉住"。
|
|
344
|
+
const suggestedActions = []
|
|
345
|
+
for (const item of planned) {
|
|
346
|
+
const actual = profileSpec(profileDir, item.name)
|
|
347
|
+
if (item.spec === null) {
|
|
348
|
+
if (item.note !== null) depNotes.push(item.note)
|
|
349
|
+
continue
|
|
350
|
+
}
|
|
351
|
+
if (actual === item.spec) {
|
|
352
|
+
if (item.spec.startsWith('link:') && item.reason !== null) {
|
|
353
|
+
depNotes.push(`${item.name}:${item.reason},已按 link: 形式记录依赖(${actual})—— 不经 npm registry 解析、不经 tarball 完整性校验,pnpm 重建 lock 也能装上`)
|
|
354
|
+
}
|
|
355
|
+
continue
|
|
356
|
+
}
|
|
357
|
+
const why = `${item.name}:${item.reason ?? '该包在 npm registry 上解析不到'};计划写入 ${item.spec},但清单里实际是 ${actual ?? '(没有这个条目)'} —— 写回**未生效**${errors.length > 0 ? `(${errors.join(';')})` : ''}`
|
|
358
|
+
depNotes.push(`${why}。请执行「钉住」动作或手动执行:${command(item.name, item.installed)}`)
|
|
359
|
+
suggestedActions.push(suggestedPinAction({ packageName: item.name, version: item.installed, profileDir, reason: why }))
|
|
360
|
+
}
|
|
325
361
|
const lockUpdated = drifted.length === 0
|
|
326
362
|
return {
|
|
327
363
|
method,
|
|
@@ -333,8 +369,11 @@ async function reconcileLockfile({ profileDir, packageName = null, packages = nu
|
|
|
333
369
|
lockNote: lockUpdated
|
|
334
370
|
? null
|
|
335
371
|
: `${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)}`,
|
|
336
|
-
// 非常规来源(link:)写回时的**用户可见**说明:绝不静默(缺陷②的隐蔽性正是"装完看不出问题"
|
|
372
|
+
// 非常规来源(link:)写回时的**用户可见**说明:绝不静默(缺陷②的隐蔽性正是"装完看不出问题")。
|
|
373
|
+
// 文案由上面的"读回真实值"步骤产出,因此永远不会出现"面板说已钉住、磁盘上仍是版本号"。
|
|
337
374
|
depNote: depNotes.length > 0 ? depNotes.join(';') : null,
|
|
375
|
+
// 结构化建议动作(2026-09-27 加法):面板可一键「执行」(服务端只认白名单动作,argv 自己拼)
|
|
376
|
+
suggestedActions,
|
|
338
377
|
command: drifted.length > 0 ? command(drifted[0].name, drifted[0].installed) : command(rows[0]?.name ?? '', rows[0]?.installed ?? null),
|
|
339
378
|
errors,
|
|
340
379
|
}
|
|
@@ -233,6 +233,17 @@ function orderedRegistries(sources) {
|
|
|
233
233
|
return [...list.filter((r) => r.primary), ...list.filter((r) => !r.primary)].map((r) => r.url)
|
|
234
234
|
}
|
|
235
235
|
|
|
236
|
+
/** 主源优先的 registry 列表(2026-09-27 加法):所有"要探测/解析 registry"的通道共用一个口径
|
|
237
|
+
* (用户可在「源管理」里改配置;读不到配置时退回镜像源,绝不返回空数组——空数组会让探测静默退化)。 */
|
|
238
|
+
function preferredRegistries() {
|
|
239
|
+
try {
|
|
240
|
+
const list = orderedRegistries(readSources())
|
|
241
|
+
return Array.isArray(list) && list.length > 0 ? list : ['https://registry.npmmirror.com']
|
|
242
|
+
} catch {
|
|
243
|
+
return ['https://registry.npmmirror.com']
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
236
247
|
function createGiteeOAuthState() {
|
|
237
248
|
const state = globalThis.crypto?.randomUUID?.() ?? `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`
|
|
238
249
|
const now = Date.now()
|
|
@@ -346,4 +357,4 @@ const GITEE_AUTH_URL = 'https://gitee.com/oauth/authorize'
|
|
|
346
357
|
|
|
347
358
|
const GITEE_TOKEN_URL = 'https://gitee.com/oauth/token'
|
|
348
359
|
const DEFAULT_SEARCH = 'dsh-plugin'
|
|
349
|
-
export { readSources, writeSources, maskSources, readSourceSecrets, writeSourceSecrets, isAllowedSourceUrl, isAllowedGitSourceUrl, readGiteeConfig, giteeStatusView, orderedRegistries, createGiteeOAuthState, consumeGiteeOAuthState, DEFAULT_SOURCES, GITEE_OAUTH_STATES, gitCloneUrls, gitCloneCandidates, GITEE_AUTH_URL, GITEE_TOKEN_URL, DEFAULT_SEARCH }
|
|
360
|
+
export { readSources, writeSources, maskSources, readSourceSecrets, writeSourceSecrets, isAllowedSourceUrl, isAllowedGitSourceUrl, readGiteeConfig, giteeStatusView, orderedRegistries, preferredRegistries, createGiteeOAuthState, consumeGiteeOAuthState, DEFAULT_SOURCES, GITEE_OAUTH_STATES, gitCloneUrls, gitCloneCandidates, GITEE_AUTH_URL, GITEE_TOKEN_URL, DEFAULT_SEARCH }
|
|
@@ -7,8 +7,10 @@ import { tmpdir } from 'node:os'
|
|
|
7
7
|
import { appendInsert } from './patch.js'
|
|
8
8
|
import { gitCloneRepo } from './repoland.js'
|
|
9
9
|
import { deriveEntryId, listEntries } from './runtime.js'
|
|
10
|
+
import { noteChannel } from './git-channel.js'
|
|
11
|
+
import { orderedRegistries, readSources } from './sources.js'
|
|
10
12
|
import { copyTree } from '../infra/fsx.js'
|
|
11
|
-
import { looksLikeGitmodules, rawTextWithFallback } from '../infra/http.js'
|
|
13
|
+
import { GITHUB_API, META_BUDGET_MS, curlJson, fetchJsonUrl, githubJson, looksLikeGitmodules, rawTextWithFallback } from '../infra/http.js'
|
|
12
14
|
import { dshHome, findPatchPath } from '../infra/paths.js'
|
|
13
15
|
|
|
14
16
|
/** 解析 .gitmodules:返回 [{name, path, url}](submodule 套装识别用)。 */
|
|
@@ -92,16 +94,140 @@ function resolveInstallKind(requestKind, probeText) {
|
|
|
92
94
|
return looksLikeGitmodules(probeText) ? 'suite' : 'plugin'
|
|
93
95
|
}
|
|
94
96
|
|
|
97
|
+
/** 仓库元数据(默认分支)探测:githubJson 与 curlJson 竞速 + META_BUDGET_MS 降级(黑洞期不卡 40s)。
|
|
98
|
+
* 0.5.19 从 install-job.js 原样搬来(**只搬移,表达式一字未改**):套装判定前要先读根包名
|
|
99
|
+
* (见 shouldRunSuiteInstall),这次探测的结果缓存在 job.repoMeta 上供下游复用,同一个作业不重复联网。
|
|
100
|
+
* 8s 而非旧值 3s:IPv6 无路由的环境里单条通道就要 5.4s,3s 预算必输 → branch 恒为 main,
|
|
101
|
+
* 默认分支为 dev 的仓库会取错分支(2026-09-20 另一位用户实测)。 */
|
|
102
|
+
async function fetchRepoMeta(repo) {
|
|
103
|
+
const meta = await Promise.race([
|
|
104
|
+
Promise.any([
|
|
105
|
+
githubJson(`${GITHUB_API}/repos/${repo}`),
|
|
106
|
+
curlJson(`${GITHUB_API}/repos/${repo}`, 12000, {}, { ipv4: true }),
|
|
107
|
+
]),
|
|
108
|
+
new Promise((resolve) => setTimeout(() => resolve(null), META_BUDGET_MS)),
|
|
109
|
+
]).catch(() => null)
|
|
110
|
+
return meta ?? null
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** 这个包名在 registry 上**确实存在**吗?只有确定性命中才算"存在":404 / 超时 / 不可达一律当"查不到"。
|
|
114
|
+
* 口径必须这么窄的理由:查不到不能推翻 .gitmodules 判据 —— 网络问题不该把套装安装变成插件安装。
|
|
115
|
+
* 用既有 npm 元数据能力(与 market.js 读 packument 同一套:配置里的软件源顺序 + fetchJsonUrl),
|
|
116
|
+
* 最多看前 2 个源、每源 6 秒封顶;测试用 probes.namePublished 替换(见 test-suite-fallback.mjs)。 */
|
|
117
|
+
async function namePublishedOnRegistry(name) {
|
|
118
|
+
const encoded = name.startsWith('@')
|
|
119
|
+
? `@${encodeURIComponent(name.slice(1).split('/')[0])}%2f${encodeURIComponent(name.split('/').slice(1).join('/'))}`
|
|
120
|
+
: encodeURIComponent(name)
|
|
121
|
+
for (const reg of orderedRegistries(readSources()).slice(0, 2)) {
|
|
122
|
+
try {
|
|
123
|
+
// eslint-disable-next-line no-await-in-loop
|
|
124
|
+
const data = await fetchJsonUrl(`${reg}/${encoded}`, 6000)
|
|
125
|
+
const hasLatest = typeof data?.['dist-tags']?.latest === 'string'
|
|
126
|
+
const hasVersions = data !== null && typeof data === 'object' && data.versions !== null && typeof data.versions === 'object' && Object.keys(data.versions).length > 0
|
|
127
|
+
if (hasLatest || hasVersions) return true
|
|
128
|
+
} catch {}
|
|
129
|
+
}
|
|
130
|
+
return false
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** 套装判定(唯一入口,0.5.19 改错):**先看根包是否已发布,再决定要不要按 .gitmodules 走套装**。
|
|
134
|
+
* 真机事故(2026-09-27,用户点装 zhu1090093659/dsh-web 全家桶):市场卡片点「添加到本地」只带 owner/repo、
|
|
135
|
+
* 不带包名 → candidates=[] → 旧流程直接探 .gitmodules(该仓库根目录确实有一份)→ 第一步就 clone 整个仓库
|
|
136
|
+
* (429 MB),而真正能装上的子包 @linxin666/dsh-web-all(5.97 MB)根本没机会被尝试。
|
|
137
|
+
* 判据顺序(每一步都只做"确认",不做"猜测"):
|
|
138
|
+
* ① 读根 package.json(下游本来也要读,结果缓存进 job 复用)→ 有 name 且 registry 上存在 → **插件通道**;
|
|
139
|
+
* ② 否则按 .gitmodules **内容**判(内容不像 gitmodules 就不是套装 —— 2026-09-19 事故的口径不变);
|
|
140
|
+
* ③ 判成套装后再问一句 registry:根包没发布、但**子包**已发布(真机 dsh-web 就是这种)→ 插件通道。
|
|
141
|
+
* 理由:仓库里有已发布的可安装单元时,按包名装才是"装得上 + 能随 lock 更新"的那条路;
|
|
142
|
+
* 子包也都没发布(子模块是纯 git 组件)→ 照旧走套装装配,能力一点没少。
|
|
143
|
+
* 返回 true = 走套装安装。副作用:job.repoMeta / job.defaultBranch / job.rootPkgProbe / job.subpackageProbe
|
|
144
|
+
* 缓存本次探测结果,下游候选循环直接复用(install-job.js 里带 `??` 的那几行)。 */
|
|
145
|
+
async function shouldRunSuiteInstall(job, probes = {}) {
|
|
146
|
+
// 同一作业只判一次:缓存下来后 install-job(判定套装分支)与套装作业入口(显式「安装套装」)
|
|
147
|
+
// 共用同一个结论,第二次调用零联网 —— 见 runSuiteInstallJob 顶部的同一道判据。
|
|
148
|
+
if (job.suiteDecision !== undefined) return job.suiteDecision
|
|
149
|
+
const decide = (value, reason) => {
|
|
150
|
+
job.suiteDecision = value
|
|
151
|
+
job.suiteDecisionReason = reason
|
|
152
|
+
return value
|
|
153
|
+
}
|
|
154
|
+
if (job.kind === 'skill') return decide(false, '技能请求不走套装通道')
|
|
155
|
+
const gmProbe = typeof probes.probeGitmodules === 'function' ? probes.probeGitmodules : probeGitmodules
|
|
156
|
+
const meta = await fetchRepoMeta(job.repo).catch(() => null)
|
|
157
|
+
job.repoMeta = meta
|
|
158
|
+
const branch = meta?.default_branch ?? 'main'
|
|
159
|
+
job.defaultBranch = branch
|
|
160
|
+
let root = null
|
|
161
|
+
if (typeof probes.fetchRepoPackageEx === 'function') {
|
|
162
|
+
// 探测异常不改变结论:回落成"按 .gitmodules 判"(旧行为),不让一次探测把安装打挂
|
|
163
|
+
try { root = await probes.fetchRepoPackageEx(job.repo, branch) } catch { root = null }
|
|
164
|
+
job.rootPkgProbe = root
|
|
165
|
+
}
|
|
166
|
+
const rootPkg = root !== null && root !== undefined && root.pkg !== null && typeof root.pkg === 'object' ? root.pkg : null
|
|
167
|
+
const name = rootPkg !== null && typeof rootPkg.name === 'string' ? rootPkg.name : ''
|
|
168
|
+
const published = async (pkgName) => (typeof probes.namePublished === 'function'
|
|
169
|
+
? (await probes.namePublished(pkgName)) === true
|
|
170
|
+
: namePublishedOnRegistry(pkgName))
|
|
171
|
+
if (name !== '' && (await published(name)) === true) {
|
|
172
|
+
job.rootPublished = name
|
|
173
|
+
noteChannel(job, `根包 ${name} 已发布到 npm:优先插件通道(不判套装、不克隆仓库)`)
|
|
174
|
+
return decide(false, `根包 ${name} 已发布到 npm`)
|
|
175
|
+
}
|
|
176
|
+
if (resolveInstallKind(job.kind, await gmProbe(job.repo)) !== 'suite') return decide(false, '仓库根目录没有有效的 .gitmodules(不是 submodule 套装仓库)')
|
|
177
|
+
if (name !== '' && typeof probes.subpackageCandidates === 'function') {
|
|
178
|
+
let subs = []
|
|
179
|
+
try { subs = await probes.subpackageCandidates(job.repo, branch) } catch { subs = [] }
|
|
180
|
+
if (Array.isArray(subs) && subs.length > 0) {
|
|
181
|
+
job.subpackageProbe = subs
|
|
182
|
+
for (const cand of subs.slice(0, 3)) {
|
|
183
|
+
// eslint-disable-next-line no-await-in-loop
|
|
184
|
+
if ((await published(cand)) === true) {
|
|
185
|
+
job.subpackagePreferred = cand
|
|
186
|
+
noteChannel(job, `根包未发布到 npm,但子包 ${cand} 已发布:优先插件通道(跳过套装克隆,避免白拉整个仓库)`)
|
|
187
|
+
return decide(false, `根包未发布到 npm,但子包 ${cand} 已发布到 npm`)
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return decide(true, '')
|
|
193
|
+
}
|
|
194
|
+
|
|
95
195
|
/** 套装安装(submodule 聚合仓库):照仓库 install.ps1/README 语义——
|
|
96
196
|
* clone 套装 → 手动镜像拉取子模块 → 按类型装配(bundle 插件含 Release tgz 兜底 / 普通插件 / 技能 / agent 预设)。
|
|
97
197
|
* 不执行第三方脚本本体(安全护栏:脚本型只读语义不运行)。 */
|
|
98
|
-
async function runSuiteInstallJob(job, ports) {
|
|
198
|
+
async function runSuiteInstallJob(job, ports, deps = {}) {
|
|
199
|
+
// 根克隆的实现可注入(测试注入缝,沿用 runInstallJob 的 deps 风格:生产调用方不传第三个参数)
|
|
200
|
+
const gitClone = typeof deps.gitClone === 'function' ? deps.gitClone : gitCloneRepo
|
|
201
|
+
// 0.5.19:套装作业入口的**同一道判据** —— 前端 hasSuite=true 的卡片点「添加到本地」时发的是
|
|
202
|
+
// kind=suite,路由会直接调到这里(routes/install.js 的 runSuiteThenFallback),根本不经过 install-job
|
|
203
|
+
// 的候选循环;真机 dsh-web 的 429 MB 就是这条路拉起来的。判定结论缓存在 job.suiteDecision 上:
|
|
204
|
+
// install-job 已经判过(并注入过探测桩)时这里零联网直接复用;路由调用时由它把真实探测传进来。
|
|
205
|
+
const decided = job.suiteDecision === undefined
|
|
206
|
+
? await shouldRunSuiteInstall(job, deps.probes ?? {})
|
|
207
|
+
: job.suiteDecision
|
|
208
|
+
if (decided === false) {
|
|
209
|
+
job.stage = 'detecting'
|
|
210
|
+
return { notASuite: true, reason: `${job.suiteDecisionReason ?? '该仓库有已发布到 npm 的可安装包'},已自动回落普通插件安装` }
|
|
211
|
+
}
|
|
99
212
|
const tmpDir = join(tmpdir(), `dsh-suite-${job.id}-${Date.now()}`)
|
|
100
213
|
const report = []
|
|
101
214
|
try {
|
|
102
215
|
job.stage = 'preparing'
|
|
103
216
|
mkdirSync(tmpDir, { recursive: true })
|
|
104
|
-
|
|
217
|
+
try {
|
|
218
|
+
await gitClone(job.repo, tmpDir, job.source)
|
|
219
|
+
} catch (cloneError) {
|
|
220
|
+
// 0.5.19(改错):套装仓库**克隆失败**不再把作业判 failed。
|
|
221
|
+
// 旧代码整个函数只有一个 try/catch → 根克隆一失败就 job.status='failed'(真机 dsh-web:429 MB 巨仓、
|
|
222
|
+
// ghproxy 的 git 协议 0 B/s,必然失败),而 notASuite(把决定权交回普通通道)**只在"克隆成功但
|
|
223
|
+
// .gitmodules 为空"时才返回** → 克隆失败等于没有任何回落,用户只看到一个失败的任务。
|
|
224
|
+
// 克隆失败只证明"这条路走不通",不证明"装不上":回落普通通道(npm → Release → git,按包名施工)。
|
|
225
|
+
job.stage = 'detecting'
|
|
226
|
+
return {
|
|
227
|
+
notASuite: true,
|
|
228
|
+
reason: `套装仓库克隆失败(${cloneError instanceof Error ? cloneError.message : String(cloneError)}),已自动回落普通插件安装`,
|
|
229
|
+
}
|
|
230
|
+
}
|
|
105
231
|
const subs = readGitmodules(tmpDir)
|
|
106
232
|
if (subs.length === 0) {
|
|
107
233
|
// 探测与仓库实际内容不符(假阳性 / 仓库已重构):**不报失败**,把决定权交回调用方
|
|
@@ -217,4 +343,4 @@ async function runSuiteInstallJob(job, ports) {
|
|
|
217
343
|
job.finishedAt = Date.now()
|
|
218
344
|
}
|
|
219
345
|
}
|
|
220
|
-
export { readGitmodules, findPresetDirs, packageEntryExists, runSuiteInstallJob, probeGitmodules, resolveInstallKind }
|
|
346
|
+
export { readGitmodules, findPresetDirs, packageEntryExists, runSuiteInstallJob, probeGitmodules, resolveInstallKind, fetchRepoMeta, shouldRunSuiteInstall }
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// L2 · routes —— 结构化建议动作(POST /plugin-console/run-suggested,2026-09-27 加法)
|
|
2
|
+
//
|
|
3
|
+
// 为什么要有它:安装结果卡片会下发 `job.suggestedAction = { kind, label, command, payload }`
|
|
4
|
+
// (见 domain/install-job.js 与 domain/dep-source.js#suggestedPinAction),用户要求能**直接点执行**,
|
|
5
|
+
// 而不是自己抄一条命令去终端跑。
|
|
6
|
+
//
|
|
7
|
+
// 安全语义(写死,配套测试 tests/test-plugin-actions.mjs 与 tests/test-route-inventory.mjs):
|
|
8
|
+
// · 请求体只认 `{ action, packageName, version, profile }`;`command`/`argv`/`shell`… 任一出现即 400,
|
|
9
|
+
// 命令行**全部**由 domain/plugin-actions.js 的白名单动作自己拼;
|
|
10
|
+
// · `profile` 只作回显 —— profileDir 一律取当前实例(profileDirOf)的目录,**绝不**按请求体挑目录;
|
|
11
|
+
// · 动作只对"本 profile 自己清单里的包"生效(服务端读盘判定,不信客户端)。
|
|
12
|
+
import { runSuggestedAction } from '../domain/plugin-actions.js'
|
|
13
|
+
import { preferredRegistries } from '../domain/sources.js'
|
|
14
|
+
import { sendError, sendJson } from '../infra/httpd.js'
|
|
15
|
+
import { profileDirOf } from '../infra/paths.js'
|
|
16
|
+
|
|
17
|
+
async function routeRunSuggested(req, res, rc) {
|
|
18
|
+
const profileDir = profileDirOf(rc.ctx)
|
|
19
|
+
if (typeof profileDir !== 'string' || profileDir === '') {
|
|
20
|
+
sendError(res, 400, '无法定位 profile 目录(读不到 cordis.yml 的 include 条目)')
|
|
21
|
+
return
|
|
22
|
+
}
|
|
23
|
+
const result = await runSuggestedAction({ body: rc.body, profileDir, registries: preferredRegistries() })
|
|
24
|
+
if (result.ok === false && result.status === 400) {
|
|
25
|
+
sendError(res, 400, result.error)
|
|
26
|
+
return
|
|
27
|
+
}
|
|
28
|
+
const { status, error, ...view } = result
|
|
29
|
+
sendJson(res, 200, { ok: result.ok === true, ...view, ...(typeof error === 'string' ? { error } : {}) })
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export { routeRunSuggested }
|
|
@@ -15,6 +15,7 @@ import { dirname } from 'node:path'
|
|
|
15
15
|
import { compareFingerprint, computeFingerprint, readCompatMode, readFingerprint, readRollbackRecord, stampFingerprint, writeCompatMode } from '../domain/compat-state.js'
|
|
16
16
|
import { currentFrameworkVersion } from '../domain/framework.js'
|
|
17
17
|
import { applyDependencyBackfill, planDependencyBackfill } from '../domain/manifest.js'
|
|
18
|
+
import { preferredRegistries } from '../domain/sources.js'
|
|
18
19
|
import { reconcileLockfile } from '../domain/selfupdate.js'
|
|
19
20
|
import { sendError, sendJson } from '../infra/httpd.js'
|
|
20
21
|
import { baseDirOf, profileDirOf, resolvePackageJson } from '../infra/paths.js'
|
|
@@ -93,16 +94,26 @@ async function routeDeclareInstalled(req, res, rc) {
|
|
|
93
94
|
const profileDir = profileDirOf(rc.ctx)
|
|
94
95
|
try {
|
|
95
96
|
if (mode === 'apply') {
|
|
96
|
-
|
|
97
|
+
// 2026-09-27:补声明同样按 registry 可解析性决定写回形态(非 registry 包写 link:),
|
|
98
|
+
// 且必须传**用户配置的** registry 列表 —— 否则探测只用默认源,自定义源/备源形同虚设。
|
|
99
|
+
const registries = preferredRegistries()
|
|
100
|
+
const result = await applyDependencyBackfill(profileDir, { registries })
|
|
97
101
|
// 关键:只写清单不写 lock → 之后任何 pnpm 操作都会因清单/lock 不一致失败(2026-09-24 CI 实测)
|
|
98
102
|
let lockNote = null
|
|
99
|
-
|
|
103
|
+
let suggestedAction = Array.isArray(result.suggestedActions) && result.suggestedActions.length > 0 ? result.suggestedActions[0] : null
|
|
104
|
+
try {
|
|
105
|
+
const lock = await reconcileLockfile({ profileDir, registries })
|
|
106
|
+
if (lock.lockNote !== null) lockNote = lock.lockNote
|
|
107
|
+
if (Array.isArray(lock.suggestedActions) && lock.suggestedActions.length > 0) suggestedAction = lock.suggestedActions[0]
|
|
108
|
+
} catch (error) { lockNote = `lock 对账失败:${error instanceof Error ? error.message : String(error)}` }
|
|
100
109
|
sendJson(res, 200, {
|
|
101
110
|
ok: true,
|
|
102
111
|
mode,
|
|
103
112
|
declared: result.declared,
|
|
104
113
|
skipped: result.skipped,
|
|
105
114
|
lockNote,
|
|
115
|
+
depNote: result.depNote ?? null,
|
|
116
|
+
suggestedAction,
|
|
106
117
|
note: result.declared.length === 0
|
|
107
118
|
? '没有需要补声明的插件'
|
|
108
119
|
: `已补声明 ${result.declared.length} 项 —— 它们现在会出现在官方/第三方插件页的「已安装」里,也不会再被 pnpm 还原`,
|
|
@@ -13,6 +13,7 @@ import { DEFAULT_SEARCH } from '../domain/sources.js'
|
|
|
13
13
|
import { runSuiteInstallJob } from '../domain/suite.js'
|
|
14
14
|
import { readBody, sendError } from '../infra/httpd.js'
|
|
15
15
|
import { routeAiConsent, routeAiEmpowerCancel, routeAiEmpowerList, routeAiEmpowerPlan, routeAiEmpowerRun, routeAiEmpowerStatus } from './ai.js'
|
|
16
|
+
import { routeRunSuggested } from './actions.js'
|
|
16
17
|
import { routeComponentAutostart, routeComponentStart, routeComponentStatus, routeComponentStop, routeComponents, routeRepoClone, routeRepoLandConfig, routeRepoList, routeRepoOpen, routeRepoRemove } from './components.js'
|
|
17
18
|
import { routeCompatMode, routeCompatStamp, routeCompatStatusGet, routeDeclareInstalled } from './compat.js'
|
|
18
19
|
import { routeFrameworkUpgrade } from './framework-upgrade.js'
|
|
@@ -132,6 +133,8 @@ const ROUTES = [
|
|
|
132
133
|
// 依赖锁体检与重建(2026-09-27 加法):check 纯只读;repair 必须由用户显式调用
|
|
133
134
|
{ methods: ['POST'], path: `${ROUTE_PREFIX}/lockfile-check`, handler: routeLockfileCheck },
|
|
134
135
|
{ methods: ['POST'], path: `${ROUTE_PREFIX}/lockfile-repair`, handler: routeLockfileRepair },
|
|
136
|
+
// 结构化建议动作(2026-09-27 加法):只认白名单动作类型,argv 由服务端自拼(绝不接受命令字符串)
|
|
137
|
+
{ methods: ['POST'], path: `${ROUTE_PREFIX}/run-suggested`, handler: routeRunSuggested },
|
|
135
138
|
|
|
136
139
|
]
|
|
137
140
|
|