@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.
@@ -0,0 +1,382 @@
1
+ /**
2
+ * 向导页的 HTML(单文件、样式与脚本全内联、零外部资源)。
3
+ *
4
+ * 三条纪律,改这个文件之前先看一眼:
5
+ * 1. **命令字符串只从 state 里读**(服务端已经写好 `data-command`),页面 JS 绝不拼命令。
6
+ * 这里的 JS 只做三件事:复制、重查、把新读数写回同一批 DOM 节点。
7
+ * 2. **不做"必须按 1→2→3"的假仪式**:组件之间没有代码依赖,页面只给建议顺序 + 为什么。
8
+ * 3. 一切内容用 textContent / 属性赋值写入,不拼 innerHTML —— state 里有包名与清单正文,
9
+ * 就算它们被改成怪字符,也不能变成页面上的脚本。
10
+ */
11
+
12
+ const HTML_ESCAPES = {
13
+ '&': '&',
14
+ '<': '&lt;',
15
+ '>': '&gt;',
16
+ '"': '&quot;',
17
+ "'": '&#39;',
18
+ };
19
+
20
+ export function escapeHtml(value) {
21
+ return String(value === null || value === undefined ? '' : value).replace(/[&<>"']/g, (c) => HTML_ESCAPES[c]);
22
+ }
23
+
24
+ function typeLabel(type) {
25
+ return type === 'plugin' ? '插件' : '命令行';
26
+ }
27
+
28
+ /** 页面上的时间统一按本地时区印(首屏是服务端渲染的,别让首屏显示 ISO 原文、重查后又变成本地时间) */
29
+ function formatStamp(value) {
30
+ const t = new Date(value);
31
+ if (Number.isNaN(t.getTime())) return String(value || '');
32
+ return t.toLocaleString('zh-CN', { hour12: false });
33
+ }
34
+
35
+ function statusOf(item) {
36
+ if (!item.installed) return { cls: 'badge-bad', text: '未装', hint: '复制命令去终端跑' };
37
+ if (item.inRange === 'unknown') return { cls: 'badge-warn', text: '已装', hint: '版本带预发布后缀,区间不判' };
38
+ if (item.inRange !== 'yes') return { cls: 'badge-warn', text: '已装', hint: '版本不在期望区间内' };
39
+ return { cls: 'badge-ok', text: '已装', hint: item.action === 'skip' ? '会跳过' : '会重跑' };
40
+ }
41
+
42
+ function renderRow(item) {
43
+ const status = statusOf(item);
44
+ const command = item.command || '';
45
+ const copyBtn = command
46
+ ? `<button type="button" class="btn btn-copy" data-copy data-command="${escapeHtml(command)}">复制命令</button>`
47
+ : `<button type="button" class="btn btn-copy" data-copy disabled title="本项已满足:不加 --upgrade 时 CLI 什么都不跑,没有可复制的命令">复制命令</button>`;
48
+ return [
49
+ `<tr data-key="${escapeHtml(item.name)}" data-step="${escapeHtml(item.step)}">`,
50
+ ` <td class="num">${escapeHtml(item.step)}</td>`,
51
+ ` <td class="pkg"><code>${escapeHtml(item.name)}</code>${item.note ? `<div class="note">${escapeHtml(item.note)}</div>` : ''}</td>`,
52
+ ` <td><span class="tag tag-${escapeHtml(item.type)}">${escapeHtml(typeLabel(item.type))}</span></td>`,
53
+ ` <td data-field="status"><span class="badge ${status.cls}">${status.text}</span><span class="hint">${status.hint}</span></td>`,
54
+ ` <td class="mono" data-field="version">${escapeHtml(item.installedVersion || '—')}</td>`,
55
+ ` <td class="mono" data-field="range" title="${escapeHtml(item.rangeText)}">${escapeHtml(item.versionRange)}</td>`,
56
+ ' <td class="ops">',
57
+ ` ${copyBtn}`,
58
+ ` <button type="button" class="btn btn-refresh" data-refresh data-key="${escapeHtml(item.name)}">重查</button>`,
59
+ ' </td>',
60
+ '</tr>',
61
+ ].join('\n');
62
+ }
63
+
64
+ function renderOrder(state) {
65
+ if (!state.order || !state.order.length) return '';
66
+ const steps = state.order.map((entry) => [
67
+ ' <li>',
68
+ ` <span class="step">${escapeHtml(entry.step)}</span>`,
69
+ ` <strong>${escapeHtml(entry.group)}</strong>`,
70
+ ` <span class="why">${escapeHtml(entry.why)}</span>`,
71
+ ` <div class="order-items">${entry.items.map((n) => `<code>${escapeHtml(n)}</code>`).join(' ')}</div>`,
72
+ ' </li>',
73
+ ].join('\n')).join('\n');
74
+ return [
75
+ '<section class="card">',
76
+ ' <h2>建议顺序(不是"必须的顺序")</h2>',
77
+ ` <ol class="order-list">\n${steps}\n </ol>`,
78
+ ...state.orderNotes.map((n) => ` <p class="order-note">${escapeHtml(n)}</p>`),
79
+ '</section>',
80
+ ].join('\n');
81
+ }
82
+
83
+ function renderExcluded(state) {
84
+ if (!state.excluded || !state.excluded.length) return '';
85
+ const rows = state.excluded
86
+ .map((e) => ` <li><code>${escapeHtml(e.id)}</code> —— ${escapeHtml(e.reason)}</li>`)
87
+ .join('\n');
88
+ return [
89
+ '<section class="card">',
90
+ ' <h2>不可安装项(清单里写明了原因,演练也会跳过)</h2>',
91
+ ` <ul class="excluded">\n${rows}\n </ul>`,
92
+ '</section>',
93
+ ].join('\n');
94
+ }
95
+
96
+ const CSS = `
97
+ :root{color-scheme:light;--bg:#f6f7fb;--card:#fff;--ink:#1b2330;--muted:#66707f;--line:#e3e7ef;--ok:#0f7b46;--okbg:#e6f5ec;--bad:#a3231f;--badbg:#fdeceb;--warn:#8a5a00;--warnbg:#fdf3e0;--accent:#2f5fd0}
98
+ *{box-sizing:border-box}
99
+ body{margin:0;padding:28px 20px 60px;background:var(--bg);color:var(--ink);font:15px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI","Microsoft YaHei",sans-serif}
100
+ .wrap{max-width:1120px;margin:0 auto}
101
+ h1{font-size:22px;margin:0 0 6px}
102
+ h2{font-size:16px;margin:0 0 12px}
103
+ .sub{color:var(--muted);margin:0 0 16px;max-width:70em}
104
+ .card{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:16px 18px;margin:0 0 16px}
105
+ .meta{display:grid;grid-template-columns:max-content 1fr;gap:4px 14px;font-size:13px;color:var(--muted);margin:0}
106
+ .meta dt{font-weight:600;color:var(--ink)}
107
+ .meta dd{margin:0;word-break:break-all;font-family:ui-monospace,SFMono-Regular,Consolas,"Courier New",monospace}
108
+ .banner{border-radius:10px;padding:10px 14px;margin:0 0 16px;font-size:14px;display:none;border:1px solid transparent}
109
+ .banner.show{display:block}
110
+ .banner.ok{background:var(--okbg);border-color:#b9e3cb;color:var(--ok)}
111
+ .banner.bad{background:var(--badbg);border-color:#f4c7c4;color:var(--bad)}
112
+ .banner.info{background:#eef2fb;border-color:#cdd9f3;color:#2c3f66}
113
+ table{width:100%;border-collapse:collapse;font-size:14px}
114
+ th,td{text-align:left;padding:9px 10px;border-bottom:1px solid var(--line);vertical-align:top}
115
+ th{font-size:12px;letter-spacing:.04em;color:var(--muted);font-weight:600;text-transform:none;white-space:nowrap}
116
+ tbody tr:last-child td{border-bottom:none}
117
+ tbody tr.flash{animation:flash 1.2s ease-out}
118
+ @keyframes flash{from{background:#fff8d9}to{background:transparent}}
119
+ .num{color:var(--muted);width:2.2em}
120
+ .pkg code{font-size:13px}
121
+ .note{color:var(--muted);font-size:12px;margin-top:3px;max-width:46em}
122
+ .mono,code{font-family:ui-monospace,SFMono-Regular,Consolas,"Courier New",monospace}
123
+ .tag{display:inline-block;font-size:12px;padding:1px 8px;border-radius:999px;border:1px solid var(--line);color:var(--muted);white-space:nowrap}
124
+ .tag-plugin{border-color:#cdd9f3;background:#eef2fb;color:#2c3f66}
125
+ .badge{display:inline-block;font-size:12px;font-weight:700;padding:1px 8px;border-radius:999px;margin-right:6px;white-space:nowrap}
126
+ .badge-ok{background:var(--okbg);color:var(--ok)}
127
+ .badge-bad{background:var(--badbg);color:var(--bad)}
128
+ .badge-warn{background:var(--warnbg);color:var(--warn)}
129
+ .hint{font-size:12px;color:var(--muted);white-space:nowrap}
130
+ .ops{white-space:nowrap}
131
+ .btn{font:inherit;font-size:13px;padding:4px 10px;border-radius:8px;border:1px solid var(--line);background:#fff;color:var(--ink);cursor:pointer;margin-right:6px}
132
+ .btn:hover:not(:disabled){border-color:#bcc6d8;background:#f7f9fc}
133
+ .btn:disabled{color:#a6adba;cursor:not-allowed;background:#fafbfd}
134
+ .btn-refresh{color:var(--accent);border-color:#cdd9f3;background:#f4f7fe}
135
+ .token{background:#eaf0ff;color:var(--accent);border-radius:6px;padding:0 6px}
136
+ .order-list{list-style:none;margin:0;padding:0}
137
+ .order-list li{position:relative;padding:6px 0 14px 34px;border-left:2px solid var(--line);margin-left:11px}
138
+ .order-list li:last-child{border-left-color:transparent;padding-bottom:0}
139
+ .order-list .step{position:absolute;left:-13px;top:4px;width:24px;height:24px;border-radius:50%;background:var(--accent);color:#fff;font-size:12px;font-weight:700;display:flex;align-items:center;justify-content:center}
140
+ .order-list .why{display:block;color:var(--muted);font-size:13px;margin-top:2px}
141
+ .order-items{margin-top:4px;font-size:12px;color:var(--muted)}
142
+ .order-items code{margin-right:10px}
143
+ .order-note{font-size:13px;color:var(--muted);margin:12px 0 0}
144
+ .order-note strong{color:var(--ink)}
145
+ .excluded{margin:0;padding-left:20px;font-size:13px;color:var(--muted)}
146
+ .excluded code{color:var(--ink)}
147
+ .sec{font-size:13px;color:var(--muted);margin:0}
148
+ footer{font-size:12px;color:var(--muted);margin-top:6px}
149
+ footer code{background:#eceff5;border-radius:4px;padding:1px 5px}
150
+ .bar{display:flex;flex-wrap:wrap;align-items:center;gap:12px;margin:0 0 12px}
151
+ .bar .count{font-size:13px;color:var(--muted)}
152
+ .bar .count b{color:var(--ink)}
153
+ `;
154
+
155
+ const SCRIPT = `
156
+ (function () {
157
+ 'use strict';
158
+ var banner = document.getElementById('banner');
159
+ function setBanner(text, kind) {
160
+ banner.textContent = text;
161
+ banner.className = 'banner show ' + (kind || 'info');
162
+ }
163
+ function clearBanner() {
164
+ banner.className = 'banner';
165
+ banner.textContent = '';
166
+ }
167
+ function statusOf(item) {
168
+ if (!item.installed) return { cls: 'badge-bad', text: '未装', hint: '复制命令去终端跑' };
169
+ if (item.inRange === 'unknown') return { cls: 'badge-warn', text: '已装', hint: '版本带预发布后缀,区间不判' };
170
+ if (item.inRange !== 'yes') return { cls: 'badge-warn', text: '已装', hint: '版本不在期望区间内' };
171
+ return { cls: 'badge-ok', text: '已装', hint: item.action === 'skip' ? '会跳过' : '会重跑' };
172
+ }
173
+ function applyState(state) {
174
+ var rows = document.querySelectorAll('tr[data-key]');
175
+ var byKey = Object.create(null);
176
+ for (var i = 0; i < state.items.length; i += 1) byKey[state.items[i].name] = state.items[i];
177
+ var matched = 0;
178
+ for (var r = 0; r < rows.length; r += 1) {
179
+ var row = rows[r];
180
+ var item = byKey[row.getAttribute('data-key')];
181
+ if (!item) continue;
182
+ matched += 1;
183
+ var st = statusOf(item);
184
+ var cell = row.querySelector('[data-field="status"]');
185
+ cell.textContent = '';
186
+ var badge = document.createElement('span');
187
+ badge.className = 'badge ' + st.cls;
188
+ badge.textContent = st.text;
189
+ var hint = document.createElement('span');
190
+ hint.className = 'hint';
191
+ hint.textContent = st.hint;
192
+ cell.appendChild(badge);
193
+ cell.appendChild(hint);
194
+ row.querySelector('[data-field="version"]').textContent = item.installedVersion || '—';
195
+ row.querySelector('[data-field="range"]').textContent = item.versionRange;
196
+ row.querySelector('[data-field="range"]').setAttribute('title', item.rangeText);
197
+ var copy = row.querySelector('[data-copy]');
198
+ if (item.command) {
199
+ copy.setAttribute('data-command', item.command);
200
+ copy.disabled = false;
201
+ copy.removeAttribute('title');
202
+ } else {
203
+ copy.removeAttribute('data-command');
204
+ copy.disabled = true;
205
+ copy.setAttribute('title', '本项已满足:不加 --upgrade 时 CLI 什么都不跑,没有可复制的命令');
206
+ }
207
+ }
208
+ var counts = state.counts || {};
209
+ var setText = function (key, value) {
210
+ var el = document.querySelector('[data-count="' + key + '"]');
211
+ if (el) el.textContent = String(value);
212
+ };
213
+ setText('install', counts.install);
214
+ setText('skip', counts.skip);
215
+ setText('selected', counts.selected);
216
+ var gen = document.querySelector('[data-generated]');
217
+ if (gen) gen.textContent = new Date(state.generatedAt).toLocaleString('zh-CN', { hour12: false });
218
+ if (matched !== rows.length) {
219
+ setBanner('清单条目变过了(页面 ' + rows.length + ' 行 / 现在 ' + state.items.length + ' 项):刷新一次页面才对得上。', 'info');
220
+ }
221
+ return matched === rows.length;
222
+ }
223
+ function flash(key) {
224
+ var row = document.querySelector('tr[data-key="' + (window.CSS && CSS.escape ? CSS.escape(key) : key) + '"]');
225
+ if (!row) return;
226
+ row.classList.remove('flash');
227
+ void row.offsetWidth;
228
+ row.classList.add('flash');
229
+ setTimeout(function () { row.classList.remove('flash'); }, 1300);
230
+ }
231
+ function copyText(text) {
232
+ if (navigator.clipboard && navigator.clipboard.writeText) {
233
+ return navigator.clipboard.writeText(text).catch(function () { return legacyCopy(text); });
234
+ }
235
+ return legacyCopy(text);
236
+ }
237
+ function legacyCopy(text) {
238
+ var ta = document.createElement('textarea');
239
+ ta.value = text;
240
+ ta.setAttribute('readonly', '');
241
+ ta.style.position = 'fixed';
242
+ ta.style.top = '-1000px';
243
+ document.body.appendChild(ta);
244
+ ta.select();
245
+ var ok = false;
246
+ try { ok = document.execCommand('copy'); } catch (e) { ok = false; }
247
+ document.body.removeChild(ta);
248
+ return ok ? undefined : Promise.reject(new Error('浏览器不允许复制'));
249
+ }
250
+ var busy = false;
251
+ function refresh(key) {
252
+ if (busy) return;
253
+ busy = true;
254
+ var buttons = document.querySelectorAll('[data-refresh]');
255
+ for (var i = 0; i < buttons.length; i += 1) buttons[i].disabled = true;
256
+ setBanner('重查中……(只重跑探测,不改任何状态)', 'info');
257
+ fetch('/state', { headers: { accept: 'application/json' }, cache: 'no-store' })
258
+ .then(function (res) { return res.json(); })
259
+ .then(function (state) {
260
+ if (!state || state.ok !== true) {
261
+ var why = state && state.errors ? state.errors.join(' / ') : '返回里没有 items';
262
+ setBanner('重查没成:' + why, 'bad');
263
+ return;
264
+ }
265
+ var sameSet = applyState(state);
266
+ if (sameSet) setBanner('重查完成:待装 ' + state.counts.install + ' 项 · 已满足 ' + state.counts.skip + ' 项(' + new Date(state.generatedAt).toLocaleTimeString('zh-CN', { hour12: false }) + ')', 'ok');
267
+ if (key) flash(key);
268
+ })
269
+ .catch(function (err) {
270
+ setBanner('重查失败:' + (err && err.message ? err.message : err), 'bad');
271
+ })
272
+ .then(function () {
273
+ for (var j = 0; j < buttons.length; j += 1) buttons[j].disabled = false;
274
+ busy = false;
275
+ });
276
+ }
277
+ document.addEventListener('click', function (ev) {
278
+ var btn = ev.target.closest ? ev.target.closest('button') : null;
279
+ if (!btn) return;
280
+ if (btn.hasAttribute('data-copy')) {
281
+ var cmd = btn.getAttribute('data-command');
282
+ if (!cmd) return;
283
+ copyText(cmd).then(function () {
284
+ var old = btn.textContent;
285
+ btn.textContent = '已复制 ✓';
286
+ setTimeout(function () { btn.textContent = old; }, 1100);
287
+ setBanner('已复制:' + cmd, 'ok');
288
+ }).catch(function () {
289
+ setBanner('复制不了(浏览器拦了剪贴板):手动选下面这行 —— ' + cmd, 'bad');
290
+ });
291
+ } else if (btn.hasAttribute('data-refresh')) {
292
+ refresh(btn.getAttribute('data-key'));
293
+ }
294
+ });
295
+ document.addEventListener('keydown', function (ev) {
296
+ if (ev.key === 'Escape') clearBanner();
297
+ });
298
+ })();
299
+ `;
300
+
301
+ /**
302
+ * 渲染整页。
303
+ * @param {object} state lib/plan.mjs#buildSuiteState 给出的 `state`
304
+ * @param {object} [opts] `{ url }`:打印在页脚,便于用户确认自己连的到底是哪一个地址
305
+ */
306
+ export function renderPage(state, opts = {}) {
307
+ const counts = state.counts || { total: 0, selected: 0, install: 0, skip: 0 };
308
+ const rows = (state.items || []).map(renderRow).join('\n');
309
+ const url = opts.url || '';
310
+ return `<!doctype html>
311
+ <html lang="zh-CN">
312
+ <head>
313
+ <meta charset="utf-8">
314
+ <meta name="viewport" content="width=device-width, initial-scale=1">
315
+ <meta name="referrer" content="no-referrer">
316
+ <title>大管家全家桶 · 安装向导(只读)</title>
317
+ <style>${CSS}</style>
318
+ </head>
319
+ <body>
320
+ <div class="wrap">
321
+ <h1>大管家全家桶 · 安装向导(只读)</h1>
322
+ <p class="sub">
323
+ 这个页面<span class="token">不执行任何命令</span>:它只告诉你每一项该在终端里跑哪一行(复制走),以及按「重查」再看看现在的状态。
324
+ 装还是你自己在终端跑 —— 装什么、成没成,得由安装器当场核对,不让一个网页替你做。
325
+ </p>
326
+
327
+ <div id="banner" class="banner"></div>
328
+
329
+ <section class="card">
330
+ <dl class="meta">
331
+ <dt>清单</dt><dd>${escapeHtml(state.manifest || '')}</dd>
332
+ <dt>数据根</dt><dd>${escapeHtml(state.home || '')}</dd>
333
+ <dt>profile</dt><dd>${escapeHtml(state.profile || '')}${state.profileExists ? '' : '(尚不存在,真装时由宿主自动创建)'}</dd>
334
+ <dt>全局包落点</dt><dd>${escapeHtml(state.npmPrefix || '')}(来源:${escapeHtml(state.npmPrefixSource || '')})</dd>
335
+ <dt>本次读数</dt><dd data-generated>${escapeHtml(formatStamp(state.generatedAt))}</dd>
336
+ </dl>
337
+ </section>
338
+
339
+ ${renderOrder(state)}
340
+
341
+ <section class="card">
342
+ <h2>清单(共 <span data-count="selected">${escapeHtml(counts.selected)}</span> 项选中 · 待装 <span data-count="install">${escapeHtml(counts.install)}</span> · 已满足 <span data-count="skip">${escapeHtml(counts.skip)}</span>)</h2>
343
+ <div class="bar">
344
+ <button type="button" class="btn btn-refresh" data-refresh data-key="">重查全部</button>
345
+ <span class="count">「重查」按的是同一份 <code>GET /state</code>(整批重探):清单是一次算出来的,拆开只查一行反而会给出互相矛盾的读数。</span>
346
+ </div>
347
+ <table>
348
+ <thead>
349
+ <tr>
350
+ <th>#</th>
351
+ <th>包名</th>
352
+ <th>类型</th>
353
+ <th>状态</th>
354
+ <th>版本</th>
355
+ <th>期望区间</th>
356
+ <th>操作</th>
357
+ </tr>
358
+ </thead>
359
+ <tbody>
360
+ ${rows}
361
+ </tbody>
362
+ </table>
363
+ </section>
364
+
365
+ ${renderExcluded(state)}
366
+
367
+ <section class="card">
368
+ <h2>装完怎么验(想手工核就走这两条,都不用跑 <code>--help</code>)</h2>
369
+ <p class="sec">① 插件类:<code>dsh --profile ${escapeHtml(state.profile || 'web')} --dump-config</code>,在输出里搜 <code># == &lt;包名&gt;</code> —— 恰好 1 次才算真挂上。</p>
370
+ <p class="sec">② 命令行类:看 <code>&lt;全局前缀&gt;/node_modules/&lt;包&gt;/package.json</code> 在不在(只查文件,不执行命令)。</p>
371
+ <p class="sec">③ 想省事的整批自检:<code>node &lt;dsh-butler-core 仓根&gt;/selfcheck-replica.mjs --manifest suite-manifest.json</code>。</p>
372
+ </section>
373
+
374
+ <footer>
375
+ 安全边界:只绑 <code>127.0.0.1</code>(别的机器连不上)· 只有 <code>GET</code>(除 <code>/</code> 与 <code>/state</code> 之外一律 405)· 没有写入口、不跑 <code>dsh</code>/<code>npm</code> 命令${url ? ` · 当前地址 <code>${escapeHtml(url)}</code>` : ''}
376
+ </footer>
377
+ </div>
378
+ <script>${SCRIPT}</script>
379
+ </body>
380
+ </html>
381
+ `;
382
+ }
@@ -0,0 +1,285 @@
1
+ /**
2
+ * `--ui`:把「这次会做什么」摆成一个**挂在 DSH 外面**的本地只读页面。
3
+ *
4
+ * 为什么不塞进 DSH 侧边栏(这是本模块存在的理由,不是懒):
5
+ * - 插件页要装插件 + 写 cordis.patch.yml + 重启;DSH 一升级,client DOM 注入就可能崩。
6
+ * - 本页零安装、零重启,连"DSH 还没装"的时候都能开起来 —— 那时候它正好能告诉你"你得先装 DSH"。
7
+ * - 代价只有一个:要监听一个本地端口。所以**只许 127.0.0.1**,写死,不接受任何形式的放宽。
8
+ *
9
+ * 三条红线(改这个文件之前先读):
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`,
14
+ * 页面上「复制命令」读到的那行字符串也是它 —— 页面自己不许拼命令。
15
+ */
16
+ import http from 'node:http';
17
+ import { resolveNpmPrefix } from './state.mjs';
18
+ import { buildSuiteState } from './plan.mjs';
19
+ import { renderPage } from './ui-page.mjs';
20
+
21
+ /** 写死。不读环境变量、不加参数 —— 这类"临时放宽一下"的开关最后都会留在生产里。 */
22
+ export const UI_HOST = '127.0.0.1';
23
+ export const DEFAULT_UI_PORT = 45080;
24
+ /** 端口被占时往后试几个(45080, 45081, …),都不行就让系统随便挑一个 */
25
+ export const PORT_SCAN = 20;
26
+
27
+ const BASE_HEADERS = {
28
+ 'cache-control': 'no-store',
29
+ 'x-content-type-options': 'nosniff',
30
+ 'referrer-policy': 'no-referrer',
31
+ };
32
+
33
+ const HTML_CSP = "default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; connect-src 'self'; base-uri 'none'; form-action 'none'";
34
+
35
+ function writeHtml(res, status, body) {
36
+ res.writeHead(status, {
37
+ ...BASE_HEADERS,
38
+ 'content-type': 'text/html; charset=utf-8',
39
+ 'content-security-policy': HTML_CSP,
40
+ });
41
+ res.end(body);
42
+ }
43
+
44
+ function writeJson(res, status, body, extra = {}) {
45
+ res.writeHead(status, {
46
+ ...BASE_HEADERS,
47
+ 'content-type': 'application/json; charset=utf-8',
48
+ ...extra,
49
+ });
50
+ res.end(JSON.stringify(body));
51
+ }
52
+
53
+ /**
54
+ * 端口口径:没给(null/undefined)⇒ 默认 45080;给了 0 ⇒ 明确表示"让系统挑",尊重它。
55
+ * (`port = DEFAULT_UI_PORT` 的默认参数挡不住显式传进来的 null —— 这个坑实测踩过:
56
+ * `--ui` 不带 --port 时服务落到了系统随机端口,打印的地址和文档里写的默认端口对不上。)
57
+ */
58
+ export function resolvePort(requested) {
59
+ if (requested === null || requested === undefined) return DEFAULT_UI_PORT;
60
+ return requested;
61
+ }
62
+
63
+ /** 只允许环回地址。传别的一律抛,绝不给"先跑起来再说"的机会。 */
64
+ export function assertLoopbackHost(host) {
65
+ if (host !== UI_HOST) {
66
+ throw new Error(`向导页只许绑 ${UI_HOST}(现在传的是 ${JSON.stringify(host)}):绑 0.0.0.0 / :: 等于把本机服务摊给同网段的每台机器`);
67
+ }
68
+ return host;
69
+ }
70
+
71
+ /**
72
+ * 建 server(不 listen),把路由表焊死在这里。
73
+ * @param {{getState:() => Promise<object>}} deps
74
+ */
75
+ export function createUiServer({ getState }) {
76
+ if (typeof getState !== 'function') throw new Error('createUiServer 需要 getState(返回与 --dry-run 同源的 state)');
77
+ const server = http.createServer((req, res) => {
78
+ handle(req, res, { getState, server }).catch((err) => {
79
+ // 处理器里出啥事都不能让进程崩:状态码照实报,正文说明白
80
+ try {
81
+ writeJson(res, 500, { ok: false, error: 'internal-error', message: String(err && err.message ? err.message : err) });
82
+ } catch {
83
+ res.destroy();
84
+ }
85
+ });
86
+ });
87
+ server.on('clientError', (err, socket) => {
88
+ if (socket.writable) socket.end('HTTP/1.1 400 Bad Request\r\nconnection: close\r\n\r\n');
89
+ else socket.destroy();
90
+ });
91
+ return server;
92
+ }
93
+
94
+ /** 路由表(纯函数式,好测):返回 'page' | 'state' | 'method-not-allowed' | 'not-found' | 'bad-request' */
95
+ export function routeOf(method, rawUrl) {
96
+ let pathname;
97
+ try {
98
+ pathname = new URL(String(rawUrl || '/'), `http://${UI_HOST}`).pathname;
99
+ } catch {
100
+ return { kind: 'bad-request' };
101
+ }
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' };
107
+ }
108
+
109
+ async function handle(req, res, deps) {
110
+ const route = routeOf(req.method, req.url);
111
+ if (route.kind === 'bad-request') {
112
+ req.resume();
113
+ writeJson(res, 400, { ok: false, error: 'bad-request' });
114
+ return;
115
+ }
116
+ if (route.kind === 'method-not-allowed') {
117
+ // 不解析请求体、不落任何状态:把连接读空然后挂断式回答
118
+ req.resume();
119
+ writeJson(res, 405, {
120
+ ok: false,
121
+ error: 'method-not-allowed',
122
+ allow: 'GET',
123
+ message: '这个向导页是只读的:只有 GET / 与 GET /state,没有写入口,也不会替你跑命令',
124
+ }, { allow: 'GET' });
125
+ return;
126
+ }
127
+ if (route.kind === 'not-found') {
128
+ req.resume();
129
+ writeJson(res, 404, { ok: false, error: 'not-found', paths: ['/', '/state'] });
130
+ return;
131
+ }
132
+ if (route.kind === 'page') {
133
+ let state;
134
+ try {
135
+ state = await deps.getState();
136
+ } catch (err) {
137
+ writeHtml(res, 200, renderPage({
138
+ ok: false,
139
+ manifest: '',
140
+ home: '',
141
+ profile: '',
142
+ npmPrefix: '',
143
+ items: [],
144
+ counts: { total: 0, selected: 0, install: 0, skip: 0 },
145
+ order: [],
146
+ orderNotes: [],
147
+ excluded: [],
148
+ generatedAt: new Date().toISOString(),
149
+ errors: [String(err && err.message ? err.message : err)],
150
+ }, { url: urlOf(deps.server) }));
151
+ return;
152
+ }
153
+ writeHtml(res, 200, renderPage(state, { url: urlOf(deps.server) }));
154
+ return;
155
+ }
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) });
162
+ }
163
+ }
164
+
165
+ function urlOf(server) {
166
+ try {
167
+ const addr = server.address();
168
+ return addr && typeof addr === 'object' ? `http://${UI_HOST}:${addr.port}/` : `http://${UI_HOST}:?/`;
169
+ } catch {
170
+ return `http://${UI_HOST}:?/`;
171
+ }
172
+ }
173
+
174
+ /**
175
+ * 在环回地址上听;端口被占就往后试,最后兜底让系统挑。
176
+ * @returns {Promise<{port:number, address:string, requested:number, changed:boolean}>}
177
+ */
178
+ export function listenLoopback(server, { port = DEFAULT_UI_PORT, attempts = PORT_SCAN } = {}) {
179
+ assertLoopbackHost(UI_HOST);
180
+ const candidates = [];
181
+ if (Number.isInteger(port) && port > 0) {
182
+ for (let i = 0; i < attempts && port + i <= 65535; i += 1) candidates.push(port + i);
183
+ }
184
+ candidates.push(0); // 兜底:让系统分配(只有全部撞车才会走到这里)
185
+
186
+ return new Promise((resolve, reject) => {
187
+ let index = 0;
188
+ const tryNext = () => {
189
+ const candidate = candidates[index];
190
+ index += 1;
191
+ const onError = (err) => {
192
+ server.removeListener('listening', onListening);
193
+ if (err && (err.code === 'EADDRINUSE' || err.code === 'EACCES')) {
194
+ if (index < candidates.length) tryNext();
195
+ else reject(err);
196
+ return;
197
+ }
198
+ reject(err);
199
+ };
200
+ const onListening = () => {
201
+ server.removeListener('error', onError);
202
+ const addr = server.address();
203
+ // 兜底再核一次:真的只绑在 127.0.0.1 上,而不是"我以为我绑了"
204
+ if (!addr || addr.address !== UI_HOST) {
205
+ server.close();
206
+ reject(new Error(`听的地址是 ${addr && addr.address},不是 ${UI_HOST} —— 拒绝启动(这是红线,不是配置)`));
207
+ return;
208
+ }
209
+ resolve({ port: addr.port, address: addr.address, requested: port, changed: port > 0 && addr.port !== port });
210
+ };
211
+ server.once('error', onError);
212
+ server.once('listening', onListening);
213
+ server.listen(candidate, UI_HOST);
214
+ };
215
+ tryNext();
216
+ });
217
+ }
218
+
219
+ /**
220
+ * 起向导页(CLI 与夹具共用)。
221
+ * 这里解析**一次** npm 全局前缀,之后每个请求都用它 ⇒ 处理请求的路径零 spawn。
222
+ */
223
+ export async function startUiServer({ options, port, attempts = PORT_SCAN }) {
224
+ const wantedPort = resolvePort(port);
225
+ const npm = await resolveNpmPrefix(options.npmPrefix);
226
+ // 向导页永远是"演练口径":不带 --apply / --uninstall(CLI 层已拒绝这两个组合)
227
+ const uiOptions = { ...options, apply: false, uninstall: false, json: false };
228
+ const buildState = () => buildSuiteState(uiOptions, { npm });
229
+
230
+ const first = await buildState();
231
+ if (!first.ok) {
232
+ return { ok: false, exitCode: first.exitCode, log: first.log };
233
+ }
234
+
235
+ const getState = async () => {
236
+ const built = await buildState();
237
+ if (built.ok) return built.state;
238
+ // 清单/选择出问题(例如用户刚把清单改坏了):别 500,也别硬撑旧数据 —— 把错误原样给页面
239
+ return {
240
+ ok: false,
241
+ exitCode: built.exitCode,
242
+ generatedAt: new Date().toISOString(),
243
+ errors: built.log.filter((l) => l && l.trim()),
244
+ };
245
+ };
246
+
247
+ const server = createUiServer({ getState });
248
+ const bound = await listenLoopback(server, { port: wantedPort, attempts });
249
+ return {
250
+ ok: true,
251
+ server,
252
+ port: bound.port,
253
+ address: bound.address,
254
+ requestedPort: wantedPort,
255
+ portChanged: bound.changed,
256
+ url: `http://${UI_HOST}:${bound.port}/`,
257
+ close: () => new Promise((resolve) => server.close(() => resolve())),
258
+ };
259
+ }
260
+
261
+ /** `--ui` 的入口:起服务、打印地址、一直挂着直到 Ctrl+C。返回退出码。 */
262
+ export async function runUiCli(options, out = process.stdout) {
263
+ const started = await startUiServer({ options, port: options.uiPort });
264
+ if (!started.ok) {
265
+ for (const line of started.log) out.write(`${line}\n`);
266
+ return started.exitCode;
267
+ }
268
+ if (started.portChanged) {
269
+ out.write(`⚠ 端口 ${started.requestedPort} 被占了,已改用 ${started.port}\n`);
270
+ }
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');
276
+
277
+ await new Promise((resolve) => {
278
+ const done = () => resolve();
279
+ process.once('SIGINT', done);
280
+ process.once('SIGTERM', done);
281
+ });
282
+ await started.close();
283
+ out.write('\n向导页已关。\n');
284
+ return 0;
285
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@kira905/dsh-butler-install",
3
- "version": "0.1.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.",
3
+ "version": "0.2.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 read-only local wizard page (--ui) that lives outside DSH: loopback only, GET only, never executes anything.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "bin": {