@kira905/dsh-butler-install 0.1.0 → 0.2.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.md +58 -1
- package/bin/dsh-butler-install.mjs +4 -0
- package/lib/cli-args.mjs +27 -2
- package/lib/plan.mjs +354 -0
- package/lib/run.mjs +33 -224
- package/lib/ui-page.mjs +382 -0
- package/lib/ui-server.mjs +285 -0
- package/package.json +2 -2
package/lib/run.mjs
CHANGED
|
@@ -8,19 +8,14 @@
|
|
|
8
8
|
* 不看"我以为它装过了"。
|
|
9
9
|
* 3. 退出码只有三档有意义:0 全绿 / 3 失败但回滚干净 / 4 失败且回滚不干净。
|
|
10
10
|
*/
|
|
11
|
-
import fs from 'node:fs';
|
|
12
11
|
import path from 'node:path';
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import { satisfies, parseRange, describeRange } from './semver.mjs';
|
|
12
|
+
import { describeItem } from './manifest.mjs';
|
|
13
|
+
import { satisfies } from './semver.mjs';
|
|
16
14
|
import {
|
|
17
|
-
resolveHome,
|
|
18
15
|
readProfile,
|
|
19
16
|
profileInstalledVersion,
|
|
20
17
|
globalInstalledVersion,
|
|
21
|
-
resolveNpmPrefix,
|
|
22
18
|
findExistingBin,
|
|
23
|
-
findOnPath,
|
|
24
19
|
readInstalledDshDecl,
|
|
25
20
|
} from './state.mjs';
|
|
26
21
|
import { runProbe } from './probe.mjs';
|
|
@@ -28,16 +23,19 @@ import { analyzeDump, checkMounted, skippedBundlesFromStderr } from './dump.mjs'
|
|
|
28
23
|
import { takeSnapshot, restoreSnapshot, verifySnapshotRestored, SNAPSHOT_WARNING } from './snapshot.mjs';
|
|
29
24
|
import { spawnCommand, formatCommandLine } from './exec.mjs';
|
|
30
25
|
import { EXIT } from './cli-args.mjs';
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
26
|
+
import {
|
|
27
|
+
DEFAULT_MANIFEST,
|
|
28
|
+
selectItems,
|
|
29
|
+
installStepFor,
|
|
30
|
+
uninstallStepFor,
|
|
31
|
+
assessItem,
|
|
32
|
+
criteriaText,
|
|
33
|
+
buildSuiteState,
|
|
34
|
+
} from './plan.mjs';
|
|
35
|
+
|
|
36
|
+
// 「清单 → 选中 → 现状 → 每项要跑的命令」这一层搬去了 lib/plan.mjs(--ui 的 GET /state 与这里同源)。
|
|
37
|
+
// 这几个名字老早就在本文件里导出过,夹具也在用,所以在这儿原样再导出,别改 import 路径。
|
|
38
|
+
export { EXIT, DEFAULT_MANIFEST, selectItems, installStepFor, uninstallStepFor, assessItem, criteriaText };
|
|
41
39
|
|
|
42
40
|
function firstLine(text) {
|
|
43
41
|
return String(text || '').trim().split(/\r?\n/)[0] || '';
|
|
@@ -47,60 +45,6 @@ function stamp() {
|
|
|
47
45
|
return new Date().toISOString().replace(/[-:]/g, '').replace(/\..*$/, '');
|
|
48
46
|
}
|
|
49
47
|
|
|
50
|
-
/** 按 --only / --skip 选条目;组名、包名、短名、别名都认 */
|
|
51
|
-
export function selectItems(manifest, { only = [], skip = [] } = {}) {
|
|
52
|
-
const items = manifest.items;
|
|
53
|
-
const errors = [];
|
|
54
|
-
const groups = [...new Set(items.map((i) => i.group))];
|
|
55
|
-
const resolve = (token) => items.filter((i) => (
|
|
56
|
-
i.name === token
|
|
57
|
-
|| shortName(i.name) === token
|
|
58
|
-
|| i.group === token
|
|
59
|
-
|| (Array.isArray(i.aliases) && i.aliases.includes(token))
|
|
60
|
-
));
|
|
61
|
-
|
|
62
|
-
let selected = items.slice();
|
|
63
|
-
if (only.length) {
|
|
64
|
-
const picked = new Set();
|
|
65
|
-
for (const token of only) {
|
|
66
|
-
const hits = resolve(token);
|
|
67
|
-
if (!hits.length) {
|
|
68
|
-
errors.push(`--only 里的 ${JSON.stringify(token)} 没匹配到任何条目(可选组:${groups.join(' / ')};也可以直接写包名或别名)`);
|
|
69
|
-
continue;
|
|
70
|
-
}
|
|
71
|
-
for (const h of hits) picked.add(h.name);
|
|
72
|
-
}
|
|
73
|
-
selected = items.filter((i) => picked.has(i.name));
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
const dropped = new Set();
|
|
77
|
-
for (const token of skip) {
|
|
78
|
-
const hits = resolve(token);
|
|
79
|
-
if (!hits.length) errors.push(`--skip 里的 ${JSON.stringify(token)} 没匹配到任何条目`);
|
|
80
|
-
for (const h of hits) dropped.add(h.name);
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
return { items: selected.filter((i) => !dropped.has(i.name)), errors, groups };
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
export function installStepFor(item, { profile, npmPrefix }) {
|
|
87
|
-
// 装的时候把**清单里的期望区间**一起点名(`<包>@^x.y.z`)。
|
|
88
|
-
// 为什么必须带上:不带区间时拿到的是注册表 `latest` 标签指的那个版本,而它未必在
|
|
89
|
-
// 我们期望的区间里(实测踩过:latest 标签指着的版本与最高版本号不是一回事)。
|
|
90
|
-
// 带区间 = 让解析器只能落进期望区间,落不进去就报错,而不是"装了个别的版本还报成功"。
|
|
91
|
-
const spec = `${item.name}@${item.versionRange}`;
|
|
92
|
-
if (item.type === 'plugin') {
|
|
93
|
-
return { file: 'dsh', args: ['plugin', '--profile', profile, 'add', spec], env: {} };
|
|
94
|
-
}
|
|
95
|
-
return { file: 'npm', args: ['i', '-g', spec], env: npmPrefix ? { npm_config_prefix: npmPrefix } : {} };
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
export function uninstallStepFor(item, { profile, npmPrefix }) {
|
|
99
|
-
if (item.type === 'plugin') {
|
|
100
|
-
return { file: 'dsh', args: ['plugin', '--profile', profile, 'remove', item.name], env: {} };
|
|
101
|
-
}
|
|
102
|
-
return { file: 'npm', args: ['rm', '-g', item.name], env: npmPrefix ? { npm_config_prefix: npmPrefix } : {} };
|
|
103
|
-
}
|
|
104
48
|
|
|
105
49
|
function execEnv(ctx, extra) {
|
|
106
50
|
return { DSH_HOME: ctx.home, ...(extra || {}) };
|
|
@@ -150,49 +94,6 @@ async function getDumpAnalysis(ctx) {
|
|
|
150
94
|
return dumpCache;
|
|
151
95
|
}
|
|
152
96
|
|
|
153
|
-
/** 判定某一项"现在是否已满足",纯读文件 */
|
|
154
|
-
export function assessItem(item, ctx, profile, { upgrade = false } = {}) {
|
|
155
|
-
const mount = item.mount || (item.type === 'plugin' ? 'auto' : 'none');
|
|
156
|
-
if (item.type === 'plugin') {
|
|
157
|
-
const version = profileInstalledVersion(ctx.home, ctx.profile, item.name);
|
|
158
|
-
const depDeclared = Object.prototype.hasOwnProperty.call(profile.deps || {}, item.name);
|
|
159
|
-
const bundleRegistered = (profile.bundles || []).includes(item.name);
|
|
160
|
-
const range = version ? satisfies(item.versionRange, version) : 'no';
|
|
161
|
-
const decl = readInstalledDshDecl(ctx.home, ctx.profile, item.name);
|
|
162
|
-
// 该不该进组合列表:**以包实体的声明为准**。本版没声明 bundle 的包,
|
|
163
|
-
// 不登记也是正确状态(登记反而是空的),所以这里不拿"没登记"判它未满足。
|
|
164
|
-
const mountOk = mount !== 'auto' || bundleRegistered || !decl.hasBundle;
|
|
165
|
-
const satisfied = Boolean(version) && depDeclared && mountOk && (range === 'yes' || range === 'unknown');
|
|
166
|
-
return {
|
|
167
|
-
item,
|
|
168
|
-
type: 'plugin',
|
|
169
|
-
mount,
|
|
170
|
-
version,
|
|
171
|
-
depDeclared,
|
|
172
|
-
bundleRegistered,
|
|
173
|
-
range,
|
|
174
|
-
satisfied,
|
|
175
|
-
action: satisfied && !upgrade ? 'skip' : 'install',
|
|
176
|
-
};
|
|
177
|
-
}
|
|
178
|
-
const version = globalInstalledVersion(ctx.npmPrefix, item.name);
|
|
179
|
-
const binFile = item.probe && item.probe.cmd
|
|
180
|
-
? findExistingBin({ cmd: item.probe.cmd, home: ctx.home, profile: ctx.profile, npmPrefix: ctx.npmPrefix })
|
|
181
|
-
: null;
|
|
182
|
-
const range = version ? satisfies(item.versionRange, version) : 'no';
|
|
183
|
-
const satisfied = Boolean(version) && Boolean(binFile) && (range === 'yes' || range === 'unknown');
|
|
184
|
-
return {
|
|
185
|
-
item,
|
|
186
|
-
type: 'cli',
|
|
187
|
-
mount: 'none',
|
|
188
|
-
version,
|
|
189
|
-
binFile,
|
|
190
|
-
range,
|
|
191
|
-
satisfied,
|
|
192
|
-
action: satisfied && !upgrade ? 'skip' : 'install',
|
|
193
|
-
};
|
|
194
|
-
}
|
|
195
|
-
|
|
196
97
|
function rangeStatus(range, version) {
|
|
197
98
|
if (!version) return { status: 'fail', detail: '未安装' };
|
|
198
99
|
if (range === 'yes') return { status: 'pass', detail: `实体 ${version},落在期望区间内` };
|
|
@@ -269,96 +170,28 @@ export async function runSuite(options) {
|
|
|
269
170
|
const stream = options.json ? process.stderr : process.stdout;
|
|
270
171
|
stream.write(`${text}\n`);
|
|
271
172
|
};
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
items: [],
|
|
279
|
-
warnings: [],
|
|
280
|
-
excluded: [],
|
|
281
|
-
rollback: null,
|
|
282
|
-
exitCode: null,
|
|
283
|
-
};
|
|
173
|
+
// 清单 → 选中 → 现状 → 每项要跑的命令:全部在 lib/plan.mjs 里算一次。
|
|
174
|
+
// 这里只负责"印人话";`--ui` 的 GET /state 是同一份数据的另一种渲染 —— 两处必然一致。
|
|
175
|
+
const built = await buildSuiteState(options);
|
|
176
|
+
for (const line of built.log) say(line);
|
|
177
|
+
|
|
178
|
+
const summary = built.summary;
|
|
284
179
|
const warn = (text) => {
|
|
285
180
|
summary.warnings.push(text);
|
|
286
181
|
say(`⚠ ${text}`);
|
|
287
182
|
};
|
|
288
183
|
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
try {
|
|
293
|
-
manifest = loadManifest(manifestPath);
|
|
294
|
-
} catch (err) {
|
|
295
|
-
say(formatManifestError(err, path.basename(manifestPath)));
|
|
296
|
-
summary.exitCode = EXIT.USAGE;
|
|
297
|
-
return { exitCode: EXIT.USAGE, summary, text: lines.join('\n') };
|
|
298
|
-
}
|
|
299
|
-
summary.manifest = manifest.path;
|
|
300
|
-
summary.excluded = manifest.excluded.map((e) => ({ id: e.id, reason: e.reason }));
|
|
301
|
-
|
|
302
|
-
// ── 2. 选条目 ──────────────────────────────────────────────
|
|
303
|
-
const selection = selectItems(manifest, options);
|
|
304
|
-
if (selection.errors.length) {
|
|
305
|
-
for (const e of selection.errors) say(`✖ ${e}`);
|
|
306
|
-
summary.exitCode = EXIT.USAGE;
|
|
307
|
-
return { exitCode: EXIT.USAGE, summary, text: lines.join('\n') };
|
|
308
|
-
}
|
|
309
|
-
if (!selection.items.length) {
|
|
310
|
-
say('✖ 选完之后没有任何条目可做(检查 --only / --skip)');
|
|
311
|
-
summary.exitCode = EXIT.USAGE;
|
|
312
|
-
return { exitCode: EXIT.USAGE, summary, text: lines.join('\n') };
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
// ── 3. 上下文 ──────────────────────────────────────────────
|
|
316
|
-
const home = resolveHome(options.home);
|
|
317
|
-
const profile = readProfile(home, options.profile);
|
|
318
|
-
const npm = await resolveNpmPrefix(options.npmPrefix);
|
|
319
|
-
const ctx = { home, profile: options.profile, npmPrefix: npm.prefix };
|
|
320
|
-
summary.home = home;
|
|
321
|
-
summary.npmPrefix = npm.prefix;
|
|
322
|
-
|
|
323
|
-
say(`清单:${manifest.path}`);
|
|
324
|
-
say(`共 ${manifest.items.length} 项,本次选中 ${selection.items.length} 项(组:${[...new Set(selection.items.map((i) => i.group))].join(' / ')})`);
|
|
325
|
-
say(`数据根:${home}`);
|
|
326
|
-
say(`profile:${options.profile}(${profile.exists ? '已存在' : '尚不存在,真装时由宿主自动创建'})`);
|
|
327
|
-
say(`全局包落点:${npm.prefix}(来源:${npm.source})`);
|
|
328
|
-
if (manifest.excluded.length) {
|
|
329
|
-
for (const entry of manifest.excluded) {
|
|
330
|
-
say(`⏭ 不可安装项 ${entry.id}:${entry.reason}`);
|
|
331
|
-
}
|
|
184
|
+
if (!built.ok) {
|
|
185
|
+
summary.exitCode = built.exitCode;
|
|
186
|
+
return { exitCode: built.exitCode, summary, text: lines.join('\n') };
|
|
332
187
|
}
|
|
333
|
-
say('');
|
|
334
188
|
|
|
335
|
-
|
|
336
|
-
const
|
|
337
|
-
const
|
|
338
|
-
const
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
// ── 4. 计划 ────────────────────────────────────────────────
|
|
343
|
-
const plan = selection.items.map((item) => assessItem(item, ctx, profile, { upgrade: options.upgrade }));
|
|
344
|
-
summary.items = plan.map((p) => ({
|
|
345
|
-
name: p.item.name,
|
|
346
|
-
type: p.item.type,
|
|
347
|
-
group: p.item.group,
|
|
348
|
-
mount: p.mount,
|
|
349
|
-
installedVersion: p.version,
|
|
350
|
-
inRange: p.range,
|
|
351
|
-
satisfied: p.satisfied,
|
|
352
|
-
action: p.action,
|
|
353
|
-
}));
|
|
354
|
-
|
|
355
|
-
const toInstall = plan.filter((p) => p.action === 'install');
|
|
356
|
-
const skipped = plan.filter((p) => p.action === 'skip');
|
|
357
|
-
|
|
358
|
-
if (!options.uninstall) {
|
|
359
|
-
say(`建议顺序(组件之间没有代码依赖,"顺序"只是排查建议):先装面板 → 界面增强 → 记忆 → 命令行工具一批。`);
|
|
360
|
-
say('');
|
|
361
|
-
}
|
|
189
|
+
const { plan, preflight } = built;
|
|
190
|
+
const { toInstall, skipped } = built;
|
|
191
|
+
const ctx = built.ctx;
|
|
192
|
+
const home = built.ctx.home;
|
|
193
|
+
const profile = built.profile;
|
|
194
|
+
const npm = built.npm;
|
|
362
195
|
|
|
363
196
|
// ── 5a. 演练:一条命令都不跑 ───────────────────────────────
|
|
364
197
|
if (!options.apply) {
|
|
@@ -372,12 +205,10 @@ export async function runSuite(options) {
|
|
|
372
205
|
if (entry.action === 'skip') {
|
|
373
206
|
say(' 动作:跳过(已满足)');
|
|
374
207
|
} else {
|
|
375
|
-
|
|
376
|
-
? uninstallStepFor(entry.item, { profile: options.profile, npmPrefix: npm.prefix })
|
|
377
|
-
: installStepFor(entry.item, { profile: options.profile, npmPrefix: npm.prefix });
|
|
208
|
+
// 命令与判据都是 lib/plan.mjs 生成那一份,这里只印,绝不另拼一遍
|
|
378
209
|
say(` 动作:${options.uninstall ? '卸载' : '安装'}`);
|
|
379
|
-
say(` 命令:${
|
|
380
|
-
say(` 判据:${
|
|
210
|
+
say(` 命令:${options.uninstall ? entry.commandUninstall : entry.command}`);
|
|
211
|
+
say(` 判据:${entry.criteria}`);
|
|
381
212
|
}
|
|
382
213
|
say('');
|
|
383
214
|
});
|
|
@@ -626,28 +457,6 @@ export async function runSuite(options) {
|
|
|
626
457
|
return { exitCode: summary.exitCode, summary, text: lines.join('\n') };
|
|
627
458
|
}
|
|
628
459
|
|
|
629
|
-
function criteriaText(item) {
|
|
630
|
-
const parts = [];
|
|
631
|
-
const kind = (item.probe && item.probe.kind) || 'existence';
|
|
632
|
-
if (kind === 'existence') {
|
|
633
|
-
parts.push(item.type === 'plugin'
|
|
634
|
-
? `profile 的 node_modules/${item.name}/package.json 存在(只查文件,不执行)`
|
|
635
|
-
: `命令文件 ${item.probe.cmd} 在 .bin 或全局前缀下存在(只查文件,不执行)`);
|
|
636
|
-
} else if (kind === 'help') {
|
|
637
|
-
parts.push(`先静态核 ${item.probe.cmd} 源码里确有 --help 分支,再跑 ${item.probe.cmd} --help`);
|
|
638
|
-
} else {
|
|
639
|
-
parts.push(`跑 ${item.probe.cmd} ${(item.probe.argv || []).join(' ')}(清单声明只读:${item.probe.readonlyBecause})`);
|
|
640
|
-
}
|
|
641
|
-
parts.push(`实体版本落在 ${describeRange(parseRange(item.versionRange))}`);
|
|
642
|
-
if (item.type === 'plugin' && (item.mount || 'auto') === 'auto') {
|
|
643
|
-
parts.push('dump-config 里组合头恰好 1 行、条目 name 恰好 1 条、该 id 在整棵树里恰好 1 次');
|
|
644
|
-
}
|
|
645
|
-
if (item.type === 'plugin' && item.mount === 'manual') {
|
|
646
|
-
parts.push('挂载不判(本包按设计不进组合列表)');
|
|
647
|
-
}
|
|
648
|
-
return parts.join(' · ');
|
|
649
|
-
}
|
|
650
|
-
|
|
651
460
|
function printSummary(say, plan, failure) {
|
|
652
461
|
say('');
|
|
653
462
|
say('== 汇总 ==');
|