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,181 @@
1
+ /**
2
+ * 各入力欄の「この値はどこで取るか」。
3
+ *
4
+ * 実測では、手作業13件のうち GUI で隠せるのは2件だけだった。残りは LINE と
5
+ * Cloudflare のブラウザ作業で、ターミナルとは関係ない。
6
+ * つまりこのツールの value は「ターミナルを隠すこと」より
7
+ * **「手順書と入力欄が同じ画面に並ぶこと」**にある。
8
+ *
9
+ * ## 資産を二重に持たない
10
+ *
11
+ * 画像と詳しい手順は**配布キット側にあるものをそのまま参照する**。
12
+ * ここに置くのは、画面から目を離さずに済む程度の短い手順と、該当箇所へのリンクだけ。
13
+ *
14
+ * 画像は https で読む。こちらの画面は http://localhost だが、
15
+ * 「http のページが https の画像を読む」向きは混在コンテンツにならない。
16
+ * 逆向き(GitHub Pages の画面から localhost を叩く)は成立しないので、やらない。
17
+ */
18
+
19
+ const KIT = "https://saniman.github.io/walover-line-harness-kit/docs/setup";
20
+ const IMAGES = `${KIT}/images`;
21
+
22
+ const LINE_MANAGER = "https://manager.line.biz/";
23
+ const LINE_CONSOLE = "https://developers.line.biz/console/";
24
+ /** 未ログインでも redirect_uri で行き先を保持する(実測済み) */
25
+ const CF_R2 = "https://dash.cloudflare.com/?to=/:account/r2/overview";
26
+
27
+ /**
28
+ * @typedef {object} Guide
29
+ * @property {string[]} [steps] 画面から目を離さずに追える程度の短い手順
30
+ * @property {string} [warn] 間違えやすいところ
31
+ * @property {{label: string, url: string}[]} [links] 作業する画面へのリンク
32
+ * @property {{url: string, alt: string}[]} [images] 配布キットにある実画面(複数可)
33
+ * @property {{url: string, label: string}} [manual] 手順書の該当箇所
34
+ */
35
+
36
+ /**
37
+ * プロンプト id をキーにする。フォームの欄も、実行中に人が答える場面も同じ扱いにできる。
38
+ * @type {Record<string, Guide>}
39
+ */
40
+ export const GUIDE = {
41
+ projectName: {
42
+ steps: ["好きな名前で構いません。Cloudflare 上の Worker と D1 の名前になります。"],
43
+ warn: "あとから変えられません。",
44
+ },
45
+
46
+ workersSubdomain: {
47
+ steps: [
48
+ "まだ決めていなければ空のままで構いません。本家が候補を出してくれます。",
49
+ "この名前は Worker の URL の一部になります。",
50
+ ],
51
+ warn: "全世界で早い者勝ちです。すでに使われていると、本家が別の名前を求めます。",
52
+ manual: { url: `${KIT}/cloudflare.html`, label: "1. Cloudflare の準備" },
53
+ },
54
+
55
+ // ── Messaging API チャネル ───────────────────────────────────────
56
+ lineChannelId: {
57
+ steps: [
58
+ "LINE Official Account Manager を開き、アカウントを選ぶ",
59
+ "右上の歯車アイコン(設定)→ サイドメニューの「Messaging API」",
60
+ "ステータスが「未利用」なら「Messaging APIを利用する」を押し、プロバイダーを選んで利用規約に同意する",
61
+ "表示された Channel ID を「コピー」ボタンで写す",
62
+ ],
63
+ warn: "「未利用」のままだと Channel ID はどこにも表示されません。まずボタンを押すのが最初の一歩です。",
64
+ links: [{ label: "LINE Official Account Manager を開く", url: LINE_MANAGER }],
65
+ images: [
66
+ {
67
+ url: `${IMAGES}/line-channel-id-secret.png`,
68
+ alt: "Messaging API の設定画面。Channel ID と Channel secret が並んで表示され、それぞれに「コピー」ボタンがある",
69
+ },
70
+ ],
71
+ manual: { url: `${KIT}/line.html#step1`, label: "Step 1:① Channel ID を取得する" },
72
+ },
73
+
74
+ lineChannelSecret: {
75
+ steps: ["Channel ID と同じ画面に Channel secret が並んでいます。「コピー」ボタンで写してください。"],
76
+ warn: "LINE 公式アカウントのパスワードに相当します。スクリーンショットに写さない・チャットに貼らないでください。",
77
+ links: [{ label: "LINE Official Account Manager を開く", url: LINE_MANAGER }],
78
+ images: [
79
+ {
80
+ url: `${IMAGES}/line-channel-id-secret.png`,
81
+ alt: "Messaging API の設定画面。Channel ID と Channel secret が並んで表示されている",
82
+ },
83
+ ],
84
+ manual: { url: `${KIT}/line.html#step2`, label: "Step 2:② チャネルシークレットを取得する" },
85
+ },
86
+
87
+ lineChannelAccessToken: {
88
+ steps: [
89
+ "LINE Developers Console を開く",
90
+ "Step 1 で決めたプロバイダー → Messaging API チャネル",
91
+ "「Messaging API設定」タブ → 一番下までスクロール",
92
+ "「チャネルアクセストークン(長期)」の「発行」を押す",
93
+ ],
94
+ warn: "こちらもパスワード相当です。短い文字列しか出ていない場合は発行し直してください。",
95
+ links: [{ label: "LINE Developers Console を開く", url: LINE_CONSOLE }],
96
+ images: [
97
+ {
98
+ url: `${IMAGES}/line-access-token.png`,
99
+ alt: "Messaging API設定タブの下部。「チャネルアクセストークン(長期)」の下に「発行」ボタンがある",
100
+ },
101
+ ],
102
+ manual: { url: `${KIT}/line.html#step3`, label: "Step 3:③ チャネルアクセストークンを発行する" },
103
+ },
104
+
105
+ // ── LINE Login チャネル ──────────────────────────────────────────
106
+ lineLoginChannelId: {
107
+ steps: [
108
+ "LINE Developers Console を開く",
109
+ "Step 1 と同じプロバイダーを選ぶ",
110
+ "「新規チャネル作成」→ 4種類のうち「LINEログイン」を選ぶ",
111
+ "「チャネル基本設定」タブの「基本情報」にある「チャネル ID」を、コピーボタンで写す",
112
+ ],
113
+ warn: "上の Channel ID とは別物です。同じ値にはなりません。Messaging API を選ばないよう注意してください。",
114
+ links: [{ label: "LINE Developers Console を開く", url: LINE_CONSOLE }],
115
+ images: [
116
+ {
117
+ url: `${IMAGES}/line-new-channel-type.png`,
118
+ alt: "新規チャネル作成のダイアログ。LINEログイン / Messaging API / ブロックチェーンサービス / LINEミニアプリ の4種類から選ぶ",
119
+ },
120
+ {
121
+ url: `${IMAGES}/line-login-channel-id.png`,
122
+ alt: "LINE ログインチャネルの「チャネル基本設定」タブ。「基本情報」の「チャネルID」に数字が表示され、右にコピーボタンがある",
123
+ },
124
+ ],
125
+ manual: { url: `${KIT}/line.html#step4`, label: "Step 4:④ LINE ログインチャネルを作る" },
126
+ },
127
+
128
+ liffId: {
129
+ steps: [
130
+ "作成した LINE ログインチャネル → 「LIFF」タブ → 「追加」",
131
+ "サイズ: Full / エンドポイント URL: https://example.com(仮の値。あとで変わります)",
132
+ "Scope: openid / profile / chat_message.write の3つすべてにチェック",
133
+ "友だち追加オプション: On (Aggressive)",
134
+ "作成した LIFF アプリを「公開済み」にする",
135
+ "一覧の「LIFF ID」列のコピーボタンで写す(隣の「LIFF URL」ではありません)",
136
+ ],
137
+ warn: "友だち追加オプションは既定が On (Normal) なので、On (Aggressive) に変更してください。ここは間違えてもエラーにならず、あとから原因を追いにくい場所です。LIFF タブに出る「LINEミニアプリを推奨」の案内は無視して「追加」で進めてください。",
138
+ links: [{ label: "LINE Developers Console を開く", url: LINE_CONSOLE }],
139
+ images: [
140
+ {
141
+ url: `${IMAGES}/line-liff-form.png`,
142
+ alt: "LIFFアプリを追加するフォーム。LIFFアプリ名、サイズ、エンドポイントURL、Scope、友だち追加オプションを入力する",
143
+ },
144
+ {
145
+ url: `${IMAGES}/line-liff-list.png`,
146
+ alt: "LINE ログインチャネルの LIFF タブ。作成した LIFF アプリが一覧に並び、LIFF ID と LIFF URL にそれぞれコピーボタンがある",
147
+ },
148
+ ],
149
+ manual: { url: `${KIT}/line.html#step5`, label: "Step 5:⑤ LIFF アプリを作る" },
150
+ },
151
+
152
+ addMcp: {
153
+ steps: ["Claude Code や Cursor から、作った環境を触れるようにする設定です。よく分からなければそのままで構いません。"],
154
+ },
155
+
156
+ // ── 実行中に人が答える場面 ───────────────────────────────────────
157
+ r2Enabled: {
158
+ steps: [
159
+ "Cloudflare ダッシュボードの R2 を開く",
160
+ "支払い情報(クレジットカード)を登録する",
161
+ "R2 の利用を開始する",
162
+ ],
163
+ warn: "「カード登録済み」と「R2 有効化済み」は別です。カードを登録しただけで進むと、あとの手順で必ず失敗します。",
164
+ links: [{ label: "Cloudflare の R2 を開く", url: CF_R2 }],
165
+ manual: { url: `${KIT}/cloudflare.html`, label: "1. Cloudflare の準備" },
166
+ },
167
+
168
+ subdomainManualCheck: {
169
+ steps: [
170
+ "本家が表示した URL をブラウザで開く",
171
+ "「サブドメインの登録」で好きな名前を登録する",
172
+ "登録が終わったら「確認する」を選ぶ",
173
+ ],
174
+ manual: { url: `${KIT}/cloudflare.html`, label: "1. Cloudflare の準備" },
175
+ },
176
+ };
177
+
178
+ /** @param {string} promptId */
179
+ export function guideFor(promptId) {
180
+ return GUIDE[promptId] ?? null;
181
+ }
@@ -0,0 +1,334 @@
1
+ import { createServer } from "node:http";
2
+ import { fileURLToPath } from "node:url";
3
+ import { dirname, join } from "node:path";
4
+
5
+ import { serveStatic } from "./static.js";
6
+ import {
7
+ publicSnapshot,
8
+ clear,
9
+ setAnswers,
10
+ startRun,
11
+ currentRun,
12
+ formSchema,
13
+ guideMap,
14
+ } from "./state.js";
15
+ import { ptyBackend } from "./pty.js";
16
+ import { getVersionStatus } from "./cli-version.js";
17
+ import { checkLeftoverState, leftoverWarning } from "./leftover.js";
18
+
19
+ const HERE = dirname(fileURLToPath(import.meta.url));
20
+ const WEB_ROOT = join(HERE, "..", "web");
21
+
22
+ /**
23
+ * 外部から到達させない。127.0.0.1 に固定して bind する。
24
+ * このサーバーは参加者のシークレットを扱うため、0.0.0.0 で待ち受けてはならない。
25
+ */
26
+ export const HOST = "127.0.0.1";
27
+ const DEFAULT_PORT = 4321;
28
+ const MAX_PORT_TRIES = 20;
29
+ const MAX_BODY_BYTES = 64 * 1024;
30
+
31
+ /**
32
+ * 別のサイトのページから、このサーバーを勝手に操作されないようにする。
33
+ *
34
+ * 127.0.0.1 に bind していても、ブラウザで開いた第三者のページは
35
+ * `http://127.0.0.1:4321/api/...` に投げられる。シークレットを渡す口を
36
+ * 作った以上、Host と Origin は見ておく。
37
+ */
38
+ function isLocalRequest(req, port) {
39
+ const allowed = new Set([`127.0.0.1:${port}`, `localhost:${port}`, `[::1]:${port}`]);
40
+ if (!allowed.has(req.headers.host ?? "")) return false;
41
+
42
+ const origin = req.headers.origin;
43
+ if (origin === undefined) return true; // fetch 以外(画面の直接読み込みなど)
44
+ return [...allowed].some((host) => origin === `http://${host}`);
45
+ }
46
+
47
+ function json(res, status, body) {
48
+ const payload = JSON.stringify(body);
49
+ res.writeHead(status, {
50
+ "Content-Type": "application/json; charset=utf-8",
51
+ "Content-Length": Buffer.byteLength(payload),
52
+ "Cache-Control": "no-store",
53
+ });
54
+ res.end(payload);
55
+ }
56
+
57
+ /**
58
+ * 本文の読み取り。
59
+ *
60
+ * `JSON.parse` の例外メッセージには**入力の一部がそのまま入る**
61
+ * (例: `Unexpected token 'x', "x{"lineCha"... is not valid JSON`)。
62
+ * 本文にはチャネルシークレットが入るので、その例外を素通しすると
63
+ * 壊れたリクエスト1本でシークレットがログに出る。
64
+ * ここで捕まえて、中身を持たない例外に差し替える。
65
+ */
66
+ class BadRequestError extends Error {
67
+ constructor(message) {
68
+ super(message);
69
+ this.name = "BadRequestError";
70
+ }
71
+ }
72
+
73
+ async function readJson(req) {
74
+ const chunks = [];
75
+ let size = 0;
76
+ for await (const chunk of req) {
77
+ size += chunk.length;
78
+ if (size > MAX_BODY_BYTES) throw new BadRequestError("本文が大きすぎます");
79
+ chunks.push(chunk);
80
+ }
81
+ if (size === 0) return {};
82
+
83
+ try {
84
+ return JSON.parse(Buffer.concat(chunks).toString("utf8"));
85
+ } catch {
86
+ // 例外の中身は捨てる。入力を持ち回らない
87
+ throw new BadRequestError("本文が JSON として読めません");
88
+ }
89
+ }
90
+
91
+ async function handleApi(req, res, port) {
92
+ const { pathname } = new URL(req.url, "http://localhost");
93
+ if (!pathname.startsWith("/api/")) return false;
94
+
95
+ if (!isLocalRequest(req, port)) {
96
+ json(res, 403, { error: "このサーバーは手元からのみ操作できます" });
97
+ return true;
98
+ }
99
+
100
+ const method = req.method ?? "GET";
101
+
102
+ try {
103
+ return await route(req, res, pathname, method);
104
+ } catch (err) {
105
+ if (err instanceof BadRequestError) {
106
+ json(res, 400, { error: err.message });
107
+ return true;
108
+ }
109
+ throw err;
110
+ }
111
+ }
112
+
113
+ async function route(req, res, pathname, method) {
114
+
115
+ if (pathname === "/api/health" && method === "GET") {
116
+ const backend = ptyBackend();
117
+ json(res, 200, { ok: true, pty: backend !== null, ptyBackend: backend });
118
+ return true;
119
+ }
120
+
121
+ // 値は返さない。入力済みかどうかだけ
122
+ if (pathname === "/api/state" && method === "GET") {
123
+ json(res, 200, publicSnapshot());
124
+ return true;
125
+ }
126
+
127
+ // 本家 CLI の版のズレ。ネットワークが無くても落とさない
128
+ if (pathname === "/api/version" && method === "GET") {
129
+ json(res, 200, await getVersionStatus());
130
+ return true;
131
+ }
132
+
133
+ // 失敗・中断で残る本家のステートファイル。中身は読まず、あるかどうかだけ見る
134
+ if (pathname === "/api/leftover" && method === "GET") {
135
+ const leftover = await checkLeftoverState(currentRun()?.cwd);
136
+ json(res, 200, { ...leftover, warning: leftoverWarning(leftover) });
137
+ return true;
138
+ }
139
+
140
+ // 実行中の場面でも「どこで取るか」を引けるようにする
141
+ if (pathname === "/api/guide" && method === "GET") {
142
+ json(res, 200, guideMap());
143
+ return true;
144
+ }
145
+
146
+ // #2 のフォームが参照する入力欄の定義
147
+ if (pathname === "/api/contract" && method === "GET") {
148
+ json(res, 200, formSchema());
149
+ return true;
150
+ }
151
+
152
+ if (pathname === "/api/answers" && method === "POST") {
153
+ const body = await readJson(req);
154
+ const { accepted, rejected } = setAnswers(body ?? {});
155
+ // 受け取った値は返さない。受け付けた欄の名前と、弾いた理由だけ
156
+ json(res, 200, { accepted, rejected, state: publicSnapshot() });
157
+ return true;
158
+ }
159
+
160
+ if (pathname === "/api/run/start" && method === "POST") {
161
+ const body = await readJson(req);
162
+ try {
163
+ startRun(body?.cliSpec ? { cliSpec: String(body.cliSpec) } : undefined);
164
+ json(res, 200, publicSnapshot());
165
+ } catch (err) {
166
+ json(res, 409, { error: err instanceof Error ? err.message : String(err) });
167
+ }
168
+ return true;
169
+ }
170
+
171
+ // 進捗を流す。伏字を通した画面だけを送る
172
+ if (pathname === "/api/run/stream" && method === "GET") {
173
+ const run = currentRun();
174
+ if (!run) {
175
+ json(res, 409, { error: "セットアップが動いていません" });
176
+ return true;
177
+ }
178
+ streamRun(req, res, run);
179
+ return true;
180
+ }
181
+
182
+ // 仕上げの手作業に使う値。**API Key を含むので、口をここ1つに絞っている**
183
+ if (pathname === "/api/run/completion" && method === "GET") {
184
+ const run = currentRun();
185
+ json(res, 200, run?.completionForDisplay() ?? null);
186
+ return true;
187
+ }
188
+
189
+ if (pathname === "/api/run/screen" && method === "GET") {
190
+ const run = currentRun();
191
+ // 伏字を通した画面だけを返す
192
+ json(res, 200, { lines: run ? run.screen() : [], state: run?.state() ?? null });
193
+ return true;
194
+ }
195
+
196
+ if (pathname === "/api/run/answer" && method === "POST") {
197
+ const run = currentRun();
198
+ if (!run) {
199
+ json(res, 409, { error: "セットアップが動いていません" });
200
+ return true;
201
+ }
202
+ const body = await readJson(req);
203
+ try {
204
+ if (body?.manual === true) {
205
+ // 止まっているときに、画面を見た人が自分で打つ逃げ道
206
+ run.answerManually(String(body.text ?? ""));
207
+ } else {
208
+ run.answerHuman({
209
+ promptId: String(body?.promptId ?? ""),
210
+ text: body?.text === undefined ? undefined : String(body.text),
211
+ optionIndex: typeof body?.optionIndex === "number" ? body.optionIndex : undefined,
212
+ confirm: typeof body?.confirm === "boolean" ? body.confirm : undefined,
213
+ });
214
+ }
215
+ json(res, 200, run.state());
216
+ } catch (err) {
217
+ json(res, 409, { error: err instanceof Error ? err.message : String(err) });
218
+ }
219
+ return true;
220
+ }
221
+
222
+ if (pathname === "/api/run/stop" && method === "POST") {
223
+ currentRun()?.stop();
224
+ json(res, 200, publicSnapshot());
225
+ return true;
226
+ }
227
+
228
+ json(res, 404, { error: "Not Found" });
229
+ return true;
230
+ }
231
+
232
+ /** 画面に出す行数の上限。全部送ると、長い実行で無駄が大きい */
233
+ const STREAM_LINES = 400;
234
+
235
+ /** 一定時間なにも送らないと切れる経路があるので、コメント行で生存を伝える */
236
+ const HEARTBEAT_MS = 20000;
237
+
238
+ /**
239
+ * Server-Sent Events で実行中の画面を流す。
240
+ *
241
+ * **送るのは {@link SetupRun#screen} が返す伏字済みの行だけ。**
242
+ * 生のバイト列を流すと、本家がエコーした値がそのままブラウザに出る。
243
+ * 端末の再描画(`\r` によるスピナー)はサーバー側で解決済みなので、
244
+ * 受け取る側は行を並べるだけでよい。
245
+ */
246
+ export function streamRun(req, res, run) {
247
+ res.writeHead(200, {
248
+ "Content-Type": "text/event-stream; charset=utf-8",
249
+ "Cache-Control": "no-store",
250
+ Connection: "keep-alive",
251
+ "X-Accel-Buffering": "no",
252
+ });
253
+
254
+ const send = (event, payload) => {
255
+ if (res.writableEnded) return;
256
+ res.write(`event: ${event}\ndata: ${JSON.stringify(payload)}\n\n`);
257
+ };
258
+
259
+ const push = () => send("update", { lines: run.screen(STREAM_LINES), state: run.state() });
260
+
261
+ push();
262
+ run.on("update", push);
263
+
264
+ const beat = setInterval(() => {
265
+ if (!res.writableEnded) res.write(": ping\n\n");
266
+ }, HEARTBEAT_MS);
267
+
268
+ const finish = () => {
269
+ push();
270
+ cleanup();
271
+ res.end();
272
+ };
273
+ const cleanup = () => {
274
+ clearInterval(beat);
275
+ run.off("update", push);
276
+ run.off("exit", finish);
277
+ };
278
+
279
+ run.on("exit", finish);
280
+ req.on("close", cleanup);
281
+ }
282
+
283
+ function createApp(getPort) {
284
+ return createServer(async (req, res) => {
285
+ try {
286
+ if (await handleApi(req, res, getPort())) return;
287
+ if (await serveStatic(req, res, WEB_ROOT)) return;
288
+ res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" }).end("Not Found");
289
+ } catch (err) {
290
+ // 例外メッセージには本文の断片が混ざりうる(=シークレット)。
291
+ // 種類だけ出して、中身はログにも画面にも出さない
292
+ console.error("[error]", err instanceof Error ? err.name : "unknown", req.method, req.url);
293
+ if (!res.headersSent) res.writeHead(500);
294
+ res.end("Internal Server Error");
295
+ }
296
+ });
297
+ }
298
+
299
+ /**
300
+ * 空いているポートを探して listen する。
301
+ * @param {number} [startPort]
302
+ * @returns {Promise<{ server: import("node:http").Server, port: number, url: string }>}
303
+ */
304
+ export function start(startPort = DEFAULT_PORT) {
305
+ let port = startPort;
306
+ const server = createApp(() => port);
307
+
308
+ return new Promise((resolve, reject) => {
309
+ let tries = 0;
310
+
311
+ const attempt = () => {
312
+ server.once("error", (err) => {
313
+ if (err.code === "EADDRINUSE" && ++tries < MAX_PORT_TRIES) {
314
+ port += 1;
315
+ attempt();
316
+ return;
317
+ }
318
+ reject(err);
319
+ });
320
+
321
+ server.listen(port, HOST, () => {
322
+ resolve({ server, port, url: `http://${HOST}:${port}` });
323
+ });
324
+ };
325
+
326
+ attempt();
327
+ });
328
+ }
329
+
330
+ /** 終了時にメモリ上のシークレットを破棄する */
331
+ export function shutdown(server) {
332
+ clear();
333
+ return new Promise((resolve) => server.close(resolve));
334
+ }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * 失敗・中断時に残る本家のステートファイルを見つけて警告する。
3
+ *
4
+ * 本家 CLI は進捗を `<repoDir>/.line-harness-setup.json` に書く。
5
+ *
6
+ * - **成功時は削除される**
7
+ * - **失敗・Ctrl-C 時は残る**
8
+ * - 中身は `SetupState` を丸ごと JSON 化したもので、
9
+ * `lineChannelSecret` / `lineChannelAccessToken` / `apiKey` が**平文で入る**
10
+ * - パーミッションは既定の 0644
11
+ *
12
+ * そして失敗した場面は、参加者がサポートにファイルやスクショを送りたくなる場面と
13
+ * ちょうど重なる。本家は改変しない方針なので、こちらで気づかせる。
14
+ *
15
+ * ## ここでやらないこと
16
+ *
17
+ * - **中身を読まない。** シークレットが入っているので `stat` だけで判断する
18
+ * - **消さない。** 再開に必要な進捗(completedSteps)も同じファイルに入っている
19
+ */
20
+
21
+ import { stat } from "node:fs/promises";
22
+ import { homedir } from "node:os";
23
+ import { join } from "node:path";
24
+
25
+ /** 本家が書くファイル名 */
26
+ export const STATE_FILE_NAME = ".line-harness-setup.json";
27
+
28
+ /**
29
+ * 本家が repoDir を決める順番(0.2.11 のソースより)。
30
+ *
31
+ * 1. `--repo-dir <path>`
32
+ * 2. カレントに pnpm-workspace.yaml があればカレント
33
+ * 3. `$HOME/.line-harness`
34
+ *
35
+ * このツールは 1 も 2 も使わないので 3 になるが、
36
+ * 参加者が自分でターミナルから実行した場合に備えて候補を広めに見る。
37
+ *
38
+ * @param {string} [cwd] 実行時のカレント
39
+ * @returns {string[]}
40
+ */
41
+ export function stateFilePaths(cwd) {
42
+ const home = process.env.HOME || process.env.USERPROFILE || homedir();
43
+ const candidates = [join(home, ".line-harness")];
44
+ if (cwd && cwd !== home) candidates.push(cwd);
45
+ return candidates.map((dir) => join(dir, STATE_FILE_NAME));
46
+ }
47
+
48
+ /**
49
+ * @typedef {object} Leftover
50
+ * @property {boolean} exists
51
+ * @property {string|null} path 見つかった場所
52
+ * @property {string|null} mode パーミッション(8進3桁)
53
+ * @property {boolean} readableByOthers 自分以外にも読めるか
54
+ * @property {string|null} modifiedAt
55
+ */
56
+
57
+ /**
58
+ * 残っていないかを見る。**中身は読まない。**
59
+ * @param {string} [cwd]
60
+ * @returns {Promise<Leftover>}
61
+ */
62
+ export async function checkLeftoverState(cwd) {
63
+ for (const path of stateFilePaths(cwd)) {
64
+ try {
65
+ const info = await stat(path);
66
+ if (!info.isFile()) continue;
67
+
68
+ const mode = (info.mode & 0o777).toString(8).padStart(3, "0");
69
+ return {
70
+ exists: true,
71
+ path,
72
+ mode,
73
+ // group / other に読み取りビットが立っているか
74
+ readableByOthers: (info.mode & 0o044) !== 0,
75
+ modifiedAt: info.mtime.toISOString(),
76
+ };
77
+ } catch {
78
+ // 無ければ次の候補へ
79
+ }
80
+ }
81
+
82
+ return { exists: false, path: null, mode: null, readableByOthers: false, modifiedAt: null };
83
+ }
84
+
85
+ /**
86
+ * 参加者に出す文言。**このファイルを送らせないことが最優先。**
87
+ * @param {Leftover} leftover
88
+ */
89
+ export function leftoverWarning(leftover) {
90
+ if (!leftover.exists) return null;
91
+
92
+ return {
93
+ title: "途中経過のファイルが残っています",
94
+ lines: [
95
+ "セットアップが最後まで進まなかったため、本家のツールが途中経過を保存しました。",
96
+ "",
97
+ ` ${leftover.path}`,
98
+ "",
99
+ "このファイルには**チャネルシークレットとアクセストークンが、そのまま読める形で**入っています。",
100
+ "**このファイルも、開いた画面も、誰にも送らないでください。**(サポートへの問い合わせでも同じです)",
101
+ "",
102
+ "もう一度同じ手順でセットアップを実行し、最後まで完了すれば、このファイルは自動で消えます。",
103
+ "途中から再開されるので、入力し直す必要はありません(選択を求められたら「続行」を選んでください)。",
104
+ ],
105
+ // 消すのはこちらの判断でやらない。再開に必要な進捗も同じファイルに入っている
106
+ canDelete: false,
107
+ tighten: leftover.readableByOthers
108
+ ? `いますぐ他の人から読めないようにするなら、ターミナルで: chmod 600 ${leftover.path}`
109
+ : null,
110
+ };
111
+ }