@kira905/dsh-butler-install 0.2.1 → 0.3.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.
@@ -0,0 +1,104 @@
1
+ /**
2
+ * 「45080 上是不是已经有一个我们自己的向导页」+ 「开浏览器」这两个动作。
3
+ *
4
+ * 为什么单独一层(2026-10-31 做 exe 分发物时拆出来的):
5
+ * exe 的入口(`scripts/sea-entry.mjs`)要复用同一份探测与开浏览器逻辑,但它**不能** import
6
+ * `bin/install-ui.mjs` —— 那个文件顶层有 `await runEntry()`,ESM 打包成单文件 CJS(SEA 只吃 CJS)
7
+ * 时 TLA 编不过去。与其在包里复制一份"看起来一样的探测"(两份实现必然漂移),不如把这两个函数
8
+ * 搬到 lib 层:`bin/install-ui.mjs` 继续 re-export 它们(对外 API 与夹具引用路径一个不变)。
9
+ *
10
+ * 这里的判据一条都不能松(原注释照留):
11
+ * 认人靠 `/state` 里的 `service` 标记,不是"能连上"、也不是 `ok:true`。
12
+ * 45080 上完全可能坐着别的本地服务,认错人比不起服务坏得多。
13
+ */
14
+ import http from 'node:http';
15
+ import { spawn } from 'node:child_process';
16
+
17
+ import { UI_HOST, DEFAULT_UI_PORT, UI_SERVICE_ID } from './ui-server.mjs';
18
+
19
+ /** 探测超时:本地环回,1.5 秒还不回答就当"没有" */
20
+ export const PROBE_TIMEOUT_MS = 1500;
21
+
22
+ /**
23
+ * 探"这个端口上是不是已经有一个我们自己的向导页"。
24
+ * 判据不是"能连上"、也不是 `ok:true` —— 而是 `/state` 里那个 `service` 标记:
25
+ * 45080 上完全可能坐着别的本地服务,认错人比不起服务坏得多。
26
+ *
27
+ * @returns {Promise<{ok:boolean, port?:number, url?:string, profile?:string, reason?:string}>}
28
+ */
29
+ export function probeExistingUi({ port = DEFAULT_UI_PORT, host = UI_HOST, timeoutMs = PROBE_TIMEOUT_MS } = {}) {
30
+ return new Promise((resolve) => {
31
+ let settled = false;
32
+ const done = (value) => {
33
+ if (settled) return;
34
+ settled = true;
35
+ resolve(value);
36
+ };
37
+ let req;
38
+ try {
39
+ req = http.request({
40
+ host,
41
+ port,
42
+ path: '/state',
43
+ method: 'GET',
44
+ headers: { accept: 'application/json', connection: 'close' },
45
+ timeout: timeoutMs,
46
+ }, (res) => {
47
+ let text = '';
48
+ res.setEncoding('utf8');
49
+ res.on('data', (chunk) => {
50
+ text += chunk;
51
+ if (text.length > 2 * 1024 * 1024) {
52
+ req.destroy();
53
+ done({ ok: false, reason: '/state 太大(不像我们的页面)' });
54
+ }
55
+ });
56
+ res.on('end', () => {
57
+ if (res.statusCode !== 200) {
58
+ done({ ok: false, reason: `/state 回了 HTTP ${res.statusCode}` });
59
+ return;
60
+ }
61
+ let body;
62
+ try {
63
+ body = JSON.parse(text);
64
+ } catch {
65
+ done({ ok: false, reason: '/state 不是 JSON' });
66
+ return;
67
+ }
68
+ if (!body || body.ok !== true || body.service !== UI_SERVICE_ID) {
69
+ done({ ok: false, reason: `${port} 上确实有人在听,但不是我们自己的向导页(service=${body && body.service ? body.service : '无'})` });
70
+ return;
71
+ }
72
+ done({ ok: true, port, url: `http://${host}:${port}/`, profile: body.profile, generatedAt: body.generatedAt });
73
+ });
74
+ });
75
+ } catch (err) {
76
+ done({ ok: false, reason: String(err && err.message ? err.message : err) });
77
+ return;
78
+ }
79
+ req.on('timeout', () => {
80
+ req.destroy();
81
+ done({ ok: false, reason: `探测超时(${timeoutMs}ms)` });
82
+ });
83
+ req.on('error', (err) => {
84
+ done({ ok: false, reason: String(err && err.code ? err.code : err && err.message ? err.message : err) });
85
+ });
86
+ req.end();
87
+ });
88
+ }
89
+
90
+ /** 开浏览器:Win 用 `cmd /c start "" <url>`(空标题位不能省,否则 start 会把 URL 当窗口标题);mac `open`;其它 `xdg-open` */
91
+ export function openBrowser(url) {
92
+ try {
93
+ if (process.platform === 'win32') {
94
+ spawn('cmd', ['/c', 'start', '', url], { windowsHide: true, stdio: 'ignore', detached: true }).unref();
95
+ } else if (process.platform === 'darwin') {
96
+ spawn('open', [url], { stdio: 'ignore', detached: true }).unref();
97
+ } else {
98
+ spawn('xdg-open', [url], { stdio: 'ignore', detached: true }).unref();
99
+ }
100
+ return true;
101
+ } catch {
102
+ return false;
103
+ }
104
+ }
@@ -0,0 +1,147 @@
1
+ /**
2
+ * 撤回的两条路 —— 这里只做「诚实」的那两个动作(方案 §11.6 · B5 定的重写口径)。
3
+ *
4
+ * 🔴 三不许(改这个文件之前先读,每一条都是"看起来能跑、实际会毁东西"):
5
+ * 1. **不许把 `--rollback` 当动作**:它只是 `lib/run.mjs` 里的**失败策略枚举**(`step|batch`),
6
+ * 只在"某一批失败之后"那段代码里被读;外面根本没有"拿某个快照回滚"的函数。
7
+ * 2. **不许无条件 restoreSnapshot**:它会 `copyFileSync` 覆盖四份声明层文件。用户在"装完 → 点撤回"
8
+ * 之间完全可能用 DSH 侧边栏又装了个插件(宿主也在写同一份 package.json)——
9
+ * 盲目覆盖等于把人家刚装的东西**静默抹掉**。所以动作 B 必须先过前置核对。
10
+ * 3. **不许声称"已撤回到安装前"**:动作 B 只还原声明层四份文件;`node_modules` 里的实体
11
+ * 与 `npm -g` 的 CLI 包**不会消失**,重开页面照样显示"已装"。文案必须与真实效果一致。
12
+ *
13
+ * 于是两个动作:
14
+ * - **动作 A【卸载这次装的】**(默认推荐)= 逐项走 `--uninstall` 语义:幂等、**不覆盖任何文件**,
15
+ * 它看的是"现在装着什么",所以才能真的让"已装"变回"未装"。
16
+ * - **动作 B【恢复声明层到装前】** = `restoreSnapshot` + 前置核对(当前四份文件的 sha 必须等于
17
+ * 本轮收尾时的 sha;不等 ⇒ 拒动并说清"这期间有别的程序改过这些文件")。
18
+ */
19
+ import path from 'node:path';
20
+ import { SNAPSHOT_FILES, SNAPSHOT_WARNING, restoreSnapshot, verifySnapshotRestored, sha256 } from './snapshot.mjs';
21
+ import { runSuite, EXIT } from './run.mjs';
22
+
23
+ /** 四份声明层文件的 sha 指纹(文件不存在记 null —— "不存在"本身也是状态) */
24
+ export function declarationFingerprint(profileDir) {
25
+ const files = {};
26
+ for (const name of SNAPSHOT_FILES) {
27
+ try {
28
+ files[name] = sha256(path.join(profileDir, name));
29
+ } catch {
30
+ files[name] = null;
31
+ }
32
+ }
33
+ return files;
34
+ }
35
+
36
+ /**
37
+ * 动作 B 的前置核对:当前 sha 必须逐份等于「本轮收尾时的 sha」。
38
+ *
39
+ * 为什么不是"快照里的 sha":快照是**开跑前**的;而这四份文件在整批跑的过程中本来就一直在变
40
+ * (每装一项就变一次)。真正该比的是"这一轮结束那一刻的样子"——它等于当前的样子才说明
41
+ * 这期间没别人动过手。用快照 sha 去比当前 sha,整批装完之后必然"不等",等于永远拒动。
42
+ *
43
+ * @param {{settlement: {at:string, files: Record<string,string|null>}|null, profileDir: string}} args
44
+ */
45
+ export function checkPrecondition({ settlement, profileDir }) {
46
+ if (!settlement || !settlement.files) {
47
+ return {
48
+ ok: false,
49
+ reason: 'no-settlement',
50
+ changed: [],
51
+ message: '没有"本轮收尾时的 sha"可核(服务重启过,或者这一轮还没跑完):不能盲目拿旧快照覆盖现在的声明层',
52
+ };
53
+ }
54
+ const now = declarationFingerprint(profileDir);
55
+ const changed = [];
56
+ for (const name of SNAPSHOT_FILES) {
57
+ const expected = settlement.files[name] ?? null;
58
+ const actual = now[name] ?? null;
59
+ if (expected !== actual) changed.push({ name, expected, actual });
60
+ }
61
+ if (changed.length) {
62
+ return {
63
+ ok: false,
64
+ reason: 'changed',
65
+ changed,
66
+ message: `这期间有别的程序改过这些文件(${changed.map((c) => c.name).join(' / ')})—— 现在覆盖会把人家刚写的东西一起抹掉,所以拒动`,
67
+ };
68
+ }
69
+ return { ok: true, reason: 'clean', changed: [], verifiedAt: new Date().toISOString() };
70
+ }
71
+
72
+ /**
73
+ * 动作 A:把「这一次装成的项」逐项卸掉(`--uninstall` 语义)。
74
+ *
75
+ * 注意它**不是**"回滚":卸载看的是"现在装着什么"。所以:
76
+ * - 用户在装完之后自己又装回来的,也会被一起卸掉(这正是"卸载这次装的"该有的行为,但要写清);
77
+ * - 它不写、不覆盖任何声明层文件内容,只让宿主自己摘条目。
78
+ *
79
+ * @param {{options: object, names: string[], hooks?: object}} args
80
+ */
81
+ export async function uninstallBatch({ options, names, hooks = {} }) {
82
+ const list = Array.isArray(names) ? names.filter(Boolean) : [];
83
+ if (!list.length) {
84
+ return {
85
+ ok: false,
86
+ reason: 'nothing-installed',
87
+ exitCode: null,
88
+ message: '这一轮没有装成任何项,没有可卸载的',
89
+ };
90
+ }
91
+ const runOptions = {
92
+ ...options,
93
+ // 卸载看现状 ⇒ 必须真跑(演练档会在最前面就 return);--uninstall 在 CLI 层与 --ui 互斥,
94
+ // 这里是页面内部的第二个动作,不是命令行开关,所以直接给 runSuite 的选项。
95
+ apply: true,
96
+ uninstall: true,
97
+ only: list.slice(),
98
+ skip: [],
99
+ json: false,
100
+ snapshotDir: null,
101
+ };
102
+ const result = await runSuite(runOptions, hooks);
103
+ return {
104
+ ok: result.exitCode === EXIT.OK,
105
+ exitCode: result.exitCode,
106
+ names: list.slice(),
107
+ text: result.text,
108
+ summary: result.summary,
109
+ honest: [
110
+ '这是「卸载这次装的」:走 --uninstall 语义,看的是现在装着什么,所以它会真的让"已装"变回"未装"。',
111
+ '它不覆盖任何文件:不写 package.json / cordis.patch.yml 的内容,只让宿主自己摘掉条目。',
112
+ 'node_modules 里的实体文件与包管理器缓存仍可能留着残留 —— 那是宿主的已知行为,不是"没卸干净"。',
113
+ ],
114
+ };
115
+ }
116
+
117
+ /**
118
+ * 动作 B:把声明层四份文件恢复到**本轮开跑前**的样子。
119
+ * 调用方必须先跑过 `checkPrecondition`;本函数不做拦截(它只负责"按快照写回 + 复核")。
120
+ *
121
+ * @param {{snapshotRoot: string, profileDir: string}} args snapshotRoot = 本批的快照根(里面含 pre-run/)
122
+ */
123
+ export function restoreDeclarations({ snapshotRoot, profileDir }) {
124
+ const dir = path.join(snapshotRoot, 'pre-run');
125
+ let actions = [];
126
+ let verify = { ok: false, mismatches: [] };
127
+ let error = null;
128
+ try {
129
+ actions = restoreSnapshot({ dir, profileDir });
130
+ verify = verifySnapshotRestored({ dir, profileDir });
131
+ } catch (err) {
132
+ error = String(err && err.message ? err.message : err);
133
+ }
134
+ return {
135
+ ok: !error && verify.ok,
136
+ snapshotDir: dir,
137
+ actions,
138
+ verify,
139
+ error,
140
+ warning: SNAPSHOT_WARNING,
141
+ honest: [
142
+ '只还原**声明层**四份文件(package.json / pnpm-lock.yaml / cordis.patch.yml / pnpm-workspace.yaml)。',
143
+ 'node_modules 里的包实体与 npm -g 装的命令行工具**不会**因此消失 ⇒ 重开页面大概率仍显示"已装"。',
144
+ '想真的让"已装"变回"未装",请用另一个动作【卸载这次装的】。',
145
+ ],
146
+ };
147
+ }