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.
@@ -0,0 +1,178 @@
1
+ /**
2
+ * セッション状態。**プロセスのメモリ上にのみ保持する。**
3
+ *
4
+ * LINE のチャネルシークレット / アクセストークンをここで扱うため、
5
+ * 以下を守ること:
6
+ * - ディスクに書かない(ファイル・ログ・DB のいずれにも)
7
+ * - プロセス終了とともに破棄する
8
+ * - 外部に送らない(このサーバーは localhost にしか bind しない)
9
+ * - **API 応答に値を含めない**(入力済みかどうかだけを返す)
10
+ */
11
+
12
+ import { SetupRun } from "./runner.js";
13
+ import {
14
+ FORM_FIELDS,
15
+ FORM_GROUPS,
16
+ SECRET_FIELDS,
17
+ PROMPTS,
18
+ CONTRACT_CLI_VERSION,
19
+ validateAnswer,
20
+ crossCheckAnswers,
21
+ } from "./prompt-contract.js";
22
+ import { guideFor, GUIDE } from "./guide.js";
23
+
24
+ /** @type {{ answers: Record<string, string|boolean>, run: SetupRun|null }} */
25
+ const state = {
26
+ /** フォームに入った値。ここから外に出すのは「入っているかどうか」だけ */
27
+ answers: Object.create(null),
28
+ /** 実行中のセットアップ */
29
+ run: null,
30
+ };
31
+
32
+ /**
33
+ * フォームの値を受け取る。**値はここから先に出さない。**
34
+ *
35
+ * 画面でも検証するが、ここでも同じ規則で見る。画面を通さずに投げられる以上、
36
+ * 「不正な値を本家に渡さない」を画面側の善意に任せてはいけない。
37
+ *
38
+ * @param {Record<string, unknown>} values
39
+ * @returns {{ accepted: string[], rejected: Record<string, string> }}
40
+ */
41
+ export function setAnswers(values) {
42
+ const known = new Set(FORM_FIELDS.map((f) => f.field));
43
+ const accepted = [];
44
+ /** @type {Record<string, string>} */
45
+ const rejected = {};
46
+
47
+ for (const [key, value] of Object.entries(values)) {
48
+ // 契約に無い欄は受け取らない(そのまま本家に流れる経路を作らない)
49
+ if (!known.has(key)) continue;
50
+
51
+ const problem = validateAnswer(key, value);
52
+ if (problem !== null) {
53
+ // 通らなかった値は**保持しない**
54
+ rejected[key] = problem;
55
+ continue;
56
+ }
57
+
58
+ state.answers[key] = /** @type {string|boolean} */ (value);
59
+ accepted.push(key);
60
+ }
61
+
62
+ state.run?.setAnswers(state.answers);
63
+ return { accepted, rejected };
64
+ }
65
+
66
+ /** 項目をまたいだ問題。値は含まない */
67
+ export function answerProblems() {
68
+ return crossCheckAnswers(state.answers);
69
+ }
70
+
71
+ /** 入力済みの欄。**値は含めない** */
72
+ export function filledFields() {
73
+ return Object.entries(state.answers)
74
+ .filter(([, v]) => v !== undefined && v !== "")
75
+ .map(([k]) => k);
76
+ }
77
+
78
+ /** #2 のフォームが参照する、入力欄の定義。値は一切含まない */
79
+ export function formSchema() {
80
+ return {
81
+ cliVersion: CONTRACT_CLI_VERSION,
82
+ groups: FORM_GROUPS,
83
+ fields: FORM_FIELDS.map((f) => {
84
+ const spec = PROMPTS.find((p) => p.id === f.promptId);
85
+ return {
86
+ field: f.field,
87
+ promptId: f.promptId,
88
+ kind: f.kind,
89
+ secret: f.secret,
90
+ group: f.group,
91
+ // 画面用のラベル。無ければ本家の文言をそのまま使う。
92
+ // 照合に使うのは message のほうなので、ここは読みやすさを優先してよい
93
+ label: spec?.formLabel ?? spec?.message ?? f.field,
94
+ hint: spec?.formHint ?? null,
95
+ placeholder: spec?.placeholder ?? null,
96
+ note: spec?.note ?? null,
97
+ // 「この値はどこで取るか」。資産は配布キット側を参照する
98
+ guide: guideFor(f.promptId),
99
+ optional: f.validation?.optional === true,
100
+ pattern: f.validation?.pattern ?? null,
101
+ minLength: f.validation?.minLength ?? null,
102
+ message: f.validation?.message ?? null,
103
+ };
104
+ }),
105
+ };
106
+ }
107
+
108
+ /**
109
+ * 起動してよいのは本家 CLI だけ。API の本文から任意のパッケージを
110
+ * 走らせられる経路を作らない。
111
+ */
112
+ const CLI_SPEC = /^create-line-harness@(?:\d+\.\d+\.\d+|latest)$/;
113
+
114
+ /** @param {{ cliSpec?: string }} [options] */
115
+ export function startRun(options) {
116
+ if (options?.cliSpec && !CLI_SPEC.test(options.cliSpec)) {
117
+ throw new Error("起動できるのは本家 CLI だけです");
118
+ }
119
+
120
+ // 取り違えたまま始めると、片方のチャネルの ID が
121
+ // もう片方として Cloudflare に書き込まれる。始める前に止める
122
+ const problems = crossCheckAnswers(state.answers);
123
+ const first = Object.values(problems)[0];
124
+ if (first) throw new Error(first);
125
+
126
+ if (state.run && state.run.status !== "finished") {
127
+ throw new Error("すでにセットアップが動いています");
128
+ }
129
+ // 前回の run が持っていたシークレットの写しを先に捨てる
130
+ state.run?.dispose();
131
+
132
+ const run = new SetupRun(options);
133
+ run.setAnswers(state.answers);
134
+
135
+ // start() は擬似端末を用意できないと同期で投げる。
136
+ // 先に state.run へ入れてしまうと、status が "running" のまま
137
+ // pty が無い run が residue として残り、以後 start も stop も
138
+ // 受け付けなくなる(プロセスを立て直すまで復旧しない)
139
+ try {
140
+ run.start();
141
+ } catch (err) {
142
+ run.dispose();
143
+ throw err;
144
+ }
145
+
146
+ state.run = run;
147
+ return run;
148
+ }
149
+
150
+ /** 実行中に人が答える場面でも同じ案内を引けるようにする */
151
+ export function guideMap() {
152
+ return GUIDE;
153
+ }
154
+
155
+ export function currentRun() {
156
+ return state.run;
157
+ }
158
+
159
+ /** 画面に返してよい形。**値は含めず、入力済みかどうかだけ** */
160
+ export function publicSnapshot() {
161
+ return {
162
+ filledFields: filledFields(),
163
+ problems: crossCheckAnswers(state.answers),
164
+ secretFields: [...SECRET_FIELDS],
165
+ running: state.run !== null && state.run.status !== "finished",
166
+ run: state.run?.state() ?? null,
167
+ };
168
+ }
169
+
170
+ /** 破棄。プロセス終了時と、明示的なリセット時に呼ぶ */
171
+ export function clear() {
172
+ state.run?.dispose();
173
+ state.run = null;
174
+ for (const key of Object.keys(state.answers)) {
175
+ state.answers[key] = "";
176
+ delete state.answers[key];
177
+ }
178
+ }
@@ -0,0 +1,46 @@
1
+ import { createReadStream } from "node:fs";
2
+ import { stat } from "node:fs/promises";
3
+ import { join, normalize, extname } from "node:path";
4
+
5
+ const MIME = {
6
+ ".html": "text/html; charset=utf-8",
7
+ ".js": "text/javascript; charset=utf-8",
8
+ ".css": "text/css; charset=utf-8",
9
+ ".json": "application/json; charset=utf-8",
10
+ ".svg": "image/svg+xml",
11
+ ".png": "image/png",
12
+ ".jpg": "image/jpeg",
13
+ ".webp": "image/webp",
14
+ ".ico": "image/x-icon",
15
+ };
16
+
17
+ /**
18
+ * webRoot 配下だけを配信する。
19
+ * パストラバーサル(../ による外部参照)は正規化後の接頭辞チェックで弾く。
20
+ */
21
+ export async function serveStatic(req, res, webRoot) {
22
+ const urlPath = new URL(req.url, "http://localhost").pathname;
23
+ const rel = urlPath === "/" ? "index.html" : decodeURIComponent(urlPath).replace(/^\/+/, "");
24
+ const target = normalize(join(webRoot, rel));
25
+
26
+ if (!target.startsWith(webRoot)) {
27
+ res.writeHead(403).end("Forbidden");
28
+ return true;
29
+ }
30
+
31
+ try {
32
+ const info = await stat(target);
33
+ if (!info.isFile()) return false;
34
+
35
+ res.writeHead(200, {
36
+ "Content-Type": MIME[extname(target)] ?? "application/octet-stream",
37
+ "Content-Length": info.size,
38
+ // 手元ツールなのでキャッシュさせない(開発中の取り違えを防ぐ)
39
+ "Cache-Control": "no-store",
40
+ });
41
+ createReadStream(target).pipe(res);
42
+ return true;
43
+ } catch {
44
+ return false;
45
+ }
46
+ }
@@ -0,0 +1,210 @@
1
+ /**
2
+ * 極小の仮想端末。
3
+ *
4
+ * 本家 CLI は `@clack/prompts` を使っており、1文字入力するたびに
5
+ * 「カーソルを戻す → 行を消す → 書き直す」を繰り返す。
6
+ * そのため PTY から届くバイト列をそのまま連結すると、
7
+ * 入力途中の値が何度も混ざった別物になり、プロンプトの判定に使えない。
8
+ *
9
+ * ここでは clack が実際に使うエスケープだけを解釈して、
10
+ * **いま画面に見えている状態**を行の配列として復元する。
11
+ * (実測: CUU/CUD/CUF/CUB `A B C D` / CHA `G` / EL `K` / ED `J` / SGR `m` / DECTCEM `?25l ?25h`)
12
+ *
13
+ * 表示にも判定にも、この復元結果だけを使う。生のバイト列は外に出さない。
14
+ */
15
+
16
+ const DEFAULT_MAX_LINES = 2000;
17
+
18
+ export class Terminal {
19
+ /** @param {{ maxLines?: number }} [options] */
20
+ constructor(options = {}) {
21
+ this.maxLines = options.maxLines ?? DEFAULT_MAX_LINES;
22
+ /** @type {string[]} */
23
+ this.lines = [""];
24
+ this.row = 0;
25
+ this.col = 0;
26
+ /** 先頭から捨てた行数。呼び出し側が差分を追うために使う */
27
+ this.discarded = 0;
28
+ this.pending = "";
29
+ }
30
+
31
+ /** @param {string} chunk */
32
+ write(chunk) {
33
+ // 直前の write が escape の途中で切れている場合があるので継ぎ足す
34
+ let input = this.pending + chunk;
35
+ this.pending = "";
36
+ let i = 0;
37
+
38
+ while (i < input.length) {
39
+ const ch = input[i];
40
+
41
+ if (ch === "\x1b") {
42
+ const consumed = this.#escape(input, i);
43
+ if (consumed === -1) {
44
+ // まだ全部届いていない。次の chunk と合わせて解釈する
45
+ this.pending = input.slice(i);
46
+ return;
47
+ }
48
+ i += consumed;
49
+ continue;
50
+ }
51
+
52
+ if (ch === "\n") {
53
+ this.#down(1);
54
+ i += 1;
55
+ continue;
56
+ }
57
+ if (ch === "\r") {
58
+ this.col = 0;
59
+ i += 1;
60
+ continue;
61
+ }
62
+ if (ch === "\b") {
63
+ this.col = Math.max(0, this.col - 1);
64
+ i += 1;
65
+ continue;
66
+ }
67
+ if (ch === "\x07") {
68
+ i += 1;
69
+ continue;
70
+ }
71
+
72
+ // 制御文字以外はそのまま上書きで置く
73
+ this.#put(ch);
74
+ i += 1;
75
+ }
76
+
77
+ this.#trim();
78
+ }
79
+
80
+ /**
81
+ * ESC から始まる並びを1つ消費する。
82
+ * @returns {number} 消費した文字数。未完なら -1
83
+ */
84
+ #escape(input, start) {
85
+ const next = input[start + 1];
86
+ if (next === undefined) return -1;
87
+
88
+ // OSC: ESC ] ... BEL | ESC \
89
+ if (next === "]") {
90
+ for (let i = start + 2; i < input.length; i += 1) {
91
+ if (input[i] === "\x07") return i - start + 1;
92
+ if (input[i] === "\x1b" && input[i + 1] === "\\") return i - start + 2;
93
+ }
94
+ return -1;
95
+ }
96
+
97
+ // CSI: ESC [ params intermediate final
98
+ if (next === "[") {
99
+ let i = start + 2;
100
+ while (i < input.length && /[\d;?]/.test(input[i])) i += 1;
101
+ if (i >= input.length) return -1;
102
+ const final = input[i];
103
+ const params = input.slice(start + 2, i);
104
+ this.#csi(final, params);
105
+ return i - start + 1;
106
+ }
107
+
108
+ // それ以外の 2 文字エスケープは読み飛ばす
109
+ return 2;
110
+ }
111
+
112
+ #csi(final, params) {
113
+ if (params.startsWith("?")) return; // ?25l / ?25h など。表示状態は持たない
114
+
115
+ const nums = params.split(";").map((p) => (p === "" ? undefined : Number(p)));
116
+ const n = nums[0] ?? 1;
117
+
118
+ switch (final) {
119
+ case "A": // カーソル上
120
+ this.row = Math.max(0, this.row - n);
121
+ break;
122
+ case "B": // カーソル下
123
+ this.#down(n);
124
+ break;
125
+ case "C": // カーソル右
126
+ this.col += n;
127
+ break;
128
+ case "D": // カーソル左
129
+ this.col = Math.max(0, this.col - n);
130
+ break;
131
+ case "G": // 桁指定(clack は 1 桁目に戻すのに使う)
132
+ this.col = Math.max(0, (nums[0] ?? 1) - 1);
133
+ break;
134
+ case "K": // 行内消去
135
+ this.#eraseLine(nums[0] ?? 0);
136
+ break;
137
+ case "J": // 画面消去
138
+ this.#eraseDisplay(nums[0] ?? 0);
139
+ break;
140
+ default:
141
+ // m(色)を含め、表示に影響しないものは捨てる
142
+ break;
143
+ }
144
+ }
145
+
146
+ #down(n) {
147
+ this.row += n;
148
+ while (this.lines.length <= this.row) this.lines.push("");
149
+ }
150
+
151
+ #put(ch) {
152
+ const line = this.lines[this.row] ?? "";
153
+ const padded = line.length < this.col ? line + " ".repeat(this.col - line.length) : line;
154
+ this.lines[this.row] = padded.slice(0, this.col) + ch + padded.slice(this.col + 1);
155
+ this.col += 1;
156
+ }
157
+
158
+ #eraseLine(mode) {
159
+ const line = this.lines[this.row] ?? "";
160
+ if (mode === 1) {
161
+ this.lines[this.row] = " ".repeat(Math.min(this.col, line.length)) + line.slice(this.col);
162
+ } else if (mode === 2) {
163
+ this.lines[this.row] = "";
164
+ } else {
165
+ this.lines[this.row] = line.slice(0, this.col);
166
+ }
167
+ }
168
+
169
+ #eraseDisplay(mode) {
170
+ if (mode === 2) {
171
+ // 画面全消し。捨てた分を数えておかないと行番号が振り直しになり、
172
+ // 呼び出し側が「同じ枠」と誤認する(応答済み判定が別の枠に当たる)
173
+ this.discarded += this.lines.length;
174
+ this.lines = [""];
175
+ this.row = 0;
176
+ this.col = 0;
177
+ return;
178
+ }
179
+ if (mode === 1) {
180
+ this.lines[this.row] = " ".repeat(this.col);
181
+ for (let i = 0; i < this.row; i += 1) this.lines[i] = "";
182
+ return;
183
+ }
184
+ // mode 0: カーソル位置から画面末尾まで
185
+ this.lines[this.row] = (this.lines[this.row] ?? "").slice(0, this.col);
186
+ this.lines.length = this.row + 1;
187
+ }
188
+
189
+ #trim() {
190
+ const excess = this.lines.length - this.maxLines;
191
+ if (excess <= 0) return;
192
+ this.lines.splice(0, excess);
193
+ this.discarded += excess;
194
+ this.row = Math.max(0, this.row - excess);
195
+ }
196
+
197
+ /** いま見えている行。末尾の空行は落とす */
198
+ snapshot() {
199
+ const out = this.lines.slice();
200
+ let end = out.length;
201
+ while (end > 0 && out[end - 1].trim() === "") end -= 1;
202
+ return out.slice(0, end).map((l) => l.replace(/\s+$/, ""));
203
+ }
204
+
205
+ /** @param {number} n */
206
+ tail(n) {
207
+ const lines = this.snapshot();
208
+ return lines.slice(Math.max(0, lines.length - n));
209
+ }
210
+ }