walover-line-harness-gui 0.1.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,859 @@
1
+ <!doctype html>
2
+ <html lang="ja">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>LINE Harness セットアップ</title>
7
+ <style>
8
+ /*
9
+ * 基調は LINE グリーン #00b900。
10
+ *
11
+ * ただし #00b900 に白文字は 2.64:1 しかなく、WCAG AA(4.5:1)にも
12
+ * 大きめ文字の基準(3:1)にも届かない。会場の照明やプロジェクタ環境で
13
+ * 読めなくなるので、**面としては #00b900 をそのまま使い、
14
+ * 白文字を載せるところだけ同系の濃い緑にする**。
15
+ *
16
+ * 白 on #00b900 → 2.64:1 ×
17
+ * 白 on #008a00 → 4.53:1 ○ ボタン
18
+ * #067d00 on 白 → 5.33:1 ○ 文字・リンク
19
+ * #00b900 on 暗背景 → 7.15:1 ○ 暗モードはそのまま使える
20
+ */
21
+ :root {
22
+ color-scheme: light dark;
23
+ --bg: #ffffff; --fg: #1f2328; --muted: #59636e; --field-bg: #ffffff;
24
+ --line: #d1d9e0;
25
+ --brand: #00b900; /* 基調。線・面・囲みに使う */
26
+ --brand-ink: #067d00; /* 明るい背景に置く文字 */
27
+ --brand-fill: #008a00; /* 白文字を載せる面 */
28
+ --brand-fill-fg: #ffffff;
29
+ --brand-tint: #eafaea; /* ごく薄い面 */
30
+ --warn-bg: #fff8c5; --warn-line: #d4a72c;
31
+ --err: #b3261e; --err-bg: #fff0ee; --err-line: #e5a5a0;
32
+ /* 取り違え防止のため、2つのチャネルは色相から変える */
33
+ --messaging: #00b900; --messaging-ink: #067d00;
34
+ --login: #7048e8; --login-ink: #5f3dc4;
35
+ }
36
+ @media (prefers-color-scheme: dark) {
37
+ :root { --bg: #0d1117; --fg: #e6edf3; --muted: #9198a1; --field-bg: #0d1117;
38
+ --line: #3d444d;
39
+ --brand: #00b900;
40
+ --brand-ink: #3ddc44;
41
+ --brand-fill: #00b900; --brand-fill-fg: #04210f;
42
+ --brand-tint: #102310;
43
+ --warn-bg: #282215; --warn-line: #9e6a03;
44
+ --err: #ff8b81; --err-bg: #2b1614; --err-line: #6f2a24;
45
+ --messaging: #00b900; --messaging-ink: #3ddc44;
46
+ --login: #9775fa; --login-ink: #b197fc; }
47
+ }
48
+ * { box-sizing: border-box; }
49
+ body {
50
+ margin: 0; padding: 2rem 1.25rem 5rem; background: var(--bg); color: var(--fg);
51
+ font-family: -apple-system, BlinkMacSystemFont, "Hiragino Sans", "Noto Sans JP", sans-serif;
52
+ line-height: 1.7;
53
+ }
54
+ main { max-width: 44rem; margin: 0 auto; }
55
+ h1 { font-size: 1.5rem; margin: 0 0 .25rem; padding-left: .7rem;
56
+ border-left: .3rem solid var(--brand); line-height: 1.4; }
57
+ .lead { color: var(--muted); margin: 0 0 1.5rem; }
58
+ a { color: var(--brand-ink); }
59
+ code { background: color-mix(in srgb, var(--fg) 8%, transparent); padding: .1rem .35rem; border-radius: .3rem; }
60
+
61
+ .banner { border-radius: .6rem; padding: .9rem 1.1rem; margin-bottom: 1rem; font-size: .92rem;
62
+ white-space: pre-line; border: 1px solid var(--warn-line); background: var(--warn-bg); }
63
+ .banner.bad { border-color: var(--err-line); background: var(--err-bg); color: var(--err); }
64
+ .banner strong { display: block; }
65
+
66
+ .status { display: flex; align-items: center; gap: .5rem; font-size: .85rem; color: var(--muted);
67
+ margin-bottom: 1.5rem; }
68
+ .dot { width: .55rem; height: .55rem; border-radius: 50%; background: var(--muted); flex: none; }
69
+ .dot.ok { background: var(--brand); }
70
+ .dot.bad { background: var(--err); }
71
+
72
+ fieldset { border: 1px solid var(--line); border-radius: .6rem; padding: 1.25rem;
73
+ margin: 0 0 1.25rem; border-left-width: 4px; }
74
+ fieldset[data-group="project"] { border-left-color: var(--brand); }
75
+ fieldset[data-group="messaging"] { border-left-color: var(--messaging); }
76
+ fieldset[data-group="login"] { border-left-color: var(--login); }
77
+ fieldset[data-group="options"] { border-left-color: var(--line); }
78
+ legend { font-weight: 600; padding: 0 .4rem; }
79
+ fieldset[data-group="project"] legend { color: var(--brand-ink); }
80
+ fieldset[data-group="messaging"] legend { color: var(--messaging-ink); }
81
+ fieldset[data-group="login"] legend { color: var(--login-ink); }
82
+ .group-desc { color: var(--muted); font-size: .88rem; margin: 0 0 1.25rem; }
83
+
84
+ .field { margin-bottom: 1.25rem; }
85
+ .field:last-child { margin-bottom: 0; }
86
+ .field label { display: block; font-weight: 600; font-size: .95rem; margin-bottom: .15rem; }
87
+ .field .optional { font-weight: 400; color: var(--muted); font-size: .85rem; }
88
+ .field .hint { color: var(--muted); font-size: .85rem; margin: 0 0 .4rem; }
89
+ details.guide { margin: 0 0 .5rem; border: 1px solid var(--line); border-radius: .4rem;
90
+ background: color-mix(in srgb, var(--brand) 4%, transparent); }
91
+ details.guide > summary { cursor: pointer; padding: .4rem .7rem; font-size: .85rem;
92
+ color: var(--brand-ink); font-weight: 600; list-style-position: inside; }
93
+ details.guide[open] > summary { border-bottom: 1px solid var(--line); }
94
+ .guide-body { padding: .7rem .9rem; font-size: .85rem; }
95
+ .guide-body ol { margin: 0 0 .6rem; padding-left: 1.3rem; }
96
+ .guide-body li { margin-bottom: .15rem; }
97
+ .guide-warn { background: var(--warn-bg); border: 1px solid var(--warn-line); border-radius: .35rem;
98
+ padding: .5rem .7rem; margin: 0 0 .6rem; }
99
+ .guide-links { display: flex; flex-wrap: wrap; gap: .5rem; margin-bottom: .6rem; }
100
+ .guide-links a { display: inline-block; padding: .3rem .8rem; border-radius: 999px;
101
+ border: 1px solid var(--brand); text-decoration: none; font-weight: 600; }
102
+ .guide-body img { display: block; max-width: 100%; border: 1px solid var(--line);
103
+ border-radius: .35rem; margin-bottom: .6rem; }
104
+ .guide-manual { margin: 0; }
105
+ .row { display: flex; gap: .5rem; }
106
+ input[type="text"], input[type="password"] {
107
+ flex: 1; min-width: 0; font: inherit; font-size: .95rem; padding: .5rem .65rem;
108
+ border: 1px solid var(--line); border-radius: .4rem; background: var(--field-bg); color: var(--fg);
109
+ }
110
+ input:focus-visible, button:focus-visible { outline: 2px solid var(--brand); outline-offset: 1px; }
111
+ input:focus { border-color: var(--brand); }
112
+ .field.invalid input { border-color: var(--err-line); }
113
+ .error { color: var(--err); font-size: .85rem; margin: .35rem 0 0; }
114
+ .error:empty { display: none; }
115
+
116
+ .toggle { font: inherit; font-size: .85rem; padding: .4rem .7rem; white-space: nowrap;
117
+ border: 1px solid var(--line); border-radius: .4rem; background: transparent;
118
+ color: var(--fg); cursor: pointer; }
119
+ .toggle:hover { border-color: var(--brand); color: var(--brand-ink); }
120
+ .check { display: flex; align-items: flex-start; gap: .6rem; }
121
+ .check input { margin-top: .45rem; }
122
+
123
+ .actions { display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; margin-top: 1.5rem; }
124
+ .primary { font: inherit; font-weight: 600; padding: .65rem 1.6rem; border: 0; border-radius: 999px;
125
+ background: var(--brand-fill); color: var(--brand-fill-fg); cursor: pointer; }
126
+ .primary:hover:not(:disabled) { filter: brightness(1.08); }
127
+ .primary:disabled { opacity: .5; cursor: not-allowed; }
128
+ .result { font-size: .9rem; }
129
+ .result.ok { color: var(--brand-ink); }
130
+ .result.bad { color: var(--err); }
131
+
132
+ #run { margin-top: 2rem; }
133
+ #run h2 { font-size: 1.1rem; margin: 0 0 .75rem; padding-left: .6rem;
134
+ border-left: .25rem solid var(--brand); }
135
+ .screen { background: #0d1117; color: #e6edf3; border-radius: .6rem; padding: 1rem 1.1rem;
136
+ margin: 0; overflow-x: auto; max-height: 26rem; overflow-y: auto;
137
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
138
+ font-size: .8rem; line-height: 1.5; white-space: pre; }
139
+ .screen:empty::before { content: "起動しています…"; color: #9198a1; }
140
+ .ask { border: 1px solid var(--brand); background: var(--brand-tint); border-radius: .6rem;
141
+ padding: 1rem 1.1rem; margin-bottom: 1rem; }
142
+ .ask h3 { font-size: .95rem; margin: 0 0 .35rem; }
143
+ .ask p { margin: 0 0 .75rem; font-size: .9rem; }
144
+ .ask .choices { display: flex; flex-wrap: wrap; gap: .5rem; }
145
+ .ask button { font: inherit; font-size: .9rem; padding: .45rem 1rem; border-radius: 999px;
146
+ border: 1px solid var(--brand); background: var(--bg); color: var(--fg); cursor: pointer; }
147
+ .ask button.go { background: var(--brand-fill); color: var(--brand-fill-fg); border-color: transparent;
148
+ font-weight: 600; }
149
+ .ask .row { margin-bottom: .75rem; }
150
+ .ask .cli-echo { color: var(--muted); font-size: .8rem; margin: -.4rem 0 .75rem; }
151
+ .run-howto { background: var(--brand-tint); border: 1px solid color-mix(in srgb, var(--brand) 30%, transparent);
152
+ border-radius: .5rem; padding: .7rem .9rem; margin: 0 0 1rem; font-size: .85rem; }
153
+ .stopped { border-color: var(--err-line); background: var(--err-bg); }
154
+ .leftover { border: 1px solid var(--warn-line); background: var(--warn-bg); border-radius: .6rem;
155
+ padding: 1rem 1.1rem; margin-bottom: 1rem; font-size: .9rem; white-space: pre-line; }
156
+ .leftover h3 { font-size: .95rem; margin: 0 0 .5rem; }
157
+ .leftover code { display: inline-block; word-break: break-all; }
158
+ .stopped h3 { color: var(--err); }
159
+ .done { border-color: var(--brand); background: var(--brand-tint); }
160
+ .finish { border: 1px solid var(--brand); border-radius: .6rem; padding: 1.1rem 1.25rem; margin-bottom: 1rem; }
161
+ .finish > h3 { font-size: 1.05rem; margin: 0 0 .3rem; }
162
+ .finish > .lead2 { color: var(--muted); font-size: .9rem; margin: 0 0 1.1rem; }
163
+ .step { border-top: 1px solid var(--line); padding: .9rem 0; }
164
+ .step:first-of-type { border-top: 0; padding-top: 0; }
165
+ .step h4 { font-size: .95rem; margin: 0 0 .2rem; }
166
+ .step h4 .num { display: inline-block; min-width: 1.6rem; color: var(--brand-ink); font-weight: 700; }
167
+ .step .where { color: var(--muted); font-size: .82rem; margin: 0 0 .5rem .0; }
168
+ .step ul { margin: .5rem 0 0; padding-left: 1.2rem; font-size: .87rem; }
169
+ .step .warn2 { background: var(--warn-bg); border: 1px solid var(--warn-line); border-radius: .35rem;
170
+ padding: .5rem .7rem; margin: .6rem 0 0; font-size: .85rem; }
171
+ .copy { display: flex; align-items: stretch; gap: .4rem; margin: .35rem 0; }
172
+ .copy .label { font-size: .78rem; color: var(--muted); min-width: 8.5rem; align-self: center; }
173
+ .copy code { flex: 1; min-width: 0; background: color-mix(in srgb, var(--fg) 7%, transparent);
174
+ border-radius: .35rem; padding: .45rem .6rem; font-size: .82rem;
175
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
176
+ overflow-x: auto; white-space: nowrap; }
177
+ .copy button { font: inherit; font-size: .8rem; padding: .35rem .8rem; white-space: nowrap;
178
+ border: 1px solid var(--brand); border-radius: .35rem; background: var(--bg);
179
+ color: var(--brand-ink); cursor: pointer; }
180
+ .copy button.done { background: var(--brand-fill); color: var(--brand-fill-fg); border-color: transparent; }
181
+ .copy .openlink { font-size: .8rem; padding: .35rem .8rem; white-space: nowrap; text-decoration: none;
182
+ border: 1px solid transparent; border-radius: .35rem;
183
+ background: var(--brand-fill); color: var(--brand-fill-fg); font-weight: 600;
184
+ display: flex; align-items: center; }
185
+ .apikey { border: 1px solid var(--warn-line); background: var(--warn-bg); border-radius: .5rem;
186
+ padding: .8rem 1rem; margin-top: 1rem; }
187
+ .apikey h4 { margin: 0 0 .3rem; font-size: .95rem; }
188
+ .apikey p { margin: 0 0 .5rem; font-size: .85rem; }
189
+ details.rawlog { margin-top: 1rem; }
190
+ details.rawlog > summary { cursor: pointer; font-size: .85rem; color: var(--muted); }
191
+ .footnote { color: var(--muted); font-size: .85rem; background: var(--brand-tint);
192
+ border: 1px solid color-mix(in srgb, var(--brand) 30%, transparent);
193
+ border-radius: .6rem; margin-top: 2.5rem; padding: 1rem 1.25rem; }
194
+ .footnote ul { padding-left: 1.1rem; margin: .5rem 0 0; }
195
+ </style>
196
+ </head>
197
+ <body>
198
+ <main>
199
+ <h1>LINE Harness セットアップ</h1>
200
+ <p class="lead">このページは、あなたのパソコンの中だけで動いています。</p>
201
+
202
+ <div id="banners"></div>
203
+
204
+ <div class="status">
205
+ <span class="dot" id="dot"></span><span id="status">確認中…</span>
206
+ </div>
207
+
208
+ <form id="form" autocomplete="off" novalidate hidden></form>
209
+
210
+ <section id="run" hidden>
211
+ <h2>セットアップの実行</h2>
212
+ <p class="run-howto">
213
+ 下の黒い枠は<strong>ツールの出力</strong>です。ここに文字を打つ必要はありません。<br>
214
+ 操作が必要なときは、この上にボタンが出ます。<strong>キーボードの Enter は使いません。</strong>
215
+ </p>
216
+ <div id="run-ask"></div>
217
+ <pre class="screen" id="run-screen"></pre>
218
+ </section>
219
+
220
+ <div class="footnote">
221
+ <strong>入力した情報の扱い</strong>
222
+ <ul>
223
+ <li>入力は<strong>このパソコンの外に出ません</strong>。WALOVER のサーバーも経由しません</li>
224
+ <li>チャネルシークレットなどは<strong>メモリ上だけ</strong>で扱い、ファイルにも残しません</li>
225
+ <li>ターミナルで <code>Ctrl+C</code> を押すと、すべて破棄されます</li>
226
+ </ul>
227
+ </div>
228
+ </main>
229
+
230
+ <script type="module">
231
+ const $ = (id) => document.getElementById(id);
232
+ const el = (tag, props = {}, children = []) => {
233
+ const node = Object.assign(document.createElement(tag), props);
234
+ for (const child of children) node.append(child);
235
+ return node;
236
+ };
237
+
238
+ const banner = (title, body, bad = false) => {
239
+ const node = el("div", { className: bad ? "banner bad" : "banner" });
240
+ node.append(el("strong", { textContent: title }));
241
+ if (body) node.append(document.createTextNode(body));
242
+ $("banners").append(node);
243
+ };
244
+
245
+ // ── 前提の確認 ────────────────────────────────────────────────────
246
+ let health;
247
+ try {
248
+ health = await (await fetch("/api/health")).json();
249
+ $("dot").classList.add("ok");
250
+ $("status").textContent = "サーバーに接続できています";
251
+ } catch {
252
+ $("dot").classList.add("bad");
253
+ $("status").textContent = "サーバーに接続できません。ターミナルの表示を確認してください。";
254
+ throw new Error("no server");
255
+ }
256
+
257
+ if (health.pty === false) {
258
+ // 擬似端末が無いと本家 CLI は起動できない。値を入れてもらう前に伝える
259
+ banner(
260
+ "セットアップを開始できません",
261
+ "擬似端末を用意できませんでした(python3 が見つかりません)。" +
262
+ "macOS では、ターミナルで xcode-select --install を実行すると入ります。",
263
+ true,
264
+ );
265
+ }
266
+
267
+ // 本家 CLI の版のズレ(固定版で動かすので、差があっても止めずに知らせるだけ)
268
+ try {
269
+ const version = await (await fetch("/api/version")).json();
270
+ if (version.warning) banner("本家 CLI のバージョン", version.warning);
271
+ } catch {
272
+ /* 版が確認できなくても入力は進められる */
273
+ }
274
+
275
+ // 前回の実行が失敗したまま残っていることがある。フォームより先に伝える
276
+ try {
277
+ const leftover = await (await fetch("/api/leftover")).json();
278
+ if (leftover.warning) {
279
+ const body = [...leftover.warning.lines, leftover.warning.tighten]
280
+ .filter(Boolean)
281
+ .join("\n")
282
+ .replace(/\*\*/g, "");
283
+ banner(leftover.warning.title, body);
284
+ }
285
+ } catch {
286
+ /* 見られなくても入力は進められる */
287
+ }
288
+
289
+ // ── フォームの組み立て ───────────────────────────────────────────
290
+ // 項目・文言・検証規則はすべてサーバーの契約から取る。
291
+ // ここで独自に持つと、本家との対応がズレたときに気づけない
292
+ const schema = await (await fetch("/api/contract")).json();
293
+ // 実行中に人が答える場面(R2 の有効化など)でも同じ案内を出す
294
+ const guides = await fetch("/api/guide")
295
+ .then((r) => r.json())
296
+ .catch(() => ({}));
297
+ const form = $("form");
298
+ const inputs = new Map();
299
+
300
+ for (const group of schema.groups) {
301
+ const fields = schema.fields.filter((f) => f.group === group.id);
302
+ if (fields.length === 0) continue;
303
+
304
+ const box = el("fieldset");
305
+ box.dataset.group = group.id;
306
+ box.append(el("legend", { textContent: group.title }));
307
+ if (group.description) box.append(el("p", { className: "group-desc", textContent: group.description }));
308
+
309
+ for (const field of fields) box.append(renderField(field));
310
+ form.append(box);
311
+ }
312
+
313
+ const submit = el("button", { className: "primary", type: "submit", textContent: "入力内容を保存する" });
314
+ const result = el("p", { className: "result" });
315
+ form.append(el("div", { className: "actions" }, [submit, result]));
316
+ form.hidden = false;
317
+
318
+ function renderField(field) {
319
+ const box = el("div", { className: "field" });
320
+ const id = `f-${field.field}`;
321
+
322
+ if (field.kind === "confirm") {
323
+ const input = el("input", { type: "checkbox", id, checked: true });
324
+ inputs.set(field.field, input);
325
+ const label = el("label", { htmlFor: id, textContent: field.label });
326
+ box.append(el("div", { className: "check" }, [input, label]));
327
+ if (field.hint) box.append(el("p", { className: "hint", textContent: field.hint }));
328
+ return box;
329
+ }
330
+
331
+ const label = el("label", { htmlFor: id });
332
+ label.append(document.createTextNode(field.label));
333
+ if (field.optional) label.append(el("span", { className: "optional", textContent: "(任意)" }));
334
+ box.append(label);
335
+
336
+ const hint = [field.hint, field.note].filter(Boolean).join(" ");
337
+ if (hint) box.append(el("p", { className: "hint", textContent: hint }));
338
+ const guide = renderGuide(field.guide);
339
+ if (guide) box.append(guide);
340
+
341
+ const input = el("input", {
342
+ id,
343
+ type: field.secret ? "password" : "text",
344
+ placeholder: field.placeholder ?? "",
345
+ // ブラウザや拡張に拾わせない
346
+ autocomplete: "off",
347
+ autocapitalize: "off",
348
+ autocorrect: "off",
349
+ spellcheck: false,
350
+ });
351
+ inputs.set(field.field, input);
352
+
353
+ const row = el("div", { className: "row" }, [input]);
354
+ if (field.secret) {
355
+ const toggle = el("button", { className: "toggle", type: "button", textContent: "表示" });
356
+ toggle.addEventListener("click", () => {
357
+ const shown = input.type === "text";
358
+ input.type = shown ? "password" : "text";
359
+ toggle.textContent = shown ? "表示" : "隠す";
360
+ });
361
+ row.append(toggle);
362
+ }
363
+ box.append(row);
364
+ box.append(el("p", { className: "error", id: `${id}-error` }));
365
+
366
+ input.addEventListener("blur", () => showProblem(field, check(field)));
367
+ input.addEventListener("input", () => {
368
+ if (box.classList.contains("invalid")) showProblem(field, check(field));
369
+ });
370
+ return box;
371
+ }
372
+
373
+ /**
374
+ * 「この値はどこで取るか」。
375
+ *
376
+ * 実測では手作業13件のうち GUI で隠せるのは2件だけで、残りは LINE と
377
+ * Cloudflare のブラウザ作業だった。手順書と入力欄が同じ画面に並ぶことが
378
+ * このツールの value なので、欄のすぐ隣に置く。
379
+ *
380
+ * 画像と詳しい手順は配布キット側のものを参照する(資産を二重に持たない)。
381
+ */
382
+ function renderGuide(guide, open = false) {
383
+ if (!guide) return null;
384
+
385
+ const box = el("details", { className: "guide" });
386
+ if (open) box.open = true;
387
+ box.append(el("summary", { textContent: "どこで取るか" }));
388
+
389
+ const body = el("div", { className: "guide-body" });
390
+
391
+ if (guide.steps?.length) {
392
+ const list = el("ol");
393
+ for (const step of guide.steps) list.append(el("li", { textContent: step }));
394
+ body.append(list);
395
+ }
396
+
397
+ if (guide.warn) body.append(el("p", { className: "guide-warn", textContent: guide.warn }));
398
+
399
+ if (guide.links?.length) {
400
+ const links = el("div", { className: "guide-links" });
401
+ for (const link of guide.links) {
402
+ // 作業は別タブで。この画面に戻ってこられるようにする
403
+ links.append(
404
+ el("a", { href: link.url, target: "_blank", rel: "noreferrer noopener",
405
+ textContent: `${link.label} ↗` }),
406
+ );
407
+ }
408
+ body.append(links);
409
+ }
410
+
411
+ if (guide.images?.length) {
412
+ // 開いたときに初めて読みにいく。畳まれたままなら要求も出さない
413
+ const slot = el("div");
414
+ box.addEventListener(
415
+ "toggle",
416
+ () => {
417
+ if (!box.open) return;
418
+ for (const image of guide.images) {
419
+ const img = el("img", { src: image.url, alt: image.alt });
420
+ // ネットワークが無いと読めない。読めなくても手順は残す
421
+ img.addEventListener("error", () => img.remove());
422
+ slot.append(img);
423
+ }
424
+ },
425
+ { once: true },
426
+ );
427
+ body.append(slot);
428
+ }
429
+
430
+ if (guide.manual) {
431
+ body.append(
432
+ el("p", { className: "guide-manual" }, [
433
+ el("a", { href: guide.manual.url, target: "_blank", rel: "noreferrer noopener",
434
+ textContent: `手順書で詳しく見る: ${guide.manual.label} ↗` }),
435
+ ]),
436
+ );
437
+ }
438
+
439
+ box.append(body);
440
+ return box;
441
+ }
442
+
443
+ /** サーバーから渡された規則で見る。画面側で独自の規則を作らない */
444
+ function check(field) {
445
+ const input = inputs.get(field.field);
446
+ if (field.kind === "confirm") return null;
447
+
448
+ const value = input.value.trim();
449
+ if (value === "") return field.optional ? null : field.message;
450
+ if (field.minLength !== null && value.length < field.minLength) return field.message;
451
+ if (field.pattern !== null && !new RegExp(field.pattern).test(value)) return field.message;
452
+ return null;
453
+ }
454
+
455
+ function showProblem(field, problem) {
456
+ const box = inputs.get(field.field).closest(".field");
457
+ const slot = document.getElementById(`f-${field.field}-error`);
458
+ if (!slot) return;
459
+ slot.textContent = problem ?? "";
460
+ box.classList.toggle("invalid", problem !== null);
461
+ }
462
+
463
+ /**
464
+ * Messaging API チャネルと LINE Login チャネルは別のチャネルなので、
465
+ * Channel ID が一致することはない。実測で最大の混乱要因だった箇所で、
466
+ * 本家はこれを見ていないため気づかないまま最後まで進んでしまう
467
+ */
468
+ function crossCheck() {
469
+ const a = inputs.get("lineChannelId")?.value.trim();
470
+ const b = inputs.get("lineLoginChannelId")?.value.trim();
471
+ if (!a || a !== b) return null;
472
+ return "Messaging API チャネルと LINE Login チャネルは別のチャネルです。同じ ID にはなりません。取得元をもう一度確認してください。";
473
+ }
474
+
475
+ // ── 実行 ─────────────────────────────────────────────────────────
476
+ const runBox = $("run");
477
+ const askBox = $("run-ask");
478
+ const screen = $("run-screen");
479
+ let startButton = null;
480
+
481
+ function showStart() {
482
+ if (startButton) return;
483
+ startButton = el("button", { className: "primary", type: "button", textContent: "セットアップを開始する" });
484
+ startButton.addEventListener("click", startRun);
485
+ form.querySelector(".actions").append(startButton);
486
+ }
487
+
488
+ async function startRun() {
489
+ startButton.disabled = true;
490
+ const res = await fetch("/api/run/start", {
491
+ method: "POST",
492
+ headers: { "Content-Type": "application/json" },
493
+ body: "{}",
494
+ });
495
+ if (!res.ok) {
496
+ result.textContent = (await res.json()).error ?? "開始できませんでした。";
497
+ result.className = "result bad";
498
+ startButton.disabled = false;
499
+ return;
500
+ }
501
+ // 入力はサーバーに渡した。ここから先は実行の画面
502
+ for (const input of inputs.values()) input.disabled = true;
503
+ submit.disabled = true;
504
+ runBox.hidden = false;
505
+ runBox.scrollIntoView({ behavior: "smooth", block: "start" });
506
+ listen();
507
+ }
508
+
509
+ function listen() {
510
+ const source = new EventSource("/api/run/stream");
511
+ source.addEventListener("update", (event) => {
512
+ const { lines, state } = JSON.parse(event.data);
513
+ // 端末の再描画はサーバー側で解決済み。ここは行を並べるだけ
514
+ const atBottom = screen.scrollTop + screen.clientHeight >= screen.scrollHeight - 24;
515
+ screen.textContent = lines.join("\n");
516
+ if (atBottom) screen.scrollTop = screen.scrollHeight;
517
+ renderAsk(state);
518
+ if (state.status === "finished") {
519
+ source.close();
520
+ // 失敗・中断だとシークレットを含むファイルが残る。終わった直後に必ず見る
521
+ showLeftover();
522
+ }
523
+ });
524
+ source.addEventListener("error", () => source.close());
525
+ }
526
+
527
+ function renderAsk(state) {
528
+ askBox.replaceChildren();
529
+
530
+ if (state.status === "finished") {
531
+ const code = state.exit?.code;
532
+ if (code === 0 && state.hasCompletion) {
533
+ // 仕上げの手作業に使う値を、貼り付けやすい形で出す。
534
+ // 端末表示は畳む(もう読む必要がない)
535
+ showCompletion();
536
+ return;
537
+ }
538
+ const box = el("div", { className: code === 0 ? "ask done" : "ask stopped" });
539
+ box.append(el("h3", { textContent: code === 0 ? "終了しました" : "途中で終わりました" }));
540
+ box.append(
541
+ el("p", {
542
+ textContent:
543
+ code === 0
544
+ ? "画面の最後に出ている案内にしたがって、残りの手作業を進めてください。"
545
+ : "画面の最後に出ているメッセージを確認してください。同じ手順でやり直すと、続きから再開できます。",
546
+ }),
547
+ );
548
+ askBox.append(box);
549
+ return;
550
+ }
551
+
552
+ if (state.halt) {
553
+ const box = el("div", { className: "ask stopped" });
554
+ box.append(el("h3", { textContent: state.halt.message }));
555
+ if (state.halt.detail) box.append(el("p", { textContent: state.halt.detail }));
556
+ box.append(
557
+ el("p", { textContent: "下の画面を見て、必要であれば自分で入力してください。" }),
558
+ );
559
+
560
+ const input = el("input", { type: "text", autocomplete: "off" });
561
+ const go = el("button", { className: "go", type: "button", textContent: "送信" });
562
+ go.addEventListener("click", () =>
563
+ answer({ manual: true, text: input.value }).then(() => (input.value = "")),
564
+ );
565
+ box.append(el("div", { className: "row" }, [el("div", { className: "row" }, [input])]));
566
+ box.append(el("div", { className: "choices" }, [go]));
567
+ askBox.append(box);
568
+ return;
569
+ }
570
+
571
+ const pending = state.pending;
572
+ if (!pending) return;
573
+
574
+ const box = el("div", { className: "ask" });
575
+ box.append(el("h3", { textContent: pending.label ?? pending.message }));
576
+ if (pending.note) box.append(el("p", { textContent: pending.note }));
577
+
578
+ // 本家が端末に出している文言。下の黒い枠と突き合わせられるように残すが、
579
+ // 「Enter を押してください」のようにこの画面では出来ない指示も含むので控えめに出す
580
+ if (pending.message && pending.message !== (pending.label ?? pending.message)) {
581
+ box.append(
582
+ el("p", { className: "cli-echo", textContent: `ツールの表示:「${pending.message}」` }),
583
+ );
584
+ }
585
+
586
+ // ここで止まっているので、手順は畳まずに開いた状態で出す
587
+ const guide = renderGuide(guides[pending.promptId], true);
588
+ if (guide) box.append(guide);
589
+
590
+ // 入力欄の値がまだ無い場合は、フォームと同じ経路で受け取る
591
+ if (pending.reason === "missing-value") {
592
+ const input = el("input", { type: "text", autocomplete: "off" });
593
+ const go = el("button", { className: "go", type: "button", textContent: "送信" });
594
+ go.addEventListener("click", async () => {
595
+ await fetch("/api/answers", {
596
+ method: "POST",
597
+ headers: { "Content-Type": "application/json" },
598
+ body: JSON.stringify({ [pending.field]: input.value.trim() }),
599
+ });
600
+ });
601
+ box.append(el("div", { className: "row" }, [input]));
602
+ box.append(el("div", { className: "choices" }, [go]));
603
+ askBox.append(box);
604
+ return;
605
+ }
606
+
607
+ const choices = el("div", { className: "choices" });
608
+
609
+ if (pending.kind === "select" && pending.options) {
610
+ pending.options.forEach((label, index) => {
611
+ const button = el("button", { type: "button", textContent: label });
612
+ if (index === 0) button.classList.add("go");
613
+ button.addEventListener("click", () =>
614
+ answer({ promptId: pending.promptId, optionIndex: index }),
615
+ );
616
+ choices.append(button);
617
+ });
618
+ } else if (pending.kind === "confirm") {
619
+ for (const [label, value] of [["はい", true], ["いいえ", false]]) {
620
+ const button = el("button", { type: "button", textContent: label });
621
+ if (value) button.classList.add("go");
622
+ button.addEventListener("click", () =>
623
+ answer({ promptId: pending.promptId, confirm: value }),
624
+ );
625
+ choices.append(button);
626
+ }
627
+ } else {
628
+ const button = el("button", { className: "go", type: "button", textContent: "完了しました" });
629
+ button.addEventListener("click", () => answer({ promptId: pending.promptId, text: "" }));
630
+ choices.append(button);
631
+ }
632
+
633
+ box.append(choices);
634
+ askBox.append(box);
635
+ }
636
+
637
+ /** 値をひとつ出す。貼るものはコピー、開くものはリンクにする */
638
+ function copyRow(label, value, { masked = false, open = false } = {}) {
639
+ const row = el("div", { className: "copy" });
640
+ row.append(el("span", { className: "label", textContent: label }));
641
+
642
+ const code = el("code", { textContent: masked ? "•".repeat(24) : value });
643
+ row.append(code);
644
+
645
+ if (open) {
646
+ // 別タブで開く。この画面には戻ってこられるようにする
647
+ row.append(
648
+ el("a", {
649
+ className: "openlink",
650
+ href: value,
651
+ target: "_blank",
652
+ rel: "noreferrer noopener",
653
+ textContent: "開く ↗",
654
+ }),
655
+ );
656
+ }
657
+
658
+ if (masked) {
659
+ let shown = false;
660
+ const reveal = el("button", { type: "button", textContent: "表示" });
661
+ reveal.addEventListener("click", () => {
662
+ shown = !shown;
663
+ code.textContent = shown ? value : "•".repeat(24);
664
+ reveal.textContent = shown ? "隠す" : "表示";
665
+ });
666
+ row.append(reveal);
667
+ }
668
+
669
+ const copy = el("button", { type: "button", textContent: "コピー" });
670
+ copy.addEventListener("click", async () => {
671
+ try {
672
+ // http://127.0.0.1 は secure context なので clipboard が使える
673
+ await navigator.clipboard.writeText(value);
674
+ } catch {
675
+ // 使えない環境向けの逃げ道
676
+ const area = el("textarea", { value });
677
+ document.body.append(area);
678
+ area.select();
679
+ document.execCommand("copy");
680
+ area.remove();
681
+ }
682
+ copy.textContent = "コピーしました";
683
+ copy.classList.add("done");
684
+ setTimeout(() => {
685
+ copy.textContent = "コピー";
686
+ copy.classList.remove("done");
687
+ }, 1600);
688
+ });
689
+ row.append(copy);
690
+ return row;
691
+ }
692
+
693
+ /**
694
+ * 仕上げの手作業。
695
+ *
696
+ * 本家は端末の枠の中に6件まとめて出して終わる。そこから手で写すのは事故のもとで、
697
+ * とくに **API Key は再表示できない**。貼り付ける値ごとにコピーできる形にする。
698
+ */
699
+ async function showCompletion() {
700
+ let data;
701
+ try {
702
+ data = await (await fetch("/api/run/completion")).json();
703
+ } catch {
704
+ return;
705
+ }
706
+ if (!data) return;
707
+
708
+ const box = el("div", { className: "finish" });
709
+ box.append(el("h3", { textContent: "セットアップが完了しました" }));
710
+ box.append(
711
+ el("p", {
712
+ className: "lead2",
713
+ textContent: "あと6つ、ブラウザでの設定が残っています。上から順に進めてください。",
714
+ }),
715
+ );
716
+
717
+ data.steps.forEach((step, i) => {
718
+ const item = el("div", { className: "step" });
719
+ const head = el("h4");
720
+ head.append(el("span", { className: "num", textContent: `${i + 1}.` }));
721
+ head.append(document.createTextNode(step.title));
722
+ item.append(head);
723
+ item.append(el("p", { className: "where", textContent: step.where }));
724
+
725
+ for (const v of step.values) {
726
+ item.append(copyRow(v.label, v.value, { open: v.action === "open" }));
727
+ }
728
+
729
+ if (step.checklist.length) {
730
+ const list = el("ul");
731
+ for (const line of step.checklist) list.append(el("li", { textContent: line }));
732
+ item.append(list);
733
+ }
734
+ if (step.warn) item.append(el("p", { className: "warn2", textContent: step.warn }));
735
+ box.append(item);
736
+ });
737
+
738
+ const key = el("div", { className: "apikey" });
739
+ key.append(el("h4", { textContent: "API Key" }));
740
+ key.append(
741
+ el("p", {
742
+ textContent:
743
+ "この値は二度と表示できません。いま、パスワード管理アプリなど安全な場所に保存してください。",
744
+ }),
745
+ );
746
+ key.append(copyRow("API Key", data.apiKey, { masked: true }));
747
+ box.append(key);
748
+
749
+ askBox.replaceChildren(box);
750
+
751
+ // 「黒い枠は出力です」の案内は、畳んだあとでは噛み合わない
752
+ document.querySelector(".run-howto")?.remove();
753
+
754
+ // 端末表示は畳む。読む必要はもう無いが、消しはしない
755
+ const raw = el("details", { className: "rawlog" });
756
+ raw.append(el("summary", { textContent: "ツールの出力を見る" }));
757
+ screen.replaceWith(raw);
758
+ raw.append(screen);
759
+ }
760
+
761
+ /**
762
+ * 本家は失敗・中断時に、シークレットを平文で含む途中経過ファイルを残す。
763
+ * 失敗した場面は、参加者がサポートにファイルを送りたくなる場面と重なる
764
+ */
765
+ async function showLeftover() {
766
+ let data;
767
+ try {
768
+ data = await (await fetch("/api/leftover")).json();
769
+ } catch {
770
+ return;
771
+ }
772
+ if (!data.warning) return;
773
+ if (document.getElementById("leftover")) return;
774
+
775
+ const body = [...data.warning.lines, data.warning.tighten]
776
+ .filter(Boolean)
777
+ .join("\n")
778
+ .replace(/\*\*/g, "");
779
+ const box = el("div", { className: "leftover", id: "leftover" });
780
+ box.append(el("h3", { textContent: data.warning.title }));
781
+ box.append(document.createTextNode(body));
782
+ askBox.prepend(box);
783
+ runBox.hidden = false;
784
+ }
785
+
786
+ async function answer(body) {
787
+ await fetch("/api/run/answer", {
788
+ method: "POST",
789
+ headers: { "Content-Type": "application/json" },
790
+ body: JSON.stringify(body),
791
+ });
792
+ }
793
+
794
+ form.addEventListener("submit", async (event) => {
795
+ event.preventDefault();
796
+ result.textContent = "";
797
+ result.className = "result";
798
+
799
+ let bad = 0;
800
+ for (const field of schema.fields) {
801
+ const problem = check(field);
802
+ showProblem(field, problem);
803
+ if (problem) bad += 1;
804
+ }
805
+
806
+ const collision = crossCheck();
807
+ if (collision) {
808
+ for (const name of ["lineChannelId", "lineLoginChannelId"]) {
809
+ const field = schema.fields.find((f) => f.field === name);
810
+ if (field) showProblem(field, collision);
811
+ }
812
+ bad += 1;
813
+ }
814
+
815
+ if (bad > 0) {
816
+ result.textContent = "入力を確認してください。";
817
+ result.classList.add("bad");
818
+ return;
819
+ }
820
+
821
+ const values = {};
822
+ for (const field of schema.fields) {
823
+ const input = inputs.get(field.field);
824
+ values[field.field] = field.kind === "confirm" ? input.checked : input.value.trim();
825
+ }
826
+
827
+ submit.disabled = true;
828
+ try {
829
+ const res = await fetch("/api/answers", {
830
+ method: "POST",
831
+ headers: { "Content-Type": "application/json" },
832
+ body: JSON.stringify(values),
833
+ });
834
+ const body = await res.json();
835
+
836
+ const rejected = Object.entries(body.rejected ?? {});
837
+ if (rejected.length > 0) {
838
+ for (const [name, message] of rejected) {
839
+ const field = schema.fields.find((f) => f.field === name);
840
+ if (field) showProblem(field, message);
841
+ }
842
+ result.textContent = "入力を確認してください。";
843
+ result.classList.add("bad");
844
+ return;
845
+ }
846
+
847
+ result.textContent = "保存しました。";
848
+ result.classList.add("ok");
849
+ showStart();
850
+ } catch {
851
+ result.textContent = "保存できませんでした。ターミナルの表示を確認してください。";
852
+ result.classList.add("bad");
853
+ } finally {
854
+ submit.disabled = false;
855
+ }
856
+ });
857
+ </script>
858
+ </body>
859
+ </html>