@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-page.mjs
CHANGED
|
@@ -1,13 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 向导页的 HTML(单文件、样式与脚本全内联、零外部资源)。
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* 1.
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
4
|
+
* 四条纪律,改这个文件之前先看一眼:
|
|
5
|
+
* 1. **命令字符串只从服务端来**:单条命令来自 `state.items[i].command`,整批命令来自
|
|
6
|
+
* `preview.equivalentCommand`(**带 `--apply`**)。页面 JS 绝不自己拼命令 ——
|
|
7
|
+
* 拼出来的那条和安装器真跑的必然不是同一条。
|
|
8
|
+
* 2. **状态文案不算在页面里**:`statusOf()` 只存在于 `lib/item-status.mjs` 一处,
|
|
9
|
+
* 服务端算好随 `state`(`item.status`)与 `progress`(`item.label`)一起下发,
|
|
10
|
+
* 页面脚本只读不算(方案 §11.6 · S1:两份实现必然漂移)。
|
|
11
|
+
* 3. **不做"必须按 1→2→3"的假仪式**:组件之间没有代码依赖,页面只给建议顺序 + 为什么。
|
|
12
|
+
* 4. 一切内容用 textContent / 属性赋值写入,不拼 innerHTML —— state 里有包名与清单正文,
|
|
9
13
|
* 就算它们被改成怪字符,也不能变成页面上的脚本。
|
|
14
|
+
*
|
|
15
|
+
* 这一版和 0.2.x 的"只读页"最大的不同:**页面能真装**。
|
|
16
|
+
* 所以文案必须同时说清两件事:①点了才装、不点不装;②安全边界到哪为止(见页脚)。
|
|
10
17
|
*/
|
|
18
|
+
import { statusOf } from './item-status.mjs';
|
|
11
19
|
|
|
12
20
|
const HTML_ESCAPES = {
|
|
13
21
|
'&': '&',
|
|
@@ -32,13 +40,6 @@ function formatStamp(value) {
|
|
|
32
40
|
return t.toLocaleString('zh-CN', { hour12: false });
|
|
33
41
|
}
|
|
34
42
|
|
|
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
43
|
function renderRow(item) {
|
|
43
44
|
const status = statusOf(item);
|
|
44
45
|
const command = item.command || '';
|
|
@@ -50,7 +51,7 @@ function renderRow(item) {
|
|
|
50
51
|
` <td class="num">${escapeHtml(item.step)}</td>`,
|
|
51
52
|
` <td class="pkg"><code>${escapeHtml(item.name)}</code>${item.note ? `<div class="note">${escapeHtml(item.note)}</div>` : ''}</td>`,
|
|
52
53
|
` <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 data-field="status"><span class="badge ${status.cls}">${status.text}</span><span class="hint">${escapeHtml(status.hint)}</span></td>`,
|
|
54
55
|
` <td class="mono" data-field="version">${escapeHtml(item.installedVersion || '—')}</td>`,
|
|
55
56
|
` <td class="mono" data-field="range" title="${escapeHtml(item.rangeText)}">${escapeHtml(item.versionRange)}</td>`,
|
|
56
57
|
' <td class="ops">',
|
|
@@ -93,22 +94,43 @@ function renderExcluded(state) {
|
|
|
93
94
|
].join('\n');
|
|
94
95
|
}
|
|
95
96
|
|
|
97
|
+
/** 前置条件:这两条不满足,后面全白搭 —— 所以摆在最上面,而不是藏在页脚 */
|
|
98
|
+
function renderPrereq() {
|
|
99
|
+
return [
|
|
100
|
+
'<section class="card prereq">',
|
|
101
|
+
' <h2>开始之前:两个前提</h2>',
|
|
102
|
+
' <ul>',
|
|
103
|
+
' <li><b>要有 Node.js 22 或更新</b>。这个安装器自己就是个 node 脚本:没有 node 它连页面都起不来'
|
|
104
|
+
+ '(双击那个 <code>.cmd</code> 会告诉你"没找到 node"并给你下载地址)。下载地址:<code>https://nodejs.org/en/download</code></li>',
|
|
105
|
+
' <li><b>要已经装好 DSH</b>。🔴 <b>本工具不装 DSH</b>,也不装 node —— 它只往你<b>已有</b>的 DSH 里装插件与命令行工具。</li>',
|
|
106
|
+
' </ul>',
|
|
107
|
+
' <p class="sec">下面那张表读的是你这台机器上的真实数据根:只看文件、不执行命令、不写一个字节。</p>',
|
|
108
|
+
'</section>',
|
|
109
|
+
].join('\n');
|
|
110
|
+
}
|
|
111
|
+
|
|
96
112
|
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}
|
|
113
|
+
: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;--run:#1f4fa8;--runbg:#e8efff}
|
|
98
114
|
*{box-sizing:border-box}
|
|
99
115
|
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
116
|
.wrap{max-width:1120px;margin:0 auto}
|
|
101
117
|
h1{font-size:22px;margin:0 0 6px}
|
|
102
118
|
h2{font-size:16px;margin:0 0 12px}
|
|
119
|
+
h3{font-size:15px;margin:16px 0 8px}
|
|
120
|
+
h4{font-size:14px;margin:14px 0 6px;color:var(--muted)}
|
|
103
121
|
.sub{color:var(--muted);margin:0 0 16px;max-width:70em}
|
|
104
122
|
.card{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:16px 18px;margin:0 0 16px}
|
|
123
|
+
.prereq{background:#fbfcff;border-color:#dce5f7}
|
|
124
|
+
.prereq ul{margin:0 0 10px;padding-left:20px}
|
|
125
|
+
.prereq li{margin:4px 0}
|
|
105
126
|
.meta{display:grid;grid-template-columns:max-content 1fr;gap:4px 14px;font-size:13px;color:var(--muted);margin:0}
|
|
106
127
|
.meta dt{font-weight:600;color:var(--ink)}
|
|
107
128
|
.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}
|
|
129
|
+
.banner{border-radius:10px;padding:10px 14px;margin:0 0 16px;font-size:14px;display:none;border:1px solid transparent;white-space:pre-wrap}
|
|
109
130
|
.banner.show{display:block}
|
|
110
131
|
.banner.ok{background:var(--okbg);border-color:#b9e3cb;color:var(--ok)}
|
|
111
132
|
.banner.bad{background:var(--badbg);border-color:#f4c7c4;color:var(--bad)}
|
|
133
|
+
.banner.warn{background:var(--warnbg);border-color:#f0dcb4;color:var(--warn)}
|
|
112
134
|
.banner.info{background:#eef2fb;border-color:#cdd9f3;color:#2c3f66}
|
|
113
135
|
table{width:100%;border-collapse:collapse;font-size:14px}
|
|
114
136
|
th,td{text-align:left;padding:9px 10px;border-bottom:1px solid var(--line);vertical-align:top}
|
|
@@ -126,13 +148,44 @@ tbody tr.flash{animation:flash 1.2s ease-out}
|
|
|
126
148
|
.badge-ok{background:var(--okbg);color:var(--ok)}
|
|
127
149
|
.badge-bad{background:var(--badbg);color:var(--bad)}
|
|
128
150
|
.badge-warn{background:var(--warnbg);color:var(--warn)}
|
|
129
|
-
.
|
|
151
|
+
.badge-run{background:var(--runbg);color:var(--run)}
|
|
152
|
+
.badge-wait{background:#f1f2f6;color:var(--muted)}
|
|
153
|
+
.badge-skip{background:#f1f2f6;color:var(--muted)}
|
|
154
|
+
.hint{font-size:12px;color:var(--muted);white-space:normal}
|
|
130
155
|
.ops{white-space:nowrap}
|
|
131
|
-
.btn{font:inherit;font-size:13px;padding:
|
|
156
|
+
.btn{font:inherit;font-size:13px;padding:6px 12px;border-radius:8px;border:1px solid var(--line);background:#fff;color:var(--ink);cursor:pointer;margin-right:6px}
|
|
132
157
|
.btn:hover:not(:disabled){border-color:#bcc6d8;background:#f7f9fc}
|
|
133
158
|
.btn:disabled{color:#a6adba;cursor:not-allowed;background:#fafbfd}
|
|
159
|
+
.btn-primary{background:var(--accent);border-color:var(--accent);color:#fff;font-weight:600;padding:8px 18px}
|
|
160
|
+
.btn-primary:hover:not(:disabled){background:#264fae;border-color:#264fae;color:#fff}
|
|
161
|
+
.btn-danger{color:var(--bad);border-color:#f0c4c1;background:#fff6f5}
|
|
134
162
|
.btn-refresh{color:var(--accent);border-color:#cdd9f3;background:#f4f7fe}
|
|
135
163
|
.token{background:#eaf0ff;color:var(--accent);border-radius:6px;padding:0 6px}
|
|
164
|
+
.confirm{border-top:1px solid var(--line);margin-top:14px;padding-top:6px}
|
|
165
|
+
.confirm-items{margin:0;padding-left:22px;font-size:13px}
|
|
166
|
+
.confirm-items li{margin:2px 0}
|
|
167
|
+
.confirm-items .g{color:var(--muted)}
|
|
168
|
+
.confirm-actions{margin-top:16px}
|
|
169
|
+
.equiv{background:#f7f9fc;border:1px solid var(--line);border-radius:10px;padding:10px 12px;margin:14px 0 4px}
|
|
170
|
+
.equiv-head{font-size:12px;color:var(--muted);margin-bottom:6px}
|
|
171
|
+
.equiv code{display:block;font-size:13px;word-break:break-all;margin:0 0 8px;color:var(--ink)}
|
|
172
|
+
.gauge{height:8px;background:#e9edf5;border-radius:999px;overflow:hidden;width:260px;max-width:50vw}
|
|
173
|
+
.gauge-fill{height:100%;width:0;background:var(--run);transition:width .25s ease}
|
|
174
|
+
.run-note{font-size:13px;margin:10px 0 4px;white-space:pre-wrap}
|
|
175
|
+
.run-note.bad{color:var(--bad)}
|
|
176
|
+
.run-note.ok{color:var(--ok)}
|
|
177
|
+
table.prog td{font-size:13px}
|
|
178
|
+
.run-actions{margin-top:14px}
|
|
179
|
+
.restart{margin-top:16px;border:1px solid #b9e3cb;background:var(--okbg);border-radius:10px;padding:12px 14px;color:#0d5f36}
|
|
180
|
+
.restart b{font-size:15px}
|
|
181
|
+
.restart div{margin-top:6px;font-size:13px;color:#14663c}
|
|
182
|
+
.log-box{margin-top:14px;font-size:13px;color:var(--muted)}
|
|
183
|
+
.log-box summary{cursor:pointer}
|
|
184
|
+
.log-box pre{max-height:260px;overflow:auto;background:#0f1724;color:#cfe1f7;border-radius:10px;padding:10px 12px;font-size:12px;line-height:1.5;white-space:pre-wrap;word-break:break-all}
|
|
185
|
+
.rb{display:grid;grid-template-columns:1fr 1fr;gap:16px}
|
|
186
|
+
@media (max-width:820px){.rb{grid-template-columns:1fr}}
|
|
187
|
+
.rb-item{border:1px solid var(--line);border-radius:10px;padding:12px 14px}
|
|
188
|
+
.rb-item p{font-size:13px;margin:8px 0 0}
|
|
136
189
|
.order-list{list-style:none;margin:0;padding:0}
|
|
137
190
|
.order-list li{position:relative;padding:6px 0 14px 34px;border-left:2px solid var(--line);margin-left:11px}
|
|
138
191
|
.order-list li:last-child{border-left-color:transparent;padding-bottom:0}
|
|
@@ -145,7 +198,7 @@ tbody tr.flash{animation:flash 1.2s ease-out}
|
|
|
145
198
|
.excluded{margin:0;padding-left:20px;font-size:13px;color:var(--muted)}
|
|
146
199
|
.excluded code{color:var(--ink)}
|
|
147
200
|
.sec{font-size:13px;color:var(--muted);margin:0}
|
|
148
|
-
footer{font-size:12px;color:var(--muted);margin-top:6px}
|
|
201
|
+
footer{font-size:12px;color:var(--muted);margin-top:6px;line-height:1.8}
|
|
149
202
|
footer code{background:#eceff5;border-radius:4px;padding:1px 5px}
|
|
150
203
|
.bar{display:flex;flex-wrap:wrap;align-items:center;gap:12px;margin:0 0 12px}
|
|
151
204
|
.bar .count{font-size:13px;color:var(--muted)}
|
|
@@ -155,7 +208,16 @@ footer code{background:#eceff5;border-radius:4px;padding:1px 5px}
|
|
|
155
208
|
const SCRIPT = `
|
|
156
209
|
(function () {
|
|
157
210
|
'use strict';
|
|
211
|
+
var tokenMeta = document.querySelector('meta[name="install-token"]');
|
|
212
|
+
var TOKEN = tokenMeta ? (tokenMeta.getAttribute('content') || '') : '';
|
|
158
213
|
var banner = document.getElementById('banner');
|
|
214
|
+
var busy = false;
|
|
215
|
+
var polling = false;
|
|
216
|
+
var timer = null;
|
|
217
|
+
|
|
218
|
+
function el(id) { return document.getElementById(id); }
|
|
219
|
+
function show(node, on) { if (node) node.hidden = !on; }
|
|
220
|
+
function setText(node, value) { if (node) node.textContent = value; }
|
|
159
221
|
function setBanner(text, kind) {
|
|
160
222
|
banner.textContent = text;
|
|
161
223
|
banner.className = 'banner show ' + (kind || 'info');
|
|
@@ -164,12 +226,277 @@ const SCRIPT = `
|
|
|
164
226
|
banner.className = 'banner';
|
|
165
227
|
banner.textContent = '';
|
|
166
228
|
}
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
229
|
+
|
|
230
|
+
function api(path, body) {
|
|
231
|
+
return fetch(path, {
|
|
232
|
+
method: 'POST',
|
|
233
|
+
cache: 'no-store',
|
|
234
|
+
headers: { 'content-type': 'application/json', accept: 'application/json', 'x-install-token': TOKEN },
|
|
235
|
+
body: JSON.stringify(body || {})
|
|
236
|
+
}).then(function (res) {
|
|
237
|
+
return res.text().then(function (text) {
|
|
238
|
+
var data = null;
|
|
239
|
+
try { data = JSON.parse(text); } catch (e) { data = null; }
|
|
240
|
+
if (!data) data = { ok: false, error: 'bad-json', message: String(text).slice(0, 400) };
|
|
241
|
+
return { status: res.status, data: data };
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function setBusy(on) {
|
|
247
|
+
busy = on === true;
|
|
248
|
+
// 【取消】的可用性由进度读数说了算(renderProgress 里按 can.cancel 设),不归这里管
|
|
249
|
+
var buttons = document.querySelectorAll('button[data-action]:not([data-action="cancel"])');
|
|
250
|
+
for (var i = 0; i < buttons.length; i += 1) buttons[i].disabled = busy;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ── 第一步:算一遍(不装) ──────────────────────────────
|
|
254
|
+
function preview() {
|
|
255
|
+
setBusy(true);
|
|
256
|
+
setBanner('正在算一遍:这一步只算不做 —— 一条命令都不跑、一个文件都不写。', 'info');
|
|
257
|
+
api('/install/preview').then(function (r) {
|
|
258
|
+
if (r.status !== 200 || !r.data.ok) {
|
|
259
|
+
var why = r.data.message || (r.data.errors && r.data.errors.join(' / ')) || r.data.error || ('HTTP ' + r.status);
|
|
260
|
+
setBanner('算不出来:' + why, 'bad');
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
renderConfirm(r.data);
|
|
264
|
+
show(el('confirm-box'), true);
|
|
265
|
+
show(el('install-actions'), false);
|
|
266
|
+
setBanner('算好了。下面写的就是点了【确认,开始】之后会做的事;现在还没动任何东西。', 'info');
|
|
267
|
+
}).catch(function (err) {
|
|
268
|
+
setBanner('请求没发出去:' + (err && err.message ? err.message : err), 'bad');
|
|
269
|
+
}).then(function () { setBusy(false); });
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function renderConfirm(p) {
|
|
273
|
+
setText(el('c-profile'), p.profile || '—');
|
|
274
|
+
setText(el('c-counts'), '要装 ' + (p.willInstall ? p.willInstall.length : 0) + ' 项 · 已满足跳过 ' + (p.skipped ? p.skipped.length : 0) + ' 项 · 清单共 ' + ((p.items && p.items.length) || 0) + ' 项');
|
|
275
|
+
setText(el('c-estimate'), (p.estimate && p.estimate.text) || '—');
|
|
276
|
+
var minutes = p.budgetMs ? Math.round(p.budgetMs / 60000) : 0;
|
|
277
|
+
setText(el('c-budget'), minutes ? ('整批最多跑 ' + minutes + ' 分钟,超了就停下并回滚本批') : '—');
|
|
278
|
+
setText(el('c-snapshot'), p.snapshotHint || '—');
|
|
279
|
+
setText(el('c-note'), p.note || '');
|
|
280
|
+
var list = el('c-items');
|
|
281
|
+
while (list.firstChild) list.removeChild(list.firstChild);
|
|
282
|
+
var items = p.items || [];
|
|
283
|
+
setText(el('c-total'), String(items.length));
|
|
284
|
+
for (var i = 0; i < items.length; i += 1) {
|
|
285
|
+
var it = items[i];
|
|
286
|
+
var li = document.createElement('li');
|
|
287
|
+
var name = document.createElement('code');
|
|
288
|
+
name.textContent = it.name;
|
|
289
|
+
li.appendChild(name);
|
|
290
|
+
var tail = document.createElement('span');
|
|
291
|
+
tail.className = 'g';
|
|
292
|
+
tail.textContent = ' · ' + it.group + ' · ' + (it.type === 'plugin' ? '插件' : '命令行')
|
|
293
|
+
+ ' · ' + (it.action === 'install' ? '会装' : '已满足,跳过');
|
|
294
|
+
li.appendChild(tail);
|
|
295
|
+
list.appendChild(li);
|
|
296
|
+
}
|
|
297
|
+
var cmd = p.equivalentCommand || '(这一批没有要装的项,也就没有对应的命令)';
|
|
298
|
+
setText(el('c-command'), cmd);
|
|
299
|
+
var copyBtn = document.querySelector('[data-copy-batch]');
|
|
300
|
+
if (copyBtn) copyBtn.setAttribute('data-command', cmd);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// ── 第二步:真装 ────────────────────────────────────────
|
|
304
|
+
function start() {
|
|
305
|
+
setBusy(true);
|
|
306
|
+
setBanner('开跑了:进度会自己刷新(每 0.8 秒问一次)。装的过程中别关这个页面。', 'info');
|
|
307
|
+
api('/install/start', { confirm: true }).then(function (r) {
|
|
308
|
+
if (r.status === 202) {
|
|
309
|
+
show(el('confirm-box'), false);
|
|
310
|
+
show(el('install-card'), true);
|
|
311
|
+
show(el('run-card'), true);
|
|
312
|
+
startPolling();
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
if (r.status === 409 && r.data.progress) {
|
|
316
|
+
show(el('run-card'), true);
|
|
317
|
+
renderProgress(r.data.progress);
|
|
318
|
+
startPolling();
|
|
319
|
+
setBanner('已经有一批在跑了(同一时刻只许一批)—— 跟着看进度就行。', 'info');
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
var why = r.data.message || r.data.error || ('HTTP ' + r.status);
|
|
323
|
+
setBanner('没开始:' + why, 'bad');
|
|
324
|
+
show(el('install-actions'), true);
|
|
325
|
+
}).catch(function (err) {
|
|
326
|
+
setBanner('请求没发出去:' + (err && err.message ? err.message : err), 'bad');
|
|
327
|
+
}).then(function () { setBusy(false); });
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function stopPolling() {
|
|
331
|
+
polling = false;
|
|
332
|
+
if (timer) { clearTimeout(timer); timer = null; }
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function startPolling() {
|
|
336
|
+
if (polling) return;
|
|
337
|
+
polling = true;
|
|
338
|
+
tick();
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function tick() {
|
|
342
|
+
if (!polling) return;
|
|
343
|
+
fetch('/install/progress', { headers: { accept: 'application/json' }, cache: 'no-store' })
|
|
344
|
+
.then(function (res) { return res.json(); })
|
|
345
|
+
.then(function (p) {
|
|
346
|
+
if (!p || p.ok !== true) {
|
|
347
|
+
stopPolling();
|
|
348
|
+
setBanner('进度读不出来:' + ((p && (p.message || p.error)) || '返回里没有 status'), 'bad');
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
renderProgress(p);
|
|
352
|
+
if (p.status === 'running' || p.status === 'cancelling' || p.status === 'rolling-back') {
|
|
353
|
+
timer = setTimeout(tick, 800);
|
|
354
|
+
} else {
|
|
355
|
+
stopPolling();
|
|
356
|
+
onSettled(p);
|
|
357
|
+
}
|
|
358
|
+
})
|
|
359
|
+
.catch(function () {
|
|
360
|
+
// 一次读不到不算失败:这个页面可能正好在服务重启的空档里
|
|
361
|
+
timer = setTimeout(tick, 1500);
|
|
362
|
+
});
|
|
172
363
|
}
|
|
364
|
+
|
|
365
|
+
function renderProgress(p) {
|
|
366
|
+
show(el('run-card'), true);
|
|
367
|
+
var total = p.total || 0;
|
|
368
|
+
var done = 0;
|
|
369
|
+
var items = p.items || [];
|
|
370
|
+
for (var i = 0; i < items.length; i += 1) {
|
|
371
|
+
var st = items[i].status;
|
|
372
|
+
if (st && st !== 'pending' && st !== 'running') done += 1;
|
|
373
|
+
}
|
|
374
|
+
var gauge = el('run-gauge');
|
|
375
|
+
if (gauge) gauge.style.width = (total ? Math.round((done / total) * 100) : 0) + '%';
|
|
376
|
+
setText(el('run-count'), total ? ('第 ' + Math.min(p.index || 0, total) + '/' + total + ' 步 · 已完成 ' + done) : '还没开跑');
|
|
377
|
+
if (p.current && p.current.name) setText(el('run-headline'), '正在装:' + p.current.name);
|
|
378
|
+
else if (p.status === 'running') setText(el('run-headline'), '安装中');
|
|
379
|
+
else if (p.status === 'done') setText(el('run-headline'), '装完了');
|
|
380
|
+
else if (p.status === 'cancelled' || p.status === 'cancelling') setText(el('run-headline'), '已取消,装了一半');
|
|
381
|
+
|
|
382
|
+
var body = el('prog-body');
|
|
383
|
+
while (body.firstChild) body.removeChild(body.firstChild);
|
|
384
|
+
for (var j = 0; j < items.length; j += 1) {
|
|
385
|
+
var it = items[j];
|
|
386
|
+
var label = it.label || { cls: 'badge-wait', text: it.status || '待装' };
|
|
387
|
+
var tr = document.createElement('tr');
|
|
388
|
+
var c1 = document.createElement('td'); c1.className = 'num'; c1.textContent = String(it.index);
|
|
389
|
+
var c2 = document.createElement('td');
|
|
390
|
+
var code = document.createElement('code'); code.textContent = it.name;
|
|
391
|
+
c2.appendChild(code);
|
|
392
|
+
var c3 = document.createElement('td');
|
|
393
|
+
var badge = document.createElement('span'); badge.className = 'badge ' + label.cls; badge.textContent = label.text;
|
|
394
|
+
c3.appendChild(badge);
|
|
395
|
+
var c4 = document.createElement('td'); c4.className = 'hint'; c4.textContent = it.detail || '';
|
|
396
|
+
tr.appendChild(c1); tr.appendChild(c2); tr.appendChild(c3); tr.appendChild(c4);
|
|
397
|
+
body.appendChild(tr);
|
|
398
|
+
mergeRow(it.name, label, it.detail || '');
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
var note = el('run-note');
|
|
402
|
+
if (note) {
|
|
403
|
+
var lines = [p.message || ''];
|
|
404
|
+
if (p.quietFor60s) lines.push('⏳ 这一项已经 60 秒没输出了 —— npm 静默下载时正常,不是卡死(真卡住会有超时上限)。');
|
|
405
|
+
note.textContent = lines.filter(Boolean).join('\\n');
|
|
406
|
+
note.className = 'run-note ' + (p.status === 'failed' || p.status === 'error' ? 'bad' : (p.status === 'done' ? 'ok' : ''));
|
|
407
|
+
}
|
|
408
|
+
var log = el('run-log');
|
|
409
|
+
if (log) log.textContent = (p.lines || []).join('\\n');
|
|
410
|
+
var cancelBtn = el('btn-cancel');
|
|
411
|
+
if (cancelBtn) cancelBtn.disabled = !(p.can && p.can.cancel) || busy;
|
|
412
|
+
if (p.status === 'done') show(el('restart-box'), true);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function mergeRow(name, label, detail) {
|
|
416
|
+
var row = null;
|
|
417
|
+
var all = document.querySelectorAll('tr[data-key]');
|
|
418
|
+
for (var i = 0; i < all.length; i += 1) {
|
|
419
|
+
if (all[i].getAttribute('data-key') === name) { row = all[i]; break; }
|
|
420
|
+
}
|
|
421
|
+
if (!row) return;
|
|
422
|
+
setStatusCell(row, label.cls, label.text, detail || '');
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function setStatusCell(row, cls, text, hint) {
|
|
426
|
+
var cell = row.querySelector('[data-field="status"]');
|
|
427
|
+
if (!cell) return;
|
|
428
|
+
while (cell.firstChild) cell.removeChild(cell.firstChild);
|
|
429
|
+
var badge = document.createElement('span');
|
|
430
|
+
badge.className = 'badge ' + cls;
|
|
431
|
+
badge.textContent = text;
|
|
432
|
+
var span = document.createElement('span');
|
|
433
|
+
span.className = 'hint';
|
|
434
|
+
span.textContent = hint;
|
|
435
|
+
cell.appendChild(badge);
|
|
436
|
+
cell.appendChild(span);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function onSettled(p) {
|
|
440
|
+
show(el('rollback-card'), Boolean(p.result) || p.status === 'cancelled' || p.status === 'failed');
|
|
441
|
+
// 这一批结束了就把【开始安装】放回来:卸完之后想重装一遍,不该逼人刷新页面
|
|
442
|
+
show(el('install-actions'), true);
|
|
443
|
+
if (p.status === 'done') {
|
|
444
|
+
setBanner('装完了。⑥ 最后一步:要重启 DSH,插件才会出现在侧边栏(见下面那块绿框)。', 'ok');
|
|
445
|
+
// 装完之后重读一次清单:版本列与"待装/已满足"计数要跟着变过来,不然页面还挂着开跑前的读数
|
|
446
|
+
refreshTable();
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
if (p.status === 'cancelled') {
|
|
450
|
+
setBanner('已取消,装了一半。已经装好的项留着;要让它们真的变回"未装",用下面的【卸载这次装的】。', 'warn');
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
if (p.status === 'failed') {
|
|
454
|
+
setBanner('这一批有失败项(看下面每行的"说明")。按设定回滚了这一步之前,报告里写着回滚干净了没有。', 'bad');
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
if (p.status === 'error') {
|
|
458
|
+
setBanner('安装器自己抛错了:这一批的结果不可信,请照下面的日志排查。', 'bad');
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function cancel() {
|
|
463
|
+
setBusy(true);
|
|
464
|
+
api('/install/cancel').then(function (r) {
|
|
465
|
+
var d = r.data || {};
|
|
466
|
+
if (r.status === 202 || r.status === 200) {
|
|
467
|
+
setBanner(d.message || '正在停:在杀当前那条命令的进程树。', 'warn');
|
|
468
|
+
} else {
|
|
469
|
+
setBanner('取消没成:' + (d.message || d.error || ('HTTP ' + r.status)), 'bad');
|
|
470
|
+
}
|
|
471
|
+
}).catch(function (err) {
|
|
472
|
+
setBanner('请求没发出去:' + (err && err.message ? err.message : err), 'bad');
|
|
473
|
+
}).then(function () { setBusy(false); });
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function doRollback(kind) {
|
|
477
|
+
var name = kind === 'uninstall' ? '卸载这次装的' : '只恢复配置文件到装前';
|
|
478
|
+
setBusy(true);
|
|
479
|
+
setBanner('正在' + name + '……', 'info');
|
|
480
|
+
api('/install/rollback', { action: kind }).then(function (r) {
|
|
481
|
+
var d = r.data || {};
|
|
482
|
+
if (r.status === 200 && d.ok) {
|
|
483
|
+
var tail = d.text || d.honest || d.warning || '';
|
|
484
|
+
setBanner(name + ':成了。' + (tail ? '\\n它自己的话:' + tail : ''), 'ok');
|
|
485
|
+
// 撤完之后重读一次清单:让"已装 / 未装"当场变过来(这才是那条判据)
|
|
486
|
+
refreshTable();
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
if (r.status === 412) {
|
|
490
|
+
setBanner('拒动(这是设计):' + (d.message || d.reason || '前置核对没过') + (d.hint ? '\\n—— ' + d.hint : ''), 'bad');
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
setBanner(name + ':没成。' + (d.message || d.error || ('HTTP ' + r.status)), 'bad');
|
|
494
|
+
}).catch(function (err) {
|
|
495
|
+
setBanner('请求没发出去:' + (err && err.message ? err.message : err), 'bad');
|
|
496
|
+
}).then(function () { setBusy(false); });
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// ── 清单表:重查 / 复制 ────────────────────────────────
|
|
173
500
|
function applyState(state) {
|
|
174
501
|
var rows = document.querySelectorAll('tr[data-key]');
|
|
175
502
|
var byKey = Object.create(null);
|
|
@@ -180,17 +507,9 @@ const SCRIPT = `
|
|
|
180
507
|
var item = byKey[row.getAttribute('data-key')];
|
|
181
508
|
if (!item) continue;
|
|
182
509
|
matched += 1;
|
|
183
|
-
|
|
184
|
-
var
|
|
185
|
-
|
|
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);
|
|
510
|
+
// 🔴 状态文案由服务端算好下发(item.status):页面只认不算,免得两份实现漂移
|
|
511
|
+
var st = item.status || { cls: 'badge-bad', text: '未装', hint: '' };
|
|
512
|
+
setStatusCell(row, st.cls, st.text, st.hint || '');
|
|
194
513
|
row.querySelector('[data-field="version"]').textContent = item.installedVersion || '—';
|
|
195
514
|
row.querySelector('[data-field="range"]').textContent = item.versionRange;
|
|
196
515
|
row.querySelector('[data-field="range"]').setAttribute('title', item.rangeText);
|
|
@@ -206,13 +525,13 @@ const SCRIPT = `
|
|
|
206
525
|
}
|
|
207
526
|
}
|
|
208
527
|
var counts = state.counts || {};
|
|
209
|
-
var
|
|
210
|
-
var
|
|
211
|
-
if (
|
|
528
|
+
var setCount = function (key, value) {
|
|
529
|
+
var node = document.querySelector('[data-count="' + key + '"]');
|
|
530
|
+
if (node) node.textContent = String(value);
|
|
212
531
|
};
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
532
|
+
setCount('install', counts.install);
|
|
533
|
+
setCount('skip', counts.skip);
|
|
534
|
+
setCount('selected', counts.selected);
|
|
216
535
|
var gen = document.querySelector('[data-generated]');
|
|
217
536
|
if (gen) gen.textContent = new Date(state.generatedAt).toLocaleString('zh-CN', { hour12: false });
|
|
218
537
|
if (matched !== rows.length) {
|
|
@@ -220,6 +539,7 @@ const SCRIPT = `
|
|
|
220
539
|
}
|
|
221
540
|
return matched === rows.length;
|
|
222
541
|
}
|
|
542
|
+
|
|
223
543
|
function flash(key) {
|
|
224
544
|
var row = document.querySelector('tr[data-key="' + (window.CSS && CSS.escape ? CSS.escape(key) : key) + '"]');
|
|
225
545
|
if (!row) return;
|
|
@@ -228,15 +548,17 @@ const SCRIPT = `
|
|
|
228
548
|
row.classList.add('flash');
|
|
229
549
|
setTimeout(function () { row.classList.remove('flash'); }, 1300);
|
|
230
550
|
}
|
|
231
|
-
|
|
551
|
+
|
|
552
|
+
function copyText(textValue) {
|
|
232
553
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
233
|
-
return navigator.clipboard.writeText(
|
|
554
|
+
return navigator.clipboard.writeText(textValue).catch(function () { return legacyCopy(textValue); });
|
|
234
555
|
}
|
|
235
|
-
return legacyCopy(
|
|
556
|
+
return legacyCopy(textValue);
|
|
236
557
|
}
|
|
237
|
-
|
|
558
|
+
|
|
559
|
+
function legacyCopy(textValue) {
|
|
238
560
|
var ta = document.createElement('textarea');
|
|
239
|
-
ta.value =
|
|
561
|
+
ta.value = textValue;
|
|
240
562
|
ta.setAttribute('readonly', '');
|
|
241
563
|
ta.style.position = 'fixed';
|
|
242
564
|
ta.style.top = '-1000px';
|
|
@@ -247,37 +569,48 @@ const SCRIPT = `
|
|
|
247
569
|
document.body.removeChild(ta);
|
|
248
570
|
return ok ? undefined : Promise.reject(new Error('浏览器不允许复制'));
|
|
249
571
|
}
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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' })
|
|
572
|
+
|
|
573
|
+
var refreshing = false;
|
|
574
|
+
function refreshTable() {
|
|
575
|
+
return fetch('/state', { headers: { accept: 'application/json' }, cache: 'no-store' })
|
|
258
576
|
.then(function (res) { return res.json(); })
|
|
259
577
|
.then(function (state) {
|
|
260
578
|
if (!state || state.ok !== true) {
|
|
261
579
|
var why = state && state.errors ? state.errors.join(' / ') : '返回里没有 items';
|
|
262
580
|
setBanner('重查没成:' + why, 'bad');
|
|
263
|
-
return;
|
|
581
|
+
return null;
|
|
264
582
|
}
|
|
265
583
|
var sameSet = applyState(state);
|
|
266
|
-
|
|
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;
|
|
584
|
+
return { state: state, sameSet: sameSet };
|
|
275
585
|
});
|
|
276
586
|
}
|
|
587
|
+
|
|
588
|
+
function refresh(key) {
|
|
589
|
+
if (refreshing) return;
|
|
590
|
+
refreshing = true;
|
|
591
|
+
var buttons = document.querySelectorAll('[data-refresh]');
|
|
592
|
+
for (var i = 0; i < buttons.length; i += 1) buttons[i].disabled = true;
|
|
593
|
+
setBanner('重查中……(只重跑探测,不改任何状态)', 'info');
|
|
594
|
+
refreshTable().then(function (out) {
|
|
595
|
+
if (!out) return;
|
|
596
|
+
if (out.sameSet) {
|
|
597
|
+
var s = out.state;
|
|
598
|
+
setBanner('重查完成:待装 ' + s.counts.install + ' 项 · 已满足 ' + s.counts.skip + ' 项(' + new Date(s.generatedAt).toLocaleTimeString('zh-CN', { hour12: false }) + ')', 'ok');
|
|
599
|
+
}
|
|
600
|
+
if (key) flash(key);
|
|
601
|
+
}).catch(function (err) {
|
|
602
|
+
setBanner('重查失败:' + (err && err.message ? err.message : err), 'bad');
|
|
603
|
+
}).then(function () {
|
|
604
|
+
for (var j = 0; j < buttons.length; j += 1) buttons[j].disabled = false;
|
|
605
|
+
refreshing = false;
|
|
606
|
+
});
|
|
607
|
+
}
|
|
608
|
+
|
|
277
609
|
document.addEventListener('click', function (ev) {
|
|
278
610
|
var btn = ev.target.closest ? ev.target.closest('button') : null;
|
|
279
|
-
if (!btn) return;
|
|
280
|
-
|
|
611
|
+
if (!btn || btn.disabled) return;
|
|
612
|
+
|
|
613
|
+
if (btn.hasAttribute('data-copy') || btn.hasAttribute('data-copy-batch')) {
|
|
281
614
|
var cmd = btn.getAttribute('data-command');
|
|
282
615
|
if (!cmd) return;
|
|
283
616
|
copyText(cmd).then(function () {
|
|
@@ -288,10 +621,23 @@ const SCRIPT = `
|
|
|
288
621
|
}).catch(function () {
|
|
289
622
|
setBanner('复制不了(浏览器拦了剪贴板):手动选下面这行 —— ' + cmd, 'bad');
|
|
290
623
|
});
|
|
291
|
-
|
|
292
|
-
refresh(btn.getAttribute('data-key'));
|
|
624
|
+
return;
|
|
293
625
|
}
|
|
626
|
+
if (btn.hasAttribute('data-refresh')) { refresh(btn.getAttribute('data-key')); return; }
|
|
627
|
+
|
|
628
|
+
var action = btn.getAttribute('data-action');
|
|
629
|
+
if (!action) return;
|
|
630
|
+
if (action === 'preview') preview();
|
|
631
|
+
else if (action === 'start') start();
|
|
632
|
+
else if (action === 'dismiss') {
|
|
633
|
+
show(el('confirm-box'), false);
|
|
634
|
+
show(el('install-actions'), true);
|
|
635
|
+
setBanner('好,什么都没动。想装的时候再点【开始安装】。', 'info');
|
|
636
|
+
} else if (action === 'cancel') cancel();
|
|
637
|
+
else if (action === 'rollback-uninstall') doRollback('uninstall');
|
|
638
|
+
else if (action === 'rollback-restore') doRollback('restore-declarations');
|
|
294
639
|
});
|
|
640
|
+
|
|
295
641
|
document.addEventListener('keydown', function (ev) {
|
|
296
642
|
if (ev.key === 'Escape') clearBanner();
|
|
297
643
|
});
|
|
@@ -300,43 +646,110 @@ const SCRIPT = `
|
|
|
300
646
|
|
|
301
647
|
/**
|
|
302
648
|
* 渲染整页。
|
|
303
|
-
* @param {object} state lib/plan.mjs#buildSuiteState 给出的 `state
|
|
304
|
-
* @param {object} [opts] `{ url }
|
|
649
|
+
* @param {object} state lib/plan.mjs#buildSuiteState 给出的 `state`(已经过 lib/item-status.mjs#decorateState)
|
|
650
|
+
* @param {object} [opts] `{ url, token }`:url 打印在页脚(便于用户确认自己连的是哪一个地址);
|
|
651
|
+
* token 内嵌成 `<meta name="install-token">`,页面脚本随 POST 一起发
|
|
652
|
+
* (闸 2:令牌只从页面读,绝不拼进 URL —— 拼进 URL 就落进历史记录与代理日志)
|
|
305
653
|
*/
|
|
306
654
|
export function renderPage(state, opts = {}) {
|
|
307
655
|
const counts = state.counts || { total: 0, selected: 0, install: 0, skip: 0 };
|
|
308
656
|
const rows = (state.items || []).map(renderRow).join('\n');
|
|
309
657
|
const url = opts.url || '';
|
|
658
|
+
const token = typeof opts.token === 'string' ? opts.token : '';
|
|
310
659
|
return `<!doctype html>
|
|
311
660
|
<html lang="zh-CN">
|
|
312
661
|
<head>
|
|
313
662
|
<meta charset="utf-8">
|
|
314
663
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
315
664
|
<meta name="referrer" content="no-referrer">
|
|
316
|
-
<
|
|
665
|
+
<meta name="install-token" content="${escapeHtml(token)}">
|
|
666
|
+
<title>大管家全家桶 · 安装向导</title>
|
|
317
667
|
<style>${CSS}</style>
|
|
318
668
|
</head>
|
|
319
669
|
<body>
|
|
320
670
|
<div class="wrap">
|
|
321
|
-
<h1>大管家全家桶 ·
|
|
671
|
+
<h1>大管家全家桶 · 安装向导</h1>
|
|
322
672
|
<p class="sub">
|
|
323
|
-
这个页面<
|
|
324
|
-
|
|
673
|
+
这个页面<b>能装</b>:按下【开始安装】只是算一遍要做什么,再按【确认,开始】才真装。
|
|
674
|
+
<span class="token">不点确认,一条命令都不会跑</span>。
|
|
675
|
+
装完记得重启 DSH —— 插件要重启才会出现在侧边栏。
|
|
325
676
|
</p>
|
|
326
677
|
|
|
327
678
|
<div id="banner" class="banner"></div>
|
|
328
679
|
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
<
|
|
335
|
-
<
|
|
336
|
-
</
|
|
680
|
+
${renderPrereq()}
|
|
681
|
+
|
|
682
|
+
<section class="card" id="install-card">
|
|
683
|
+
<h2>装</h2>
|
|
684
|
+
<div id="install-actions">
|
|
685
|
+
<button type="button" class="btn btn-primary" data-action="preview">开始安装</button>
|
|
686
|
+
<span class="count">先算一遍:要装哪些、装到哪个 profile、回滚点拍在哪 —— <b>那一步一条命令都不跑</b>。</span>
|
|
687
|
+
</div>
|
|
688
|
+
<div id="confirm-box" class="confirm" hidden>
|
|
689
|
+
<h3>按【确认,开始】之后,会做这些事</h3>
|
|
690
|
+
<dl class="meta">
|
|
691
|
+
<dt>目标 profile</dt><dd id="c-profile">—</dd>
|
|
692
|
+
<dt>要装 / 跳过</dt><dd id="c-counts">—</dd>
|
|
693
|
+
<dt>预计时长</dt><dd id="c-estimate">—</dd>
|
|
694
|
+
<dt>墙钟上限</dt><dd id="c-budget">—</dd>
|
|
695
|
+
<dt>回滚点(快照拍在这)</dt><dd id="c-snapshot">—</dd>
|
|
696
|
+
</dl>
|
|
697
|
+
<h4>逐项清单(<span id="c-total">0</span> 项)</h4>
|
|
698
|
+
<ol id="c-items" class="confirm-items"></ol>
|
|
699
|
+
<p class="sec" id="c-note"></p>
|
|
700
|
+
<div class="equiv">
|
|
701
|
+
<div class="equiv-head">同一件事的命令行版本 —— 想自己在终端跑就用这条。<b>它带 <code>--apply</code></b>:不加 <code>--apply</code> 的那条只是演练,跑完什么都不装。</div>
|
|
702
|
+
<code id="c-command">—</code>
|
|
703
|
+
<button type="button" class="btn" data-copy-batch data-command="">复制这条</button>
|
|
704
|
+
</div>
|
|
705
|
+
<div class="confirm-actions">
|
|
706
|
+
<button type="button" class="btn btn-primary" data-action="start">确认,开始</button>
|
|
707
|
+
<button type="button" class="btn" data-action="dismiss">先不装</button>
|
|
708
|
+
</div>
|
|
709
|
+
</div>
|
|
337
710
|
</section>
|
|
338
711
|
|
|
339
|
-
|
|
712
|
+
<section class="card" id="run-card" hidden>
|
|
713
|
+
<h2 id="run-headline">安装进度</h2>
|
|
714
|
+
<div class="bar">
|
|
715
|
+
<div class="gauge"><div class="gauge-fill" id="run-gauge"></div></div>
|
|
716
|
+
<span class="count" id="run-count"></span>
|
|
717
|
+
</div>
|
|
718
|
+
<p class="run-note" id="run-note"></p>
|
|
719
|
+
<table class="prog">
|
|
720
|
+
<thead><tr><th>#</th><th>包名</th><th>状态</th><th>说明</th></tr></thead>
|
|
721
|
+
<tbody id="prog-body"></tbody>
|
|
722
|
+
</table>
|
|
723
|
+
<div class="run-actions">
|
|
724
|
+
<button type="button" class="btn btn-danger" data-action="cancel" id="btn-cancel">取消</button>
|
|
725
|
+
<span class="count">取消 = 杀掉当前那条命令的进程树。<b>已经装好的项会留着</b>,这不是整批干净回滚。</span>
|
|
726
|
+
</div>
|
|
727
|
+
<div id="restart-box" class="restart" hidden>
|
|
728
|
+
<b>⑥ 最后一步:要重启 DSH,插件才会出现在侧边栏。</b>
|
|
729
|
+
<div>插件这会儿已经在 profile 里了,但宿主只在启动时读一次组合列表 —— 不重启,侧边栏不会多出任何东西。这是最容易让人以为"装了没用、被骗了"的地方。</div>
|
|
730
|
+
<div>怎么重启:关掉 DSH 的那个窗口,再双击 <code>dsh-start.cmd</code>(或者跑一次 <code>dsh-restart.ps1</code>)。</div>
|
|
731
|
+
</div>
|
|
732
|
+
<details class="log-box" id="log-box">
|
|
733
|
+
<summary>看安装器自己的日志(最多留最近 60 行摘要)</summary>
|
|
734
|
+
<pre id="run-log"></pre>
|
|
735
|
+
</details>
|
|
736
|
+
</section>
|
|
737
|
+
|
|
738
|
+
<section class="card" id="rollback-card" hidden>
|
|
739
|
+
<h2>不满意?两条撤回,区别很大,别点错</h2>
|
|
740
|
+
<div class="rb">
|
|
741
|
+
<div class="rb-item">
|
|
742
|
+
<button type="button" class="btn btn-primary" data-action="rollback-uninstall">卸载这次装的</button>
|
|
743
|
+
<p><b>推荐。</b>真的把这一轮装上去的卸掉:插件从 profile 里摘下来,全局命令行包从全局前缀里删掉。卸完重查,状态会真的变回"未装"。</p>
|
|
744
|
+
<p class="sec">它只看"这一轮装成了什么",<b>不覆盖任何文件</b> —— 所以你随后在 DSH 侧边栏另装的插件不受影响。</p>
|
|
745
|
+
</div>
|
|
746
|
+
<div class="rb-item">
|
|
747
|
+
<button type="button" class="btn" data-action="rollback-restore">只恢复配置文件到装前</button>
|
|
748
|
+
<p><b>只还声明层</b>(<code>package.json</code> / <code>pnpm-lock.yaml</code> / <code>cordis.patch.yml</code> / <code>pnpm-workspace.yaml</code>)到开跑前那一份。<b><code>node_modules</code> 里的实体文件与全局 CLI 包还在</b>,所以重开会显示"已装"。</p>
|
|
749
|
+
<p class="sec">🔴 如果这期间有别的程序(比如正在跑的 DSH 宿主)改过这几份文件,它会<b>拒动</b>并告诉你是哪一份对不上 —— 那种情况请用上面那个【卸载这次装的】。</p>
|
|
750
|
+
</div>
|
|
751
|
+
</div>
|
|
752
|
+
</section>
|
|
340
753
|
|
|
341
754
|
<section class="card">
|
|
342
755
|
<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>
|
|
@@ -344,6 +757,13 @@ ${renderOrder(state)}
|
|
|
344
757
|
<button type="button" class="btn btn-refresh" data-refresh data-key="">重查全部</button>
|
|
345
758
|
<span class="count">「重查」按的是同一份 <code>GET /state</code>(整批重探):清单是一次算出来的,拆开只查一行反而会给出互相矛盾的读数。</span>
|
|
346
759
|
</div>
|
|
760
|
+
<dl class="meta" style="margin-bottom:12px">
|
|
761
|
+
<dt>清单</dt><dd>${escapeHtml(state.manifest || '')}</dd>
|
|
762
|
+
<dt>数据根</dt><dd>${escapeHtml(state.home || '')}</dd>
|
|
763
|
+
<dt>profile</dt><dd>${escapeHtml(state.profile || '')}${state.profileExists ? '' : '(尚不存在,真装时由宿主自动创建)'}</dd>
|
|
764
|
+
<dt>全局包落点</dt><dd>${escapeHtml(state.npmPrefix || '')}(来源:${escapeHtml(state.npmPrefixSource || '')})</dd>
|
|
765
|
+
<dt>本次读数</dt><dd data-generated>${escapeHtml(formatStamp(state.generatedAt))}</dd>
|
|
766
|
+
</dl>
|
|
347
767
|
<table>
|
|
348
768
|
<thead>
|
|
349
769
|
<tr>
|
|
@@ -362,6 +782,8 @@ ${rows}
|
|
|
362
782
|
</table>
|
|
363
783
|
</section>
|
|
364
784
|
|
|
785
|
+
${renderOrder(state)}
|
|
786
|
+
|
|
365
787
|
${renderExcluded(state)}
|
|
366
788
|
|
|
367
789
|
<section class="card">
|
|
@@ -369,10 +791,19 @@ ${renderExcluded(state)}
|
|
|
369
791
|
<p class="sec">① 插件类:<code>dsh --profile ${escapeHtml(state.profile || 'web')} --dump-config</code>,在输出里搜 <code># == <包名></code> —— 恰好 1 次才算真挂上。</p>
|
|
370
792
|
<p class="sec">② 命令行类:看 <code><全局前缀>/node_modules/<包>/package.json</code> 在不在(只查文件,不执行命令)。</p>
|
|
371
793
|
<p class="sec">③ 想省事的整批自检:<code>node <dsh-butler-core 仓根>/selfcheck-replica.mjs --manifest suite-manifest.json</code>。</p>
|
|
794
|
+
<p class="sec">④ 插件装完要<b>重启 DSH</b> 才会出现在侧边栏 —— 见上面进度区那块绿框。</p>
|
|
372
795
|
</section>
|
|
373
796
|
|
|
374
797
|
<footer>
|
|
375
|
-
|
|
798
|
+
<b>安全边界(写死的,不是可选项)</b><br>
|
|
799
|
+
· 只绑 <code>127.0.0.1</code>:同一局域网的别的机器连不上,也没有 <code>0.0.0.0</code> 开关。<br>
|
|
800
|
+
· 写入口只有四条(<code>POST /install/preview</code> · <code>/install/start</code> · <code>/install/cancel</code> · <code>/install/rollback</code>),
|
|
801
|
+
每条都要<b>安装令牌</b>(页面从内嵌 meta 读,绝不拼进 URL)<b>+ 同源校验</b>(<code>Origin</code> 逐字等于本服务的实际地址)。
|
|
802
|
+
别的站点发来的请求进不来;除这四条之外的写方法一律 405。<br>
|
|
803
|
+
· 🔴 <b>这条要如实说</b>:它防不住<b>本机、同一个 Windows 用户下的其他程序</b> —— 那些程序读得到这个页面、也调得到这些接口。
|
|
804
|
+
它是一个"给本机同一个人用的安装器",不是多用户隔离的服务。<br>
|
|
805
|
+
· 装什么、成没成,判据一律在服务端现跑;这个页面只是个按按钮的壳。<br>
|
|
806
|
+
· 当前地址 <code>${escapeHtml(url || '—')}</code>
|
|
376
807
|
</footer>
|
|
377
808
|
</div>
|
|
378
809
|
<script>${SCRIPT}</script>
|