walover-line-harness-gui 0.2.0 → 0.4.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.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "LINE Harness のセットアップを、ターミナルを開かずにブラウザから行うためのローカルツール",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -6,8 +6,12 @@
6
6
  * そのため**動作確認済みの版を固定して呼ぶ**。
7
7
  *
8
8
  * ただし固定したまま黙っていると、本家の修正に永久に追いつかない。
9
- * 起動時に npm の `latest` と比べ、差があれば**警告だけ**出す。
10
- * 自動では追従しない。追従には契約の再照合(`npm run check:contract`)が要る。
9
+ * 起動時に npm の `latest` と比べ、差があれば知らせる。自動では追従しない。
10
+ * 追従には契約の再照合(`npm run check:contract`)が要る。
11
+ *
12
+ * **強さを取り違えない。** 新しい版が出ているだけなら参加者はこのまま進めてよく、
13
+ * 警告として出すと進めてよいのに手が止まる。一方、固定版が npm で見つからないのは
14
+ * 人が確かめる必要がある。この区別を {@link VersionNotice} の tone で表す。
11
15
  *
12
16
  * > npm パッケージ(`create-line-harness`)とアプリ本体(`line-oss-crm`)は
13
17
  * > バージョンが別で、プロンプトは npm パッケージ側にある。
@@ -64,7 +68,23 @@ export async function resolveVersion(spec = "latest", options = {}) {
64
68
  * @property {string} pinned 固定している版
65
69
  * @property {string|null} latest npm の最新版。取れなければ null
66
70
  * @property {"pinned-is-latest"|"newer-available"|"pinned-is-newer"|"unknown"} status
67
- * @property {string|null} warning 画面に出す文言
71
+ * @property {VersionNotice|null} notice 画面に出すもの
72
+ * @property {string|null} warning ターミナルに出す平文(notice から作る)
73
+ */
74
+
75
+ /**
76
+ * 画面に出すときの中身。
77
+ *
78
+ * **読者は非開発者の勉強会参加者。** 版が古いこと自体は参加者の関心事ではなく、
79
+ * 知りたいのは「自分はこのまま進めてよいのか」だけ。
80
+ * headline はその答えを先に書き、版番号のような事情は detail に送る。
81
+ *
82
+ * tone は強さ。`info` は畳んで出してよいもの、`warn` は人の判断が要るもの。
83
+ *
84
+ * @typedef {object} VersionNotice
85
+ * @property {"info"|"warn"} tone
86
+ * @property {string} headline 参加者が最初に読む一文
87
+ * @property {string} detail 事情の説明。畳んだ中に置く
68
88
  */
69
89
 
70
90
  /**
@@ -84,6 +104,7 @@ export async function checkCliVersion(options = {}) {
84
104
  pinned,
85
105
  latest: null,
86
106
  status: "unknown",
107
+ notice: null,
87
108
  warning: null,
88
109
  };
89
110
  }
@@ -91,31 +112,45 @@ export async function checkCliVersion(options = {}) {
91
112
  const order = compareVersions(pinned, latest);
92
113
 
93
114
  if (order === 0) {
94
- return { pinned, latest, status: "pinned-is-latest", warning: null };
115
+ return { pinned, latest, status: "pinned-is-latest", notice: null, warning: null };
95
116
  }
96
117
 
97
118
  if (order < 0) {
98
- return {
119
+ // 新しい版が出ているだけ。**参加者はこのまま進めてよい。**
120
+ // ここを警告として強く出すと、進めてよいのに手が止まる
121
+ return withWarning({
99
122
  pinned,
100
123
  latest,
101
124
  status: "newer-available",
102
- warning: [
103
- `本家 CLI の新しい版 v${latest} が出ています(このツールが確認済みなのは v${pinned})。`,
104
- "動作未確認のため、追従には検証が必要です。このままでも v" + pinned + " でセットアップできます。",
105
- ].join("\n"),
106
- };
125
+ notice: {
126
+ tone: "info",
127
+ headline: `このまま進めて構いません。動作確認済みの v${pinned} でセットアップします。`,
128
+ detail:
129
+ `本家 CLI の新しい版 v${latest} が出ています(このツールが確認済みなのは v${pinned})。` +
130
+ "追従にはプロンプト契約の再照合と検証が要るため、自動では追いかけません。",
131
+ },
132
+ });
107
133
  }
108
134
 
109
- // 固定版のほうが新しい。npm 側が取り下げられた場合などに起きる
110
- return {
135
+ // 固定版のほうが新しい。npm 側が取り下げられた場合などに起きる。
136
+ // これは**人が確かめる必要がある**ので、弱めない
137
+ return withWarning({
111
138
  pinned,
112
139
  latest,
113
140
  status: "pinned-is-newer",
114
- warning: [
115
- `固定している v${pinned} が npm の最新 v${latest} より新しくなっています。`,
116
- "npm 側で取り下げられた可能性があります。動作未確認のため、確認が必要です。",
117
- ].join("\n"),
118
- };
141
+ notice: {
142
+ tone: "warn",
143
+ headline: "固定している版が npm で見つかりません。開発者に連絡してください。",
144
+ detail:
145
+ `固定している v${pinned} が npm の最新 v${latest} より新しくなっています。` +
146
+ "npm 側で取り下げられた可能性があります。動作未確認のため、確認が必要です。",
147
+ },
148
+ });
149
+ }
150
+
151
+ /** ターミナル用の平文。画面と同じ内容を1つの文字列にする */
152
+ function withWarning(status) {
153
+ return { ...status, warning: `${status.notice.headline}\n${status.notice.detail}` };
119
154
  }
120
155
 
121
156
  /** @type {Promise<VersionStatus>|null} */
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 } 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,14 @@ 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
+ // **始めても無駄な条件は、始める前に返す。**
119
+ // 擬似端末が無い / Node が機械に合っていない、はどちらも必ず失敗する
120
+ json(res, 200, {
121
+ ok: true,
122
+ pty: backend !== null,
123
+ ptyBackend: backend,
124
+ node: checkNodeRuntime(),
125
+ });
118
126
  return true;
