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,343 @@
1
+ /**
2
+ * 擬似端末(PTY)で子プロセスを起動する。
3
+ *
4
+ * 本家 CLI は実 TTY を要求するのでパイプでは起動できない。Node に PTY を作る
5
+ * 標準 API は無いため、外から用意する。
6
+ *
7
+ * ## 二段構え
8
+ *
9
+ * | | |
10
+ * |---|---|
11
+ * | 主 | `node-pty`。**Windows(ConPTY)と macOS / Linux の両方**を1つの経路で賄える |
12
+ * | 予備 | 同梱の {@link ./pty-relay.py}。Python 標準ライブラリの `pty` を使う。**Unix 専用** |
13
+ *
14
+ * ## npm が install script を止めても node-pty は使える
15
+ *
16
+ * npm 11 以降、依存パッケージの install script は既定でブロックされる。
17
+ * node-pty はそこでネイティブバイナリを用意するので、
18
+ * 素朴に見ると `npx` 配布では壊れる。実際 `posix_spawnp failed.` で落ちた。
19
+ *
20
+ * だが原因はバイナリの不在ではなかった。
21
+ *
22
+ * - node-pty の読み込みは `prebuilds/<platform>-<arch>/` への
23
+ * フォールバックを**元から持っている**(`lib/utils.js`)
24
+ * - prebuild は tarball に同梱されている(darwin / win32 の x64・arm64)
25
+ * - macOS / Linux だけ、`spawn-helper` に**実行権限が付かない**のが原因だった
26
+ * (post-install が chmod する役目を担っていて、それが走らないため)
27
+ *
28
+ * → 起動前にこちらで実行権限を付ければ動く。Windows は ConPTY を使い
29
+ * `spawn-helper` を必要としないので、そもそもこの問題が起きない。
30
+ */
31
+
32
+ import { spawn, spawnSync } from "node:child_process";
33
+ import { createRequire } from "node:module";
34
+ import { StringDecoder } from "node:string_decoder";
35
+ import { chmodSync, statSync } from "node:fs";
36
+ import { delimiter, dirname, join } from "node:path";
37
+ import { fileURLToPath } from "node:url";
38
+
39
+ const HERE = dirname(fileURLToPath(import.meta.url));
40
+ const RELAY = join(HERE, "pty-relay.py");
41
+ const require = createRequire(import.meta.url);
42
+
43
+ /** clack が枠を折り返さないよう十分に広く取る(プロンプト文言の照合が壊れるため) */
44
+ export const PTY_COLS = 200;
45
+ export const PTY_ROWS = 50;
46
+
47
+ const IS_WINDOWS = process.platform === "win32";
48
+
49
+ // ── node-pty ────────────────────────────────────────────────────────
50
+
51
+ /** @type {{ module: unknown }|null} 読み込み結果。失敗も含めて一度だけ判定する */
52
+ let nodePty;
53
+
54
+ /**
55
+ * `spawn-helper` に実行権限を付ける。
56
+ *
57
+ * npm が post-install を止めると、prebuild の spawn-helper が 0644 のままになり、
58
+ * 起動時に `posix_spawnp failed.` になる。ここで補う。
59
+ */
60
+ function fixSpawnHelper() {
61
+ if (IS_WINDOWS) return; // ConPTY は spawn-helper を使わない
62
+
63
+ try {
64
+ const root = dirname(dirname(require.resolve("node-pty")));
65
+ const helper = join(root, "prebuilds", `${process.platform}-${process.arch}`, "spawn-helper");
66
+ const mode = statSync(helper).mode;
67
+ if ((mode & 0o111) === 0) chmodSync(helper, 0o755);
68
+ } catch {
69
+ // prebuild を使っていない(自前ビルド済み)場合などは、そのままで問題ない
70
+ }
71
+ }
72
+
73
+ /**
74
+ * `LH_PTY_BACKEND` で方式を固定できる。
75
+ *
76
+ * 参加者の環境で片方だけが動かないときに、切り分けるための逃げ道。
77
+ * `node-pty` / `python` / `none` を受け付ける。
78
+ */
79
+ const forcedBackend = () => process.env.LH_PTY_BACKEND;
80
+
81
+ /** @returns {any|null} */
82
+ export function loadNodePty() {
83
+ const forced = forcedBackend();
84
+ if (forced && forced !== "node-pty") return null;
85
+ if (nodePty !== undefined) return nodePty.module;
86
+
87
+ try {
88
+ fixSpawnHelper();
89
+ nodePty = { module: require("node-pty") };
90
+ } catch {
91
+ nodePty = { module: null };
92
+ }
93
+ return nodePty.module;
94
+ }
95
+
96
+ // ── Python リレー(Unix のみ)────────────────────────────────────────
97
+
98
+ const PYTHON_CANDIDATES = [
99
+ "python3",
100
+ "/usr/bin/python3",
101
+ "/opt/homebrew/bin/python3",
102
+ "/usr/local/bin/python3",
103
+ ];
104
+
105
+ /**
106
+ * PTY を作れる python3 を探す。**Windows では使わない。**
107
+ *
108
+ * `LH_PTY_PYTHON` が指定されているときは**それだけを見る**。
109
+ * 指定したものが動かないのに黙って別のものに落ちると、
110
+ * どれで動いているのか分からなくなる。
111
+ *
112
+ * @returns {string|null}
113
+ */
114
+ export function findPython() {
115
+ if (IS_WINDOWS) return null;
116
+ const forced = forcedBackend();
117
+ if (forced && forced !== "python") return null;
118
+
119
+ const candidates = process.env.LH_PTY_PYTHON
120
+ ? [process.env.LH_PTY_PYTHON]
121
+ : PYTHON_CANDIDATES;
122
+
123
+ for (const candidate of candidates) {
124
+ const probe = spawnSync(candidate, ["-c", "import pty, termios, fcntl, select"], {
125
+ stdio: "ignore",
126
+ });
127
+ if (probe.status === 0) return candidate;
128
+ }
129
+ return null;
130
+ }
131
+
132
+ /**
133
+ * いま使える方式。画面の起動前チェックに出す。
134
+ * @returns {"node-pty"|"python"|null}
135
+ */
136
+ export function ptyBackend() {
137
+ if (loadNodePty()) return "node-pty";
138
+ if (findPython()) return "python";
139
+ return null;
140
+ }
141
+
142
+ export class PtyUnavailableError extends Error {
143
+ constructor() {
144
+ const help = IS_WINDOWS
145
+ ? ["Windows では node-pty が必要です。", "いったん終了して、もう一度お試しください。"]
146
+ : [
147
+ "macOS では Xcode Command Line Tools を入れると python3 が使えるようになります:",
148
+ " xcode-select --install",
149
+ ];
150
+
151
+ super(
152
+ [
153
+ "擬似端末(PTY)を用意できませんでした。",
154
+ "",
155
+ "本家 CLI は実際の端末を要求するため、PTY 無しでは起動できません。",
156
+ ...help,
157
+ ].join("\n"),
158
+ );
159
+ this.name = "PtyUnavailableError";
160
+ }
161
+ }
162
+
163
+ // ── 起動 ────────────────────────────────────────────────────────────
164
+
165
+ /**
166
+ * 子に渡す環境。
167
+ * @param {Record<string,string>} extra
168
+ */
169
+ function childEnv(extra) {
170
+ return {
171
+ ...process.env,
172
+ // 本家 CLI を、このサーバーを動かしているのと同じ Node で動かす。
173
+ // Apple Silicon で x86_64 の Node に落ちると依存インストールが SIGILL で死ぬため、
174
+ // 「起動に使った Node」をそのまま引き継ぐのが確実。
175
+ // 区切り文字は OS で違う(Windows は ";")ので path.delimiter を使う
176
+ PATH: `${dirname(process.execPath)}${delimiter}${process.env.PATH ?? ""}`,
177
+ // clack の記号(◆ │ └ ● ○)を確定させる。
178
+ // Windows の clack は TERM=xterm-256color を unicode 対応と見なすので、
179
+ // ここを渡さないと ASCII 代替記号になり、プロンプトの照合が崩れる
180
+ TERM: "xterm-256color",
181
+ LH_PTY_COLS: String(PTY_COLS),
182
+ LH_PTY_ROWS: String(PTY_ROWS),
183
+ ...extra,
184
+ };
185
+ }
186
+
187
+ /**
188
+ * Windows での起動の仕方。
189
+ *
190
+ * `npx` / `npm` の実体は `npx.cmd` というバッチファイルで、
191
+ * **`CreateProcess` はバッチファイルを直接実行できない**
192
+ * (node-pty の ConPTY 経路は commandLine を組んで CreateProcess に渡す)。
193
+ * `cmd.exe /c` を噛ませる必要がある。
194
+ *
195
+ * @param {string} command
196
+ * @param {string[]} args
197
+ * @returns {{ command: string, args: string[] }}
198
+ */
199
+ function resolveLaunch(command, args) {
200
+ if (!IS_WINDOWS) return { command, args };
201
+ if (command !== "npx" && command !== "npm") return { command, args };
202
+
203
+ // /d = AutoRun をスキップ、/s = 引用符の扱いを素直にする
204
+ const shell = process.env.ComSpec || "cmd.exe";
205
+ return { command: shell, args: ["/d", "/s", "/c", command, ...args] };
206
+ }
207
+
208
+ /**
209
+ * @typedef {object} PtyHandle
210
+ * @property {(data: string) => void} write 子の stdin(PTY)へ書く
211
+ * @property {(signal?: NodeJS.Signals) => void} kill
212
+ * @property {number|null} pid
213
+ * @property {"node-pty"|"python"} backend
214
+ */
215
+
216
+ /**
217
+ * @param {object} options
218
+ * @param {string} options.command
219
+ * @param {string[]} options.args
220
+ * @param {string} [options.cwd]
221
+ * @param {Record<string,string>} [options.env] 親の環境に足す分
222
+ * @param {(chunk: string) => void} options.onData
223
+ * @param {(info: { code: number|null, signal: NodeJS.Signals|null }) => void} options.onExit
224
+ * @param {(err: Error) => void} [options.onError]
225
+ * @returns {PtyHandle}
226
+ */
227
+ export function spawnPty(options) {
228
+ const pty = loadNodePty();
229
+ if (pty) return spawnWithNodePty(pty, options);
230
+
231
+ const python = findPython();
232
+ if (python) return spawnWithRelay(python, options);
233
+
234
+ throw new PtyUnavailableError();
235
+ }
236
+
237
+ function spawnWithNodePty(pty, { command, args, cwd, env = {}, onData, onExit, onError }) {
238
+ const launch = resolveLaunch(command, args);
239
+ let child;
240
+ try {
241
+ child = pty.spawn(launch.command, launch.args, {
242
+ name: "xterm-256color",
243
+ cols: PTY_COLS,
244
+ rows: PTY_ROWS,
245
+ cwd,
246
+ env: childEnv(env),
247
+ });
248
+ } catch (err) {
249
+ onError?.(err instanceof Error ? err : new Error(String(err)));
250
+ throw err;
251
+ }
252
+
253
+ // 終了済みの相手に write / kill を送らない。
254
+ // Windows(ConPTY)では、終わったあとに kill を投げるとヒープ破損で
255
+ // プロセスごと落ちることがある(CI で exitCode 3221226356 を確認)。
256
+ // 例外を握るだけでは防げないので、送る前に止める。
257
+ let exited = false;
258
+
259
+ child.onData((chunk) => onData(chunk));
260
+ child.onExit(({ exitCode, signal }) => {
261
+ exited = true;
262
+ onExit({ code: exitCode ?? null, signal: signal ? String(signal) : null });
263
+ });
264
+
265
+ return {
266
+ backend: "node-pty",
267
+ pid: child.pid ?? null,
268
+ write(data) {
269
+ if (exited) return;
270
+ try {
271
+ child.write(data);
272
+ } catch {
273
+ /* 競合して終わった */
274
+ }
275
+ },
276
+ kill(signal = "SIGTERM") {
277
+ if (exited) return;
278
+ exited = true;
279
+ try {
280
+ child.kill(IS_WINDOWS ? undefined : signal);
281
+ } catch {
282
+ /* 競合して終わった */
283
+ }
284
+ },
285
+ };
286
+ }
287
+
288
+ function spawnWithRelay(python, { command, args, cwd, env = {}, onData, onExit, onError }) {
289
+ // リレーは Unix 専用なので、ここで Windows の分岐は要らない
290
+ const child = spawn(python, [RELAY, command, ...args], {
291
+ cwd,
292
+ stdio: ["pipe", "pipe", "pipe"],
293
+ env: childEnv(env),
294
+ });
295
+
296
+ // PTY からは UTF-8 が chunk 境界で割れて届く。文字単位に組み直してから渡す
297
+ const decoder = new StringDecoder("utf8");
298
+ child.stdout.on("data", (buf) => {
299
+ const text = decoder.write(buf);
300
+ if (text) onData(text);
301
+ });
302
+
303
+ // リレー自体の異常だけがここに来る(子の出力は PTY 側に出る)
304
+ child.stderr.on("data", (buf) => {
305
+ const text = buf.toString("utf8").trim();
306
+ if (text) onError?.(new Error(`pty-relay: ${text}`));
307
+ });
308
+
309
+ child.on("error", (err) => onError?.(err));
310
+
311
+ // "exit" は stdout にまだ中身が残っていても先に来る。
312
+ // ここで後始末をすると本家の最後の数行を取りこぼすので、
313
+ // 終了コードだけ控えて、実際の通知は "close" まで待つ。
314
+ /** @type {{ code: number|null, signal: NodeJS.Signals|null }} */
315
+ let status = { code: null, signal: null };
316
+ child.on("exit", (code, signal) => {
317
+ status = { code, signal };
318
+ });
319
+ let exited = false;
320
+ child.on("close", () => {
321
+ exited = true;
322
+ const rest = decoder.end();
323
+ if (rest) onData(rest);
324
+ onExit(status);
325
+ });
326
+
327
+ return {
328
+ backend: "python",
329
+ pid: child.pid ?? null,
330
+ write(data) {
331
+ if (exited) return;
332
+ if (child.stdin.writable) child.stdin.write(data, "utf8");
333
+ },
334
+ kill(signal = "SIGTERM") {
335
+ if (exited) return;
336
+ try {
337
+ child.kill(signal);
338
+ } catch {
339
+ /* 競合して終わった */
340
+ }
341
+ },
342
+ };
343
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * 伏字化。
3
+ *
4
+ * 本家 CLI は `p.password` を使っておらず、チャネルシークレットもアクセストークンも
5
+ * `p.text` で受け取る。つまり**入力した値がそのまま端末にエコーされる**(実測済み)。
6
+ * 子プロセスの出力をそのまま画面に流すと、シークレットがブラウザに出る。
7
+ *
8
+ * ここを通していない文字列を、画面・API 応答・ログに出さないこと。
9
+ */
10
+
11
+ export const MASK = "••••••••";
12
+
13
+ /**
14
+ * 既知のシークレット値を伏字に置き換える。
15
+ *
16
+ * 長いものから順に置換する(短い値が長い値の一部だった場合に取りこぼさないため)。
17
+ *
18
+ * @param {string} text
19
+ * @param {Iterable<string>} secrets
20
+ */
21
+ export function redactText(text, secrets) {
22
+ let out = text;
23
+ const values = [...secrets]
24
+ .filter((v) => typeof v === "string" && v.length >= 4)
25
+ .sort((a, b) => b.length - a.length);
26
+
27
+ for (const value of values) {
28
+ out = out.split(value).join(MASK);
29
+ }
30
+ return out;
31
+ }
32
+
33
+ /**
34
+ * @param {string[]} lines
35
+ * @param {Iterable<string>} secrets
36
+ */
37
+ export function redactLines(lines, secrets) {
38
+ const values = [...secrets];
39
+ return lines.map((line) => redactText(line, values));
40
+ }
41
+
42
+ /**
43
+ * 入力途中の行を丸ごと伏せる。
44
+ *
45
+ * エコーは1文字ずつ届くため、確定前の行にはシークレットの**先頭数文字**が出る。
46
+ * 完全一致の置換では拾えないので、シークレットを入力中の値行は中身を見ずに潰す。
47
+ *
48
+ * @param {string} line `│ ` を含む値行
49
+ */
50
+ export function maskValueLine(line) {
51
+ const m = /^(\s*[\u2502|][ \t]*)(\S.*)$/u.exec(line);
52
+ if (!m) return line;
53
+ return `${m[1]}${MASK}`;
54
+ }