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.
- package/LICENSE +21 -0
- package/README.md +152 -0
- package/package.json +27 -0
- package/src/server/browser.js +22 -0
- package/src/server/cli-version.js +136 -0
- package/src/server/cli.js +51 -0
- package/src/server/completion.js +177 -0
- package/src/server/guide.js +181 -0
- package/src/server/index.js +334 -0
- package/src/server/leftover.js +111 -0
- package/src/server/prompt-contract.js +373 -0
- package/src/server/prompt-detector.js +181 -0
- package/src/server/pty-relay.py +130 -0
- package/src/server/pty.js +343 -0
- package/src/server/redact.js +54 -0
- package/src/server/runner.js +607 -0
- package/src/server/state.js +178 -0
- package/src/server/static.js +46 -0
- package/src/server/terminal.js +210 -0
- package/src/web/index.html +859 -0
|
@@ -0,0 +1,607 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 本家 CLI を PTY で走らせ、**プロンプト契約に一致したときだけ**応答する。
|
|
3
|
+
*
|
|
4
|
+
* 設計の芯はひとつだけ:
|
|
5
|
+
*
|
|
6
|
+
* > 文言が一致しなければ、何も送らずに止める。
|
|
7
|
+
*
|
|
8
|
+
* 順番でマッチさせると、本家にプロンプトが1つ増えただけで値が1つずつズレる。
|
|
9
|
+
* その最悪形はチャネルシークレットがプロジェクト名の欄に入ることで、
|
|
10
|
+
* 値は画面にエコーされ、Worker 名として Cloudflare に残る。
|
|
11
|
+
* 機能不全ではなく情報漏洩なので、迷ったら止める側に倒す。
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { EventEmitter } from "node:events";
|
|
15
|
+
import { homedir } from "node:os";
|
|
16
|
+
|
|
17
|
+
import { Terminal } from "./terminal.js";
|
|
18
|
+
import { detectPrompt } from "./prompt-detector.js";
|
|
19
|
+
import { matchPrompt, SECRET_FIELDS, CONTRACT_CLI_VERSION } from "./prompt-contract.js";
|
|
20
|
+
import { spawnPty } from "./pty.js";
|
|
21
|
+
import { redactLines, redactText, maskValueLine } from "./redact.js";
|
|
22
|
+
import { parseCompletion, completionSteps } from "./completion.js";
|
|
23
|
+
|
|
24
|
+
const KEY_ENTER = "\r";
|
|
25
|
+
const KEY_DOWN = "\x1b[B";
|
|
26
|
+
const KEY_UP = "\x1b[A";
|
|
27
|
+
|
|
28
|
+
/** 画面が落ち着くまでの待ち時間。clack は1文字ごとに描き直すため、まとめてから判定する */
|
|
29
|
+
const QUIET_MS = 80;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 落ち着くのを待つ上限。
|
|
33
|
+
*
|
|
34
|
+
* 出力が途切れないと「落ち着いたら評価する」は永久に走らない。実測では
|
|
35
|
+
* **60ms 間隔で出力が続くと2秒間で評価回数 0**(`npm install` や
|
|
36
|
+
* `wrangler deploy` の出力はこれよりずっと速い)。その間、画面は完全に止まる。
|
|
37
|
+
* 進捗が見えないと参加者は「固まった」と思って中断するので、上限で必ず出す。
|
|
38
|
+
*/
|
|
39
|
+
const MAX_WAIT_MS = 250;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 同じ枠が出たまま何も進まないときに、諦めて人に委ねるまでの時間。
|
|
43
|
+
*
|
|
44
|
+
* 枠の識別に画面上の絶対行番号を使っているため、**再描画の位置が動く端末**では
|
|
45
|
+
* 「同じ枠が描き直された」ことを見分けられない場面がある
|
|
46
|
+
* (Windows の ConPTY で、選択肢の移動確認と入力エラーの検出が該当)。
|
|
47
|
+
*
|
|
48
|
+
* 見分けられないと、こちらは待ち続け、本家も入力を待ち続けて画面が止まる。
|
|
49
|
+
* 黙って固まるくらいなら、止まったことを伝えて人に委ねる。
|
|
50
|
+
*/
|
|
51
|
+
const STUCK_MS = 45000;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* @typedef {"idle"|"running"|"waiting"|"halted"|"finished"} RunStatus
|
|
55
|
+
*/
|
|
56
|
+
|
|
57
|
+
export class SetupRun extends EventEmitter {
|
|
58
|
+
/**
|
|
59
|
+
* @param {object} [options]
|
|
60
|
+
* @param {string} [options.cliSpec] 起動する npm パッケージ指定
|
|
61
|
+
* @param {string} [options.cwd]
|
|
62
|
+
* @param {number} [options.quietMs]
|
|
63
|
+
* @param {number} [options.maxWaitMs]
|
|
64
|
+
* @param {number} [options.stuckMs]
|
|
65
|
+
* @param {{ command: string, args: string[] }} [options.commandOverride] テスト用
|
|
66
|
+
*/
|
|
67
|
+
constructor(options = {}) {
|
|
68
|
+
super();
|
|
69
|
+
// 既定は**契約を照合した版**に合わせる。@latest にすると、本家が
|
|
70
|
+
// プロンプトを変えた瞬間に全員が「未知のプロンプト」で止まる。
|
|
71
|
+
// 新しい版が出ていることの検知と警告は #5 で行う。
|
|
72
|
+
this.cliSpec = options.cliSpec ?? `create-line-harness@${CONTRACT_CLI_VERSION}`;
|
|
73
|
+
this.cwd = options.cwd ?? homedir();
|
|
74
|
+
this.quietMs = options.quietMs ?? QUIET_MS;
|
|
75
|
+
this.maxWaitMs = options.maxWaitMs ?? MAX_WAIT_MS;
|
|
76
|
+
this.commandOverride = options.commandOverride ?? null;
|
|
77
|
+
|
|
78
|
+
this.term = new Terminal();
|
|
79
|
+
/** @type {RunStatus} */
|
|
80
|
+
this.status = "idle";
|
|
81
|
+
/** @type {import("./pty.js").PtyHandle|null} */
|
|
82
|
+
this.pty = null;
|
|
83
|
+
/** @type {{ code: number|null, signal: NodeJS.Signals|null }|null} */
|
|
84
|
+
this.exit = null;
|
|
85
|
+
/** 中継まわりの直近の異常。走行は止めない @type {string|null} */
|
|
86
|
+
this.lastError = null;
|
|
87
|
+
/**
|
|
88
|
+
* 完了画面から取り出した値。**API Key を含むのでメモリ上のみ。**
|
|
89
|
+
* `state()` には載せず、専用の口からだけ渡す
|
|
90
|
+
* @type {import("./completion.js").Completion|null}
|
|
91
|
+
*/
|
|
92
|
+
this.completion = null;
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* 参加者がフォームに入れた値。**メモリ上のみ。**ここから外に出すのは
|
|
96
|
+
* 「入力済みかどうか」だけで、値そのものは API 応答にも画面にも出さない。
|
|
97
|
+
* @type {Record<string, string|boolean>}
|
|
98
|
+
*/
|
|
99
|
+
this.answers = Object.create(null);
|
|
100
|
+
|
|
101
|
+
/** いま人の判断を待っているプロンプト @type {object|null} */
|
|
102
|
+
this.pending = null;
|
|
103
|
+
/** フェイルクローズで止まった理由 @type {object|null} */
|
|
104
|
+
this.halt = null;
|
|
105
|
+
|
|
106
|
+
/** 直近に検出した枠 @type {import("./prompt-detector.js").DetectedPrompt|null} */
|
|
107
|
+
this.detected = null;
|
|
108
|
+
/** 直近に検出した枠の絶対行番号。画面が流れても位置を見失わないため @type {number|null} */
|
|
109
|
+
this.detectedFrameId = null;
|
|
110
|
+
/** 直近に検出した枠に一致した契約 @type {import("./prompt-contract.js").PromptSpec|null} */
|
|
111
|
+
this.spec = null;
|
|
112
|
+
|
|
113
|
+
/** 応答済みの枠(絶対行番号)。同じ枠に二度送らないための印 @type {Set<number>} */
|
|
114
|
+
this.answeredFrames = new Set();
|
|
115
|
+
/** 契約ごとの応答回数。フォーム由来の再出現は「拒否された」とみなす @type {Map<string, number>} */
|
|
116
|
+
this.answeredCounts = new Map();
|
|
117
|
+
/** select の確認待ち @type {{ frameId: number, target: number, labels: string[] }|null} */
|
|
118
|
+
this.pendingSelect = null;
|
|
119
|
+
|
|
120
|
+
this.timer = null;
|
|
121
|
+
this.forced = null;
|
|
122
|
+
/** 最後に何かが進んだ時刻。止まっているかの判定に使う */
|
|
123
|
+
this.progressedAt = Date.now();
|
|
124
|
+
this.stuckMs = options.stuckMs ?? STUCK_MS;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ── 入力値 ─────────────────────────────────────────────────────────
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* フォームの値を渡す。**ディスクにも API 応答にも出さない。**
|
|
131
|
+
* @param {Record<string, string|boolean>} values
|
|
132
|
+
*/
|
|
133
|
+
setAnswers(values) {
|
|
134
|
+
for (const [key, value] of Object.entries(values)) {
|
|
135
|
+
this.answers[key] = value;
|
|
136
|
+
}
|
|
137
|
+
// 値が埋まったことで待ちが解ける場合がある
|
|
138
|
+
if (this.status === "waiting" && this.pending?.reason === "missing-value") {
|
|
139
|
+
this.#schedule();
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** 値そのものは返さない。入力済みかどうかだけ */
|
|
144
|
+
filledFields() {
|
|
145
|
+
return Object.entries(this.answers)
|
|
146
|
+
.filter(([, v]) => v !== undefined && v !== "")
|
|
147
|
+
.map(([k]) => k);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** メモリ上のシークレット値。伏字化にだけ使う(外に出さない) */
|
|
151
|
+
#secretValues() {
|
|
152
|
+
const values = Object.entries(this.answers)
|
|
153
|
+
.filter(([k, v]) => SECRET_FIELDS.has(k) && typeof v === "string" && v.length > 0)
|
|
154
|
+
.map(([, v]) => /** @type {string} */ (v));
|
|
155
|
+
|
|
156
|
+
// 本家が完了画面に出す API Key も伏せる。
|
|
157
|
+
// 「再表示できない」値なので、端末表示から写させるのではなく
|
|
158
|
+
// 専用の欄でコピーさせる(completionForDisplay)
|
|
159
|
+
if (this.completion?.apiKey) values.push(this.completion.apiKey);
|
|
160
|
+
return values;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ── 起動と終了 ─────────────────────────────────────────────────────
|
|
164
|
+
|
|
165
|
+
start() {
|
|
166
|
+
if (this.status !== "idle") throw new Error("すでに起動しています");
|
|
167
|
+
|
|
168
|
+
const { command, args } = this.commandOverride ?? {
|
|
169
|
+
command: "npx",
|
|
170
|
+
args: ["--yes", this.cliSpec, "setup"],
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
this.status = "running";
|
|
174
|
+
this.pty = spawnPty({
|
|
175
|
+
command,
|
|
176
|
+
args,
|
|
177
|
+
cwd: this.cwd,
|
|
178
|
+
onData: (chunk) => this.#onData(chunk),
|
|
179
|
+
onExit: (info) => this.#onExit(info),
|
|
180
|
+
// EventEmitter の "error" を listener 無しで emit すると
|
|
181
|
+
// プロセスごと落ちる。中継の stderr は診断情報にすぎないので、
|
|
182
|
+
// 記録するだけにして走行は止めない
|
|
183
|
+
onError: (err) => {
|
|
184
|
+
this.lastError = err instanceof Error ? err.message : String(err);
|
|
185
|
+
this.emit("pty-error", err);
|
|
186
|
+
},
|
|
187
|
+
});
|
|
188
|
+
this.emit("update", this.state());
|
|
189
|
+
return this;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
stop() {
|
|
193
|
+
this.pty?.kill("SIGTERM");
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** 終了時にメモリ上のシークレットを破棄する */
|
|
197
|
+
dispose() {
|
|
198
|
+
this.stop();
|
|
199
|
+
if (this.timer) clearTimeout(this.timer);
|
|
200
|
+
if (this.forced) clearTimeout(this.forced);
|
|
201
|
+
for (const key of Object.keys(this.answers)) {
|
|
202
|
+
this.answers[key] = "";
|
|
203
|
+
delete this.answers[key];
|
|
204
|
+
}
|
|
205
|
+
this.term = new Terminal();
|
|
206
|
+
this.detected = null;
|
|
207
|
+
this.detectedFrameId = null;
|
|
208
|
+
this.spec = null;
|
|
209
|
+
this.completion = null;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
#onExit(info) {
|
|
213
|
+
this.exit = info;
|
|
214
|
+
this.status = "finished";
|
|
215
|
+
// 仕上げの手作業に使う値を取り出す。読めなければ端末表示のままにする
|
|
216
|
+
this.completion = parseCompletion(this.term.snapshot(), {
|
|
217
|
+
// 画面から読めなくても、フォームに入っていた値で補える
|
|
218
|
+
liffId: typeof this.answers.liffId === "string" ? this.answers.liffId : undefined,
|
|
219
|
+
});
|
|
220
|
+
this.pending = null;
|
|
221
|
+
this.pendingSelect = null;
|
|
222
|
+
this.emit("update", this.state());
|
|
223
|
+
this.emit("exit", info);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
#onData(chunk) {
|
|
227
|
+
this.term.write(chunk);
|
|
228
|
+
this.#schedule();
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
#schedule() {
|
|
232
|
+
if (this.timer) clearTimeout(this.timer);
|
|
233
|
+
this.timer = setTimeout(() => this.#fire(), this.quietMs);
|
|
234
|
+
|
|
235
|
+
// 出力が途切れないと上のタイマーは毎回引き直されて永久に発火しない。
|
|
236
|
+
// 別に上限のタイマーを立てて、必ず一定間隔で画面を出す
|
|
237
|
+
this.forced ??= setTimeout(() => this.#fire(), this.maxWaitMs);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
#fire() {
|
|
241
|
+
if (this.timer) clearTimeout(this.timer);
|
|
242
|
+
if (this.forced) clearTimeout(this.forced);
|
|
243
|
+
this.timer = null;
|
|
244
|
+
this.forced = null;
|
|
245
|
+
|
|
246
|
+
try {
|
|
247
|
+
this.#evaluate();
|
|
248
|
+
} catch (err) {
|
|
249
|
+
this.#haltWith("internal-error", "内部エラーで自動応答を止めました", String(err));
|
|
250
|
+
}
|
|
251
|
+
this.emit("update", this.state());
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ── 判定 ───────────────────────────────────────────────────────────
|
|
255
|
+
|
|
256
|
+
#frameId(detected) {
|
|
257
|
+
return this.term.discarded + detected.startLine;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
#evaluate() {
|
|
261
|
+
if (this.status === "halted" || this.status === "finished") return;
|
|
262
|
+
|
|
263
|
+
const lines = this.term.snapshot();
|
|
264
|
+
const detected = detectPrompt(lines);
|
|
265
|
+
this.detected = detected;
|
|
266
|
+
this.detectedFrameId = detected ? this.#frameId(detected) : null;
|
|
267
|
+
|
|
268
|
+
if (!detected || !detected.complete) {
|
|
269
|
+
// プロンプトが出ていない = 本家が作業中
|
|
270
|
+
this.spec = null;
|
|
271
|
+
this.pending = null;
|
|
272
|
+
if (this.status !== "running") this.status = "running";
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const frameId = this.#frameId(detected);
|
|
277
|
+
|
|
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
|
+
// select の移動後の確認待ち
|
|
291
|
+
if (this.pendingSelect) {
|
|
292
|
+
this.#confirmSelect(detected, frameId);
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// 本家が入力を弾いた場合。値が合っていないので自動では直せない。
|
|
297
|
+
//
|
|
298
|
+
// `▲` は clack の log.warn と共用なので、画面の見た目だけでは
|
|
299
|
+
// 「入力エラー」と「ただの警告ログ」を区別できない。
|
|
300
|
+
// 入力エラーは**自分が答えた枠がその場で描き直される**形でしか起きないので、
|
|
301
|
+
// 応答済みの枠かどうかで判断する。
|
|
302
|
+
if (detected.state === "error") {
|
|
303
|
+
if (this.answeredFrames.has(frameId)) {
|
|
304
|
+
this.#haltWith(
|
|
305
|
+
"input-error",
|
|
306
|
+
"本家 CLI が入力を受け付けませんでした",
|
|
307
|
+
detected.errorText ?? "",
|
|
308
|
+
detected,
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
// 答えていない枠の ▲ は警告ログ。応答対象ではない
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// 同じ枠には二度送らない(エコーで枠が描き直されるため)
|
|
316
|
+
if (this.answeredFrames.has(frameId)) return;
|
|
317
|
+
|
|
318
|
+
const matches = matchPrompt(detected);
|
|
319
|
+
|
|
320
|
+
if (matches.length === 0) {
|
|
321
|
+
this.#haltWith(
|
|
322
|
+
"unknown-prompt",
|
|
323
|
+
"知らないプロンプトが出たので自動応答を止めました",
|
|
324
|
+
"本家 CLI のバージョンが変わった可能性があります。画面を見て手で答えてください。",
|
|
325
|
+
detected,
|
|
326
|
+
);
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
if (matches.length > 1) {
|
|
330
|
+
this.#haltWith(
|
|
331
|
+
"ambiguous-prompt",
|
|
332
|
+
"プロンプトを一意に特定できませんでした",
|
|
333
|
+
`候補: ${matches.map((m) => m.id).join(", ")}`,
|
|
334
|
+
detected,
|
|
335
|
+
);
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const spec = matches[0];
|
|
340
|
+
this.spec = spec;
|
|
341
|
+
|
|
342
|
+
if (spec.answer === "human") {
|
|
343
|
+
this.#waitForHuman(spec, detected, "human");
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const value = this.answers[/** @type {string} */ (spec.field)];
|
|
348
|
+
if (value === undefined || value === "") {
|
|
349
|
+
this.#waitForHuman(spec, detected, "missing-value");
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// フォーム由来のプロンプトが二度出た = 本家が前の値を受け付けなかった
|
|
354
|
+
// (workers.dev サブドメインの重複など)。同じ値を送り直しても直らない
|
|
355
|
+
if ((this.answeredCounts.get(spec.id) ?? 0) > 0) {
|
|
356
|
+
this.#haltWith(
|
|
357
|
+
"repeat-prompt",
|
|
358
|
+
"同じ入力をもう一度求められました",
|
|
359
|
+
"前に送った値が受け付けられなかったようです。値を見直してください。",
|
|
360
|
+
detected,
|
|
361
|
+
);
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
this.#send(spec, detected, frameId, value);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
#waitForHuman(spec, detected, reason) {
|
|
369
|
+
this.progressedAt = Date.now();
|
|
370
|
+
this.status = "waiting";
|
|
371
|
+
const secrets = this.#secretValues();
|
|
372
|
+
this.pending = {
|
|
373
|
+
promptId: spec.id,
|
|
374
|
+
reason,
|
|
375
|
+
// 画面の見出し。本家の文言は端末前提なので、そのままは出さない
|
|
376
|
+
label: spec.askLabel ?? spec.formLabel ?? spec.message,
|
|
377
|
+
// 本家が実際に出している文言。下の端末表示と突き合わせられるように残す
|
|
378
|
+
message: spec.message,
|
|
379
|
+
kind: spec.kind,
|
|
380
|
+
note: spec.note ?? null,
|
|
381
|
+
field: spec.field ?? null,
|
|
382
|
+
// 選択肢は本家が動的に作る場合がある(Cloudflare アカウント一覧など)
|
|
383
|
+
options: detected.optionLabels?.map((l) => redactText(l, secrets)) ?? null,
|
|
384
|
+
activeIndex: detected.activeIndex ?? null,
|
|
385
|
+
frameId: this.#frameId(detected),
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
#haltWith(reason, message, detail, detected = null) {
|
|
390
|
+
// detail と prompt は**本家の出力そのもの**。画面と同じく伏字を通してから外に出す。
|
|
391
|
+
// ここは screen() を経由しないので、通し忘れるとそのまま API 応答に出る
|
|
392
|
+
const secrets = this.#secretValues();
|
|
393
|
+
const safe = (text) => (typeof text === "string" ? redactText(text, secrets) : text);
|
|
394
|
+
|
|
395
|
+
this.status = "halted";
|
|
396
|
+
this.pending = null;
|
|
397
|
+
this.pendingSelect = null;
|
|
398
|
+
this.halt = {
|
|
399
|
+
reason,
|
|
400
|
+
message,
|
|
401
|
+
detail: safe(detail),
|
|
402
|
+
prompt: detected
|
|
403
|
+
? {
|
|
404
|
+
message: safe(detected.message),
|
|
405
|
+
kind: detected.kind,
|
|
406
|
+
options: detected.optionLabels?.map(safe) ?? null,
|
|
407
|
+
}
|
|
408
|
+
: null,
|
|
409
|
+
};
|
|
410
|
+
this.emit("halt", this.halt);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// ── 応答 ───────────────────────────────────────────────────────────
|
|
414
|
+
|
|
415
|
+
#send(spec, detected, frameId, value) {
|
|
416
|
+
if (spec.kind === "text") {
|
|
417
|
+
this.#markAnswered(spec, frameId);
|
|
418
|
+
this.pty?.write(`${String(value)}${KEY_ENTER}`);
|
|
419
|
+
this.status = "running";
|
|
420
|
+
this.pending = null;
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
if (spec.kind === "confirm") {
|
|
425
|
+
this.#markAnswered(spec, frameId);
|
|
426
|
+
// clack の confirm は y / n の1打鍵でその場で確定する(実装確認済み)
|
|
427
|
+
this.pty?.write(value ? "y" : "n");
|
|
428
|
+
this.status = "running";
|
|
429
|
+
this.pending = null;
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// select は契約上すべて human。ここには来ない
|
|
434
|
+
this.#haltWith(
|
|
435
|
+
"unsupported-auto-answer",
|
|
436
|
+
"この形式は自動応答の対象ではありません",
|
|
437
|
+
`kind=${spec.kind}`,
|
|
438
|
+
detected,
|
|
439
|
+
);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
#markAnswered(spec, frameId) {
|
|
443
|
+
this.progressedAt = Date.now();
|
|
444
|
+
this.answeredFrames.add(frameId);
|
|
445
|
+
this.answeredCounts.set(spec.id, (this.answeredCounts.get(spec.id) ?? 0) + 1);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* 人が決めたことを送る。
|
|
450
|
+
*
|
|
451
|
+
* @param {object} answer
|
|
452
|
+
* @param {string} answer.promptId いま待っているプロンプトと一致しなければ拒否する
|
|
453
|
+
* @param {number} [answer.optionIndex] select のとき
|
|
454
|
+
* @param {boolean} [answer.confirm] confirm のとき
|
|
455
|
+
* @param {string} [answer.text] text のとき(空文字 = Enter だけ)
|
|
456
|
+
*/
|
|
457
|
+
answerHuman(answer) {
|
|
458
|
+
this.progressedAt = Date.now();
|
|
459
|
+
if (this.status !== "waiting" || !this.pending) {
|
|
460
|
+
throw new Error("いま人の入力を待っていません");
|
|
461
|
+
}
|
|
462
|
+
if (answer.promptId !== this.pending.promptId) {
|
|
463
|
+
// 画面が進んだあとに古い応答が届いた場合。取り違えるくらいなら捨てる
|
|
464
|
+
throw new Error("待っているプロンプトと一致しません");
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
const { kind, frameId } = this.pending;
|
|
468
|
+
|
|
469
|
+
if (kind === "select") {
|
|
470
|
+
const target = answer.optionIndex;
|
|
471
|
+
const labels = this.pending.options ?? [];
|
|
472
|
+
if (typeof target !== "number" || target < 0 || target >= labels.length) {
|
|
473
|
+
throw new Error("選択肢の指定が不正です");
|
|
474
|
+
}
|
|
475
|
+
const from = this.pending.activeIndex ?? 0;
|
|
476
|
+
const diff = target - from;
|
|
477
|
+
const key = diff >= 0 ? KEY_DOWN : KEY_UP;
|
|
478
|
+
// まず移動だけして、狙った選択肢に印が付いたことを画面で確かめてから Enter を送る
|
|
479
|
+
this.pendingSelect = { frameId, target, labels };
|
|
480
|
+
this.status = "running";
|
|
481
|
+
this.pending = null;
|
|
482
|
+
if (diff !== 0) this.pty?.write(key.repeat(Math.abs(diff)));
|
|
483
|
+
else this.#schedule();
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
if (kind === "confirm") {
|
|
488
|
+
this.answeredFrames.add(frameId);
|
|
489
|
+
this.pty?.write(answer.confirm ? "y" : "n");
|
|
490
|
+
} else {
|
|
491
|
+
this.answeredFrames.add(frameId);
|
|
492
|
+
this.pty?.write(`${answer.text ?? ""}${KEY_ENTER}`);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
this.status = "running";
|
|
496
|
+
this.pending = null;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* select は「移動 → 画面で確認 → Enter」の順で送る。
|
|
501
|
+
* 印が狙った位置に無ければ Enter を送らずに止める。
|
|
502
|
+
*/
|
|
503
|
+
#confirmSelect(detected, frameId) {
|
|
504
|
+
const target = this.pendingSelect;
|
|
505
|
+
if (!target) return;
|
|
506
|
+
|
|
507
|
+
if (frameId !== target.frameId || detected.kind !== "select") {
|
|
508
|
+
this.pendingSelect = null;
|
|
509
|
+
this.#haltWith(
|
|
510
|
+
"select-verify-failed",
|
|
511
|
+
"選択肢の画面が変わったので Enter を送りませんでした",
|
|
512
|
+
"もう一度やり直してください。",
|
|
513
|
+
detected,
|
|
514
|
+
);
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
const labels = detected.optionLabels ?? [];
|
|
519
|
+
const sameLabels =
|
|
520
|
+
labels.length === target.labels.length && target.labels.every((l, i) => labels[i] === l);
|
|
521
|
+
|
|
522
|
+
if (!sameLabels || detected.activeIndex !== target.target) {
|
|
523
|
+
this.pendingSelect = null;
|
|
524
|
+
this.#haltWith(
|
|
525
|
+
"select-verify-failed",
|
|
526
|
+
"狙った選択肢に印が付いていないので Enter を送りませんでした",
|
|
527
|
+
`期待: ${target.target} / 実際: ${detected.activeIndex}`,
|
|
528
|
+
detected,
|
|
529
|
+
);
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
this.pendingSelect = null;
|
|
534
|
+
this.answeredFrames.add(frameId);
|
|
535
|
+
this.pty?.write(KEY_ENTER);
|
|
536
|
+
this.status = "running";
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* 止まっているときだけ、人が打った文字をそのまま送る。
|
|
541
|
+
*
|
|
542
|
+
* 自動応答の代わりではなく、**画面を見た人が判断して打つ**ための逃げ道。
|
|
543
|
+
* ここに保持しているシークレットを混ぜてはならない。
|
|
544
|
+
*
|
|
545
|
+
* @param {string} text
|
|
546
|
+
*/
|
|
547
|
+
answerManually(text) {
|
|
548
|
+
if (this.status !== "halted") throw new Error("止まっているときだけ手入力できます");
|
|
549
|
+
this.pty?.write(`${text}${KEY_ENTER}`);
|
|
550
|
+
this.halt = null;
|
|
551
|
+
this.status = "running";
|
|
552
|
+
// 手入力した枠にはもう触らない
|
|
553
|
+
if (this.detected) this.answeredFrames.add(this.#frameId(this.detected));
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// ── 外に出す形 ─────────────────────────────────────────────────────
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* 画面。**必ず伏字を通したもの**を返す。
|
|
560
|
+
* @param {number} [limit] 末尾から何行返すか。省略時は全部
|
|
561
|
+
*/
|
|
562
|
+
screen(limit) {
|
|
563
|
+
const secrets = this.#secretValues();
|
|
564
|
+
const lines = redactLines(this.term.snapshot(), secrets);
|
|
565
|
+
|
|
566
|
+
// 入力途中はエコーが1文字ずつ届くので、完全一致の置換では拾えない。
|
|
567
|
+
// シークレットを入力中の値行は中身を見ずに潰す。
|
|
568
|
+
//
|
|
569
|
+
// 位置は絶対行番号で持っておき、いまの画面に読み替える。
|
|
570
|
+
// 相対位置のまま覚えると、行数の上限で画面が流れたときに
|
|
571
|
+
// 伏字が別の行に当たり、入力途中の値がそのまま出てしまう。
|
|
572
|
+
if (this.detectedFrameId !== null && this.spec?.secret) {
|
|
573
|
+
const i = this.detectedFrameId - this.term.discarded + 1;
|
|
574
|
+
if (i >= 0 && lines[i] !== undefined) lines[i] = maskValueLine(lines[i]);
|
|
575
|
+
}
|
|
576
|
+
return limit === undefined ? lines : lines.slice(Math.max(0, lines.length - limit));
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* 仕上げの手作業に使う値。**API Key を含む。**
|
|
581
|
+
*
|
|
582
|
+
* 参加者はこの値を控えないと二度と手に入らないので、渡さないわけにいかない。
|
|
583
|
+
* そのぶん、載せる口をここ1つに絞る(`state()` には出さない)。
|
|
584
|
+
*/
|
|
585
|
+
completionForDisplay() {
|
|
586
|
+
if (!this.completion) return null;
|
|
587
|
+
return {
|
|
588
|
+
steps: completionSteps(this.completion),
|
|
589
|
+
apiKey: this.completion.apiKey,
|
|
590
|
+
adminUrl: this.completion.adminUrl,
|
|
591
|
+
};
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/** **値を含まない**状態。API 応答はこれを使う */
|
|
595
|
+
state() {
|
|
596
|
+
return {
|
|
597
|
+
status: this.status,
|
|
598
|
+
pending: this.pending,
|
|
599
|
+
halt: this.halt,
|
|
600
|
+
exit: this.exit,
|
|
601
|
+
lastError: this.lastError,
|
|
602
|
+
// 値は載せない。取り出せたかどうかだけ
|
|
603
|
+
hasCompletion: this.completion !== null,
|
|
604
|
+
filledFields: this.filledFields(),
|
|
605
|
+
};
|
|
606
|
+
}
|
|
607
|
+
}
|