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 +1 -1
- package/src/server/cli-version.js +52 -17
- package/src/server/cli.js +10 -0
- package/src/server/index.js +9 -1
- package/src/server/leftover.js +7 -2
- package/src/server/prompt-contract.js +5 -3
- package/src/server/runner.js +43 -14
- package/src/server/runtime.js +72 -0
- package/src/web/index.html +134 -17
package/package.json
CHANGED
|
@@ -6,8 +6,12 @@
|
|
|
6
6
|
* そのため**動作確認済みの版を固定して呼ぶ**。
|
|
7
7
|
*
|
|
8
8
|
* ただし固定したまま黙っていると、本家の修正に永久に追いつかない。
|
|
9
|
-
* 起動時に npm の `latest`
|
|
10
|
-
*
|
|
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 {
|
|
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
|
-
|
|
119
|
+
// 新しい版が出ているだけ。**参加者はこのまま進めてよい。**
|
|
120
|
+
// ここを警告として強く出すと、進めてよいのに手が止まる
|
|
121
|
+
return withWarning({
|
|
99
122
|
pinned,
|
|
100
123
|
latest,
|
|
101
124
|
status: "newer-available",
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
135
|
+
// 固定版のほうが新しい。npm 側が取り下げられた場合などに起きる。
|
|
136
|
+
// これは**人が確かめる必要がある**ので、弱めない
|
|
137
|
+
return withWarning({
|
|
111
138
|
pinned,
|
|
112
139
|
latest,
|
|
113
140
|
status: "pinned-is-newer",
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
"npm
|
|
117
|
-
|
|
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("");
|
package/src/server/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
package/src/server/leftover.js
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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
|
-
|
|
153
|
+
// 同上。0.2.12 で p.password になった
|
|
152
154
|
secret: true,
|
|
153
155
|
answer: "form",
|
|
154
156
|
field: "lineChannelAccessToken",
|
package/src/server/runner.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/src/web/index.html
CHANGED
|
@@ -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
|
-
//
|
|
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.
|
|
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 (
|
|
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
|
-
|
|
589
|
-
|
|
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
|
-
|
|
619
|
-
|
|
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
|
-
|
|
626
|
-
|
|
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
|
-
|
|
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
|
-
|
|
769
|
+
// 打つ内容が分からないこともある。そのときのために、やり直す道も並べる
|
|
770
|
+
box.append(el("div", { className: "choices" }, [go, rerunButton("中止して最初からやり直す")]));
|
|
654
771
|
askBox.append(box);
|
|
655
772
|
return;
|
|
656
773
|
}
|