119
127
  }
120
128
 
@@ -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.11";
26
+ export const CONTRACT_CLI_VERSION = "0.2.12";
27
27
 
28
28
  /**
29
29
  * @typedef {object} PromptSpec
@@ -133,7 +133,9 @@ export const PROMPTS = [
133
133
  id: "lineChannelSecret",
134
134
  message: "チャネルシークレット(英数字)",
135
135
  kind: "text",
136
- placeholder: "同じページに表示されている Channel Secret",
136
+ // 0.2.12 で本家が p.text → p.password にした。値行は伏字(▪)になり、
137
+ // placeholder は無くなった。宣言したままだと第二の照合が外れて止まる。
138
+ // message が一意なので、外しても取り違えは起きない
137
139
  secret: true,
138
140
  answer: "form",
139
141
  field: "lineChannelSecret",
@@ -148,7 +150,7 @@ export const PROMPTS = [
148
150
  id: "lineChannelAccessToken",
149
151
  message: "チャネルアクセストークン(長期)",
150
152
  kind: "text",
151
- placeholder: "上の手順で発行したトークン",
153
+ // 同上。0.2.12 で p.password になった
152
154
  secret: true,
153
155
  answer: "form",
154
156
  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,72 @@
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
+ * @typedef {object} RuntimeCheck
26
+ * @property {boolean} ok
27
+ * @property {string} arch プロセスの arch
28
+ * @property {string} cpu CPU の名前
29
+ * @property {RuntimeProblem|null} problem
30
+ *
31
+ * @typedef {object} RuntimeProblem
32
+ * @property {string} id
33
+ * @property {string} title
34
+ * @property {string} body
35
+ * @property {string[]} fix 直し方。**既存の mise / nvm を壊さない手順**にする
36
+ */
37
+
38
+ /**
39
+ * @param {{ arch?: string, cpu?: string }} [override] テスト用
40
+ * @returns {RuntimeCheck}
41
+ */
42
+ export function checkNodeRuntime(override = {}) {
43
+ const arch = override.arch ?? process.arch;
44
+ const cpu = override.cpu ?? cpus()[0]?.model ?? "";
45
+
46
+ // Apple 製 CPU なのに x64 で動いている = Rosetta 経由。
47
+ // arm64 で動いていれば何も言わない(Intel Mac の x64 も正常)
48
+ if (!(arch === "x64" && /\bApple\b/.test(cpu))) {
49
+ return { ok: true, arch, cpu, problem: null };
50
+ }
51
+
52
+ return {
53
+ ok: false,
54
+ arch,
55
+ cpu,
56
+ problem: {
57
+ id: "x64-on-apple-silicon",
58
+ title: "この Node ではセットアップできません",
59
+ body:
60
+ `この Mac は ${cpu}(Apple Silicon)ですが、いま動いている Node は x86_64 版です。` +
61
+ "このまま進めると、途中まで作られたあとで必ず失敗します" +
62
+ "(本家が使う esbuild が x86_64 版を選び、Rosetta 上で落ちるため)。",
63
+ fix: [
64
+ "nodejs.org から macOS の ARM64 版(LTS)を入れてください。",
65
+ "いま使っている mise や nvm の設定は変えなくて構いません。" +
66
+ "別の場所に置いて、そこから起動すれば足ります。",
67
+ "例: ~/.local/node-arm64 に展開したなら、" +
68
+ "~/.local/node-arm64/bin/npx walover-line-harness-gui で起動する",
69
+ ],
70
+ },
71
+ };
72
+ }
@@ -63,6 +63,16 @@
63
63
  .banner.bad { border-color: var(--err-line); background: var(--err-bg); color: var(--err); }
