walover-line-harness-gui 0.3.0 → 0.5.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "walover-line-harness-gui",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "LINE Harness のセットアップを、ターミナルを開かずにブラウザから行うためのローカルツール",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/server/cli.js CHANGED
@@ -3,6 +3,7 @@ import { start, shutdown } from "./index.js";
3
3
  import { openBrowser } from "./browser.js";
4
4
  import { ptyBackend } from "./pty.js";
5
5
  import { getVersionStatus } from "./cli-version.js";
6
+ import { checkNodeRuntime } from "./runtime.js";
6
7
 
7
8
  const { server, url } = await start();
8
9
 
@@ -14,6 +15,15 @@ console.log("");
14
15
  console.log(" ブラウザが開かない場合は、上の URL をコピーして開いてください。");
15
16
  console.log(" 終了するには Ctrl+C を押してください。");
16
17
 
18
+ const runtime = checkNodeRuntime();
19
+ if (!runtime.ok) {
20
+ // 画面を開く前に、ターミナルにも出す。ここで気づければ何も入力せずに済む
21
+ console.log("");
22
+ console.log(` ⚠ ${runtime.problem.title}`);
23
+ console.log(` ${runtime.problem.body}`);
24
+ for (const line of runtime.problem.fix) console.log(` - ${line}`);
25
+ }
26
+
17
27
  if (ptyBackend() === null) {
18
28
  // 本家 CLI は実際の端末(PTY)を要求する。無いと起動すらできないので先に伝える
19
29
  console.log("");
@@ -14,6 +14,7 @@ import {
14
14
  } from "./state.js";
15
15
  import { ptyBackend } from "./pty.js";
16
16
  import { getVersionStatus } from "./cli-version.js";
17
+ import { checkNodeRuntime, ptyProblem } from "./runtime.js";
17
18
  import { checkLeftoverState, leftoverWarning } from "./leftover.js";
18
19
 
19
20
  const HERE = dirname(fileURLToPath(import.meta.url));
@@ -114,7 +115,18 @@ async function route(req, res, pathname, method) {
114
115
 
115
116
  if (pathname === "/api/health" && method === "GET") {
116
117
  const backend = ptyBackend();
117
- json(res, 200, { ok: true, pty: backend !== null, ptyBackend: backend });
118
+ const node = checkNodeRuntime();
119
+
120
+ // **始めても無駄な条件は、始める前に返す。**
121
+ // 擬似端末が無い / Node が機械に合っていない、はどちらも必ず失敗する。
122
+ //
123
+ // 形を揃えて返すのは、画面が同じ出し方をできるようにするため。
124
+ // どれも「そのまま貼れる文章」を持っていて、参加者はコピーするだけでよい
125
+ const blockers = [];
126
+ if (backend === null) blockers.push(ptyProblem());
127
+ if (node.problem) blockers.push(node.problem);
128
+
129
+ json(res, 200, { ok: true, pty: backend !== null, ptyBackend: backend, node, blockers });
118
130
  return true;
119
131
  }
120
132
 
@@ -99,8 +99,13 @@ export function leftoverWarning(leftover) {
99
99
  "このファイルには**チャネルシークレットとアクセストークンが、そのまま読める形で**入っています。",
100
100
  "**このファイルも、開いた画面も、誰にも送らないでください。**(サポートへの問い合わせでも同じです)",
101
101
  "",
102
- "もう一度同じ手順でセットアップを実行し、最後まで完了すれば、このファイルは自動で消えます。",
103
- "途中から再開されるので、入力し直す必要はありません(選択を求められたら「続行」を選んでください)。",
102
+ "もう一度セットアップを実行し、最後まで完了すれば、このファイルは自動で消えます。",
103
+ "本家のツールは途中から再開するので、済んだところを訊かれることはありません",
104
+ "(選択を求められたら「続行」を選んでください)。",
105
+ "",
106
+ "**この画面の入力欄が空になっている場合は、もう一度入れてください。**",
107
+ "このツールは入力をメモリ上だけで扱い、ディスクに保存しないため、",
108
+ "ターミナルを終了すると消えます。終了せずに「もう一度実行する」を押せば、入力し直す必要はありません。",
104
109
  ],
105
110
  // 消すのはこちらの判断でやらない。再開に必要な進捗も同じファイルに入っている
106
111
  canDelete: false,
@@ -23,7 +23,7 @@
23
23
  */
24
24
 
25
25
  /** 契約が対象とする本家 CLI のバージョン。ズレの検知は #5 で行う */
26
- export const CONTRACT_CLI_VERSION = "0.2.12";
26
+ export const CONTRACT_CLI_VERSION = "0.2.11";
27
27
 
28
28
  /**
29
29
  * @typedef {object} PromptSpec
@@ -133,9 +133,7 @@ export const PROMPTS = [
133
133
  id: "lineChannelSecret",
134
134
  message: "チャネルシークレット(英数字)",
135
135
  kind: "text",
136
- // 0.2.12 で本家が p.text → p.password にした。値行は伏字(▪)になり、
137
- // placeholder は無くなった。宣言したままだと第二の照合が外れて止まる。
138
- // message が一意なので、外しても取り違えは起きない
136
+ placeholder: "同じページに表示されている Channel Secret",
139
137
  secret: true,
140
138
  answer: "form",
141
139
  field: "lineChannelSecret",
@@ -150,7 +148,7 @@ export const PROMPTS = [
150
148
  id: "lineChannelAccessToken",
151
149
  message: "チャネルアクセストークン(長期)",
152
150
  kind: "text",
153
- // 同上。0.2.12 で p.password になった
151
+ placeholder: "上の手順で発行したトークン",
154
152
  secret: true,
155
153
  answer: "form",
156
154
  field: "lineChannelAccessToken",
@@ -102,6 +102,8 @@ export class SetupRun extends EventEmitter {
102
102
  this.pending = null;
103
103
  /** フェイルクローズで止まった理由 @type {object|null} */
104
104
  this.halt = null;
105
+ /** 無反応を見張る時計。出力が来なくても判定を動かす @type {NodeJS.Timeout|null} */
106
+ this.watchdog = null;
105
107
 
106
108
  /** 直近に検出した枠 @type {import("./prompt-detector.js").DetectedPrompt|null} */
107
109
  this.detected = null;
@@ -198,6 +200,7 @@ export class SetupRun extends EventEmitter {
198
200
  this.stop();
199
201
  if (this.timer) clearTimeout(this.timer);
200
202
  if (this.forced) clearTimeout(this.forced);
203
+ if (this.watchdog) clearTimeout(this.watchdog);
201
204
  for (const key of Object.keys(this.answers)) {
202
205
  this.answers[key] = "";
203
206
  delete this.answers[key];
@@ -212,6 +215,7 @@ export class SetupRun extends EventEmitter {
212
215
  #onExit(info) {
213
216
  this.exit = info;
214
217
  this.status = "finished";
218
+ if (this.watchdog) clearTimeout(this.watchdog);
215
219
  // 仕上げの手作業に使う値を取り出す。読めなければ端末表示のままにする
216
220
  this.completion = parseCompletion(this.term.snapshot(), {
217
221
  // 画面から読めなくても、フォームに入っていた値で補える
@@ -224,6 +228,13 @@ export class SetupRun extends EventEmitter {
224
228
  }
225
229
 
226
230
  #onData(chunk) {
231
+ // **出力が来ている = 本家は動いている。**
232
+ //
233
+ // 進捗を「こちらが答えたとき」だけで測っていたため、本家が長く作業すると
234
+ // タイマーが止まったままになり、作業明けの最初のプロンプトが
235
+ // 照合される前に stuck で切られていた(#44)。
236
+ // npx のインストールだけで1〜2分かかるので、構造的に起きていた。
237
+ this.progressedAt = Date.now();
227
238
  this.term.write(chunk);
228
239
  this.#schedule();
229
240
  }
@@ -248,9 +259,23 @@ export class SetupRun extends EventEmitter {
248
259
  } catch (err) {
249
260
  this.#haltWith("internal-error", "内部エラーで自動応答を止めました", String(err));
250
261
  }
262
+ this.#watch();
251
263
  this.emit("update", this.state());
252
264
  }
253
265
 
266
+ /**
267
+ * 無反応を見張る。
268
+ *
269
+ * `#evaluate` は出力が来たときにしか呼ばれない。つまり**本家が黙り込むと、
270
+ * 判定そのものが動かない**。それでは「答えたのに反応が無い」を検知できないので、
271
+ * ここで時計を持つ。
272
+ */
273
+ #watch() {
274
+ if (this.watchdog) clearTimeout(this.watchdog);
275
+ if (this.status === "finished" || this.status === "halted") return;
276
+ this.watchdog = setTimeout(() => this.#fire(), this.stuckMs);
277
+ }
278
+
254
279
  // ── 判定 ───────────────────────────────────────────────────────────
255
280
 
256
281
  #frameId(detected) {
@@ -275,18 +300,6 @@ export class SetupRun extends EventEmitter {
275
300
 
276
301
  const frameId = this.#frameId(detected);
277
302
 
278
- // 同じ枠が出たまま何も進んでいないなら、諦めて人に委ねる。
279
- // 黙って固まると、参加者は何が起きているのか分からない
280
- if (this.status === "running" && Date.now() - this.progressedAt > this.stuckMs) {
281
- this.#haltWith(
282
- "stuck",
283
- "画面が進まなくなりました",
284
- "この端末では自動で答えられない場面のようです。下の画面を見て、必要な入力を自分で送ってください。",
285
- detected,
286
- );
287
- return;
288
- }
289
-
290
303
  // select の移動後の確認待ち
291
304
  if (this.pendingSelect) {
292
305
  this.#confirmSelect(detected, frameId);
@@ -312,8 +325,22 @@ export class SetupRun extends EventEmitter {
312
325
  return;
313
326
  }
314
327
 
315
- // 同じ枠には二度送らない(エコーで枠が描き直されるため)
316
- if (this.answeredFrames.has(frameId)) return;
328
+ // 同じ枠には二度送らない(エコーで枠が描き直されるため)。
329
+ //
330
+ // **stuck を見るのはここだけ。** 答えたのに枠が残ったまま出力も止まった、
331
+ // という場面に限る。まだ答えていない枠は、まず照合してみる(#44)。
332
+ // 未応答の枠を時間で切ると、答えられるプロンプトまで巻き添えにする。
333
+ if (this.answeredFrames.has(frameId)) {
334
+ if (this.status === "running" && Date.now() - this.progressedAt > this.stuckMs) {
335
+ this.#haltWith(
336
+ "stuck",
337
+ "本家 CLI が反応しなくなりました",
338
+ "送った入力のあと、画面が動かなくなりました。下の画面を見て、必要であれば自分で入力してください。",
339
+ detected,
340
+ );
341
+ }
342
+ return;
343
+ }
317
344
 
318
345
  const matches = matchPrompt(detected);
319
346
 
@@ -546,6 +573,8 @@ export class SetupRun extends EventEmitter {
546
573
  */
547
574
  answerManually(text) {
548
575
  if (this.status !== "halted") throw new Error("止まっているときだけ手入力できます");
576
+ // 人が答えたのも進捗。ここを忘れると、復帰した直後に同じ判定へ落ちる
577
+ this.progressedAt = Date.now();
549
578
  this.pty?.write(`${text}${KEY_ENTER}`);
550
579
  this.halt = null;
551
580
  this.status = "running";
@@ -0,0 +1,154 @@
1
+ /**
2
+ * 動かしている Node が、この機械に合っているか。
3
+ *
4
+ * Apple Silicon で **x86_64 の Node** を使うと、本家 CLI が依存として使う
5
+ * esbuild が x64 版を選び、Rosetta 上で落ちる。実測ではこう出た。
6
+ *
7
+ * ```
8
+ * ◇ D1 データベース作成完了
9
+ * ◇ bootstrap 適用に失敗
10
+ * ■ wrangler d1 failed:
11
+ * Error: The service is no longer running ← esbuild の子プロセスが死んだ
12
+ * ```
13
+ *
14
+ * **失敗するのは D1 を作ったあと。** R2(#26)や cron(#42)と同じ
15
+ * 「事前に分かるのに、作るものを作ってから失敗する」型で、やり直しのコストが高い。
16
+ *
17
+ * ただしこれは人に確認させる必要すら無い。**プロセスの中で分かる。**
18
+ * `pty.js` の `childEnv` が「起動に使った Node」を子へ引き継ぐので、
19
+ * ここで見ているのはそのまま本家 CLI が使う Node でもある。
20
+ */
21
+
22
+ import { cpus } from "node:os";
23
+
24
+ /**
25
+ * 参加者に渡す「助けを求める文章」。
26
+ *
27
+ * **読者は非開発者。** 何が悪いかを説明しても、直し方が分からなければ先に進めない。
28
+ * 実機で「Silicon のエラー文は非エンジニアにはハードルが高い」という指摘を受けた。
29
+ *
30
+ * そこで、**そのまま Claude Code などに貼れる文章**を用意する。
31
+ * 症状・機械の情報・やってほしいこと・壊してはいけないものを、こちらで書いておく。
32
+ * 参加者がやるのは「コピーして貼る」だけにする。
33
+ *
34
+ * @param {string[]} lines
35
+ */
36
+ const prompt = (lines) => lines.join("\n");
37
+
38
+ /**
39
+ * @typedef {object} RuntimeCheck
40
+ * @property {boolean} ok
41
+ * @property {string} arch プロセスの arch
42
+ * @property {string} cpu CPU の名前
43
+ * @property {RuntimeProblem|null} problem
44
+ *
45
+ * @typedef {object} RuntimeProblem
46
+ * @property {string} id
47
+ * @property {string} title
48
+ * @property {string} body
49
+ * @property {string[]} fix 直し方。**既存の mise / nvm を壊さない手順**にする
50
+ * @property {string} prompt そのまま貼れる、助けを求める文章
51
+ */
52
+
53
+ /**
54
+ * @param {{ arch?: string, cpu?: string }} [override] テスト用
55
+ * @returns {RuntimeCheck}
56
+ */
57
+ export function checkNodeRuntime(override = {}) {
58
+ const arch = override.arch ?? process.arch;
59
+ const cpu = override.cpu ?? cpus()[0]?.model ?? "";
60
+
61
+ // Apple 製 CPU なのに x64 で動いている = Rosetta 経由。
62
+ // arm64 で動いていれば何も言わない(Intel Mac の x64 も正常)
63
+ if (!(arch === "x64" && /\bApple\b/.test(cpu))) {
64
+ return { ok: true, arch, cpu, problem: null };
65
+ }
66
+
67
+ return {
68
+ ok: false,
69
+ arch,
70
+ cpu,
71
+ problem: {
72
+ id: "x64-on-apple-silicon",
73
+ title: "このパソコンでは、このままでは始められません",
74
+ body:
75
+ `お使いの Mac は ${cpu} ですが、いま動いている Node.js が、この Mac の種類に合っていません。` +
76
+ "このまま進めると、途中まで作られたあとで必ず失敗します。" +
77
+ "先に Node.js を入れ替える必要があります。",
78
+ fix: [
79
+ "下の文章をコピーして、Claude Code(または詳しい方)に渡してください。",
80
+ "自分で直す場合は、nodejs.org の macOS「ARM64」版(LTS)を入れてください。",
81
+ ],
82
+ prompt: prompt([
83
+ "Mac で LINE Harness のセットアップ画面を開いたら、",
84
+ "「このパソコンでは、このままでは始められません」と出て先に進めません。",
85
+ "",
86
+ "画面にはこう書かれています:",
87
+ ` お使いの Mac は ${cpu} ですが、いま動いている Node.js が、この Mac の種類に合っていません。`,
88
+ "",
89
+ "調べたところ、こうなっているようです:",
90
+ ` ・Node.js のバージョン: ${process.version}`,
91
+ ` ・Node.js の種類: ${arch}(この Mac に合っているのは arm64)`,
92
+ "",
93
+ "この Mac に合った Node.js(arm64 版)を用意して、",
94
+ "セットアップ画面を起動し直せるようにしてください。",
95
+ "",
96
+ "お願いしたい条件:",
97
+ "・いま入っている mise / nvm / Homebrew の設定は壊さないでください",
98
+ "・新しい Node.js は別の場所に入れて、そこから起動する形にしてください",
99
+ "・終わったら、起動するためのコマンドを1行で教えてください",
100
+ " (いまは npx walover-line-harness-gui で起動しています)",
101
+ ]),
102
+ },
103
+ };
104
+ }
105
+
106
+ /**
107
+ * 擬似端末を用意できない場合。
108
+ *
109
+ * 本家 CLI は実際の端末を要求するので、これが無いと起動すらできない。
110
+ * **参加者から見れば「始められない」だけ**なので、言い方も直し方もそれに合わせる。
111
+ *
112
+ * @param {{ platform?: string }} [override] テスト用
113
+ * @returns {RuntimeProblem}
114
+ */
115
+ export function ptyProblem(override = {}) {
116
+ const platform = override.platform ?? process.platform;
117
+
118
+ // 直し方は OS で違う。macOS 以外に xcode-select を案内しても意味がない
119
+ const fix =
120
+ platform === "darwin"
121
+ ? [
122
+ "下の文章をコピーして、Claude Code(または詳しい方)に渡してください。",
123
+ "自分で直す場合は、ターミナルで xcode-select --install を実行してください。",
124
+ ]
125
+ : [
126
+ "下の文章をコピーして、Claude Code(または詳しい方)に渡してください。",
127
+ "自分で直す場合は、いったんターミナルを終了して、もう一度起動してみてください。",
128
+ ];
129
+
130
+ return {
131
+ id: "no-pty",
132
+ title: "このパソコンでは、このままでは始められません",
133
+ body:
134
+ "セットアップを進めるのに必要な部品が、このパソコンで用意できませんでした。" +
135
+ "このままでは、セットアップツールを起動できません。",
136
+ fix,
137
+ prompt: prompt([
138
+ "LINE Harness のセットアップ画面を開いたら、",
139
+ "「このパソコンでは、このままでは始められません」と出て先に進めません。",
140
+ "",
141
+ "画面にはこう書かれています:",
142
+ " セットアップを進めるのに必要な部品が、このパソコンで用意できませんでした。",
143
+ "",
144
+ "このツールは擬似端末(PTY)を使います。",
145
+ `私のパソコンは ${platform} です。`,
146
+ "擬似端末を使えるようにして、セットアップ画面を起動し直せるようにしてください。",
147
+ "",
148
+ "お願いしたい条件:",
149
+ "・いま入っている開発ツールの設定は壊さないでください",
150
+ "・終わったら、起動するためのコマンドを1行で教えてください",
151
+ " (いまは npx walover-line-harness-gui で起動しています)",
152
+ ]),
153
+ };
154
+ }
@@ -73,6 +73,18 @@
73
73
  .note .note-detail { margin: .4rem 0 0 1.1rem; padding-left: .7rem;
74
74
  border-left: 2px solid var(--line); white-space: pre-line; }
75
75
 
76
+ /* 「そのまま貼れる文章」。読ませるより、コピーさせることが目的 */
77
+ .helpme { margin-top: .8rem; padding-top: .8rem; border-top: 1px solid var(--err-line); }
78
+ .helpme-lead { margin: 0 0 .4rem; font-weight: 600; font-size: .88rem; }
79
+ .helpme-text { margin: 0 0 .5rem; padding: .7rem .8rem; max-height: 11rem; overflow-y: auto;
80
+ background: var(--bg); border: 1px solid var(--line); border-radius: .4rem;
81
+ color: var(--fg); font-size: .8rem; line-height: 1.6;
82
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
83
+ white-space: pre-wrap; word-break: break-word; }
84
+ .helpme button { font: inherit; font-size: .85rem; font-weight: 600; padding: .45rem 1.1rem;
85
+ border: 0; border-radius: 999px; cursor: pointer;
86
+ background: var(--brand-fill); color: var(--brand-fill-fg); }
87
+
76
88
  .status { display: flex; align-items: center; gap: .5rem; font-size: .85rem; color: var(--muted);
77
89
  margin-bottom: 1.5rem; }
78
90
  .dot { width: .55rem; height: .55rem; border-radius: 50%; background: var(--muted); flex: none; }
@@ -285,14 +297,37 @@
285
297
  throw new Error("no server");
286
298
  }
287
299
 
288
- if (health.pty === false) {
289
- // 擬似端末が無いと本家 CLI は起動できない。値を入れてもらう前に伝える
290
- banner(
291
- "セットアップを開始できません",
292
- "擬似端末を用意できませんでした(python3 が見つかりません)。" +
293
- "macOS では、ターミナルで xcode-select --install を実行すると入ります。",
294
- true,
295
- );
300
+ /**
301
+ * 始めても必ず失敗する条件。
302
+ *
303
+ * 見つけたら**開始させない**。途中まで作られてから失敗すると、
304
+ * Cloudflare 上に中途半端なものが残り、やり直しのコストが高い。
305
+ *
306
+ * **読者は非開発者。** 原因を説明しても直し方が分からなければ先に進めないので、
307
+ * **そのまま貼れる文章**をコピーできる形で添える(実機での指摘)。
308
+ */
309
+ const blockers = (health.blockers ?? []).map((b) => b.title);
310
+ for (const problem of health.blockers ?? []) $("banners").append(renderBlocker(problem));
311
+
312
+ function renderBlocker({ title, body, fix, prompt }) {
313
+ const box = el("div", { className: "banner bad" });
314
+ box.append(el("strong", { textContent: title }));
315
+ box.append(document.createTextNode(body));
316
+
317
+ if (fix?.length) {
318
+ const list = el("ul");
319
+ for (const line of fix) list.append(el("li", { textContent: line }));
320
+ box.append(list);
321
+ }
322
+
323
+ if (prompt) {
324
+ const help = el("div", { className: "helpme" });
325
+ help.append(el("p", { className: "helpme-lead", textContent: "そのままコピーして貼ってください:" }));
326
+ help.append(el("pre", { className: "helpme-text", textContent: prompt }));
327
+ help.append(copyButton(prompt, "この文章をコピー"));
328
+ box.append(help);
329
+ }
330
+ return box;
296
331
  }
297
332
 
298
333
  // 本家 CLI の版のズレ。
@@ -367,8 +402,8 @@
367
402
  }
368
403
  $("preflight").hidden = gates.length === 0;
369
404
 
370
- /** 前提がすべて確認済みか */
371
- const gateReady = () => gates.every((g) => g.checked);
405
+ /** 始められるか。前提の確認と、始めても無駄な条件の両方を見る */
406
+ const gateReady = () => blockers.length === 0 && gates.every((g) => g.checked);
372
407
 
373
408
  for (const group of schema.groups) {
374
409
  const fields = schema.fields.filter((f) => f.group === group.id);
@@ -560,6 +595,8 @@
560
595
  const screen = $("run-screen");
561
596
  let startButton = null;
562
597
  let gateNote = null;
598
+ /** 直近の実行状態。やり直すとき、本家がまだ動いているかの判断に使う */
599
+ let lastStatus = "idle";
563
600
  /** 開始を投げている最中。この間にチェックを外されても、押し直せるようにはしない */
564
601
  let starting = false;
565
602
 
@@ -578,7 +615,10 @@
578
615
  const ready = gateReady();
579
616
  startButton.disabled = !ready;
580
617
  gateNote.replaceChildren();
581
- if (!ready) {
618
+ if (blockers.length > 0) {
619
+ // 参加者の操作では解決しない。上の赤い枠の指示に従ってもらうしかない
620
+ gateNote.textContent = `このままでは開始できません: ${blockers.join(" / ")}`;
621
+ } else if (!ready) {
582
622
  gateNote.append(document.createTextNode("開始するには、"));
583
623
  gateNote.append(el("a", { href: "#preflight", textContent: "ページ上部の前提条件" }));
584
624
  gateNote.append(document.createTextNode("を確認してください。"));
@@ -605,19 +645,71 @@
605
645
  syncGate();
606
646
  return;
607
647
  }
608
- // 入力はサーバーに渡した。ここから先は実行の画面
609
- for (const input of inputs.values()) input.disabled = true;
610
- for (const gate of gates) gate.disabled = true;
611
- submit.disabled = true;
648
+ // 入力はサーバーに渡した。ここから先は実行の画面。
649
+ // **値は消さない。** やり直すときに入れ直させないため(#45)
650
+ setFormEnabled(false);
612
651
  runBox.hidden = false;
613
652
  runBox.scrollIntoView({ behavior: "smooth", block: "start" });
614
653
  listen();
615
654
  }
616
655
 
656
+ /** 実行中は触らせない。終わったら戻す(値はそのまま) */
657
+ function setFormEnabled(on) {
658
+ for (const input of inputs.values()) input.disabled = !on;
659
+ for (const gate of gates) gate.disabled = !on;
660
+ submit.disabled = !on;
661
+ if (startButton) startButton.disabled = !on;
662
+ if (on) syncGate();
663
+ }
664
+
665
+ /**
666
+ * もう一度実行する。
667
+ *
668
+ * **ツールを落とさせない。** 入力はメモリ上にしか無いので、
669
+ * ターミナルを終了すると消える。落とさずにやり直せれば入れ直しが要らない(#45)。
670
+ */
671
+ async function restartRun() {
672
+ // 止まっている最中は本家がまだ動いている。先に終わらせる
673
+ if (!/finished/.test(lastStatus)) {
674
+ await fetch("/api/run/stop", { method: "POST" });
675
+ await new Promise((r) => setTimeout(r, 300));
676
+ }
677
+ askBox.replaceChildren();
678
+ screen.textContent = "";
679
+ document.getElementById("leftover")?.remove();
680
+
681
+ starting = true;
682
+ const res = await fetch("/api/run/start", {
683
+ method: "POST",
684
+ headers: { "Content-Type": "application/json" },
685
+ body: "{}",
686
+ });
687
+ if (!res.ok) {
688
+ starting = false;
689
+ setFormEnabled(true);
690
+ result.textContent = (await res.json()).error ?? "開始できませんでした。";
691
+ result.className = "result bad";
692
+ return;
693
+ }
694
+ setFormEnabled(false);
695
+ listen();
696
+ }
697
+
698
+ /** もう一度実行するためのボタン。止まったときと、失敗して終わったときに出す */
699
+ function rerunButton(label) {
700
+ const button = el("button", { className: "go", type: "button", textContent: label });
701
+ button.addEventListener("click", () => {
702
+ button.disabled = true;
703
+ restartRun();
704
+ });
705
+ return button;
706
+ }
707
+
617
708
  function listen() {
618
709
  const source = new EventSource("/api/run/stream");
619
710
  source.addEventListener("update", (event) => {
620
711
  const { lines, state } = JSON.parse(event.data);
712
+ lastStatus = state.status;
621
713
  // 端末の再描画はサーバー側で解決済み。ここは行を並べるだけ
622
714
  const atBottom = screen.scrollTop + screen.clientHeight >= screen.scrollHeight - 24;
623
715
  screen.textContent = lines.join("\n");
@@ -625,6 +717,9 @@
625
717
  renderAsk(state);
626
718
  if (state.status === "finished") {
627
719
  source.close();
720
+ starting = false;
721
+ // もう一度やり直せるように戻す。**値は消さない**(#45)
722
+ setFormEnabled(true);
628
723
  // 失敗・中断だとシークレットを含むファイルが残る。終わった直後に必ず見る
629
724
  showLeftover();
630
725
  }
@@ -636,23 +731,35 @@
636
731
  askBox.replaceChildren();
637
732
 
638
733
  if (state.status === "finished") {
639
- const code = state.exit?.code;
640
- if (code === 0 && state.hasCompletion) {
734
+ // **シグナルで落とされた場合も code は 0 になる**(実測: code 0 / signal 15)。
735
+ // code だけ見ると、中断を「成功」として見せてしまう
736
+ const ok = state.exit?.code === 0 && !state.exit?.signal;
737
+
738
+ if (ok && state.hasCompletion) {
641
739
  // 仕上げの手作業に使う値を、貼り付けやすい形で出す。
642
740
  // 端末表示は畳む(もう読む必要がない)
643
741
  showCompletion();
644
742
  return;
645
743
  }
646
- const box = el("div", { className: code === 0 ? "ask done" : "ask stopped" });
647
- box.append(el("h3", { textContent: code === 0 ? "終了しました" : "途中で終わりました" }));
744
+
745
+ // ここに来たのは、完了の案内を出せなかったということ。
746
+ // どの場合でも**やり直せる道を必ず添える**(#45)
747
+ const box = el("div", { className: "ask stopped" });
748
+ box.append(el("h3", { textContent: ok ? "終わりましたが、完了の案内が読み取れませんでした" : "途中で終わりました" }));
749
+ box.append(
750
+ el("p", {
751
+ textContent: "画面の最後に出ているメッセージを確認してください。原因を直したら、下のボタンでやり直せます。",
752
+ }),
753
+ );
754
+ // **ターミナルを終了させない。** 落とすと入力が消える(#45)
648
755
  box.append(
649
756
  el("p", {
650
757
  textContent:
651
- code === 0
652
- ? "画面の最後に出ている案内にしたがって、残りの手作業を進めてください。"
653
- : "画面の最後に出ているメッセージを確認してください。同じ手順でやり直すと、続きから再開できます。",
758
+ "入力した値はこの画面に残っています。ターミナルは終了しないでください。" +
759
+ "上の入力欄を直してから、やり直すこともできます。",
654
760
  }),
655
761
  );
762
+ box.append(el("div", { className: "choices" }, [rerunButton("もう一度実行する")]));
656
763
  askBox.append(box);
657
764
  return;
658
765
  }
@@ -671,7 +778,8 @@
671
778
  answer({ manual: true, text: input.value }).then(() => (input.value = "")),
672
779
  );
673
780
  box.append(el("div", { className: "row" }, [el("div", { className: "row" }, [input])]));
674
- box.append(el("div", { className: "choices" }, [go]));
781
+ // 打つ内容が分からないこともある。そのときのために、やり直す道も並べる
782
+ box.append(el("div", { className: "choices" }, [go, rerunButton("中止して最初からやり直す")]));
675
783
  askBox.append(box);
676
784
  return;
677
785
  }
@@ -774,7 +882,13 @@
774
882
  row.append(reveal);
775
883
  }
776
884
 
777
- const copy = el("button", { type: "button", textContent: "コピー" });
885
+ row.append(copyButton(value, "コピー"));
886
+ return row;
887
+ }
888
+
889
+ /** 押すと中身を写すボタン。押したことが分かるように文字を変える */
890
+ function copyButton(value, label) {
891
+ const copy = el("button", { type: "button", textContent: label });
778
892
  copy.addEventListener("click", async () => {
779
893
  try {
780
894
  // http://127.0.0.1 は secure context なので clipboard が使える
@@ -790,12 +904,11 @@
790
904
  copy.textContent = "コピーしました";
791
905
  copy.classList.add("done");
792
906
  setTimeout(() => {
793
- copy.textContent = "コピー";
907
+ copy.textContent = label;
794
908
  copy.classList.remove("done");
795
909
  }, 1600);
796
910
  });
797
- row.append(copy);
798
- return row;
911
+ return copy;
799
912
  }
800
913
 
801
914
  /**