@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.
- package/README.md +120 -19
- package/bin/install-ui.mjs +133 -0
- package/install-ui.cmd +61 -0
- package/lib/cli-args.mjs +12 -6
- package/lib/dump.mjs +52 -4
- package/lib/exec.mjs +92 -13
- package/lib/explain-failure.mjs +117 -0
- package/lib/item-status.mjs +64 -0
- package/lib/run.mjs +107 -15
- package/lib/ui-install.mjs +541 -0
- package/lib/ui-page.mjs +514 -83
- package/lib/ui-probe.mjs +104 -0
- package/lib/ui-rollback.mjs +147 -0
- package/lib/ui-server.mjs +287 -35
- package/package.json +10 -3
package/lib/ui-server.mjs
CHANGED
|
@@ -1,28 +1,50 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `--ui`:把「这次会做什么」摆成一个**挂在 DSH
|
|
2
|
+
* `--ui`:把「这次会做什么」摆成一个**挂在 DSH 外面**的本地页面。
|
|
3
3
|
*
|
|
4
4
|
* 为什么不塞进 DSH 侧边栏(这是本模块存在的理由,不是懒):
|
|
5
5
|
* - 插件页要装插件 + 写 cordis.patch.yml + 重启;DSH 一升级,client DOM 注入就可能崩。
|
|
6
6
|
* - 本页零安装、零重启,连"DSH 还没装"的时候都能开起来 —— 那时候它正好能告诉你"你得先装 DSH"。
|
|
7
7
|
* - 代价只有一个:要监听一个本地端口。所以**只许 127.0.0.1**,写死,不接受任何形式的放宽。
|
|
8
8
|
*
|
|
9
|
-
*
|
|
9
|
+
* 四条红线(改这个文件之前先读):
|
|
10
10
|
* 1. 只绑 `127.0.0.1`。任何人把它改成 `0.0.0.0` / `::` 都是把本机服务摊到局域网,夹具会红。
|
|
11
|
-
* 2.
|
|
12
|
-
*
|
|
13
|
-
*
|
|
11
|
+
* 2. 写入口**只有四条**,全部是 POST,全部要过闸:
|
|
12
|
+
* POST /install/preview —— 只算不做(两步确认的第一半)
|
|
13
|
+
* POST /install/start —— 真装(必须带 `confirm:true`)
|
|
14
|
+
* POST /install/cancel —— 取消(AbortSignal → killTree)
|
|
15
|
+
* POST /install/rollback —— 撤回的两个诚实动作(见 lib/ui-rollback.mjs)
|
|
16
|
+
* GET 只有三条:`/`(页面)、`/state`(与 --dry-run 同源的 JSON)、`/install/progress`(纯内存读)。
|
|
17
|
+
* 其余「方法 + 路径」组合一律 405/404,`Allow` 响应头**按路径给**(写死 GET 会自相矛盾)。
|
|
18
|
+
* 3. 每个 POST 先过两道前置闸:**进程级令牌**(`x-install-token`,整个进程生命周期有效)与
|
|
19
|
+
* **同源校验**(`Origin` 的 `host:port` 逐字等于 `server.address()` 的 `address:port`;
|
|
20
|
+
* `Sec-Fetch-Site` **存在才校验**,老浏览器/360 兼容模式没有这个头,硬要求就等于全按钮静默 403)。
|
|
21
|
+
* 🔴 不许把端口写死成 45080:`--port 0` 或端口被占时,写死会让所有 POST 必然 403。
|
|
22
|
+
* 4. 数据与 `--dry-run` 同源:`GET /state` 返回的就是 lib/plan.mjs#buildSuiteState 的 `state`,
|
|
14
23
|
* 页面上「复制命令」读到的那行字符串也是它 —— 页面自己不许拼命令。
|
|
15
24
|
*/
|
|
16
25
|
import http from 'node:http';
|
|
26
|
+
import crypto from 'node:crypto';
|
|
17
27
|
import { resolveNpmPrefix } from './state.mjs';
|
|
18
28
|
import { buildSuiteState } from './plan.mjs';
|
|
19
29
|
import { renderPage } from './ui-page.mjs';
|
|
30
|
+
import { decorateState } from './item-status.mjs';
|
|
31
|
+
import { createInstallRunner } from './ui-install.mjs';
|
|
32
|
+
import { killAllRunningSync, runningChildPids } from './exec.mjs';
|
|
20
33
|
|
|
21
34
|
/** 写死。不读环境变量、不加参数 —— 这类"临时放宽一下"的开关最后都会留在生产里。 */
|
|
22
35
|
export const UI_HOST = '127.0.0.1';
|
|
23
36
|
export const DEFAULT_UI_PORT = 45080;
|
|
24
37
|
/** 端口被占时往后试几个(45080, 45081, …),都不行就让系统随便挑一个 */
|
|
25
38
|
export const PORT_SCAN = 20;
|
|
39
|
+
/**
|
|
40
|
+
* 这个串出现在 `GET /state` 里:双击入口(bin/install-ui.mjs)靠它确认
|
|
41
|
+
* "45080 上那个服务到底是不是我们自己的" —— 只看 `ok:true` 会认错人。
|
|
42
|
+
*/
|
|
43
|
+
export const UI_SERVICE_ID = 'dsh-butler-install-ui';
|
|
44
|
+
/** 令牌走这个头。放头里不放 cookie:cookie 会被同站其它页面顺手带上,头不会 */
|
|
45
|
+
export const TOKEN_HEADER = 'x-install-token';
|
|
46
|
+
/** 请求体上限:这几个 POST 只收一个小 JSON,超过就拒(别让人拿 body 灌内存) */
|
|
47
|
+
export const MAX_BODY_BYTES = 64 * 1024;
|
|
26
48
|
|
|
27
49
|
const BASE_HEADERS = {
|
|
28
50
|
'cache-control': 'no-store',
|
|
@@ -68,14 +90,113 @@ export function assertLoopbackHost(host) {
|
|
|
68
90
|
return host;
|
|
69
91
|
}
|
|
70
92
|
|
|
93
|
+
/** 读请求体(小 JSON)。失败一律回 {ok:false,error},不抛 */
|
|
94
|
+
function readJsonBody(req, limit = MAX_BODY_BYTES) {
|
|
95
|
+
return new Promise((resolve) => {
|
|
96
|
+
let raw = '';
|
|
97
|
+
let done = false;
|
|
98
|
+
const settle = (value) => {
|
|
99
|
+
if (done) return;
|
|
100
|
+
done = true;
|
|
101
|
+
resolve(value);
|
|
102
|
+
};
|
|
103
|
+
req.on('data', (chunk) => {
|
|
104
|
+
if (done) return;
|
|
105
|
+
raw += chunk.toString('utf8');
|
|
106
|
+
if (raw.length > limit) {
|
|
107
|
+
req.resume();
|
|
108
|
+
settle({ ok: false, error: 'body-too-large' });
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
req.on('end', () => {
|
|
112
|
+
if (!raw.trim()) {
|
|
113
|
+
settle({ ok: true, value: {} });
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
try {
|
|
117
|
+
const value = JSON.parse(raw);
|
|
118
|
+
settle({ ok: true, value: value && typeof value === 'object' ? value : {} });
|
|
119
|
+
} catch {
|
|
120
|
+
settle({ ok: false, error: 'bad-json' });
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
req.on('error', () => settle({ ok: false, error: 'body-error' }));
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** 闸 2:进程级令牌(定长比较,避免时序侧信道) */
|
|
128
|
+
export function sameToken(req, token) {
|
|
129
|
+
const given = req.headers[TOKEN_HEADER];
|
|
130
|
+
if (typeof given !== 'string' || !given) return false;
|
|
131
|
+
if (typeof token !== 'string' || !token) return false;
|
|
132
|
+
const a = Buffer.from(given);
|
|
133
|
+
const b = Buffer.from(token);
|
|
134
|
+
if (a.length !== b.length) return false;
|
|
135
|
+
try {
|
|
136
|
+
return crypto.timingSafeEqual(a, b);
|
|
137
|
+
} catch {
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* 闸 3:同源校验。
|
|
144
|
+
* - `Origin` **必须**有,且它的 `host:port` 要**逐字等于**我们自己 listen 到的 `address:port`
|
|
145
|
+
* (🔴 不许写死 45080:`--port 0` / 端口被占时会让所有 POST 必然 403)
|
|
146
|
+
* - `Sec-Fetch-Site`:**存在才校验**。Chrome 76+ / Safari 16.4+ 才有这个头,
|
|
147
|
+
* 而双击入口打开的是系统默认浏览器(可能是 360 兼容模式)—— 硬要求等于全按钮静默 403。
|
|
148
|
+
*/
|
|
149
|
+
export function checkOrigin(req, server) {
|
|
150
|
+
const addr = server && typeof server.address === 'function' ? server.address() : null;
|
|
151
|
+
if (!addr || typeof addr !== 'object') {
|
|
152
|
+
return { ok: false, error: 'origin-unknown', reason: '服务还没 listen,拿不到自己的 address:port' };
|
|
153
|
+
}
|
|
154
|
+
const expected = `${addr.address}:${addr.port}`;
|
|
155
|
+
const origin = req.headers.origin;
|
|
156
|
+
if (typeof origin !== 'string' || !origin) {
|
|
157
|
+
return { ok: false, error: 'origin-missing', reason: 'POST 必须带 Origin(浏览器一定会带);没有它说明不是这个页面发出来的', expected };
|
|
158
|
+
}
|
|
159
|
+
let got = null;
|
|
160
|
+
try {
|
|
161
|
+
const u = new URL(origin);
|
|
162
|
+
const port = u.port || (u.protocol === 'https:' ? '443' : '80');
|
|
163
|
+
got = `${u.hostname}:${port}`;
|
|
164
|
+
} catch {
|
|
165
|
+
return { ok: false, error: 'origin-invalid', reason: `Origin 解析不了:${origin}`, expected };
|
|
166
|
+
}
|
|
167
|
+
if (got !== expected) {
|
|
168
|
+
return { ok: false, error: 'origin-mismatch', reason: `Origin 是 ${got},本服务是 ${expected}`, expected, got };
|
|
169
|
+
}
|
|
170
|
+
const site = req.headers['sec-fetch-site'];
|
|
171
|
+
if (typeof site === 'string' && site && site !== 'same-origin') {
|
|
172
|
+
return { ok: false, error: 'cross-site', reason: `Sec-Fetch-Site=${site}`, expected };
|
|
173
|
+
}
|
|
174
|
+
return { ok: true, origin, expected };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const GET_ROUTES = new Map([
|
|
178
|
+
['/', 'page'],
|
|
179
|
+
['/state', 'state'],
|
|
180
|
+
['/install/progress', 'progress'],
|
|
181
|
+
]);
|
|
182
|
+
const POST_ROUTES = new Map([
|
|
183
|
+
['/install/preview', 'preview'],
|
|
184
|
+
['/install/start', 'start'],
|
|
185
|
+
['/install/cancel', 'cancel'],
|
|
186
|
+
['/install/rollback', 'rollback'],
|
|
187
|
+
]);
|
|
188
|
+
|
|
189
|
+
export const GET_PATHS = [...GET_ROUTES.keys()];
|
|
190
|
+
export const POST_PATHS = [...POST_ROUTES.keys()];
|
|
191
|
+
|
|
71
192
|
/**
|
|
72
193
|
* 建 server(不 listen),把路由表焊死在这里。
|
|
73
|
-
* @param {{getState:() => Promise<object
|
|
194
|
+
* @param {{getState: () => Promise<object>, install?: object, token?: string}} deps
|
|
74
195
|
*/
|
|
75
|
-
export function createUiServer({ getState }) {
|
|
196
|
+
export function createUiServer({ getState, install, token }) {
|
|
76
197
|
if (typeof getState !== 'function') throw new Error('createUiServer 需要 getState(返回与 --dry-run 同源的 state)');
|
|
77
198
|
const server = http.createServer((req, res) => {
|
|
78
|
-
handle(req, res, { getState, server }).catch((err) => {
|
|
199
|
+
handle(req, res, { getState, install, token, server }).catch((err) => {
|
|
79
200
|
// 处理器里出啥事都不能让进程崩:状态码照实报,正文说明白
|
|
80
201
|
try {
|
|
81
202
|
writeJson(res, 500, { ok: false, error: 'internal-error', message: String(err && err.message ? err.message : err) });
|
|
@@ -91,7 +212,16 @@ export function createUiServer({ getState }) {
|
|
|
91
212
|
return server;
|
|
92
213
|
}
|
|
93
214
|
|
|
94
|
-
/**
|
|
215
|
+
/**
|
|
216
|
+
* 路由表(纯函数式,好测)。
|
|
217
|
+
* 返回 `{kind}`:'page' | 'state' | 'progress' | 'preview' | 'start' | 'cancel' | 'rollback'
|
|
218
|
+
* | 'method-not-allowed'(带 allow) | 'not-found' | 'bad-request'
|
|
219
|
+
*
|
|
220
|
+
* `Allow` 按路径给:
|
|
221
|
+
* - `/install/start` 这类写路径 ⇒ `Allow: POST`
|
|
222
|
+
* - `/` `/state` `/install/progress` ⇒ `Allow: GET`
|
|
223
|
+
* - 未知路径上的写方法 ⇒ 405 + `Allow: GET`(这个页面在未知路径上的答案就是"没有写入口")
|
|
224
|
+
*/
|
|
95
225
|
export function routeOf(method, rawUrl) {
|
|
96
226
|
let pathname;
|
|
97
227
|
try {
|
|
@@ -99,13 +229,26 @@ export function routeOf(method, rawUrl) {
|
|
|
99
229
|
} catch {
|
|
100
230
|
return { kind: 'bad-request' };
|
|
101
231
|
}
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
232
|
+
const verb = String(method || '').toUpperCase();
|
|
233
|
+
const getKind = GET_ROUTES.get(pathname);
|
|
234
|
+
const postKind = POST_ROUTES.get(pathname);
|
|
235
|
+
|
|
236
|
+
if (verb === 'GET') {
|
|
237
|
+
if (getKind) return { kind: getKind };
|
|
238
|
+
if (postKind) return { kind: 'method-not-allowed', allow: 'POST' };
|
|
239
|
+
return { kind: 'not-found' };
|
|
240
|
+
}
|
|
241
|
+
if (verb === 'POST') {
|
|
242
|
+
if (postKind) return { kind: postKind };
|
|
243
|
+
// 未知路径 + 写方法:写方法的答案只有一个"这儿没有写入口"
|
|
244
|
+
return { kind: 'method-not-allowed', allow: 'GET' };
|
|
245
|
+
}
|
|
246
|
+
// 其它方法(HEAD/PUT/DELETE/PATCH/OPTIONS…)一律不是入口;Allow 仍按路径给
|
|
247
|
+
return { kind: 'method-not-allowed', allow: postKind ? 'POST' : 'GET' };
|
|
107
248
|
}
|
|
108
249
|
|
|
250
|
+
const POST_KINDS = new Set(['preview', 'start', 'cancel', 'rollback']);
|
|
251
|
+
|
|
109
252
|
async function handle(req, res, deps) {
|
|
110
253
|
const route = routeOf(req.method, req.url);
|
|
111
254
|
if (route.kind === 'bad-request') {
|
|
@@ -119,14 +262,16 @@ async function handle(req, res, deps) {
|
|
|
119
262
|
writeJson(res, 405, {
|
|
120
263
|
ok: false,
|
|
121
264
|
error: 'method-not-allowed',
|
|
122
|
-
allow:
|
|
123
|
-
message: '
|
|
124
|
-
|
|
265
|
+
allow: route.allow,
|
|
266
|
+
message: `这个方法不是入口。这个路径只接受 ${route.allow};写入口只有 ${POST_PATHS.join(' / ')},而且都要令牌 + 同源。`,
|
|
267
|
+
getPaths: GET_PATHS,
|
|
268
|
+
postPaths: POST_PATHS,
|
|
269
|
+
}, { allow: route.allow });
|
|
125
270
|
return;
|
|
126
271
|
}
|
|
127
272
|
if (route.kind === 'not-found') {
|
|
128
273
|
req.resume();
|
|
129
|
-
writeJson(res, 404, { ok: false, error: 'not-found', paths:
|
|
274
|
+
writeJson(res, 404, { ok: false, error: 'not-found', paths: GET_PATHS, postPaths: POST_PATHS });
|
|
130
275
|
return;
|
|
131
276
|
}
|
|
132
277
|
if (route.kind === 'page') {
|
|
@@ -147,19 +292,74 @@ async function handle(req, res, deps) {
|
|
|
147
292
|
excluded: [],
|
|
148
293
|
generatedAt: new Date().toISOString(),
|
|
149
294
|
errors: [String(err && err.message ? err.message : err)],
|
|
150
|
-
}, { url: urlOf(deps.server) }));
|
|
295
|
+
}, { url: urlOf(deps.server), token: deps.token }));
|
|
151
296
|
return;
|
|
152
297
|
}
|
|
153
|
-
writeHtml(res, 200, renderPage(state, { url: urlOf(deps.server) }));
|
|
298
|
+
writeHtml(res, 200, renderPage(state, { url: urlOf(deps.server), token: deps.token }));
|
|
154
299
|
return;
|
|
155
300
|
}
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
301
|
+
if (route.kind === 'state') {
|
|
302
|
+
try {
|
|
303
|
+
const state = await deps.getState();
|
|
304
|
+
// service 字段是给"双击入口"认人的:只看 ok:true 会把别的本地服务当成自己的
|
|
305
|
+
writeJson(res, 200, { ...state, service: UI_SERVICE_ID });
|
|
306
|
+
} catch (err) {
|
|
307
|
+
writeJson(res, 500, { ok: false, error: 'internal-error', message: String(err && err.message ? err.message : err) });
|
|
308
|
+
}
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
if (route.kind === 'progress') {
|
|
312
|
+
// 纯内存读:不写日志、不 spawn、不加锁 —— 页面按 500–1000ms 轮询它
|
|
313
|
+
if (!deps.install) {
|
|
314
|
+
writeJson(res, 503, { ok: false, error: 'install-unavailable', message: '这个服务没有装执行器(不是 --ui 起的?)' });
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
const out = deps.install.progress();
|
|
318
|
+
writeJson(res, out.status, out.body);
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (!POST_KINDS.has(route.kind)) {
|
|
323
|
+
req.resume();
|
|
324
|
+
writeJson(res, 404, { ok: false, error: 'not-found' });
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// ── 闸 2:令牌 ────────────────────────────────────────────
|
|
329
|
+
if (!sameToken(req, deps.token)) {
|
|
330
|
+
req.resume();
|
|
331
|
+
writeJson(res, 403, {
|
|
332
|
+
ok: false,
|
|
333
|
+
error: 'bad-token',
|
|
334
|
+
message: `缺少或错误的 ${TOKEN_HEADER}:令牌只在进程启动时生成一次,印在控制台、也内嵌在页面里(整个进程生命周期有效,退出即失效)`,
|
|
335
|
+
});
|
|
336
|
+
return;
|
|
162
337
|
}
|
|
338
|
+
// ── 闸 3:同源 ────────────────────────────────────────────
|
|
339
|
+
const origin = checkOrigin(req, deps.server);
|
|
340
|
+
if (!origin.ok) {
|
|
341
|
+
req.resume();
|
|
342
|
+
writeJson(res, 403, { ok: false, error: origin.error, message: origin.reason, expected: origin.expected });
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
if (!deps.install) {
|
|
346
|
+
req.resume();
|
|
347
|
+
writeJson(res, 503, { ok: false, error: 'install-unavailable', message: '这个服务没有装执行器' });
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
// ── 读体(只收小 JSON)──────────────────────────────────
|
|
351
|
+
const body = await readJsonBody(req);
|
|
352
|
+
if (!body.ok) {
|
|
353
|
+
writeJson(res, 400, { ok: false, error: body.error, message: body.error === 'body-too-large' ? `请求体超过 ${MAX_BODY_BYTES} 字节` : '请求体不是合法 JSON' });
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
const value = body.value;
|
|
357
|
+
let out;
|
|
358
|
+
if (route.kind === 'preview') out = await deps.install.preview(value);
|
|
359
|
+
else if (route.kind === 'start') out = await deps.install.start(value);
|
|
360
|
+
else if (route.kind === 'cancel') out = deps.install.cancel(value);
|
|
361
|
+
else out = await deps.install.rollback(value);
|
|
362
|
+
writeJson(res, out.status, out.body);
|
|
163
363
|
}
|
|
164
364
|
|
|
165
365
|
function urlOf(server) {
|
|
@@ -216,14 +416,37 @@ export function listenLoopback(server, { port = DEFAULT_UI_PORT, attempts = PORT
|
|
|
216
416
|
});
|
|
217
417
|
}
|
|
218
418
|
|
|
419
|
+
/**
|
|
420
|
+
* 退出钩子:Ctrl+C / 关窗口时把**还在跑的子进程树**同步杀掉。
|
|
421
|
+
* 🔴 必须同步(`killAllRunningSync` 用的是 spawnSync):`process.on('exit')` 里只跑同步代码,
|
|
422
|
+
* 用异步 spawn 的版本等于"页面停了、npm 还在改 profile"(方案 R2)。
|
|
423
|
+
*
|
|
424
|
+
* 这里**故意不写**"interrupted 状态"(方案 §11.6 · B4):进度只在内存里,进程走了这一批就断了;
|
|
425
|
+
* 落一份"看起来还在跑/被中断"的状态文件,比让页面重开后按 `buildSuiteState()` + `verifyItem()`
|
|
426
|
+
* **现跑**给真实状态更不准 —— 而且 test-ui.mjs 断言的就是"向导页不往数据根留自己的状态"。
|
|
427
|
+
*/
|
|
428
|
+
export function installExitHooks({ out = process.stdout } = {}) {
|
|
429
|
+
let done = false;
|
|
430
|
+
const killOnce = (why) => {
|
|
431
|
+
if (done) return 0;
|
|
432
|
+
done = true;
|
|
433
|
+
const pids = runningChildPids();
|
|
434
|
+
const killed = killAllRunningSync();
|
|
435
|
+
if (pids.length) out.write(`\n⏹ ${why}:已同步杀掉 ${killed}/${pids.length} 棵子进程树(不然 npm 会在后台继续改 profile)\n`);
|
|
436
|
+
return killed;
|
|
437
|
+
};
|
|
438
|
+
process.once('exit', () => killOnce('进程退出'));
|
|
439
|
+
return { killOnce };
|
|
440
|
+
}
|
|
441
|
+
|
|
219
442
|
/**
|
|
220
443
|
* 起向导页(CLI 与夹具共用)。
|
|
221
444
|
* 这里解析**一次** npm 全局前缀,之后每个请求都用它 ⇒ 处理请求的路径零 spawn。
|
|
222
445
|
*/
|
|
223
|
-
export async function startUiServer({ options, port, attempts = PORT_SCAN }) {
|
|
446
|
+
export async function startUiServer({ options, port, attempts = PORT_SCAN, token = null, onLog = null }) {
|
|
224
447
|
const wantedPort = resolvePort(port);
|
|
225
448
|
const npm = await resolveNpmPrefix(options.npmPrefix);
|
|
226
|
-
//
|
|
449
|
+
// 向导页的**读**口径永远是"演练口径";真装只发生在 POST /install/start 里面(由执行器决定)
|
|
227
450
|
const uiOptions = { ...options, apply: false, uninstall: false, json: false };
|
|
228
451
|
const buildState = () => buildSuiteState(uiOptions, { npm });
|
|
229
452
|
|
|
@@ -234,7 +457,9 @@ export async function startUiServer({ options, port, attempts = PORT_SCAN }) {
|
|
|
234
457
|
|
|
235
458
|
const getState = async () => {
|
|
236
459
|
const built = await buildState();
|
|
237
|
-
|
|
460
|
+
// 🔴 状态文案只在这里算一次(lib/item-status.mjs#statusOf),随 state 一起下发:
|
|
461
|
+
// 页面首屏、/state、页面脚本读的都是同一个 `item.status` —— 两份实现必然漂移(§11.6 · S1)
|
|
462
|
+
if (built.ok) return decorateState(built.state);
|
|
238
463
|
// 清单/选择出问题(例如用户刚把清单改坏了):别 500,也别硬撑旧数据 —— 把错误原样给页面
|
|
239
464
|
return {
|
|
240
465
|
ok: false,
|
|
@@ -244,7 +469,18 @@ export async function startUiServer({ options, port, attempts = PORT_SCAN }) {
|
|
|
244
469
|
};
|
|
245
470
|
};
|
|
246
471
|
|
|
247
|
-
|
|
472
|
+
// 闸 2 的令牌:进程级(整个进程生命周期有效,退出即失效)。不是一次性 ——
|
|
473
|
+
// 一次性的话页面点第二个按钮就 403 了(preview 用完 start 就废)。
|
|
474
|
+
const installToken = typeof token === 'string' && token ? token : crypto.randomBytes(16).toString('hex');
|
|
475
|
+
const install = createInstallRunner({
|
|
476
|
+
options: uiOptions,
|
|
477
|
+
onLog: (line) => {
|
|
478
|
+
if (typeof onLog === 'function') onLog(line);
|
|
479
|
+
else process.stdout.write(`${line}\n`);
|
|
480
|
+
},
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
const server = createUiServer({ getState, install, token: installToken });
|
|
248
484
|
const bound = await listenLoopback(server, { port: wantedPort, attempts });
|
|
249
485
|
return {
|
|
250
486
|
ok: true,
|
|
@@ -254,10 +490,27 @@ export async function startUiServer({ options, port, attempts = PORT_SCAN }) {
|
|
|
254
490
|
requestedPort: wantedPort,
|
|
255
491
|
portChanged: bound.changed,
|
|
256
492
|
url: `http://${UI_HOST}:${bound.port}/`,
|
|
493
|
+
token: installToken,
|
|
494
|
+
install,
|
|
257
495
|
close: () => new Promise((resolve) => server.close(() => resolve())),
|
|
258
496
|
};
|
|
259
497
|
}
|
|
260
498
|
|
|
499
|
+
/** 起服务之后给人看的几行(CLI 与双击入口共用同一份文案) */
|
|
500
|
+
export function uiBannerLines(started) {
|
|
501
|
+
return [
|
|
502
|
+
'',
|
|
503
|
+
`向导页已经起来了:${started.url}`,
|
|
504
|
+
` 安装令牌(页面里也内嵌了;curl 手打要带上):${started.token}`,
|
|
505
|
+
' · 只绑 127.0.0.1:同一局域网的别的机器连不上',
|
|
506
|
+
` · 读:GET ${GET_PATHS.join('、')}`,
|
|
507
|
+
` · 写:POST ${POST_PATHS.join('、')}(都要 ${TOKEN_HEADER} + Origin 同源)`,
|
|
508
|
+
' · 页面上「复制命令」给你的就是 CLI 真会跑的那一行;命令行永远是真相,页面只是壳',
|
|
509
|
+
' · 换端口:--ui --port <n> · 关掉:Ctrl+C(关的时候会把还在跑的命令树一起杀掉)',
|
|
510
|
+
'',
|
|
511
|
+
];
|
|
512
|
+
}
|
|
513
|
+
|
|
261
514
|
/** `--ui` 的入口:起服务、打印地址、一直挂着直到 Ctrl+C。返回退出码。 */
|
|
262
515
|
export async function runUiCli(options, out = process.stdout) {
|
|
263
516
|
const started = await startUiServer({ options, port: options.uiPort });
|
|
@@ -268,17 +521,16 @@ export async function runUiCli(options, out = process.stdout) {
|
|
|
268
521
|
if (started.portChanged) {
|
|
269
522
|
out.write(`⚠ 端口 ${started.requestedPort} 被占了,已改用 ${started.port}\n`);
|
|
270
523
|
}
|
|
271
|
-
out.write(
|
|
272
|
-
|
|
273
|
-
out.write(' · 只有两个只读路由:GET /(页面)与 GET /state(与 --dry-run 同源的 JSON);其余方法与路径一律 405/404\n');
|
|
274
|
-
out.write(' · 页面上「复制命令」给你的就是 CLI 真会跑的那一行;页面自己一条命令都不执行\n');
|
|
275
|
-
out.write(' · 换端口:--ui --port <n> · 关掉:Ctrl+C\n\n');
|
|
524
|
+
for (const line of uiBannerLines(started)) out.write(`${line}\n`);
|
|
525
|
+
installExitHooks({ out });
|
|
276
526
|
|
|
277
527
|
await new Promise((resolve) => {
|
|
278
528
|
const done = () => resolve();
|
|
279
529
|
process.once('SIGINT', done);
|
|
280
530
|
process.once('SIGTERM', done);
|
|
281
531
|
});
|
|
532
|
+
// 关门前先把正在跑的命令杀掉:不然用户以为停了,npm 还在改 profile
|
|
533
|
+
killAllRunningSync();
|
|
282
534
|
await started.close();
|
|
283
535
|
out.write('\n向导页已关。\n');
|
|
284
536
|
return 0;
|
package/package.json
CHANGED
|
@@ -1,17 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kira905/dsh-butler-install",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Thin installer for the DSH butler suite: one command installs the plugin + CLI bundle through the host's own commands, then tells you whether it actually took effect, and can roll the whole batch back. Also ships a
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Thin installer for the DSH butler suite: one command installs the plugin + CLI bundle through the host's own commands, then tells you whether it actually took effect, and can roll the whole batch back. Also ships a local wizard page (--ui) that lives outside DSH and can install from the browser: nothing runs until you press confirm; loopback only, per-process token + same-origin check, four POST entry points (preview/start/cancel/rollback), two honest rollbacks. It does not isolate you from other programs running as the same local user.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"bin": {
|
|
8
8
|
"dsh-butler-install": "bin/dsh-butler-install.mjs",
|
|
9
|
+
"dsh-butler-install-ui": "bin/install-ui.mjs",
|
|
9
10
|
"dsh-butler-install-probe-scan": "bin/probe-scan.mjs"
|
|
10
11
|
},
|
|
11
12
|
"files": [
|
|
12
13
|
"bin/",
|
|
13
14
|
"lib/",
|
|
14
15
|
"suite-manifest.json",
|
|
16
|
+
"install-ui.cmd",
|
|
15
17
|
"README.md"
|
|
16
18
|
],
|
|
17
19
|
"engines": {
|
|
@@ -24,6 +26,11 @@
|
|
|
24
26
|
"rollback"
|
|
25
27
|
],
|
|
26
28
|
"scripts": {
|
|
27
|
-
"test": "node test/run-all.mjs"
|
|
29
|
+
"test": "node test/run-all.mjs",
|
|
30
|
+
"build:exe": "node scripts/build-exe.mjs"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"esbuild": "^0.28.2",
|
|
34
|
+
"postject": "1.0.0-alpha.6"
|
|
28
35
|
}
|
|
29
36
|
}
|