64
64
  .banner strong { display: block; }
65
65
 
66
+ /*
67
+ * 版のズレのように「知らせるが、進めてよい」もの。
68
+ * 警告(.banner)と同じ強さで出すと、進めてよいのに手が止まる
69
+ */
70
+ .note { font-size: .85rem; color: var(--muted); margin: 0 0 1rem; }
71
+ .note > summary { cursor: pointer; list-style-position: inside; }
72
+ .note > summary::marker { color: var(--muted); }
73
+ .note .note-detail { margin: .4rem 0 0 1.1rem; padding-left: .7rem;
74
+ border-left: 2px solid var(--line); white-space: pre-line; }
75
+
66
76
  .status { display: flex; align-items: center; gap: .5rem; font-size: .85rem; color: var(--muted);
67
77
  margin-bottom: 1.5rem; }
68
78
  .dot { width: .55rem; height: .55rem; border-radius: 50%; background: var(--muted); flex: none; }
@@ -275,8 +285,17 @@
275
285
  throw new Error("no server");
276
286
  }
277
287
 
288
+ /**
289
+ * 始めても必ず失敗する条件。
290
+ *
291
+ * 見つけたら**開始させない**。途中まで作られてから失敗すると、
292
+ * Cloudflare 上に中途半端なものが残り、やり直しのコストが高い。
293
+ */
294
+ const blockers = [];
295
+
278
296
  if (health.pty === false) {
279
297
  // 擬似端末が無いと本家 CLI は起動できない。値を入れてもらう前に伝える
298
+ blockers.push("擬似端末を用意できていません");
280
299
  banner(
281
300
  "セットアップを開始できません",
282
301
  "擬似端末を用意できませんでした(python3 が見つかりません)。" +
@@ -285,10 +304,35 @@
285
304
  );
286
305
  }
287
306
 
288
- // 本家 CLI の版のズレ(固定版で動かすので、差があっても止めずに知らせるだけ)
307
+ // Apple Silicon で x86_64 の Node を使っている場合。
308
+ // 進めると D1 を作ったあとで必ず失敗する(実測)
309
+ if (health.node && health.node.ok === false) {
310
+ const { title, body, fix } = health.node.problem;
311
+ blockers.push(title);
312
+ const node = el("div", { className: "banner bad" });
313
+ node.append(el("strong", { textContent: title }));
314
+ node.append(document.createTextNode(body));
315
+ const list = el("ul");
316
+ for (const line of fix) list.append(el("li", { textContent: line }));
317
+ node.append(list);
318
+ $("banners").append(node);
319
+ }
320
+
321
+ // 本家 CLI の版のズレ。
322
+ //
323
+ // 固定版で動かすので、新しい版が出ているだけなら**参加者はこのまま進めてよい**。
324
+ // そこを警告として強く出すと、進めてよいのに手が止まる(実機で起きた)。
325
+ // 強さはサーバーが tone で決める。ここはそれに従って出し分けるだけ
289
326
  try {
290
327
  const version = await (await fetch("/api/version")).json();
291
- if (version.warning) banner("本家 CLI のバージョン", version.warning);
328
+ if (version.notice?.tone === "warn") {
329
+ banner(version.notice.headline, version.notice.detail, true);
330
+ } else if (version.notice) {
331
+ const box = el("details", { className: "note" });
332
+ box.append(el("summary", { textContent: version.notice.headline }));
333
+ box.append(el("p", { className: "note-detail", textContent: version.notice.detail }));
334
+ $("banners").append(box);
335
+ }
292
336
  } catch {
293
337
  /* 版が確認できなくても入力は進められる */
294
338
  }
