@noob-stupid/dsh-plugin-console 0.5.39 → 0.5.41

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.
@@ -290,10 +290,16 @@ function suggestedPersistAction({ packageName, profile = null, profileDir = null
290
290
  * · ② 缺/形态不一致 → `reconcileLockfile`(既有对账通道,argv/写回形态同安装路径);
291
291
  * · ③ 缺 → `mode==='bundle'` 时补 `dsh.profile.bundles`;否则追加一条 insert 行(entry id 由
292
292
  * `deriveEntryId` 按既有规则产出)。`mode==='served'` 时**绝不写行**(已有行提供它,再写就是重复装配)。
293
+ *
294
+ * 2026-10-06 接线(0.5.41):`sourceSpec` / `sourceOrigin` 由**安装通道**一路传进来 ——
295
+ * 取样通道(curl / GitHub release 资产 / 源码装配)装的包,真实来源规格在这里被交给
296
+ * `declareProfileDependency({sourceSpec, sourceOrigin:'sampled'})` ⇒ `pnpm add <spec>` 真装,
297
+ * 依赖与 peer 由包管理器保证;真装失败则回落既有 `link:` 路径并**如实**报
298
+ * 「已装上(link 方式)· 真装失败:… · 可一键真装」(回落只对"物已在盘上"成立)。
293
299
  * 返回 `{ persisted, before, after, missing, declared, lock, notes, suggested, note, rowId }`;
294
300
  * 任何一步失败都**如实**留在 notes 里,绝不影响收口的返回值语义(persisted 只认最终读回结果)。
295
301
  */
296
- async function ensurePersisted({ profileDir, patchPath, packageName, mode = 'insert', taken = null, entries = null, registries = [], deps = {} } = {}) {
302
+ async function ensurePersisted({ profileDir, patchPath, packageName, mode = 'insert', taken = null, entries = null, registries = [], deps = {}, sourceSpec = null, sourceOrigin = null, sourceKind = null } = {}) {
297
303
  const pin = typeof deps.pin === 'function' ? deps.pin : pinProfileDependency
298
304
  const declare = typeof deps.declare === 'function' ? deps.declare : declareProfileDependency
299
305
  const setSpec = typeof deps.setSpec === 'function' ? deps.setSpec : setProfileDependency
@@ -322,7 +328,17 @@ async function ensurePersisted({ profileDir, patchPath, packageName, mode = 'ins
322
328
  declared = { changed: wrote.changed === true, spec: wrote.after ?? disk.spec, form: 'link', lockSynced: null, lockNote: null, depNote: null, suggested: null }
323
329
  notes.push(`清单已按磁盘真实值写成 ${wrote.after ?? disk.spec}(该链接指向用户自己的位置,未做物化复制)`)
324
330
  } else if (disk.form === 'version') {
325
- declared = await declare(profileDir, packageName, disk.version, { registries })
331
+ // 来源型(安装通道知道真实来源规格):把规格一并交给写入层 —— 由它决定"真装 / link:"
332
+ // (0.5.41 接线;没传规格时形状与默认值一个字都不变)。
333
+ // `deps.installSource` / `deps.syncLock` 是**离线测试缝**(与 deps.pin / deps.declare 同一风格):
334
+ // 正控/负控要能确定性地让"包管理器那一步"成功或失败、并让 lock 那一步不跑真 pnpm,
335
+ // 生产调用方不传时用的就是真实 `pnpm add` 与真实 lock 对账。
336
+ declared = await declare(profileDir, packageName, disk.version, {
337
+ registries,
338
+ ...(typeof sourceSpec === 'string' && sourceSpec !== '' ? { sourceSpec, sourceOrigin, sourceKind } : {}),
339
+ ...(typeof deps.installSource === 'function' ? { installSource: deps.installSource } : {}),
340
+ ...(deps.syncLock === false ? { syncLock: false } : {}),
341
+ })
326
342
  if (declared.changed !== true) notes.push(`清单里已有这个包(实际是 ${declared.spec ?? disk.version})`)
327
343
  if (declared.depNote !== null && declared.depNote !== undefined) notes.push(declared.depNote)
328
344
  } else {
@@ -336,7 +352,15 @@ async function ensurePersisted({ profileDir, patchPath, packageName, mode = 'ins
336
352
  const mid = persistReport({ profileDir, patchPath, packageName, entries })
337
353
  if (mid.parts.lock.ok !== true) {
338
354
  try {
339
- lock = await reconcile({ profileDir, packages: [{ name: packageName }], registries })
355
+ // 0.5.41:本次带来的是 **tarball URL** 来源规格(刚真装过)⇒ 不许对账把它规整回 link:
356
+ // (判据与 manifest.js#declareProfileDependency 的同一套:只有这一段来源型接线会点名 keepUrlSpecs)。
357
+ const keepUrlSpecs = (typeof sourceSpec === 'string' && /^https?:/u.test(sourceSpec.trim()))
358
+ || (declared !== null && declared !== undefined && declared.form === 'source' && /^https?:/u.test(String(declared.spec ?? '')))
359
+ ? [packageName] : []
360
+ lock = await reconcile({
361
+ profileDir, packages: [{ name: packageName }], registries,
362
+ ...(keepUrlSpecs.length > 0 ? { keepUrlSpecs } : {}),
363
+ })
340
364
  if (lock.lockNote !== null && lock.lockNote !== undefined) notes.push(lock.lockNote)
341
365
  else if (lock.depNote !== null && lock.depNote !== undefined) notes.push(lock.depNote)
342
366
  } catch (error) {
@@ -377,7 +401,15 @@ async function ensurePersisted({ profileDir, patchPath, packageName, mode = 'ins
377
401
  after.note = after.persisted ? null : persistNote(after)
378
402
  lockVerdict = { verified: lock.lockVerified, reason }
379
403
  }
380
- const suggested = after.persisted ? null : suggestedPersistAction({ packageName, profileDir, missing: after.missing, reason: after.note })
404
+ // 0.5.41 接线:真装失败时写入层会下发「一键真装」动作 —— 它优先于"钉住"(此时用户要的是真装那一步);
405
+ // 只有三处都没就位时仍按老规矩给「一键钉住」(收口更根本)。老客户端忽略该字段,向后兼容。
406
+ const declaredAction = declared !== null && declared !== undefined && declared.suggested !== null && declared.suggested !== undefined
407
+ ? declared.suggested
408
+ : null
409
+ const suggested = after.persisted
410
+ ? declaredAction
411
+ : suggestedPersistAction({ packageName, profileDir, missing: after.missing, reason: after.note })
412
+ const sourceInstall = declared !== null && declared !== undefined && declared.sourceInstall !== undefined ? declared.sourceInstall : null
381
413
  return {
382
414
  persisted: after.persisted,
383
415
  before,
@@ -392,6 +424,7 @@ async function ensurePersisted({ profileDir, patchPath, packageName, mode = 'ins
392
424
  notes,
393
425
  suggested,
394
426
  note: after.note,
427
+ sourceInstall,
395
428
  }
396
429
  }
397
430
 
@@ -37,18 +37,21 @@
37
37
  import { existsSync, readFileSync } from 'node:fs'
38
38
  import { join } from 'node:path'
39
39
  import { applyAllowBuilds, pendingApprovedBuilds, readWorkspaceApprovals } from './allow-builds.js'
40
- import { NPM_NAME_RE, suggestedAllowBuildsAction } from './dep-source.js'
40
+ import { NPM_NAME_RE, sameSourceSpec, suggestedAllowBuildsAction } from './dep-source.js'
41
41
  import { classifyInstallFailure, ignoredBuildsFrom } from './install-diagnose.js'
42
42
  import { runLockfileRepair } from './lockfile-health.js'
43
43
  import { pinProfileDependency, specOf } from './manifest.js'
44
44
  import { ensurePersisted } from './persist.js'
45
45
  import { lockVersion } from './selfupdate.js'
46
46
  import { overwritePreset } from './preset-source.js'
47
+ import { isRealInstallSpec, readSourceSpec, recordSourceSpec } from './source-spec.js'
47
48
  import { buildPnpmEnv, pnpmAddArgs, runPnpmWithFallback, unknownPnpmOption } from '../infra/exec.js'
48
49
 
49
50
  const DEFAULT_REGISTRY = 'https://registry.npmmirror.com'
50
- /** 动作白名单(客户端只能给这些 kind;执行分支与 argv 全在服务端)。 */
51
- const ACTION_KINDS = ['pin-dependency', 'reconcile-lock', 'allow-builds', 'overwrite-preset', 'persist-plugin']
51
+ /** 动作白名单(客户端只能给这些 kind;执行分支与 argv 全在服务端)。
52
+ * 0.5.41 登记(加法,不改既有动作):`real-install` —— 来源型安装的"出路":真装失败(回落 link:)后
53
+ * 一键把**记录里的真实来源规格**再交给包管理器(`pnpm add <spec>`)。规格只从服务端记录读。 */
54
+ const ACTION_KINDS = ['pin-dependency', 'reconcile-lock', 'allow-builds', 'overwrite-preset', 'persist-plugin', 'real-install']
52
55
  /** 一旦出现这些键就整条拒绝:本接口不存在"传命令"的位置。 */
53
56
  const COMMAND_KEYS = ['command', 'cmd', 'argv', 'args', 'exec', 'execute', 'shell', 'script', 'run', 'spawn', 'bin', 'shellCommand']
54
57
  const OUTPUT_LIMIT = 4000
@@ -73,7 +76,7 @@ function parseActionRequest(body) {
73
76
  return { ok: false, status: 400, error: `未知动作 ${action}(只允许 ${ACTION_KINDS.join(' / ')})` }
74
77
  }
75
78
  const packageName = typeof raw.packageName === 'string' ? raw.packageName.trim() : ''
76
- if (action === 'pin-dependency' || action === 'persist-plugin') {
79
+ if (action === 'pin-dependency' || action === 'persist-plugin' || action === 'real-install') {
77
80
  if (packageName === '') return { ok: false, status: 400, error: `${action} 需要 packageName` }
78
81
  if (packageName.length > 214 || !NPM_NAME_RE.test(packageName)) return { ok: false, status: 400, error: 'packageName 不是合法的 npm 包名' }
79
82
  }
@@ -307,6 +310,66 @@ async function runSuggestedAction({ body, profileDir, registries = [], deps = {}
307
310
  lock: { entry: null, synced: view?.action === 'repaired' || view?.action === 'noop' },
308
311
  }
309
312
  }
313
+ if (parsed.action === 'real-install') {
314
+ // ★ 0.5.41 加法(来源型"一键真装"):把**服务端记录里的真实来源规格**再交给包管理器装一次
315
+ // (`pnpm add <spec>`)—— 依赖与 peer 由 pnpm 保证,与官方 `dsh plugin add` 同一条通道。
316
+ // 为什么规格必须从记录读:动作接口只接受 `{ action, packageName, profile }`,客户端**没有位置**
317
+ // 传 spec 字符串(否则等于开了"任意来源/任意命令"的口子);记录由安装时写入(domain/source-spec.js)。
318
+ if (!isProfilePackage(profileDir, parsed.packageName)) {
319
+ return { ...base, ok: false, status: 400, error: `${parsed.packageName} 不在本 profile 的清单(dependencies / dsh.profile.bundles)里,拒绝改动(先安装)` }
320
+ }
321
+ const record = readSourceSpec({ packageName: parsed.packageName, home: deps.home ?? null })
322
+ if (record === null || isRealInstallSpec(record.spec) !== true) {
323
+ return {
324
+ ...base, ok: false, status: 400,
325
+ error: `没有 ${parsed.packageName} 的来源规格记录(或记录的规格不能真装):本动作只重放**这个实例装过的来源型包**的真实规格(tarball URL / git 族 / file:),registry 版本号与 link: 不在其列`,
326
+ }
327
+ }
328
+ const before = await specOf(profileDir, parsed.packageName)
329
+ const run = await runAddWithReleaseAgeRetry({ profileDir, spec: record.spec, registry, runAdd, timeoutMs: ACTION_ADD_TIMEOUT_MS })
330
+ const after = await specOf(profileDir, parsed.packageName)
331
+ const entry = lockVersion(profileDir, parsed.packageName)
332
+ const failure = run.ok === true ? null : classifyInstallFailure(`${run.stderr}\n${String(run.error?.message ?? '')}`)
333
+ const ignored = failure?.kind === 'ignored-builds'
334
+ const ignoredBuilds = ignored === true ? ignoredBuildsFrom(run.stderr) : []
335
+ // 真装结果记回记录(面板/报告据此看到"最近一次真装成没成")——写失败只影响记录,绝不影响本次结论。
336
+ recordSourceSpec({
337
+ packageName: parsed.packageName,
338
+ spec: record.spec,
339
+ kind: record.kind ?? null,
340
+ install: { ok: run.ok === true, error: run.ok === true ? null : String(run.error?.message ?? run.stderr ?? '').slice(0, 600) },
341
+ home: deps.home ?? null,
342
+ })
343
+ // 与 pin 分支同一口径(A-② 语义):清单里的 spec 已经是那条真实来源规格 ⇒ 目标状态已达成;
344
+ // ERR_PNPM_IGNORED_BUILDS 这类报错一个脚本都不执行、也不影响加载,不算"没装上"。
345
+ const reached = sameSourceSpec(profileDir, after, record.spec)
346
+ const ok = run.ok === true || (ignored === true && reached)
347
+ const notes = []
348
+ if (run.relaxedReleaseAge) notes.push('本机 pnpm 的 24h 供应链闸(minimumReleaseAge)拦下了这次真装,已**仅对这一条命令**追加 --config.minimumReleaseAge=0 重试一次(有安全代价,未写进任何配置文件)。')
349
+ if (ignored === true) notes.push(IGNORED_BUILDS_REACHED_NOTE)
350
+ if (run.ok !== true && reached) notes.push(`pnpm 那一步没跑完(exitCode=${run.exitCode}),但清单里的 spec 已经是这条真实来源规格(${after})—— 目标状态已达成,原始输出见 stderr。`)
351
+ return {
352
+ ...base,
353
+ kind: ignored === true ? 'ignored-builds' : 'real-install',
354
+ ignoredBuilds,
355
+ nextAction: ignored === true ? suggestedAllowBuildsAction({ packages: ignoredBuilds, profile: parsed.profile, reason: 'pnpm 因构建脚本未获批准而报错' }) : null,
356
+ ok,
357
+ partial: ok !== true && reached,
358
+ exitCode: run.exitCode,
359
+ command: `pnpm ${pnpmAddArgs(record.spec, registry).join(' ')}`,
360
+ stdout: run.stdout,
361
+ stderr: run.stderr,
362
+ relaxedReleaseAge: run.relaxedReleaseAge,
363
+ spec: record.spec,
364
+ manifest: { before, after, changed: before !== after, planned: record.spec, pinned: reached },
365
+ lock: { entry: entry ?? null, synced: typeof entry === 'string' && entry !== '' },
366
+ notes,
367
+ failureKind: failure?.kind ?? null,
368
+ reason: ok === true
369
+ ? `${parsed.packageName} 已按真实来源规格真装:${record.spec}(依赖与 peer 由包管理器保证)`
370
+ : `真装没跑成(exitCode=${run.exitCode}):${record.spec} —— 清单当前是 ${after ?? '(无该条目)'},原始输出见 stderr`,
371
+ }
372
+ }
310
373
  // pin-dependency
311
374
  if (!isProfilePackage(profileDir, parsed.packageName)) {
312
375
  return { ...base, ok: false, status: 400, error: `${parsed.packageName} 不在本 profile 的清单(dependencies / dsh.profile.bundles)里,拒绝改动` }
@@ -331,7 +331,7 @@ async function settleImporterEntries({ profileDir, items = [], deps = {}, regist
331
331
  * 查无此包(404)→ 物化到 `<DSH_HOME>/plugin-src/<包名>` 并写 `link:<绝对路径>`,同时回 depNote;
332
332
  * 连物化都做不到 → **跳过对齐且不碰 package.json**,只记 note。
333
333
  */
334
- async function reconcileLockfile({ profileDir, packageName = null, packages = null, registries = [], pnpmAdd = pnpmInstall, execOpts = {}, fetchJson = fetchJsonUrl, home = null, deps = {} } = {}) {
334
+ async function reconcileLockfile({ profileDir, packageName = null, packages = null, registries = [], pnpmAdd = pnpmInstall, execOpts = {}, fetchJson = fetchJsonUrl, home = null, deps = {}, keepUrlSpecs = [] } = {}) {
335
335
  const targets = Array.isArray(packages)
336
336
  ? packages.filter((p) => p && typeof p.name === 'string' && p.name !== '')
337
337
  : (packageName === null ? [] : [{ name: packageName, version: null }])
@@ -375,7 +375,14 @@ async function reconcileLockfile({ profileDir, packageName = null, packages = nu
375
375
  }
376
376
  return r.installed === r.lock || sourcePinned(r.lock)
377
377
  }
378
- const driftedOf = (rows) => rows.filter((r) => r.installed === null || !aligned(r) || misrecorded(r) || urlSpec(r))
378
+ // 0.5.41 加法:**刚按真实来源规格真装过**的包(tarball URL)**不算漂移** —— 那个 URL 是用户可见的
379
+ // 来源事实,lock 里的解析记录也正是 pnpm 自己在那次 `pnpm add <url>` 里写下的(带 integrity)。
380
+ // 这条口子只对调用方**点名**的包生效(调用方 = 刚刚真装它的那条路,见 manifest.js / persist.js),
381
+ // 其余任何 URL 形态照旧走下面的"URL → link:"规整(那条保护是给"pnpm 静默改写/来源不确定"的历史状态)。
382
+ const keepUrls = new Set((Array.isArray(keepUrlSpecs) ? keepUrlSpecs : []).filter((n) => typeof n === 'string' && n !== ''))
383
+ const driftedOf = (rows) => rows
384
+ .filter((r) => !(keepUrls.has(r.name) && typeof r.spec === 'string' && /^https?:/u.test(r.spec.trim())))
385
+ .filter((r) => r.installed === null || !aligned(r) || misrecorded(r) || urlSpec(r))
379
386
 
380
387
  const depNotes = []
381
388
  // 物化 + link: 计划(registry 查无此包时唯一安全的写回形式)。
@@ -0,0 +1,212 @@
1
+ // L1 · domain —— source-spec.js(**来源型安装的"真实来源规格"记录** + 一键真装动作;2026-10-06 接线)
2
+ //
3
+ // 为什么需要它(本轮的接线缺口):0.5.40 已经把能力做进了写入层 —— `declareProfileDependency({sourceSpec})`
4
+ // 会把**真实来源规格**写回清单,并由 `installSourceDependency` 走 `pnpm add <spec>` 真装(依赖与 peer
5
+ // 交给包管理器,与官方 `dsh plugin add` 同一条通道)。但**生产路径上没有一个调用方传过 sourceSpec**:
6
+ // curl / GitHub release 这些"取样通道"把包铺进 `node_modules` 之后,收口那一步只知道"磁盘上有个真实目录",
7
+ // 于是按老的 registry 404 判据写成 `link:`(复制到 plugin-src + 链接)——依赖还是没人管。
8
+ // 本轮把"这个包是从哪个来源装来的"从通道一路传到收口,并在两处留下可核对的东西:
9
+ // ① **记录**(本模块):`<DSH_HOME>/plugin-console/source-specs/<包名>.json` —— 记下真实来源规格与
10
+ // 最近一次真装结果。用途是让「一键真装」动作能**服务端自己**取到规格:动作接口只接受
11
+ // `{ action, packageName, profile }`,客户端**没有位置**传 spec 字符串(否则等于开了任意命令的口子)。
12
+ // ② **文案与动作**(本模块是唯一承担者):真装成功 / 真装失败的两种如实说法 + 结构化「一键真装」动作。
13
+ //
14
+ // 失败路径的语义(写死在这里,别处不许再解释一遍 —— 用户点名要求):
15
+ // A) 取样/下载**成功**、包管理器安装**失败** ⇒ 磁盘上确实有那份物化副本 ⇒ 允许回落既有 `link:`
16
+ // 路径(插件至少能加载)⇒ 文案必须含「已装上(link 方式)」+「真装失败:<原因>」+「可一键真装」。
17
+ // B) 取样/下载**本身失败** ⇒ 磁盘上什么都没有 ⇒ **没有任何东西可挂** ⇒ 只能如实失败
18
+ // (原因 + 重试);**严禁**出现"回落成功 / 已安装 / 已启用"之类说法。
19
+ // C) 本地目录:不涉及下载 ⇒ `link:` 是它的正常路径(+ 既有自足补齐)。
20
+ // 判据一句话:**回落只对"物已在盘上"成立;物不在盘上就只有如实失败**。
21
+ //
22
+ // 分层:L1 domain —— 不认识 cordis ctx;home 可注入(隔离测试用私有 DSH_HOME),一切失败都不抛。
23
+
24
+ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'
25
+ import { dirname, join } from 'node:path'
26
+ import { dshHome } from '../infra/paths.js'
27
+ import { canonicalSourceSpec, profileNameOf } from './dep-source.js'
28
+
29
+ /** 记录目录(与 link-self-sufficiency 记录同族,都在 `<DSH_HOME>/plugin-console/` 下)。 */
30
+ const SOURCE_SPEC_DIR = 'source-specs'
31
+
32
+ /** `link:` = 显式开发式安装(本地目录的正常路径),**不**交给 pnpm 真装(见 C)。 */
33
+ const LINK_SPEC_RE = /^link:/u
34
+
35
+ /** 可以交给 `pnpm add <spec>` 的**来源型**规格(tarball URL / git 族 / file: / 别名协议)。
36
+ * 刻意**不含**版本号、版本范围、dist-tag:那些是 registry 路径,写回形态由既有判据决定(不倒退)。 */
37
+ const REAL_INSTALLABLE_RE = /^(?:https?:|git\+|git:|github:|gitlab:|bitbucket:|file:|workspace:|portal:|npm:|jsr:)/u
38
+
39
+ /** 记录目录 / 记录文件(一个包一份,便于一键真装与卸载清理精确定位)。 */
40
+ function sourceSpecRecordDir(home = null) {
41
+ return join(typeof home === 'string' && home !== '' ? home : dshHome(), 'plugin-console', SOURCE_SPEC_DIR)
42
+ }
43
+ function recordFileNameOf(packageName) {
44
+ return `${String(packageName).replace(/[^A-Za-z0-9._-]+/gu, '_')}.json`
45
+ }
46
+ function sourceSpecRecordPath(home, packageName) {
47
+ return join(sourceSpecRecordDir(home), recordFileNameOf(packageName))
48
+ }
49
+
50
+ /** 读 JSON(读不到/坏文件都返回 null,绝不抛)。 */
51
+ function readJsonSafe(file) {
52
+ try {
53
+ return JSON.parse(readFileSync(file, 'utf8'))
54
+ } catch {
55
+ return null
56
+ }
57
+ }
58
+
59
+ /** 这个规格该不该交给包管理器**真装**(`link:` 不算:那是显式开发式安装)。 */
60
+ function isRealInstallSpec(spec) {
61
+ const canonical = canonicalSourceSpec(spec)
62
+ if (canonical === null || canonical === '') return false
63
+ if (LINK_SPEC_RE.test(canonical)) return false
64
+ return REAL_INSTALLABLE_RE.test(canonical)
65
+ }
66
+
67
+ /** 这个规格是不是 `link:`(显式开发式安装 / 本地目录的正常路径)。 */
68
+ function isLinkSourceSpec(spec) {
69
+ const canonical = canonicalSourceSpec(spec)
70
+ return canonical !== null && LINK_SPEC_RE.test(canonical)
71
+ }
72
+
73
+ /**
74
+ * 安装通道带回的「来源」(`{ spec, origin, kind }`)→ `ensurePersisted` 的入参。
75
+ * 空/没有规格 ⇒ **空对象**(调用方的形状与默认值与接线前逐字一致);有规格 ⇒ 三个字段一次给全,
76
+ * 收口才好判"这份物是取样通道铺的(sampled)还是包管理器装的(pnpm)"。
77
+ */
78
+ function sourcePersistOptions(source) {
79
+ if (source === null || source === undefined || typeof source !== 'object') return {}
80
+ const spec = typeof source.spec === 'string' && source.spec !== '' ? source.spec : null
81
+ if (spec === null) return {}
82
+ return { sourceSpec: spec, sourceOrigin: source.origin ?? null, sourceKind: source.kind ?? null }
83
+ }
84
+
85
+ /** 读某包的来源规格记录(没有/坏文件返回 null)。 */
86
+ function readSourceSpec({ packageName, home = null } = {}) {
87
+ if (typeof packageName !== 'string' || packageName === '') return null
88
+ const record = readJsonSafe(sourceSpecRecordPath(home, packageName))
89
+ if (record === null || typeof record !== 'object') return null
90
+ if (typeof record.spec !== 'string' || record.spec === '') return null
91
+ return record
92
+ }
93
+
94
+ /** 列出全部记录(体检/报告用)。 */
95
+ function listSourceSpecs({ home = null } = {}) {
96
+ const dir = sourceSpecRecordDir(home)
97
+ try {
98
+ return readdirSync(dir).filter((f) => f.endsWith('.json')).map((f) => ({ file: join(dir, f), record: readJsonSafe(join(dir, f)) }))
99
+ } catch {
100
+ return []
101
+ }
102
+ }
103
+
104
+ /**
105
+ * 记下某包的真实来源规格(安装时写;**幂等**:规格与最近一次真装结论都没变 ⇒ 一个字节都不写)。
106
+ * `install` 传 `installSourceDependency` 的返回值(`{ ok, spec, error }`)时,最近一次结论一并记下。
107
+ * 返回 `{ ok, changed, file, record }`;任何失败都只如实返回,**绝不抛**(安装主路径不许被它带崩)。
108
+ */
109
+ function recordSourceSpec({ packageName, spec, kind = null, install = null, home = null } = {}) {
110
+ const empty = { ok: false, changed: false, file: null, record: null }
111
+ if (typeof packageName !== 'string' || packageName === '') return empty
112
+ const canonical = canonicalSourceSpec(spec)
113
+ if (canonical === null || canonical === '') return empty
114
+ const file = sourceSpecRecordPath(home, packageName)
115
+ const installSummary = install === null || install === undefined
116
+ ? null
117
+ : { ok: install.ok === true, error: typeof install.error === 'string' && install.error !== '' ? install.error.slice(0, 600) : null }
118
+ try {
119
+ const previous = readJsonSafe(file)
120
+ const sameSpec = typeof previous?.spec === 'string' && canonicalSourceSpec(previous.spec) === canonical
121
+ const sameInstall = installSummary === null
122
+ ? true
123
+ : previous?.lastInstall?.ok === installSummary.ok && (previous?.lastInstall?.error ?? null) === installSummary.error
124
+ if (sameSpec && sameInstall) return { ok: true, changed: false, file, record: previous }
125
+ const record = {
126
+ packageName,
127
+ spec: canonical,
128
+ kind: typeof kind === 'string' && kind !== '' ? kind : (previous?.kind ?? null),
129
+ recordedAt: new Date().toISOString(),
130
+ ...(installSummary === null ? {} : { lastInstall: { ...installSummary, at: new Date().toISOString() } }),
131
+ }
132
+ mkdirSync(dirname(file), { recursive: true })
133
+ writeFileSync(file, `${JSON.stringify(record, null, 2)}\n`, 'utf8')
134
+ return { ok: true, changed: true, file, record }
135
+ } catch (error) {
136
+ return { ...empty, file, reason: String(error?.message ?? error).slice(0, 300) }
137
+ }
138
+ }
139
+
140
+ /**
141
+ * 忘掉某包的来源规格记录(**卸载即撤销**,与 `undeclareProfileDependency` 同一时机调用)。
142
+ * 删不掉时**退而求其次清空内容**(Windows 上 `<DSH_HOME>/plugin-console/` 下删除会静默落空,
143
+ * 与 junction / `*_tmp_*` 同族)—— 绝不留下指向已卸载包的陈旧规格。
144
+ */
145
+ function forgetSourceSpec({ packageName, home = null } = {}) {
146
+ if (typeof packageName !== 'string' || packageName === '') return { removed: false, emptied: false, file: null }
147
+ const file = sourceSpecRecordPath(home, packageName)
148
+ if (!existsSync(file)) return { removed: false, emptied: false, file }
149
+ try { rmSync(file, { force: true }) } catch {}
150
+ if (!existsSync(file)) return { removed: true, emptied: false, file }
151
+ let emptied = false
152
+ try {
153
+ writeFileSync(file, `${JSON.stringify({ packageName, forgotten: true, spec: null, note: '记录已作废;文件本身删除未生效,已清空以免留下陈旧规格' }, null, 2)}\n`, 'utf8')
154
+ emptied = readJsonSafe(file)?.spec === null
155
+ } catch { emptied = false }
156
+ return { removed: false, emptied, file }
157
+ }
158
+
159
+ /**
160
+ * 结构化「一键真装」动作(与 `suggestedPinAction` 同一套安全语义):
161
+ * `command` 只供展示/复制;执行侧只认 `payload.action`,**规格由服务端从记录里读**,
162
+ * payload 里没有 spec / 命令字符串的位置。
163
+ */
164
+ function suggestedRealInstallAction({ packageName, spec = null, profile = null, profileDir = null, reason = null } = {}) {
165
+ const name = profile ?? profileNameOf(profileDir)
166
+ const shown = canonicalSourceSpec(spec) ?? '<来源规格>'
167
+ return {
168
+ kind: 'real-install',
169
+ label: '一键真装(交给包管理器装依赖)',
170
+ command: `pnpm add ${shown} --registry https://registry.npmmirror.com`,
171
+ payload: { action: 'real-install', packageName, profile: name },
172
+ reason,
173
+ }
174
+ }
175
+
176
+ /**
177
+ * 真装结果 → 如实说法(**唯一承担者**)。三种形态各自把该说的话说完,绝不覆盖、绝不美化:
178
+ * · 成功:说清"按真实来源规格真装、依赖与 peer 由包管理器保证";
179
+ * · 失败 + 已回落 `link:`:说清「已装上(link 方式)· 真装失败:<原因> · 可一键真装」;
180
+ * · 失败 + 盘上什么都没有:说清"无可挂载(未改动清单)"——**不出现**任何"已装上"字样。
181
+ */
182
+ function composeSourceInstallNote({ packageName, install, plan = null } = {}) {
183
+ if (install === null || install === undefined || typeof install.ok !== 'boolean') return null
184
+ const spec = typeof install.spec === 'string' && install.spec !== '' ? install.spec : null
185
+ if (install.ok === true) {
186
+ return `${packageName}:已按真实来源规格真装(pnpm add ${spec ?? '(规格缺失)'})—— 依赖与 peer 由包管理器保证(与官方 dsh plugin add 同一条通道)`
187
+ }
188
+ const error = typeof install.error === 'string' && install.error !== '' ? install.error : '(原因未记录)'
189
+ if (plan?.form === 'link' && typeof plan.spec === 'string' && plan.spec !== '') {
190
+ return `${packageName}:已装上(link 方式)· 真装失败:${error} · 已按 link: 记录(${plan.spec}),缺失依赖由 link 自足性尽力补齐 · 可一键真装`
191
+ }
192
+ if (plan !== null && typeof plan?.spec === 'string' && plan.spec !== '') {
193
+ return `${packageName}:真装失败:${error};已按现有形态记录(${plan.spec})· 可一键真装`
194
+ }
195
+ return `${packageName}:真装失败:${error};磁盘上没有任何副本 —— 无可挂载(未改动清单)`
196
+ }
197
+
198
+ export {
199
+ SOURCE_SPEC_DIR,
200
+ REAL_INSTALLABLE_RE,
201
+ sourceSpecRecordDir,
202
+ sourceSpecRecordPath,
203
+ isRealInstallSpec,
204
+ isLinkSourceSpec,
205
+ sourcePersistOptions,
206
+ readSourceSpec,
207
+ listSourceSpecs,
208
+ recordSourceSpec,
209
+ forgetSourceSpec,
210
+ suggestedRealInstallAction,
211
+ composeSourceInstallNote,
212
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noob-stupid/dsh-plugin-console",
3
- "version": "0.5.39",
3
+ "version": "0.5.41",
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",