@kira905/dsh-butler-install 0.2.1 → 0.3.1

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/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. 只有两个 GET 路由:`GET /` 与 `GET /state`。其余方法(含 HEAD/OPTIONS)一律 405,
12
- * 其余路径一律 404;**服务端不执行任何 dsh/npm 命令,也没有任何写入口**。
13
- * 3. 数据与 `--dry-run` 同源:`GET /state` 返回的就是 lib/plan.mjs#buildSuiteState 的 `state`,
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>}} deps
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
- /** 路由表(纯函数式,好测):返回 'page' | 'state' | 'method-not-allowed' | 'not-found' | 'bad-request' */
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
- // 方法先判:非 GET 一律 405 —— 不管路径是什么,写方法的答案都只有一个"没有这种入口"
103
- if (String(method || '').toUpperCase() !== 'GET') return { kind: 'method-not-allowed' };
104
- if (pathname === '/') return { kind: 'page' };
105
- if (pathname === '/state') return { kind: 'state' };
106
- return { kind: 'not-found' };
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: 'GET',
123
- message: '这个向导页是只读的:只有 GET / 与 GET /state,没有写入口,也不会替你跑命令',
124
- }, { allow: 'GET' });
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: ['/', '/state'] });
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
- // route.kind === 'state'
157
- try {
158
- const state = await deps.getState();
159
- writeJson(res, 200, state);
160
- } catch (err) {
161
- writeJson(res, 500, { ok: false, error: 'internal-error', message: String(err && err.message ? err.message : err) });
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
- // 向导页永远是"演练口径":不带 --apply / --uninstall(CLI 层已拒绝这两个组合)
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
- if (built.ok) return built.state;
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
- const server = createUiServer({ getState });
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(`\n向导页已经起来了:${started.url}\n`);
272
- out.write(' · 只绑 127.0.0.1:同一局域网的别的机器连不上\n');
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.2.1",
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 read-only local wizard page (--ui) that lives outside DSH: loopback only, GET only, never executes anything.",
3
+ "version": "0.3.1",
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
  }
@@ -43,6 +43,16 @@
43
43
  "probe": { "kind": "existence", "cmd": "dsh-host-adapter-deploy" },
44
44
  "note": "只有客户端半,刻意不带 dsh.bundle:装进 profile 时会得到一行 declares no dsh.bundle 提示,这是设计如此、不是故障。真正的挂载由它自带的部署命令完成,本工具不替它挂,也不把它判红。"
45
45
  },
46
+ {
47
+ "name": "@kira905/dsh-task-board-local",
48
+ "versionRange": "^1.1.5",
49
+ "type": "plugin",
50
+ "group": "界面",
51
+ "mount": "auto",
52
+ "aliases": ["任务看板", "看板界面"],
53
+ "probe": { "kind": "existence" },
54
+ "note": "看板界面插件:从上游任务看板插件 fork 出来、已与上游脱钩,装完侧边栏出现「任务看板」(宿主半 + 浏览器半都在包内)。它只带 1 个运行时依赖(schemastery),peer 依赖 react 由宿主自带,别再单独装。⚠️ 它与官方任务看板插件抢同一个位置,二选一;装之前先看 profile 的组合列表里有没有官方那份,两个都挂上会互相顶。⚠️ 界面在它、命令行不在它 —— 那 30 个看板命令(board-card / board-card-run / board-act …)在另一条 @kira905/dsh-board-toolkit 里,要命令行就两条都装。下界钉在 1.1.5 有据:1.1.5(2026-10-19)是当前 npm latest,发布件自带 dsh.bundle(patch 指向 ./cordis.patch.yml)+ dsh.client,装进 profile 就会被挂载,本项按「必须挂载」判;0.0.0-stage / 1.1.4 没逐一核过,别往回降。"
55
+ },
46
56
  {
47
57
  "name": "@kira905/dsh-memory-toolkit",
48
58
  "versionRange": "^0.2.0",