@@ -346,8 +390,8 @@
346
390
  }
347
391
  $("preflight").hidden = gates.length === 0;
348
392
 
349
- /** 前提がすべて確認済みか */
350
- const gateReady = () => gates.every((g) => g.checked);
393
+ /** 始められるか。前提の確認と、始めても無駄な条件の両方を見る */
394
+ const gateReady = () => blockers.length === 0 && gates.every((g) => g.checked);
351
395
 
352
396
  for (const group of schema.groups) {
353
397
  const fields = schema.fields.filter((f) => f.group === group.id);
@@ -539,6 +583,8 @@
539
583
  const screen = $("run-screen");
540
584
  let startButton = null;
541
585
  let gateNote = null;
586
+ /** 直近の実行状態。やり直すとき、本家がまだ動いているかの判断に使う */
587
+ let lastStatus = "idle";
542
588
  /** 開始を投げている最中。この間にチェックを外されても、押し直せるようにはしない */
543
589
  let starting = false;
544
590
 
@@ -557,7 +603,10 @@
557
603
  const ready = gateReady();
558
604
  startButton.disabled = !ready;
559
605
  gateNote.replaceChildren();
560
- if (!ready) {
606
+ if (blockers.length > 0) {
607
+ // 参加者の操作では解決しない。上の赤い枠の指示に従ってもらうしかない
608
+ gateNote.textContent = `このままでは開始できません: ${blockers.join(" / ")}`;
609
+ } else if (!ready) {
561
610
  gateNote.append(document.createTextNode("開始するには、"));
562
611
  gateNote.append(el("a", { href: "#preflight", textContent: "ページ上部の前提条件" }));
563
612
  gateNote.append(document.createTextNode("を確認してください。"));
@@ -584,19 +633,71 @@
584
633
  syncGate();
585
634
  return;
586
635
  }
587
- // 入力はサーバーに渡した。ここから先は実行の画面
588
- for (const input of inputs.values()) input.disabled = true;
589
- for (const gate of gates) gate.disabled = true;
590
- submit.disabled = true;
636
+ // 入力はサーバーに渡した。ここから先は実行の画面。
637
+ // **値は消さない。** やり直すときに入れ直させないため(#45)
638
+ setFormEnabled(false);
591
639
  runBox.hidden = false;
592
640
  runBox.scrollIntoView({ behavior: "smooth", block: "start" });
593
641
  listen();
594
642
  }
595
643
 
644
+ /** 実行中は触らせない。終わったら戻す(値はそのまま) */
645
+ function setFormEnabled(on) {
646
+ for (const input of inputs.values()) input.disabled = !on;
647
+ for (const gate of gates) gate.disabled = !on;
648
+ submit.disabled = !on;
649
+ if (startButton) startButton.disabled = !on;
650
+ if (on) syncGate();
651
+ }
652
+
653
+ /**
654
+ * もう一度実行する。
655
+ *
656
+ * **ツールを落とさせない。** 入力はメモリ上にしか無いので、
657
+ * ターミナルを終了すると消える。落とさずにやり直せれば入れ直しが要らない(#45)。
658
+ */
659
+ async function restartRun() {
660
+ // 止まっている最中は本家がまだ動いている。先に終わらせる
661
+ if (!/finished/.test(lastStatus)) {
662
+ await fetch("/api/run/stop", { method: "POST" });
663
+ await new Promise((r) => setTimeout(r, 300));
664
+ }
665
+ askBox.replaceChildren();
666
+ screen.textContent = "";
667
+ document.getElementById("leftover")?.remove();
668
+
669
+ starting = true;
670
+ const res = await fetch("/api/run/start", {
671
+ method: "POST",
672
+ headers: { "Content-Type": "application/json" },
673
+ body: "{}",
674
+ });
675
+ if (!res.ok) {
676
+ starting = false;
677
+ setFormEnabled(true);
678
+ result.textContent = (await res.json()).error ?? "開始できませんでした。";
679
+ result.className = "result bad";
680
+ return;
681
+ }
682
+ setFormEnabled(false);
683
+ listen();
684
+ }
685
+
686
+ /** もう一度実行するためのボタン。止まったときと、失敗して終わったときに出す */
687
+ function rerunButton(label) {
688
+ const button = el("button", { className: "go", type: "button", textContent: label });
689
+ button.addEventListener("click", () => {
690
+ button.disabled = true;
691
+ restartRun();
692
+ });
693
+ return button;
694
+ }
695
+
596
696
  function listen() {
597
697
  const source = new EventSource("/api/run/stream");
598
698
  source.addEventListener("update", (event) => {
599
699
  const { lines, state } = JSON.parse(event.data);
700
+ lastStatus = state.status;
600
701
  // 端末の再描画はサーバー側で解決済み。ここは行を並べるだけ
601
702
  const atBottom = screen.scrollTop + screen.clientHeight >= screen.scrollHeight - 24;
602
703
  screen.textContent = lines.join("\n");
@@ -604,6 +705,9 @@
604
705
  renderAsk(state);
605
706
  if (state.status === "finished") {
606
707
  source.close();
708
+ starting = false;
709
+ // もう一度やり直せるように戻す。**値は消さない**(#45)
710
+ setFormEnabled(true);
607
711
  // 失敗・中断だとシークレットを含むファイルが残る。終わった直後に必ず見る
608
712
  showLeftover();
609
713
  }
@@ -615,23 +719,35 @@
615
719
  askBox.replaceChildren();
616
720
 
617
721
  if (state.status === "finished") {
618
- const code = state.exit?.code;
619
- if (code === 0 && state.hasCompletion) {
722
+ // **シグナルで落とされた場合も code は 0 になる**(実測: code 0 / signal 15)。
723
+ // code だけ見ると、中断を「成功」として見せてしまう
724
+ const ok = state.exit?.code === 0 && !state.exit?.signal;
725
+
726
+ if (ok && state.hasCompletion) {
620
727
  // 仕上げの手作業に使う値を、貼り付けやすい形で出す。
621
728
  // 端末表示は畳む(もう読む必要がない)
622
729
  showCompletion();
623
730
  return;
624
731
  }
625
- const box = el("div", { className: code === 0 ? "ask done" : "ask stopped" });
626
- box.append(el("h3", { textContent: code === 0 ? "終了しました" : "途中で終わりました" }));
732
+
733
+ // ここに来たのは、完了の案内を出せなかったということ。
734
+ // どの場合でも**やり直せる道を必ず添える**(#45)
735
+ const box = el("div", { className: "ask stopped" });
736
+ box.append(el("h3", { textContent: ok ? "終わりましたが、完了の案内が読み取れませんでした" : "途中で終わりました" }));
737
+ box.append(
738
+ el("p", {
739
+ textContent: "画面の最後に出ているメッセージを確認してください。原因を直したら、下のボタンでやり直せます。",
740
+ }),
741
+ );
742
+ // **ターミナルを終了させない。** 落とすと入力が消える(#45)
627
743
  box.append(
628
744
  el("p", {
629
745
  textContent:
630
- code === 0
631
- ? "画面の最後に出ている案内にしたがって、残りの手作業を進めてください。"
632
- : "画面の最後に出ているメッセージを確認してください。同じ手順でやり直すと、続きから再開できます。",
746
+ "入力した値はこの画面に残っています。ターミナルは終了しないでください。" +
747
+ "上の入力欄を直してから、やり直すこともできます。",
633
748
  }),
634
749
  );
750
+ box.append(el("div", { className: "choices" }, [rerunButton("もう一度実行する")]));
635
751
  askBox.append(box);
636
752
  return;
637
753
  }
@@ -650,7 +766,8 @@
650
766
  answer({ manual: true, text: input.value }).then(() => (input.value = "")),
651
767
  );
652
768
  box.append(el("div", { className: "row" }, [el("div", { className: "row" }, [input])]));
653
- box.append(el("div", { className: "choices" }, [go]));
769
+ // 打つ内容が分からないこともある。そのときのために、やり直す道も並べる
770
+ box.append(el("div", { className: "choices" }, [go, rerunButton("中止して最初からやり直す")]));
654
771
  askBox.append(box);
655
772
  return;
656
773
  }