walover-line-harness-gui 0.3.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.3.0",
3
+ "version": "0.4.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 } 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,
@@ -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
+ }
@@ -285,8 +285,17 @@
285
285
  throw new Error("no server");
286
286
  }
287
287
 
288
+ /**
289
+ * 始めても必ず失敗する条件。
290
+ *
291
+ * 見つけたら**開始させない**。途中まで作られてから失敗すると、
292
+ * Cloudflare 上に中途半端なものが残り、やり直しのコストが高い。
293
+ */
294
+ const blockers = [];
295
+
288
296
  if (health.pty === false) {
289
297
  // 擬似端末が無いと本家 CLI は起動できない。値を入れてもらう前に伝える
298
+ blockers.push("擬似端末を用意できていません");
290
299
  banner(
291
300
  "セットアップを開始できません",
292
301
  "擬似端末を用意できませんでした(python3 が見つかりません)。" +
@@ -295,6 +304,20 @@
295
304
  );
296
305
  }
297
306
 
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
+
298
321
  // 本家 CLI の版のズレ。
299
322
  //
300
323
  // 固定版で動かすので、新しい版が出ているだけなら**参加者はこのまま進めてよい**。
@@ -367,8 +390,8 @@
367
390
  }
368
391
  $("preflight").hidden = gates.length === 0;
369
392
 
370
- /** 前提がすべて確認済みか */
371
- const gateReady = () => gates.every((g) => g.checked);
393
+ /** 始められるか。前提の確認と、始めても無駄な条件の両方を見る */
394
+ const gateReady = () => blockers.length === 0 && gates.every((g) => g.checked);
372
395
 
373
396
  for (const group of schema.groups) {
374
397
  const fields = schema.fields.filter((f) => f.group === group.id);
@@ -560,6 +583,8 @@
560
583
  const screen = $("run-screen");
561
584
  let startButton = null;
562
585
  let gateNote = null;
586
+ /** 直近の実行状態。やり直すとき、本家がまだ動いているかの判断に使う */
587
+ let lastStatus = "idle";
563
588
  /** 開始を投げている最中。この間にチェックを外されても、押し直せるようにはしない */
564
589
  let starting = false;
565
590
 
@@ -578,7 +603,10 @@
578
603
  const ready = gateReady();
579
604
  startButton.disabled = !ready;
580
605
  gateNote.replaceChildren();
581
- if (!ready) {
606
+ if (blockers.length > 0) {
607
+ // 参加者の操作では解決しない。上の赤い枠の指示に従ってもらうしかない
608
+ gateNote.textContent = `このままでは開始できません: ${blockers.join(" / ")}`;
609
+ } else if (!ready) {
582
610
  gateNote.append(document.createTextNode("開始するには、"));
583
611
  gateNote.append(el("a", { href: "#preflight", textContent: "ページ上部の前提条件" }));
584
612
  gateNote.append(document.createTextNode("を確認してください。"));
@@ -605,19 +633,71 @@
605
633
  syncGate();
606
634
  return;
607
635
  }
608
- // 入力はサーバーに渡した。ここから先は実行の画面
609
- for (const input of inputs.values()) input.disabled = true;
610
- for (const gate of gates) gate.disabled = true;
611
- submit.disabled = true;
636
+ // 入力はサーバーに渡した。ここから先は実行の画面。
637
+ // **値は消さない。** やり直すときに入れ直させないため(#45)
638
+ setFormEnabled(false);
612
639
  runBox.hidden = false;
613
640
  runBox.scrollIntoView({ behavior: "smooth", block: "start" });
614
641
  listen();
615
642
  }
616
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
+
617
696
  function listen() {
618
697
  const source = new EventSource("/api/run/stream");
619
698
  source.addEventListener("update", (event) => {
620
699
  const { lines, state } = JSON.parse(event.data);
700
+ lastStatus = state.status;
621
701
  // 端末の再描画はサーバー側で解決済み。ここは行を並べるだけ
622
702
  const atBottom = screen.scrollTop + screen.clientHeight >= screen.scrollHeight - 24;
623
703
  screen.textContent = lines.join("\n");
@@ -625,6 +705,9 @@
625
705
  renderAsk(state);
626
706
  if (state.status === "finished") {
627
707
  source.close();
708
+ starting = false;
709
+ // もう一度やり直せるように戻す。**値は消さない**(#45)
710
+ setFormEnabled(true);
628
711
  // 失敗・中断だとシークレットを含むファイルが残る。終わった直後に必ず見る
629
712
  showLeftover();
630
713
  }
@@ -636,23 +719,35 @@
636
719
  askBox.replaceChildren();
637
720
 
638
721
  if (state.status === "finished") {
639
- const code = state.exit?.code;
640
- 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) {
641
727
  // 仕上げの手作業に使う値を、貼り付けやすい形で出す。
642
728
  // 端末表示は畳む(もう読む必要がない)
643
729
  showCompletion();
644
730
  return;
645
731
  }
646
- const box = el("div", { className: code === 0 ? "ask done" : "ask stopped" });
647
- 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)
648
743
  box.append(
649
744
  el("p", {
650
745
  textContent:
651
- code === 0
652
- ? "画面の最後に出ている案内にしたがって、残りの手作業を進めてください。"
653
- : "画面の最後に出ているメッセージを確認してください。同じ手順でやり直すと、続きから再開できます。",
746
+ "入力した値はこの画面に残っています。ターミナルは終了しないでください。" +
747
+ "上の入力欄を直してから、やり直すこともできます。",
654
748
  }),
655
749
  );
750
+ box.append(el("div", { className: "choices" }, [rerunButton("もう一度実行する")]));
656
751
  askBox.append(box);
657
752
  return;
658
753
  }
@@ -671,7 +766,8 @@
671
766
  answer({ manual: true, text: input.value }).then(() => (input.value = "")),
672
767
  );
673
768
  box.append(el("div", { className: "row" }, [el("div", { className: "row" }, [input])]));
674
- box.append(el("div", { className: "choices" }, [go]));
769
+ // 打つ内容が分からないこともある。そのときのために、やり直す道も並べる
770
+ box.append(el("div", { className: "choices" }, [go, rerunButton("中止して最初からやり直す")]));
675
771
  askBox.append(box);
676
772
  return;
677
773
  }