@hyzyn/dsh-safe 0.13.0 → 0.14.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/README.en.md +2 -1
- package/README.md +2 -1
- package/lib/cli.js +2 -1
- package/lib/i18n.js +16 -4
- package/lib/repair.js +338 -174
- package/package.json +1 -1
package/README.en.md
CHANGED
|
@@ -48,7 +48,8 @@ Error: dsh: plugin tree failed to load: failed to apply loader entry smoke-broke
|
|
|
48
48
|
| `dsh-safe doctor` | environment check: versions, DSH_HOME, profiles, ledger, patch health |
|
|
49
49
|
| `dsh-safe restore [--profile <name>] (--id <id> \| --all) [--dry-run]` | re-enable auto-disabled plugins (omit `--profile` to cover every profile in the ledger) |
|
|
50
50
|
| `dsh-safe explain [id] [--profile <name> \| --file <path>]` | interpret failure info with AI: an `id` interprets that quarantine record (with repair advice); defaults to the last failure log, falling back to the ledger; `--file`/stdin read any log (needs `DSH_SAFE_AI_KEY`) |
|
|
51
|
-
| `dsh-safe
|
|
51
|
+
| `dsh-safe -r [dsh args…]` | repair all repairable quarantined plugins first, then boot in wrap mode (`--repair` alias; mirrors `-u`) |
|
|
52
|
+
| `dsh-safe repair [id] [--all] [--profile <name>] [--to <version>] [-y] [--dry-run]` | reinstall/upgrade a quarantined plugin and auto-restore it (failures a reinstall/upgrade may fix: package resolution, export mismatches; installs via `dsh plugin`'s pnpm channel); duplicate mounts support auto-dedup (interactively pick which source to keep) |
|
|
52
53
|
| `dsh-safe help` (`-h` / `--help`) | show help |
|
|
53
54
|
| `dsh-safe --version` (`-V`) | show version |
|
|
54
55
|
|
package/README.md
CHANGED
|
@@ -48,7 +48,8 @@ Error: dsh: plugin tree failed to load: failed to apply loader entry smoke-broke
|
|
|
48
48
|
| `dsh-safe doctor` | 环境体检:版本、DSH_HOME、profiles、台账、各 patch 健康度 |
|
|
49
49
|
| `dsh-safe restore [--profile <名>] (--id <id> \| --all) [--dry-run]` | 恢复被自动禁用的插件(省略 `--profile` 时遍历台账全部 profile) |
|
|
50
50
|
| `dsh-safe explain [id] [--profile <名> \| --file <路径>]` | 用 AI 解读失败信息:指定 `id` 解读该条隔离记录(给 repair 建议);默认解读最近一次失败日志,无日志则解读隔离台账;`--file`/stdin 读任意日志(需 `DSH_SAFE_AI_KEY`) |
|
|
51
|
-
| `dsh-safe
|
|
51
|
+
| `dsh-safe -r [dsh 参数…]` | 先修复全部可修复的隔离记录,再按包装模式启动(`--repair` 等价;与 `-u` 对称) |
|
|
52
|
+
| `dsh-safe repair [id] [--all] [--profile <名>] [--to <版本>] [-y] [--dry-run]` | 重装/升级被隔离的插件并自动恢复(限重装/升级可能修复的失败:包解析失败、导出版本不匹配等;经 `dsh plugin` 的 pnpm 通道安装);重复挂载类支持自动去重(交互选择保留哪个来源,从 bundles 移除冗余) |
|
|
52
53
|
| `dsh-safe help`(`-h` / `--help`) | 显示帮助 |
|
|
53
54
|
| `dsh-safe --version`(`-V`) | 显示版本 |
|
|
54
55
|
|
package/lib/cli.js
CHANGED
|
@@ -11,7 +11,7 @@ import { createRequire } from 'node:module'
|
|
|
11
11
|
import { loadLedger, restoreQuarantine } from './quarantine.js'
|
|
12
12
|
import { runWrapped } from './wrap.js'
|
|
13
13
|
import { cmdUpdate, cmdUpdateAndBoot, maybeNotifySelfUpdate } from './update.js'
|
|
14
|
-
import { cmdRepair } from './repair.js'
|
|
14
|
+
import { cmdRepair, cmdRepairAndBoot } from './repair.js'
|
|
15
15
|
import { printDoctor } from './doctor.js'
|
|
16
16
|
import { cmdExplain } from './ai.js'
|
|
17
17
|
import { t } from './i18n.js'
|
|
@@ -142,6 +142,7 @@ export async function main(argv) {
|
|
|
142
142
|
return 0
|
|
143
143
|
}
|
|
144
144
|
if (cmd === '-u' || cmd === '--update') return cmdUpdateAndBoot(argv.slice(1), { boot: runWrapperMode })
|
|
145
|
+
if (cmd === '-r' || cmd === '--repair') return cmdRepairAndBoot(argv.slice(1), { boot: runWrapperMode })
|
|
145
146
|
|
|
146
147
|
// 包装模式:剥掉 dsh-safe 自己的旗标(必须出现在第一个位置参数之前),
|
|
147
148
|
// 其余原样转发给 dsh。
|
package/lib/i18n.js
CHANGED
|
@@ -25,8 +25,9 @@ const ZH = {
|
|
|
25
25
|
dsh-safe update [-y] [--to <版本>] [--self] [--no-restore] [--no-verify] [--pm npm|pnpm]
|
|
26
26
|
升级 dsh 与 dsh-safe 自身,并自动恢复被隔离的插件
|
|
27
27
|
dsh-safe explain [id] [--profile <名> | --file <路径>] 用 AI 解读:最近失败日志 / 隔离台账 / 任意日志
|
|
28
|
-
dsh-safe repair [id] [--profile <名>] [--to <版本>] [-y] [--dry-run]
|
|
29
|
-
|
|
28
|
+
dsh-safe repair [id] [--all] [--profile <名>] [--to <版本>] [-y] [--dry-run]
|
|
29
|
+
重装/升级被隔离的插件并自动恢复(--all 批量;限可自动修复类;省略 id 时只有一条记录则直接修)
|
|
30
|
+
dsh-safe -r [dsh 参数…] 先修复全部可修复的隔离记录,再启动(--repair 等价)
|
|
30
31
|
dsh-safe help 显示本帮助
|
|
31
32
|
dsh-safe --version 显示版本
|
|
32
33
|
|
|
@@ -142,6 +143,11 @@ const ZH = {
|
|
|
142
143
|
repairDedupePlan: '[dsh-safe] 修复方案:保留 {keep} 的挂载,从 bundles 移除 {remove},并摘除 {id} 的禁用行',
|
|
143
144
|
repairDedupeNoManifest: '[dsh-safe] profile 清单中没有可编辑的 bundles 数组,请手动处理。',
|
|
144
145
|
repairDedupeDone: '[dsh-safe] 去重完成:已从 bundles 移除 {removed};{keep} 的挂载保留、禁用行已摘除。跑 dsh-safe <启动命令> 验证。',
|
|
146
|
+
repairAllConflict: '[dsh-safe] --all 与 <id> 不能同时使用。',
|
|
147
|
+
repairUnknownArg: '[dsh-safe] repair 无法识别的参数: {arg}',
|
|
148
|
+
repairBatchPlan: '[dsh-safe] 将修复 {count} 条隔离记录: {ids}',
|
|
149
|
+
repairBatchSkip: '[dsh-safe] 跳过 {count} 条(不支持自动修复): {ids}',
|
|
150
|
+
repairBatchDone: '[dsh-safe] 批量修复完成:成功 {repaired} 条,失败 {failed} 条。',
|
|
145
151
|
repairInstallFailed: '[dsh-safe] 插件安装失败(退出码 {code}),隔离状态保持不变。',
|
|
146
152
|
repairDone: '[dsh-safe] 修复完成:跑 dsh-safe <启动命令> 验证;若仍失败会被自动重新隔离。',
|
|
147
153
|
}
|
|
@@ -166,8 +172,9 @@ Usage:
|
|
|
166
172
|
dsh-safe update [-y] [--to <ver>] [--self] [--no-restore] [--no-verify] [--pm npm|pnpm]
|
|
167
173
|
upgrade dsh and dsh-safe itself, auto-restore quarantined plugins
|
|
168
174
|
dsh-safe explain [id] [--profile <name> | --file <path>] interpret with AI: last failure log / ledger / any log
|
|
169
|
-
dsh-safe repair [id] [--profile <name>] [--to <ver>] [-y] [--dry-run]
|
|
170
|
-
reinstall/upgrade
|
|
175
|
+
dsh-safe repair [id] [--all] [--profile <name>] [--to <ver>] [-y] [--dry-run]
|
|
176
|
+
reinstall/upgrade quarantined plugins and auto-restore (--all for batch; repairable failures only; omit id when there is exactly one record)
|
|
177
|
+
dsh-safe -r [dsh args…] repair all repairable quarantined plugins, then boot (--repair alias)
|
|
171
178
|
dsh-safe help show this help
|
|
172
179
|
dsh-safe --version show version
|
|
173
180
|
|
|
@@ -286,6 +293,11 @@ Notes:
|
|
|
286
293
|
repairDedupePlan: '[dsh-safe] fix plan: keep {keep} mounted, remove {remove} from bundles, and drop the disabled row for {id}',
|
|
287
294
|
repairDedupeNoManifest: '[dsh-safe] the profile manifest has no editable bundles array; handle it manually.',
|
|
288
295
|
repairDedupeDone: '[dsh-safe] dedupe finished: removed {removed} from bundles; {keep} stays mounted and the disabled row was dropped. Run dsh-safe <boot command> to verify.',
|
|
296
|
+
repairAllConflict: '[dsh-safe] --all and <id> are mutually exclusive.',
|
|
297
|
+
repairUnknownArg: '[dsh-safe] unrecognized argument for repair: {arg}',
|
|
298
|
+
repairBatchPlan: '[dsh-safe] repairing {count} quarantine record(s): {ids}',
|
|
299
|
+
repairBatchSkip: '[dsh-safe] skipping {count} (not auto-repairable): {ids}',
|
|
300
|
+
repairBatchDone: '[dsh-safe] batch repair finished: {repaired} succeeded, {failed} failed.',
|
|
289
301
|
repairInstallFailed: '[dsh-safe] plugin install failed (exit code {code}); quarantine state left unchanged.',
|
|
290
302
|
repairDone: '[dsh-safe] repair finished: run dsh-safe <boot command> to verify; a still-broken plugin will be auto-quarantined again.',
|
|
291
303
|
}
|
package/lib/repair.js
CHANGED
|
@@ -1,14 +1,25 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @hyzyn/dsh-safe — `dsh-safe repair
|
|
2
|
+
* @hyzyn/dsh-safe — `dsh-safe repair` 与 `dsh-safe -r`:修复被隔离的插件。
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* repair 可修复的失败特征:问题出在"包的安装状态或版本适配"上,重装/升级
|
|
5
|
+
* 插件包可能修复——
|
|
6
|
+
* - 模块解析失败:包缺失/损坏/未安装 → 重装即愈;
|
|
7
|
+
* - ESM 导出不匹配(does not provide an [export|import binding]):插件与
|
|
8
|
+
* 当前 dsh API 版本不兼容,升级插件到适配版本可能修复(真实案例:
|
|
9
|
+
* describe-image 隔离于 dsh-settings 导出变更)。修复失败不恢复、无损。
|
|
10
|
+
* 注意:台账 reason 经 summarizeLine 截断过(160 字符),关键短语可能被
|
|
11
|
+
* "…" 切断,因此导出不匹配的特征只匹配到 "does not provide an" 为止。
|
|
7
12
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
13
|
+
* duplicate(同一 id 被多个 bundle 挂载)是配置层问题,重装无意义——但
|
|
14
|
+
* 支持自动去重:从 profile 清单的 bundles 移除一个来源(交互选择保留哪个,
|
|
15
|
+
* -y 保留先声明者),并摘除无效的禁用行。include 子文件里的重复不在
|
|
16
|
+
* bundle 层对照表内,自动去重帮不上,给手动指引。
|
|
17
|
+
*
|
|
18
|
+
* 安装路径修复完成后会复查该 id 的挂载来源:修复的插件若同时被聚合包等
|
|
19
|
+
* 其它 bundle 挂载,恢复后下次启动会 duplicate——自动去重防复发。
|
|
20
|
+
*
|
|
21
|
+
* `--all` 批量修复台账里全部可修复记录;`-r/--repair` = 批量修复 + 启动
|
|
22
|
+
* (与 -u 对称:位置参数全部是 dsh 启动参数)。
|
|
12
23
|
*/
|
|
13
24
|
import { spawnSync } from 'node:child_process'
|
|
14
25
|
import { createInterface } from 'node:readline/promises'
|
|
@@ -22,25 +33,190 @@ import { t } from './i18n.js'
|
|
|
22
33
|
const err = (line) => process.stderr.write(`${line}\n`)
|
|
23
34
|
const out = (line) => process.stdout.write(`${line}\n`)
|
|
24
35
|
|
|
25
|
-
/**
|
|
26
|
-
* repair 可修复的失败特征:问题出在"包的安装状态或版本适配"上,重装/升级
|
|
27
|
-
* 插件包可能修复——
|
|
28
|
-
* - 模块解析失败:包缺失/损坏/未安装 → 重装即愈;
|
|
29
|
-
* - ESM 导出不匹配(does not provide an [export|import binding]):插件与
|
|
30
|
-
* 当前 dsh API 版本不兼容,升级插件到适配版本可能修复(真实案例:
|
|
31
|
-
* describe-image 隔离于 dsh-settings 导出变更)。修复失败不恢复、无损。
|
|
32
|
-
* 注意:台账 reason 经 summarizeLine 截断过(160 字符),关键短语可能被
|
|
33
|
-
* "…" 切断,因此导出不匹配的特征只匹配到 "does not provide an" 为止。
|
|
34
|
-
*/
|
|
35
36
|
const REPAIRABLE_RE =
|
|
36
37
|
/Cannot find package|could not be resolved|ERR_MODULE_NOT_FOUND|does not provide an( (?:export|import binding))?/i
|
|
38
|
+
const DUPLICATE_RE = /duplicate loader entry id/i
|
|
39
|
+
|
|
40
|
+
const isRepairable = (reason) => REPAIRABLE_RE.test(reason ?? '') || DUPLICATE_RE.test(reason ?? '')
|
|
41
|
+
|
|
42
|
+
const readJsonIfExists = (path) => {
|
|
43
|
+
const raw = readIfExists(path)
|
|
44
|
+
if (raw === undefined) return undefined
|
|
45
|
+
try {
|
|
46
|
+
return JSON.parse(raw)
|
|
47
|
+
} catch {
|
|
48
|
+
return undefined
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** 该 id 的 bundle 挂载来源(profile/home 层是 override,不算挂载来源)。 */
|
|
53
|
+
function bundleMountSources(known, id) {
|
|
54
|
+
return [
|
|
55
|
+
...new Set(
|
|
56
|
+
known.rows.filter((r) => r.id === id && r.source !== 'profile' && r.source !== 'home').map((r) => r.source),
|
|
57
|
+
),
|
|
58
|
+
]
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** 从 profile 清单的 bundles 数组移除指定来源;没有变化返回 false。 */
|
|
62
|
+
function removeFromManifestBundles(profile, removeNames) {
|
|
63
|
+
const manifestPath = profileManifestPath(profile)
|
|
64
|
+
const manifest = readJsonIfExists(manifestPath)
|
|
65
|
+
const current = manifest?.dsh?.profile?.bundles
|
|
66
|
+
if (!Array.isArray(current)) return false
|
|
67
|
+
const next = current.filter((b) => !removeNames.includes(b))
|
|
68
|
+
if (next.length === current.length) return false
|
|
69
|
+
manifest.dsh.profile.bundles = next
|
|
70
|
+
writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`)
|
|
71
|
+
return true
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function askConfirm(question) {
|
|
75
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr })
|
|
76
|
+
return rl.question(question).then(
|
|
77
|
+
(answer) => {
|
|
78
|
+
rl.close()
|
|
79
|
+
const a = answer.trim().toLowerCase()
|
|
80
|
+
return a === 'y' || a === 'yes'
|
|
81
|
+
},
|
|
82
|
+
() => {
|
|
83
|
+
rl.close()
|
|
84
|
+
return false
|
|
85
|
+
},
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* 对单条隔离记录执行修复动作。安装确认由调用方完成(单条路径确认、批量
|
|
91
|
+
* 路径整体确认后以 yes: true 调用)。
|
|
92
|
+
* @returns {Promise<{ code: number, outcome: 'repaired'|'deduped'|'skipped'|'failed' }>}
|
|
93
|
+
*/
|
|
94
|
+
async function repairEntry({ profile, entry, to = undefined, yes = false, tty = true, dryRun = false, spawn = spawnSync, log = err, write = out }) {
|
|
95
|
+
const id = entry.id
|
|
96
|
+
|
|
97
|
+
// duplicate:自动去重(多 bundle 来源),来源不足给手动指引
|
|
98
|
+
if (DUPLICATE_RE.test(entry.reason ?? '')) {
|
|
99
|
+
const sources = bundleMountSources(collectKnownRows(profile), id)
|
|
100
|
+
if (sources.length < 2) {
|
|
101
|
+
log(t('repairDuplicate'))
|
|
102
|
+
return { code: 1, outcome: 'skipped' }
|
|
103
|
+
}
|
|
104
|
+
const manifest = readJsonIfExists(profileManifestPath(profile))
|
|
105
|
+
const declared = manifest?.dsh?.profile?.bundles
|
|
106
|
+
const ordered = Array.isArray(declared)
|
|
107
|
+
? [...sources].sort(
|
|
108
|
+
(a, b) =>
|
|
109
|
+
(declared.indexOf(a) < 0 ? 999 : declared.indexOf(a)) - (declared.indexOf(b) < 0 ? 999 : declared.indexOf(b)),
|
|
110
|
+
)
|
|
111
|
+
: sources
|
|
112
|
+
let keep = ordered[0]
|
|
113
|
+
if (!yes && !dryRun) {
|
|
114
|
+
if (!tty) {
|
|
115
|
+
log(t('updateNonInteractive'))
|
|
116
|
+
return { code: 1, outcome: 'failed' }
|
|
117
|
+
}
|
|
118
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr })
|
|
119
|
+
log(t('repairDedupePick', { id }))
|
|
120
|
+
ordered.forEach((b, i) => log(`${i + 1}. ${b}${i === 0 ? t('repairDedupeDefault') : ''}`))
|
|
121
|
+
const answer = await rl.question(t('repairDedupeChoose'))
|
|
122
|
+
rl.close()
|
|
123
|
+
const pick = Math.min(Math.max(parseInt(answer, 10) || 1, 1), ordered.length)
|
|
124
|
+
keep = ordered[pick - 1]
|
|
125
|
+
}
|
|
126
|
+
const remove = ordered.filter((b) => b !== keep)
|
|
127
|
+
log(t('repairDedupePlan', { keep, remove: remove.join(', '), id }))
|
|
128
|
+
if (dryRun) {
|
|
129
|
+
log(t('dryRunNotice'))
|
|
130
|
+
return { code: 0, outcome: 'deduped' }
|
|
131
|
+
}
|
|
132
|
+
if (!removeFromManifestBundles(profile, remove)) {
|
|
133
|
+
log(t('repairDedupeNoManifest'))
|
|
134
|
+
return { code: 1, outcome: 'failed' }
|
|
135
|
+
}
|
|
136
|
+
const { restored } = restoreQuarantine(profile, [id], false)
|
|
137
|
+
if (restored.length) {
|
|
138
|
+
const e = restored[0]
|
|
139
|
+
write(`[dsh-safe] ${t('restored')} ${e.name ?? e.id} (id: ${e.id})`)
|
|
140
|
+
}
|
|
141
|
+
log(t('repairDedupeDone', { removed: remove.join(', '), keep }))
|
|
142
|
+
log(t('repairDone'))
|
|
143
|
+
return { code: 0, outcome: 'deduped' }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// 其它非包类失败:拒接并给针对性指引
|
|
147
|
+
if (!REPAIRABLE_RE.test(entry.reason ?? '')) {
|
|
148
|
+
log(t('repairUnsupported', { reason: summarizeLine(entry.reason) }))
|
|
149
|
+
return { code: 1, outcome: 'skipped' }
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// 安装 + 恢复
|
|
153
|
+
const spec = `${entry.name ?? entry.id}@${to ?? 'latest'}`
|
|
154
|
+
const spawnTarget = resolveDshSpawnTarget('dsh')
|
|
155
|
+
const commandDesc = `dsh plugin --profile ${profile} add ${spec}`
|
|
156
|
+
log(t('repairPlan', { profile, name: entry.name ?? entry.id, command: commandDesc }))
|
|
157
|
+
if (dryRun) {
|
|
158
|
+
log(t('dryRunNotice'))
|
|
159
|
+
return { code: 0, outcome: 'repaired' }
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const { status } = spawn(spawnTarget.file, [...spawnTarget.prefix, 'plugin', '--profile', profile, 'add', spec], {
|
|
163
|
+
stdio: 'inherit',
|
|
164
|
+
shell: spawnTarget.shell,
|
|
165
|
+
env: process.env,
|
|
166
|
+
})
|
|
167
|
+
if (status !== 0) {
|
|
168
|
+
log(t('repairInstallFailed', { code: status ?? '?' }))
|
|
169
|
+
return { code: status ?? 1, outcome: 'failed' }
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const { restored } = restoreQuarantine(profile, [id], false)
|
|
173
|
+
if (!restored.length) {
|
|
174
|
+
log(t('noMatching'))
|
|
175
|
+
} else {
|
|
176
|
+
const e = restored[0]
|
|
177
|
+
write(`[dsh-safe] ${t('restored')} ${e.name ?? e.id} (id: ${e.id})`)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// 防复发:修复的插件若同时被多个 bundle 挂载(如聚合包已内置同一插件),
|
|
181
|
+
// 恢复后下次启动会 duplicate——自动去重(交互选择保留来源,-y 保留先声明者)
|
|
182
|
+
const knownAfter = collectKnownRows(profile)
|
|
183
|
+
const sources = bundleMountSources(knownAfter, id)
|
|
184
|
+
if (sources.length >= 2) {
|
|
185
|
+
const manifest = readJsonIfExists(profileManifestPath(profile))
|
|
186
|
+
const declared = manifest?.dsh?.profile?.bundles
|
|
187
|
+
const ordered = Array.isArray(declared)
|
|
188
|
+
? [...sources].sort(
|
|
189
|
+
(a, b) =>
|
|
190
|
+
(declared.indexOf(a) < 0 ? 999 : declared.indexOf(a)) - (declared.indexOf(b) < 0 ? 999 : declared.indexOf(b)),
|
|
191
|
+
)
|
|
192
|
+
: sources
|
|
193
|
+
let keep = ordered[0]
|
|
194
|
+
if (!yes) {
|
|
195
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr })
|
|
196
|
+
log(t('repairDedupePick', { id }))
|
|
197
|
+
ordered.forEach((b, i) => log(`${i + 1}. ${b}${i === 0 ? t('repairDedupeDefault') : ''}`))
|
|
198
|
+
const answer = await rl.question(t('repairDedupeChoose'))
|
|
199
|
+
rl.close()
|
|
200
|
+
const pick = Math.min(Math.max(parseInt(answer, 10) || 1, 1), ordered.length)
|
|
201
|
+
keep = ordered[pick - 1]
|
|
202
|
+
}
|
|
203
|
+
const removed = ordered.filter((b) => b !== keep)
|
|
204
|
+
if (removeFromManifestBundles(profile, removed)) {
|
|
205
|
+
log(t('repairDedupeDone', { removed: removed.join(', '), keep }))
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
log(t('repairDone'))
|
|
210
|
+
return { code: 0, outcome: 'repaired' }
|
|
211
|
+
}
|
|
37
212
|
|
|
38
213
|
function parseRepairArgs(args) {
|
|
39
|
-
const opts = { id: undefined, profile: undefined, to: undefined, yes: false, dryRun: false }
|
|
214
|
+
const opts = { id: undefined, all: false, profile: undefined, to: undefined, yes: false, dryRun: false }
|
|
40
215
|
for (let i = 0; i < args.length; i++) {
|
|
41
216
|
const a = args[i]
|
|
42
217
|
if (a === '-y' || a === '--yes') opts.yes = true
|
|
43
218
|
else if (a === '--dry-run') opts.dryRun = true
|
|
219
|
+
else if (a === '--all') opts.all = true
|
|
44
220
|
else if (a === '--profile') {
|
|
45
221
|
const v = args[++i]
|
|
46
222
|
if (v === undefined) return { error: { key: 'updateUnknownFlag', params: { arg: '--profile' } } }
|
|
@@ -52,19 +228,74 @@ function parseRepairArgs(args) {
|
|
|
52
228
|
opts.to = v
|
|
53
229
|
} else if (a.startsWith('--to=')) opts.to = a.slice('--to='.length)
|
|
54
230
|
else if (!a.startsWith('-') && opts.id === undefined) opts.id = a
|
|
55
|
-
else return { error: { key: '
|
|
231
|
+
else return { error: { key: 'repairUnknownArg', params: { arg: a } } }
|
|
56
232
|
}
|
|
233
|
+
if (opts.all && opts.id !== undefined) return { error: { key: 'repairAllConflict' } }
|
|
57
234
|
if (opts.to !== undefined && !/^[\w.+-]+$/.test(opts.to)) return { error: { key: 'updateToInvalid' } }
|
|
58
235
|
return { opts }
|
|
59
236
|
}
|
|
60
237
|
|
|
238
|
+
/** 台账定位:按 id / --all / 菜单三种形态收集修复目标。 */
|
|
239
|
+
function collectTargets(opts, ledger) {
|
|
240
|
+
const matches = []
|
|
241
|
+
for (const [profile, entries] of Object.entries(ledger.profiles)) {
|
|
242
|
+
if (opts.profile && profile !== opts.profile) continue
|
|
243
|
+
for (const entry of entries ?? []) {
|
|
244
|
+
if (opts.all || opts.id === undefined || entry.id === opts.id) matches.push({ profile, entry })
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return matches
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** 批量修复:一次确认,逐条执行,汇总结果。返回失败的条数。 */
|
|
251
|
+
async function repairBatch(targets, { to = undefined, yes = false, dryRun = false, spawn = spawnSync, log = err, write = out }) {
|
|
252
|
+
const repairable = targets.filter((t) => isRepairable(t.entry.reason))
|
|
253
|
+
const unsupported = targets.filter((t) => !repairable.includes(t))
|
|
254
|
+
log(
|
|
255
|
+
t('repairBatchPlan', {
|
|
256
|
+
count: repairable.length,
|
|
257
|
+
ids: repairable.map((t) => t.entry.id).join(', ') || '-',
|
|
258
|
+
}),
|
|
259
|
+
)
|
|
260
|
+
if (unsupported.length) {
|
|
261
|
+
log(t('repairBatchSkip', { count: unsupported.length, ids: unsupported.map((t) => t.entry.id).join(', ') }))
|
|
262
|
+
}
|
|
263
|
+
if (dryRun) {
|
|
264
|
+
for (const t of repairable) {
|
|
265
|
+
await repairEntry({ profile: t.profile, entry: t.entry, to: to, yes: true, tty: true, dryRun: true, spawn, log, write })
|
|
266
|
+
}
|
|
267
|
+
log(t('dryRunNotice'))
|
|
268
|
+
return { failed: 0 }
|
|
269
|
+
}
|
|
270
|
+
if (!yes) {
|
|
271
|
+
if (!process.stdin.isTTY) {
|
|
272
|
+
log(t('updateNonInteractive'))
|
|
273
|
+
return { failed: targets.length }
|
|
274
|
+
}
|
|
275
|
+
const ok = await askConfirm(t('updateConfirm'))
|
|
276
|
+
if (!ok) {
|
|
277
|
+
log(t('updateAborted'))
|
|
278
|
+
return { failed: 0 }
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
let repaired = 0
|
|
282
|
+
let failed = 0
|
|
283
|
+
for (const t of repairable) {
|
|
284
|
+
const { code } = await repairEntry({ profile: t.profile, entry: t.entry, to: to, yes: true, tty: true, spawn, log, write })
|
|
285
|
+
if (code === 0) repaired++
|
|
286
|
+
else failed++
|
|
287
|
+
}
|
|
288
|
+
log(t('repairBatchDone', { repaired, failed }))
|
|
289
|
+
return { failed }
|
|
290
|
+
}
|
|
291
|
+
|
|
61
292
|
/**
|
|
62
293
|
* @param {string[]} args
|
|
63
294
|
* @param {{
|
|
64
295
|
* spawn?: typeof spawnSync,
|
|
65
296
|
* log?: (line: string) => void,
|
|
66
297
|
* write?: (line: string) => void,
|
|
67
|
-
* }} [hooks]
|
|
298
|
+
* }} [hooks]
|
|
68
299
|
* @returns {Promise<number>} 退出码
|
|
69
300
|
*/
|
|
70
301
|
export async function cmdRepair(args, { spawn = spawnSync, log = err, write = out } = {}) {
|
|
@@ -78,17 +309,20 @@ export async function cmdRepair(args, { spawn = spawnSync, log = err, write = ou
|
|
|
78
309
|
return 2
|
|
79
310
|
}
|
|
80
311
|
|
|
81
|
-
// 台账定位:省略 --profile 时跨 profile 搜索;省略 id 时——
|
|
82
|
-
// 恰好一条隔离记录 → 直接修它(最常见情况);
|
|
83
|
-
// 多条 → 列出清单让用户挑(标记哪些可自动修复)。
|
|
84
312
|
const ledger = loadLedger()
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
313
|
+
const matches = collectTargets(opts, ledger)
|
|
314
|
+
|
|
315
|
+
// --all:批量修复(一次确认,默认策略)
|
|
316
|
+
if (opts.all) {
|
|
317
|
+
if (!matches.length) {
|
|
318
|
+
log(t('noRecords'))
|
|
319
|
+
return 0
|
|
90
320
|
}
|
|
321
|
+
const { failed } = await repairBatch(matches, { to: opts.to, yes: opts.yes, dryRun: opts.dryRun, spawn, log, write })
|
|
322
|
+
return failed ? 1 : 0
|
|
91
323
|
}
|
|
324
|
+
|
|
325
|
+
// 无 id:单条直接修;多条列菜单让用户挑
|
|
92
326
|
if (opts.id === undefined) {
|
|
93
327
|
if (!matches.length) {
|
|
94
328
|
log(t('noRecords'))
|
|
@@ -97,13 +331,39 @@ export async function cmdRepair(args, { spawn = spawnSync, log = err, write = ou
|
|
|
97
331
|
if (matches.length > 1) {
|
|
98
332
|
log(t('repairPickOne', { count: matches.length }))
|
|
99
333
|
for (const m of matches) {
|
|
100
|
-
const repairable =
|
|
334
|
+
const repairable = isRepairable(m.entry.reason)
|
|
101
335
|
log(` - ${m.entry.id}(${m.entry.name ?? '-'})${repairable ? '' : t('repairNotSupportedTag')}`)
|
|
102
336
|
}
|
|
103
337
|
return 0
|
|
104
338
|
}
|
|
105
339
|
opts.id = matches[0].entry.id
|
|
106
340
|
}
|
|
341
|
+
|
|
342
|
+
if (!matches.length) {
|
|
343
|
+
// duplicate 类失败不会产生台账条目(0.12.1 起的设计)——台账查无记录时,
|
|
344
|
+
// 按挂载来源直接支持去重:该 id 被 ≥2 个 bundle 挂载即视为重复挂载目标。
|
|
345
|
+
const candidates = opts.profile
|
|
346
|
+
? [opts.profile]
|
|
347
|
+
: (() => {
|
|
348
|
+
try {
|
|
349
|
+
return readdirSync(join(dshHome(), 'profiles'), { withFileTypes: true })
|
|
350
|
+
.filter((e) => e.isDirectory())
|
|
351
|
+
.map((e) => e.name)
|
|
352
|
+
} catch {
|
|
353
|
+
return []
|
|
354
|
+
}
|
|
355
|
+
})()
|
|
356
|
+
for (const p of candidates) {
|
|
357
|
+
if (bundleMountSources(collectKnownRows(p), opts.id).length >= 2) {
|
|
358
|
+
const rows = collectKnownRows(p).rows.filter((r) => r.id === opts.id)
|
|
359
|
+
matches.push({
|
|
360
|
+
profile: p,
|
|
361
|
+
entry: { id: opts.id, name: rows[0]?.name ?? null, reason: 'duplicate loader entry id' },
|
|
362
|
+
})
|
|
363
|
+
break
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
107
367
|
if (!matches.length) {
|
|
108
368
|
log(t('repairEntryMissing', { id: opts.id }))
|
|
109
369
|
return 1
|
|
@@ -113,169 +373,73 @@ export async function cmdRepair(args, { spawn = spawnSync, log = err, write = ou
|
|
|
113
373
|
return 2
|
|
114
374
|
}
|
|
115
375
|
const { profile, entry } = matches[0]
|
|
116
|
-
const name = entry.name ?? entry.id
|
|
117
|
-
|
|
118
|
-
// 类别门禁:重复挂载是配置层问题(同一 id 多处挂载),重装无意义——
|
|
119
|
-
// 但可以自动去重:从 profile 的 bundles 移除一个挂载来源
|
|
120
|
-
if (/duplicate loader entry id/i.test(entry.reason ?? '')) {
|
|
121
|
-
return dedupeForDuplicate({ id: opts.id, profile, opts, spawn, log, write })
|
|
122
|
-
}
|
|
123
|
-
// 只修"包的安装状态/版本适配"类
|
|
124
|
-
if (!REPAIRABLE_RE.test(entry.reason ?? '')) {
|
|
125
|
-
log(t('repairUnsupported', { reason: summarizeLine(entry.reason) }))
|
|
126
|
-
return 1
|
|
127
|
-
}
|
|
128
376
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
const spawnTarget = resolveDshSpawnTarget('dsh')
|
|
132
|
-
const commandDesc = `dsh plugin --profile ${profile} add ${spec}`
|
|
133
|
-
log(t('repairPlan', { profile, name, command: commandDesc }))
|
|
134
|
-
if (opts.dryRun) {
|
|
135
|
-
log(t('dryRunNotice'))
|
|
136
|
-
return 0
|
|
137
|
-
}
|
|
138
|
-
if (!opts.yes) {
|
|
377
|
+
// 单条路径:安装前确认(dry-run 免确认)
|
|
378
|
+
if (!opts.dryRun && !opts.yes) {
|
|
139
379
|
if (!process.stdin.isTTY) {
|
|
140
380
|
log(t('updateNonInteractive'))
|
|
141
381
|
return 1
|
|
142
382
|
}
|
|
143
|
-
|
|
144
|
-
if (!ok) {
|
|
383
|
+
if (!(await askConfirm(t('updateConfirm')))) {
|
|
145
384
|
log(t('updateAborted'))
|
|
146
385
|
return 0
|
|
147
386
|
}
|
|
148
387
|
}
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
stdio: 'inherit',
|
|
152
|
-
shell: spawnTarget.shell,
|
|
153
|
-
env: process.env,
|
|
154
|
-
})
|
|
155
|
-
if (status !== 0) {
|
|
156
|
-
log(t('repairInstallFailed', { code: status ?? '?' }))
|
|
157
|
-
return status ?? 1
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
const { restored } = restoreQuarantine(profile, [opts.id], false)
|
|
161
|
-
if (!restored.length) {
|
|
162
|
-
log(t('noMatching'))
|
|
163
|
-
} else {
|
|
164
|
-
const e = restored[0]
|
|
165
|
-
write(`[dsh-safe] ${t('restored')} ${e.name ?? e.id} (id: ${e.id})`)
|
|
166
|
-
}
|
|
167
|
-
log(t('repairDone'))
|
|
168
|
-
return 0
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
function askConfirm(question) {
|
|
172
|
-
const rl = createInterface({ input: process.stdin, output: process.stderr })
|
|
173
|
-
return rl.question(question).then(
|
|
174
|
-
(answer) => {
|
|
175
|
-
rl.close()
|
|
176
|
-
const a = answer.trim().toLowerCase()
|
|
177
|
-
return a === 'y' || a === 'yes'
|
|
178
|
-
},
|
|
179
|
-
() => {
|
|
180
|
-
rl.close()
|
|
181
|
-
return false
|
|
182
|
-
},
|
|
183
|
-
)
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
const readJsonIfExists = (path) => {
|
|
187
|
-
const raw = readIfExists(path)
|
|
188
|
-
if (raw === undefined) return undefined
|
|
189
|
-
try {
|
|
190
|
-
return JSON.parse(raw)
|
|
191
|
-
} catch {
|
|
192
|
-
return undefined
|
|
193
|
-
}
|
|
388
|
+
const { code } = await repairEntry({ profile, entry, to: opts.to, yes: opts.yes || !opts.dryRun ? true : false, tty: true, dryRun: opts.dryRun, spawn, log, write })
|
|
389
|
+
return code
|
|
194
390
|
}
|
|
195
391
|
|
|
196
392
|
/**
|
|
197
|
-
*
|
|
198
|
-
*
|
|
199
|
-
*
|
|
200
|
-
*
|
|
393
|
+
* `-r/--repair [dsh 参数…]`:批量修复全部可修复的隔离记录,再按包装模式
|
|
394
|
+
* 启动(与 -u 对称:位置参数全部是 dsh 启动参数)。
|
|
395
|
+
* @param {string[]} args
|
|
396
|
+
* @param {{
|
|
397
|
+
* spawn?: typeof spawnSync,
|
|
398
|
+
* boot?: (args: string[]) => Promise<number>,
|
|
399
|
+
* log?: (line: string) => void,
|
|
400
|
+
* write?: (line: string) => void,
|
|
401
|
+
* isTTY?: boolean,
|
|
402
|
+
* readStdin?: never,
|
|
403
|
+
* }} [hooks]
|
|
404
|
+
* @returns {Promise<number>} 退出码
|
|
201
405
|
*/
|
|
202
|
-
async function
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
const sources = new Set()
|
|
206
|
-
for (const row of known.rows) {
|
|
207
|
-
if (row.id !== id) continue
|
|
208
|
-
if (row.source === 'profile' || row.source === 'home') continue
|
|
209
|
-
sources.add(row.source)
|
|
210
|
-
}
|
|
211
|
-
const sourceList = [...sources]
|
|
212
|
-
if (sourceList.length < 2) {
|
|
213
|
-
// 重复不在 bundle 层(可能在 include 文件或用户层),自动去重帮不上,给指引
|
|
214
|
-
log(t('repairDuplicate'))
|
|
215
|
-
return 1
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
const manifestPath = profileManifestPath(profile)
|
|
219
|
-
const manifest = readJsonIfExists(manifestPath)
|
|
220
|
-
const bundles = manifest?.dsh?.profile?.bundles
|
|
221
|
-
if (!Array.isArray(bundles)) {
|
|
222
|
-
log(t('repairDedupeNoManifest'))
|
|
223
|
-
return 1
|
|
224
|
-
}
|
|
225
|
-
// 按 bundles 数组声明顺序排序:先声明的是既有来源,缺省保留
|
|
226
|
-
const ordered = [...sourceList].sort((a, b) => {
|
|
227
|
-
const ia = bundles.indexOf(a)
|
|
228
|
-
const ib = bundles.indexOf(b)
|
|
229
|
-
return (ia < 0 ? 999 : ia) - (ib < 0 ? 999 : ib)
|
|
230
|
-
})
|
|
231
|
-
|
|
232
|
-
const keepDefault = ordered[0]
|
|
233
|
-
const planLine = () => log(t('repairDedupePlan', { keep: keep, remove: remove.join(', '), id }))
|
|
234
|
-
if (opts.dryRun) {
|
|
235
|
-
log(t('repairDedupePlan', { keep: keepDefault, remove: ordered.slice(1).join(', '), id }))
|
|
236
|
-
log(t('dryRunNotice'))
|
|
406
|
+
export async function cmdRepairAndBoot(args, { boot, spawn = spawnSync, log = err, write = out, isTTY = process.stdin.isTTY } = {}) {
|
|
407
|
+
if (args.includes('-h') || args.includes('--help')) {
|
|
408
|
+
out(t('helpText', {}))
|
|
237
409
|
return 0
|
|
238
410
|
}
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
411
|
+
// 位置参数全部是 dsh 启动参数(修全部可修复记录);前缀旗标为 repair 选项
|
|
412
|
+
const opts = { profile: undefined, to: undefined, yes: false, dryRun: false }
|
|
413
|
+
let i = 0
|
|
414
|
+
for (; i < args.length; i++) {
|
|
415
|
+
const a = args[i]
|
|
416
|
+
if (a === '-y' || a === '--yes') opts.yes = true
|
|
417
|
+
else if (a === '--dry-run') opts.dryRun = true
|
|
418
|
+
else if (a === '--profile' && args[i + 1] !== undefined) opts.profile = args[++i]
|
|
419
|
+
else if (a.startsWith('--profile=')) opts.profile = a.slice('--profile='.length)
|
|
420
|
+
else if (a === '--to' && args[i + 1] !== undefined) opts.to = args[++i]
|
|
421
|
+
else if (a.startsWith('--to=')) opts.to = a.slice('--to='.length)
|
|
422
|
+
else break
|
|
244
423
|
}
|
|
245
|
-
if (opts.
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
const rl = createInterface({ input: process.stdin, output: process.stderr })
|
|
249
|
-
log(t('repairDedupePick', { id }))
|
|
250
|
-
ordered.forEach((b, i) => log(`${i + 1}. ${b}${i === 0 ? t('repairDedupeDefault') : ''}`))
|
|
251
|
-
const answer = await rl.question(t('repairDedupeChoose'))
|
|
252
|
-
rl.close()
|
|
253
|
-
const pick = Math.min(Math.max(parseInt(answer, 10) || 1, 1), ordered.length)
|
|
254
|
-
keep = ordered[pick - 1]
|
|
424
|
+
if (opts.to !== undefined && !/^[\w.+-]+$/.test(opts.to)) {
|
|
425
|
+
log(t('updateToInvalid'))
|
|
426
|
+
return 2
|
|
255
427
|
}
|
|
256
|
-
const
|
|
257
|
-
planLine()
|
|
428
|
+
const bootArgs = args.slice(i)
|
|
258
429
|
|
|
259
|
-
const
|
|
260
|
-
const
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
430
|
+
const ledger = loadLedger()
|
|
431
|
+
const targets = []
|
|
432
|
+
for (const [profile, entries] of Object.entries(ledger.profiles)) {
|
|
433
|
+
if (opts.profile && profile !== opts.profile) continue
|
|
434
|
+
for (const entry of entries ?? []) targets.push({ profile, entry })
|
|
264
435
|
}
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
return
|
|
436
|
+
if (!targets.length) {
|
|
437
|
+
log(t('noRecords'))
|
|
438
|
+
if (bootArgs.length) return boot(bootArgs)
|
|
439
|
+
return 0
|
|
269
440
|
}
|
|
270
|
-
fresh.dsh.profile.bundles = next
|
|
271
|
-
writeFileSync(manifestPath, `${JSON.stringify(fresh, null, 2)}\n`)
|
|
272
441
|
|
|
273
|
-
const {
|
|
274
|
-
if (
|
|
275
|
-
|
|
276
|
-
write(`[dsh-safe] ${t('restored')} ${e.name ?? e.id} (id: ${e.id})`)
|
|
277
|
-
}
|
|
278
|
-
log(t('repairDedupeDone', { removed: remove.join(', '), keep }))
|
|
279
|
-
log(t('repairDone'))
|
|
280
|
-
return 0
|
|
442
|
+
const { failed } = await repairBatch(targets, { to: opts.to, yes: opts.yes, dryRun: opts.dryRun, spawn, log, write })
|
|
443
|
+
if (bootArgs.length) return boot(bootArgs)
|
|
444
|
+
return failed ? 1 : 0
|
|
281
445
|
}
|