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,373 @@
1
+ /**
2
+ * プロンプト契約。
3
+ *
4
+ * 本家 `create-line-harness` が出すプロンプトの**文言そのもの**を並べたもの。
5
+ * 一次情報は本家 npm パッケージのバンドル(dist/index.js)から逐語で取っている。
6
+ * 対象バージョンは {@link CONTRACT_CLI_VERSION}。
7
+ *
8
+ * ## なぜ順番でマッチさせないか
9
+ *
10
+ * 本家のバージョンアップでプロンプトが1つ増えるだけで、順番マッチは値を1つずつズラす。
11
+ * 最悪の場合チャネルシークレットがプロジェクト名の欄に入り、画面にエコーされ、
12
+ * Worker 名として Cloudflare に残る。機能不全ではなく情報漏洩になる。
13
+ *
14
+ * そのため:
15
+ * - 文言が**完全一致**したときだけ応答する
16
+ * - placeholder を宣言しているものは、それも一致することを要求する(第二の照合)
17
+ * - 一致しなければ**止めて人間に委ねる**(フェイルクローズ)
18
+ *
19
+ * ## `Channel ID(数字)` と `チャネル ID(数字)`
20
+ *
21
+ * この2つは Messaging API チャネルと LINE Login チャネルで**別物**なのに、
22
+ * 見た目がほとんど同じ。placeholder が違うので、そこを第二の照合に使う。
23
+ */
24
+
25
+ /** 契約が対象とする本家 CLI のバージョン。ズレの検知は #5 で行う */
26
+ export const CONTRACT_CLI_VERSION = "0.2.11";
27
+
28
+ /**
29
+ * @typedef {object} PromptSpec
30
+ * @property {string} id 契約上の識別子
31
+ * @property {string} message 本家が出す文言(完全一致)
32
+ * @property {"text"|"select"|"confirm"} kind
33
+ * @property {string} [placeholder] 宣言した場合、初期表示の値行と完全一致することを要求する
34
+ * @property {string[]} [optionLabels] select の選択肢(完全一致)。動的なものは省略する
35
+ * @property {boolean} [secret] 画面表示から伏せる対象か
36
+ * @property {"form"|"human"} answer form=参加者が先に入力した値で応答 / human=その場で人が決める
37
+ * @property {string} [field] answer=form のときの入力欄 id
38
+ * @property {string} [askLabel] 実行中に人へ問うときの見出し。
39
+ * 本家の文言は端末を前提にしているので(例:「Enter を押してください」)、
40
+ * そのまま出すと画面に無い操作を指示してしまう
41
+ * @property {string} [note] GUI に出す補足
42
+ */
43
+
44
+ /** @type {PromptSpec[]} */
45
+ export const PROMPTS = [
46
+ // ── 再開時(前回のステートが残っている場合のみ)──────────────────────
47
+ {
48
+ id: "resumeAccountUnknown",
49
+ message: "どうしますか?",
50
+ askLabel: "前回のリソースをどう扱いますか?",
51
+ kind: "select",
52
+ optionLabels: [
53
+ "アカウント依存ステップをリセットして、現在のアカウントで作り直す(推奨)",
54
+ "リセットせず、現在のアカウントで続行する(前回のリソースが流用できれば再利用)",
55
+ "中止する",
56
+ ],
57
+ answer: "human",
58
+ note: "前回作った Cloudflare リソースがどのアカウントのものか記録されていません。作り直すか続行するかを選んでください。",
59
+ },
60
+ {
61
+ id: "accountChanged",
62
+ message: "どうしますか?",
63
+ askLabel: "別の Cloudflare アカウントで続けますか?",
64
+ kind: "select",
65
+ optionLabels: [
66
+ "現在ログイン中のアカウントで続行する(R2/D1/Worker などを作り直し)",
67
+ "中止して `wrangler login` で前回のアカウントに戻る",
68
+ ],
69
+ answer: "human",
70
+ note: "前回と違う Cloudflare アカウントでログインしています。作り直すか、中止して元のアカウントに戻るかを選んでください。",
71
+ },
72
+
73
+ // ── Cloudflare アカウント選択(複数アカウントがある場合のみ)──────────
74
+ {
75
+ id: "cloudflareAccount",
76
+ message: "使用する Cloudflare アカウントを選択してください",
77
+ askLabel: "どの Cloudflare アカウントに作りますか?",
78
+ kind: "select",
79
+ // 選択肢は `${name} (${id})` で動的。契約では固定しない
80
+ answer: "human",
81
+ note: "どの Cloudflare アカウントに作るかを選んでください。",
82
+ },
83
+
84
+ // ── Step 1. R2 の有効化待ち ───────────────────────────────────────────
85
+ {
86
+ id: "r2Enabled",
87
+ message: "R2 の有効化が完了したら Enter を押してください",
88
+ // 本家は端末で Enter を待つが、この画面に Enter は無い。
89
+ // 文言をそのまま出すと「押さないと進めない」と読まれる(実機で混乱を確認)
90
+ askLabel: "R2 の有効化は終わりましたか?",
91
+ kind: "text",
92
+ answer: "human",
93
+ note: "Cloudflare ダッシュボードで R2 を有効化してから進めてください。カード登録済みでも R2 の利用開始は別操作です。",
94
+ },
95
+
96
+ // ── Step 1 の続き。プロジェクト名 ─────────────────────────────────────
97
+ {
98
+ id: "projectName",
99
+ message: "プロジェクト名(Worker と D1 の名前に使われます)",
100
+ formLabel: "プロジェクト名",
101
+ formHint: "Worker と D1 の名前になります。空のままにすると line-harness になります。",
102
+ kind: "text",
103
+ placeholder: "line-harness",
104
+ answer: "form",
105
+ field: "projectName",
106
+ group: "project",
107
+ validation: {
108
+ optional: true, // 空なら本家が line-harness を使う
109
+ pattern: "^[a-z0-9][a-z0-9-]*$",
110
+ message: "英小文字・数字・ハイフンのみ使用できます(例: my-line-bot)",
111
+ },
112
+ },
113
+
114
+ // ── Step 2. Messaging API チャネル ───────────────────────────────────
115
+ {
116
+ id: "lineChannelId",
117
+ message: "Channel ID(数字)",
118
+ // 下の「チャネル ID(数字)」と文言がほとんど同じで、実測で最大の混乱要因だった。
119
+ // 画面ではどちらのチャネルのものかをラベル自体に入れる
120
+ formLabel: "Channel ID(Messaging API チャネル)",
121
+ kind: "text",
122
+ placeholder: "上の手順で取得した Channel ID",
123
+ answer: "form",
124
+ field: "lineChannelId",
125
+ group: "messaging",
126
+ validation: {
127
+ trim: true,
128
+ pattern: "^\\d+$",
129
+ message: "Channel ID は数字で入力してください",
130
+ },
131
+ },
132
+ {
133
+ id: "lineChannelSecret",
134
+ message: "チャネルシークレット(英数字)",
135
+ kind: "text",
136
+ placeholder: "同じページに表示されている Channel Secret",
137
+ secret: true,
138
+ answer: "form",
139
+ field: "lineChannelSecret",
140
+ group: "messaging",
141
+ validation: {
142
+ trim: true,
143
+ minLength: 10,
144
+ message: "チャネルシークレットを入力してください",
145
+ },
146
+ },
147
+ {
148
+ id: "lineChannelAccessToken",
149
+ message: "チャネルアクセストークン(長期)",
150
+ kind: "text",
151
+ placeholder: "上の手順で発行したトークン",
152
+ secret: true,
153
+ answer: "form",
154
+ field: "lineChannelAccessToken",
155
+ group: "messaging",
156
+ validation: {
157
+ trim: true,
158
+ minLength: 10,
159
+ message: "チャネルアクセストークンを入力してください",
160
+ },
161
+ },
162
+
163
+ // ── Step 3. LINE Login チャネル ──────────────────────────────────────
164
+ // 上の lineChannelId と文言が紛らわしい。placeholder で区別する
165
+ {
166
+ id: "lineLoginChannelId",
167
+ message: "チャネル ID(数字)",
168
+ formLabel: "チャネル ID(LINE Login チャネル)",
169
+ kind: "text",
170
+ placeholder: "LINE Login チャネルの ID(Messaging API とは別)",
171
+ answer: "form",
172
+ field: "lineLoginChannelId",
173
+ group: "login",
174
+ validation: {
175
+ trim: true,
176
+ pattern: "^\\d+$",
177
+ message: "チャネル ID は数字で入力してください",
178
+ },
179
+ },
180
+ {
181
+ id: "liffId",
182
+ message: "LIFF ID",
183
+ formHint: "LINE Login チャネルの「LIFF」タブで作成したアプリの ID です。",
184
+ kind: "text",
185
+ placeholder: "チャネルID-ランダム文字列(例: 2009554425-4IMBmLQ9)",
186
+ answer: "form",
187
+ field: "liffId",
188
+ group: "login",
189
+ validation: {
190
+ trim: true,
191
+ // 本家は「- を含むか」しか見ていない。ここを厳しくすると正しい値を弾く
192
+ pattern: "^.*-.*$",
193
+ message: "LIFF ID は「チャネルID-ランダム文字列」の形式です(例: 2009554425-4IMBmLQ9)",
194
+ },
195
+ },
196
+
197
+ // ── workers.dev サブドメイン(未設定のときだけ)──────────────────────
198
+ {
199
+ id: "workersSubdomain",
200
+ message:
201
+ "workers.dev サブドメイン名(Worker の URL が https://<Worker名>.<この名前>.workers.dev になります)",
202
+ formLabel: "workers.dev サブドメイン名",
203
+ formHint: "Worker の URL が https://<Worker名>.<この名前>.workers.dev になります。",
204
+ kind: "text",
205
+ // placeholder は本家が候補名を動的に入れるため固定しない
206
+ answer: "form",
207
+ field: "workersSubdomain",
208
+ group: "project",
209
+ validation: {
210
+ optional: true, // 設定済みなら本家は聞いてこない
211
+ trim: true,
212
+ pattern: "^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$",
213
+ message: "英小文字・数字・ハイフンのみ、63文字以内、先頭と末尾は英数字にしてください",
214
+ },
215
+ note: "workers.dev サブドメインは全世界で早い者勝ちです。重複すると本家が再入力を求めます。",
216
+ },
217
+ {
218
+ id: "subdomainManualCheck",
219
+ message: "ダッシュボードでの登録が終わったら「確認する」を選んでください",
220
+ askLabel: "サブドメインの登録は終わりましたか?",
221
+ kind: "select",
222
+ optionLabels: [
223
+ "確認する(登録済みかチェックします)",
224
+ "確認せずに続行する(未登録だとデプロイに失敗します)",
225
+ ],
226
+ answer: "human",
227
+ note: "CLI からの自動登録ができませんでした。ダッシュボードで登録してから「確認する」を選んでください。",
228
+ },
229
+
230
+ // ── 仕上げ ───────────────────────────────────────────────────────────
231
+ {
232
+ id: "addMcp",
233
+ message: "MCP 設定を .mcp.json に追加しますか?(Claude Code / Cursor 用)",
234
+ formLabel: "MCP 設定を .mcp.json に追加する(Claude Code / Cursor 用)",
235
+ kind: "confirm",
236
+ answer: "form",
237
+ field: "addMcp",
238
+ group: "options",
239
+ },
240
+ ];
241
+
242
+ /**
243
+ * フォームの区切り。
244
+ *
245
+ * **Messaging API チャネルと LINE Login チャネルは別物**なのに、
246
+ * 本家の文言(`Channel ID(数字)` と `チャネル ID(数字)`)はほとんど同じ。
247
+ * 実測でも最大の混乱要因だったので、画面では取得元ごとに分けて見せる。
248
+ */
249
+ export const FORM_GROUPS = [
250
+ {
251
+ id: "project",
252
+ title: "プロジェクト",
253
+ description: "Cloudflare 上に作られるものの名前です。あとから変えられません。",
254
+ },
255
+ {
256
+ id: "messaging",
257
+ title: "Messaging API チャネル",
258
+ description:
259
+ "LINE Official Account Manager の「Messaging API」画面で取得します。次の Login チャネルとは別物です。",
260
+ },
261
+ {
262
+ id: "login",
263
+ title: "LINE Login チャネル",
264
+ description:
265
+ "LINE Developers Console で新規作成した「LINEログイン」チャネルです。上の Messaging API チャネルとは別物です。",
266
+ },
267
+ { id: "options", title: "オプション", description: null },
268
+ ];
269
+
270
+ /**
271
+ * 入力値がそのまま本家に渡せるかを見る。
272
+ *
273
+ * 規則は**本家の validate をそのまま写したもの**。
274
+ * ここを本家より厳しくすると、正しい値を弾いて参加者が進めなくなる。
275
+ * 文言も本家に合わせてあるので、GUI で弾いても CLI で弾いても同じことを言う。
276
+ *
277
+ * @param {string} field
278
+ * @param {unknown} value
279
+ * @returns {string|null} 問題があれば文言、無ければ null
280
+ */
281
+ export function validateAnswer(field, value) {
282
+ const spec = PROMPTS.find((p) => p.field === field && p.answer === "form");
283
+ if (!spec) return "この項目は受け付けていません";
284
+
285
+ if (spec.kind === "confirm") {
286
+ return typeof value === "boolean" ? null : "はい / いいえ で答えてください";
287
+ }
288
+
289
+ if (typeof value !== "string") return "文字で入力してください";
290
+
291
+ const rule = spec.validation;
292
+ if (!rule) return null;
293
+
294
+ const text = rule.trim ? value.trim() : value;
295
+
296
+ if (text === "") return rule.optional ? null : rule.message;
297
+ if (rule.minLength !== undefined && text.length < rule.minLength) return rule.message;
298
+ if (rule.pattern !== undefined && !new RegExp(rule.pattern).test(text)) return rule.message;
299
+
300
+ return null;
301
+ }
302
+
303
+ /**
304
+ * 項目をまたいだ確認。
305
+ *
306
+ * Messaging API チャネルと LINE Login チャネルは**別のチャネル**なので、
307
+ * Channel ID が一致することはない。同じ値が入っていたら取り違えている。
308
+ * 本家はこれを見ていないため、気づかないまま最後まで進んでしまう。
309
+ *
310
+ * @param {Record<string, string|boolean>} answers
311
+ * @returns {Record<string, string>} 欄ごとの問題
312
+ */
313
+ export function crossCheckAnswers(answers) {
314
+ /** @type {Record<string, string>} */
315
+ const problems = {};
316
+
317
+ const messaging = typeof answers.lineChannelId === "string" ? answers.lineChannelId.trim() : "";
318
+ const login =
319
+ typeof answers.lineLoginChannelId === "string" ? answers.lineLoginChannelId.trim() : "";
320
+
321
+ if (messaging !== "" && messaging === login) {
322
+ const message =
323
+ "Messaging API チャネルと LINE Login チャネルは別のチャネルです。同じ ID にはなりません。取得元をもう一度確認してください。";
324
+ problems.lineChannelId = message;
325
+ problems.lineLoginChannelId = message;
326
+ }
327
+
328
+ return problems;
329
+ }
330
+
331
+ /** answer=form のプロンプトが要求する入力欄。#2 のフォームはこれに合わせる */
332
+ export const FORM_FIELDS = PROMPTS.filter((p) => p.answer === "form").map((p) => ({
333
+ field: /** @type {string} */ (p.field),
334
+ promptId: p.id,
335
+ kind: p.kind,
336
+ secret: p.secret === true,
337
+ group: p.group ?? "options",
338
+ validation: p.validation ?? null,
339
+ }));
340
+
341
+ /** 伏字にする入力欄 id */
342
+ export const SECRET_FIELDS = new Set(
343
+ PROMPTS.filter((p) => p.secret === true && p.field).map((p) => /** @type {string} */ (p.field)),
344
+ );
345
+
346
+ /**
347
+ * 検出したプロンプトに一致する契約を返す。
348
+ *
349
+ * **一致しなければ null。**呼び出し側はそのとき自動応答を止めること。
350
+ *
351
+ * @param {{ message: string, kind: string, valueLine?: string, optionLabels?: string[] }} detected
352
+ * @returns {PromptSpec[]} 一致した契約。0 件なら未知、2 件以上なら曖昧
353
+ */
354
+ export function matchPrompt(detected) {
355
+ return PROMPTS.filter((spec) => {
356
+ if (spec.message !== detected.message) return false;
357
+ if (spec.kind !== detected.kind) return false;
358
+
359
+ // placeholder を宣言しているものは、初期表示の値行と一致することを要求する
360
+ if (spec.placeholder !== undefined) {
361
+ if (detected.valueLine !== spec.placeholder) return false;
362
+ }
363
+
364
+ // 選択肢を宣言しているものは、画面の選択肢と完全一致することを要求する
365
+ if (spec.optionLabels !== undefined) {
366
+ const shown = detected.optionLabels ?? [];
367
+ if (shown.length !== spec.optionLabels.length) return false;
368
+ if (!spec.optionLabels.every((label, i) => shown[i] === label)) return false;
369
+ }
370
+
371
+ return true;
372
+ });
373
+ }
@@ -0,0 +1,181 @@
1
+ /**
2
+ * `@clack/prompts` が描いた画面から、いま応答を待っているプロンプトを読み取る。
3
+ *
4
+ * clack の枠は実測でこの形({@link Terminal} で復元したあとの平文):
5
+ *
6
+ * ```
7
+ * │
8
+ * ◆ プロジェクト名(Worker と D1 の名前に使われます)
9
+ * │ line-harness
10
+ * └
11
+ * ```
12
+ *
13
+ * - `◆` = 入力待ち / `▲` = 入力エラーで再入力待ち
14
+ * - select は本文が1行1選択肢(`●` が現在位置)、confirm は `● Yes / ○ No` の1行
15
+ *
16
+ * ## 記号はログ行と共用されている(実測)
17
+ *
18
+ * clack の `log.*` は**プロンプトと同じ記号を使う**。
19
+ *
20
+ * | 呼び出し | 記号 |
21
+ * |---|---|
22
+ * | `log.success` | `◆` ← 入力待ちと同じ |
23
+ * | `log.warn` | `▲` ← 入力エラーと同じ |
24
+ * | `log.step` / 確定済み | `◇` |
25
+ * | `log.error` | `■` |
26
+ *
27
+ * 本家は `log.success("LIFF ID: 入力済み(…)")` のような行を多用し、最後に
28
+ * `outro()` が `└ セットアップ完了` を書く。素直に読むと、**成功して終わった実行の
29
+ * 最終画面が「入力待ちの枠」に見える**。
30
+ *
31
+ * 見分け方はこう:
32
+ *
33
+ * - 本物の入力待ちの枠は、**中身のない `└` で閉じる**。
34
+ * `outro` / `cancel` の `└` には文言が付くので、そこで弾ける
35
+ * - `▲` が入力エラーなのかログなのかは画面だけでは決まらない。
36
+ * **自分が答えた枠かどうか**で判断するので、それは {@link SetupRun} 側で見る
37
+ *
38
+ * ここは「読む」だけで、応答の可否は {@link matchPrompt} が決める。
39
+ */
40
+
41
+ // clack は端末が unicode を扱えないときだけ ASCII の代替記号(* x | — > 空白)に落ちる。
42
+ // pty.js で TERM=xterm-256color を固定しているので、この分岐には入らない。
43
+ // 代替記号まで見に行くと `* ` で始まる普通の出力をプロンプトと誤認するので、拾わない。
44
+ const SYM_ACTIVE = ["◆"];
45
+ const SYM_ERROR = ["▲"];
46
+ const SYM_BAR = ["│"];
47
+ const SYM_END = ["└"];
48
+ const RADIO_ON = ["●"];
49
+ const RADIO_OFF = ["○"];
50
+
51
+ const anyOf = (chars) => `[${chars.map((c) => c.replace(/[\\\]^-]/g, "\\$&")).join("")}]`;
52
+
53
+ const RE_HEAD = new RegExp(`^(${anyOf(SYM_ACTIVE)}|${anyOf(SYM_ERROR)}) (.*)$`);
54
+ const RE_BAR = new RegExp(`^${anyOf(SYM_BAR)}(?: (.*))?$`);
55
+ const RE_END = new RegExp(`^${anyOf(SYM_END)}(?: (.*))?$`);
56
+ const RE_RADIO = new RegExp(`^(${anyOf(RADIO_ON)}|${anyOf(RADIO_OFF)}) (.*)$`);
57
+
58
+ /** clack はカーソル位置に █ を描く。値の比較前に落とす */
59
+ const stripCursor = (s) => s.replace(/█/g, "");
60
+
61
+ /**
62
+ * @typedef {object} DetectedPrompt
63
+ * @property {"active"|"error"} state
64
+ * @property {string} message
65
+ * @property {"text"|"select"|"confirm"} kind
66
+ * @property {string} [valueLine] text のとき、値(未入力なら placeholder)
67
+ * @property {string[]} [optionLabels] select / confirm の選択肢
68
+ * @property {number} [activeIndex] select / confirm でいま選ばれている位置
69
+ * @property {string} [errorText] state=error のときの本家のエラー文言
70
+ * @property {boolean} complete 枠が `└` まで届いているか
71
+ * @property {number} startLine snapshot 上の開始行
72
+ */
73
+
74
+ /**
75
+ * @param {string[]} lines {@link Terminal#snapshot} の結果
76
+ * @returns {DetectedPrompt|null}
77
+ */
78
+ export function detectPrompt(lines) {
79
+ // 末尾から最初に見つかる ◆ / ▲ が、いま応答を待っている枠
80
+ let head = -1;
81
+ for (let i = lines.length - 1; i >= 0; i -= 1) {
82
+ if (RE_HEAD.test(lines[i])) {
83
+ head = i;
84
+ break;
85
+ }
86
+ }
87
+ if (head === -1) return null;
88
+
89
+ const m = RE_HEAD.exec(lines[head]);
90
+ if (!m) return null;
91
+ const state = SYM_ERROR.includes(m[1]) ? "error" : "active";
92
+
93
+ // 文言が端末幅を超えて折り返された場合、続きは接頭辞なしの行になる。
94
+ // (幅は 200 桁を確保しているので通常は起きない。起きたら結合が不正確になり、
95
+ // 契約と一致せず「未知のプロンプト」として止まる=安全側に倒れる)
96
+ let messageParts = [m[2]];
97
+ let i = head + 1;
98
+ while (i < lines.length && !RE_BAR.test(lines[i]) && !RE_END.test(lines[i]) && !RE_HEAD.test(lines[i])) {
99
+ messageParts.push(lines[i]);
100
+ i += 1;
101
+ }
102
+ const message = stripCursor(messageParts.join("")).trim();
103
+
104
+ /** @type {string[]} */
105
+ const body = [];
106
+ let complete = false;
107
+ let endText;
108
+
109
+ for (; i < lines.length; i += 1) {
110
+ const end = RE_END.exec(lines[i]);
111
+ if (end) {
112
+ complete = true;
113
+ endText = end[1]?.trim() || undefined;
114
+ break;
115
+ }
116
+ const bar = RE_BAR.exec(lines[i]);
117
+ if (!bar) break;
118
+ body.push(bar[1] ?? "");
119
+ }
120
+
121
+ // 入力待ちの枠は中身のない `└` で閉じる。文言が付いていたら、それは
122
+ // `outro` / `cancel` が書いた行で、上にある `◆` は log.success のもの。
123
+ // 応答対象にしてはいけないので「まだ揃っていない」として扱う。
124
+ if (complete && state === "active" && endText !== undefined) {
125
+ complete = false;
126
+ endText = undefined;
127
+ }
128
+
129
+ const errorText = state === "error" ? endText : undefined;
130
+
131
+ // ── 種別の判定 ────────────────────────────────────────────────────
132
+ const radios = body.map((l) => RE_RADIO.exec(l.trim()));
133
+
134
+ // confirm: 1行に「● Yes / ○ No」
135
+ if (body.length === 1 && radios[0]) {
136
+ const parts = body[0].trim().split(/\s+\/\s+/);
137
+ if (parts.length === 2 && parts.every((p) => RE_RADIO.test(p))) {
138
+ const labels = parts.map((p) => /** @type {RegExpExecArray} */ (RE_RADIO.exec(p))[2].trim());
139
+ const activeIndex = parts.findIndex((p) =>
140
+ RADIO_ON.includes(/** @type {RegExpExecArray} */ (RE_RADIO.exec(p))[1]),
141
+ );
142
+ return {
143
+ state,
144
+ message,
145
+ kind: "confirm",
146
+ optionLabels: labels,
147
+ activeIndex: activeIndex === -1 ? undefined : activeIndex,
148
+ errorText,
149
+ complete,
150
+ startLine: head,
151
+ };
152
+ }
153
+ }
154
+
155
+ // select: 本文が全部「● / ○ + ラベル」
156
+ if (body.length > 0 && radios.every((r) => r !== null)) {
157
+ const labels = radios.map((r) => /** @type {RegExpExecArray} */ (r)[2].trim());
158
+ const activeIndex = radios.findIndex((r) => RADIO_ON.includes(/** @type {RegExpExecArray} */ (r)[1]));
159
+ return {
160
+ state,
161
+ message,
162
+ kind: "select",
163
+ optionLabels: labels,
164
+ activeIndex: activeIndex === -1 ? undefined : activeIndex,
165
+ errorText,
166
+ complete,
167
+ startLine: head,
168
+ };
169
+ }
170
+
171
+ // それ以外は text
172
+ return {
173
+ state,
174
+ message,
175
+ kind: "text",
176
+ valueLine: stripCursor(body[0] ?? "").trim(),
177
+ errorText,
178
+ complete,
179
+ startLine: head,
180
+ };
181
+ }
@@ -0,0 +1,130 @@
1
+ #!/usr/bin/env python3
2
+ """擬似端末(PTY)のリレー。
3
+
4
+ 本家 create-line-harness は @clack/prompts を使っており、実 TTY を要求する。
5
+ 通常のパイプで起動すると最初のプロンプトで落ちる:
6
+
7
+ Error: TTY initialization failed: uv_tty_init returned EINVAL
8
+
9
+ Node には PTY を作る標準 API がない。node-pty は install script でネイティブ
10
+ ビルドを行うが、npm 11 以降は install script が既定でブロックされるため、
11
+ `npx` 配布ではバイナリが入らず**無言で壊れる**(実測済み)。
12
+ そこで Python 標準ライブラリの `pty` だけで PTY を確保する。
13
+
14
+ このスクリプトは中継しかしない:
15
+
16
+ 親(Node)の stdin → PTY → 子(本家 CLI)
17
+ 子の出力 → PTY → 親の stdout
18
+
19
+ **シークレットは argv にも一時ファイルにも置かない。**必ず stdin 経由で流すこと
20
+ (argv は `ps` で他のプロセスから見える)。
21
+
22
+ 環境変数:
23
+ LH_PTY_ROWS / LH_PTY_COLS 端末の大きさ。既定 50x200。
24
+ clack は端末幅で枠を折り返すため、幅を十分に取って
25
+ プロンプト文言が折り返されないようにしている。
26
+ """
27
+
28
+ import errno
29
+ import fcntl
30
+ import os
31
+ import pty
32
+ import select
33
+ import signal
34
+ import struct
35
+ import sys
36
+ import termios
37
+
38
+
39
+ def _exit_code(status):
40
+ to_code = getattr(os, "waitstatus_to_exitcode", None)
41
+ if to_code is not None:
42
+ return to_code(status)
43
+ if os.WIFSIGNALED(status):
44
+ return 128 + os.WTERMSIG(status)
45
+ return os.WEXITSTATUS(status)
46
+
47
+
48
+ def main():
49
+ argv = sys.argv[1:]
50
+ if not argv:
51
+ sys.stderr.write("usage: pty-relay.py <command> [args...]\n")
52
+ return 2
53
+
54
+ rows = int(os.environ.get("LH_PTY_ROWS") or 50)
55
+ cols = int(os.environ.get("LH_PTY_COLS") or 200)
56
+
57
+ pid, master = pty.fork()
58
+ if pid == 0:
59
+ # 子: そのまま本家 CLI になる
60
+ try:
61
+ os.execvp(argv[0], argv)
62
+ except Exception as exc: # noqa: BLE001 - 子プロセスなのでここで終える
63
+ sys.stderr.write("pty-relay: exec failed: %s\n" % exc)
64
+ os._exit(127)
65
+
66
+ try:
67
+ fcntl.ioctl(master, termios.TIOCSWINSZ, struct.pack("HHHH", rows, cols, 0, 0))
68
+ except OSError:
69
+ pass
70
+
71
+ def forward(signum, _frame):
72
+ try:
73
+ os.kill(pid, signum)
74
+ except OSError:
75
+ pass
76
+
77
+ for sig in (signal.SIGTERM, signal.SIGINT, signal.SIGHUP):
78
+ try:
79
+ signal.signal(sig, forward)
80
+ except (ValueError, OSError):
81
+ pass
82
+
83
+ stdin_open = True
84
+ while True:
85
+ watch = [master] + ([0] if stdin_open else [])
86
+ try:
87
+ readable = select.select(watch, [], [])[0]
88
+ except OSError as exc:
89
+ if exc.errno == errno.EINTR:
90
+ continue
91
+ break
92
+ except InterruptedError:
93
+ continue
94
+
95
+ if master in readable:
96
+ try:
97
+ data = os.read(master, 65536)
98
+ except OSError:
99
+ data = b""
100
+ if not data:
101
+ break
102
+ os.write(1, data)
103
+
104
+ if stdin_open and 0 in readable:
105
+ try:
106
+ data = os.read(0, 65536)
107
+ except OSError:
108
+ data = b""
109
+ if not data:
110
+ stdin_open = False
111
+ else:
112
+ try:
113
+ os.write(master, data)
114
+ except OSError:
115
+ pass
116
+
117
+ try:
118
+ os.close(master)
119
+ except OSError:
120
+ pass
121
+
122
+ try:
123
+ status = os.waitpid(pid, 0)[1]
124
+ except OSError:
125
+ return 1
126
+ return _exit_code(status)
127
+
128
+
129
+ if __name__ == "__main__":
130
+ sys.exit(main())