coderail-watch 0.1.7 → 0.1.8

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/README.md CHANGED
@@ -36,6 +36,23 @@ npm version patch
36
36
  npm run publish:public
37
37
  ```
38
38
 
39
+ If publish fails with "Access token expired or revoked":
40
+
41
+ ```bash
42
+ cd tools/coderail-watch
43
+ npm logout
44
+ npm login
45
+ npm run publish:public
46
+ ```
47
+
48
+ If you use an npm access token (CI or 2FA), set it explicitly:
49
+
50
+ ```bash
51
+ cd tools/coderail-watch
52
+ npm config set //registry.npmjs.org/:_authToken <YOUR_NPM_TOKEN>
53
+ npm run publish:public
54
+ ```
55
+
39
56
  2FA note:
40
57
 
41
58
  - npm no longer allows adding new TOTP 2FA from the CLI. Enable 2FA with a security key at https://npmjs.com/settings/<your-username>/tfa before publishing.
@@ -2,17 +2,39 @@
2
2
  "use strict";
3
3
 
4
4
  const fs = require("fs");
5
+ const path = require("path");
5
6
  const readline = require("readline");
6
7
 
7
- const DEFAULT_BASE_URL = "http://localhost:8000";
8
+ const DEFAULT_BASE_URL = "https://api.dev-coderail.local";
8
9
  const ENV_BASE_URLS = {
9
- local: "http://localhost:8000",
10
+ local: "https://api.dev-coderail.local",
10
11
  staging: "https://ai-tutor-backend-stg-673992211852.asia-northeast1.run.app",
11
12
  production: "https://api.coderail.local",
12
13
  };
13
14
  const DEFAULT_RETRY_WAIT_MS = 2000;
14
15
  const ALERT_PREFIX = "[[CODERAIL_ERROR]] ";
15
16
  const ERROR_PATTERN = /(^|[\s:])(?:error|exception|traceback|panic|fatal)(?=[\s:])/i;
17
+ const DEFAULT_PROJECT_TREE_MAX_FILES = 1000;
18
+ const DEFAULT_PROJECT_TREE_MAX_BYTES = 4096;
19
+ const PROJECT_TREE_EXTENSIONS = new Set([".py", ".js"]);
20
+ const OPENAI_CHAT_API_URL = "https://api.openai.com/v1/chat/completions";
21
+ const DEFAULT_OPENAI_MODEL = "gpt-4o-mini";
22
+ const DEFAULT_SNAPSHOT_EXCLUDES = new Set([
23
+ ".git",
24
+ "node_modules",
25
+ ".venv",
26
+ "venv",
27
+ ".next",
28
+ "dist",
29
+ "build",
30
+ "tests",
31
+ ]);
32
+ const DEFAULT_SNAPSHOT_EXCLUDE_PREFIXES = new Set([
33
+ "frontend/.next",
34
+ "backend/.venv",
35
+ "backend/.pytest_cache",
36
+ ]);
37
+ const SNAPSHOT_EXCLUDE_PREFIXES = Array.from(DEFAULT_SNAPSHOT_EXCLUDE_PREFIXES);
16
38
 
17
39
  const isErrorLine = (line) => ERROR_PATTERN.test(line);
18
40
 
@@ -50,12 +72,21 @@ if (!WebSocketImpl) {
50
72
  }
51
73
  const WebSocket = WebSocketImpl;
52
74
 
53
- const buildWsUrl = (baseUrl, sessionId, projectKey, token) => {
75
+ const normalizeHttpBaseUrl = (baseUrl) => {
54
76
  let normalized = baseUrl.trim();
55
77
  if (!normalized.startsWith("http://") && !normalized.startsWith("https://")) {
56
78
  normalized = `http://${normalized}`;
57
79
  }
58
- const url = new URL(normalized);
80
+ return normalized;
81
+ };
82
+
83
+ const normalizeBaseHttpUrl = (baseUrl) => {
84
+ const normalized = normalizeHttpBaseUrl(baseUrl);
85
+ return normalized.replace(/\/$/, "");
86
+ };
87
+
88
+ const buildWsUrl = (baseUrl, sessionId, projectKey, token) => {
89
+ const url = new URL(normalizeHttpBaseUrl(baseUrl));
59
90
  const wsProtocol = url.protocol === "https:" ? "wss:" : "ws:";
60
91
  url.protocol = wsProtocol;
61
92
  url.pathname = `${url.pathname.replace(/\/$/, "")}/api/v1/terminal_watch/ws`;
@@ -68,35 +99,242 @@ const buildWsUrl = (baseUrl, sessionId, projectKey, token) => {
68
99
  return url.toString();
69
100
  };
70
101
 
102
+ const normalizeBoolArg = (value, defaultValue) => {
103
+ if (value === undefined) return defaultValue;
104
+ return value !== "false";
105
+ };
106
+
107
+ const normalizeTrueArg = (value) => value === "true";
108
+
109
+ const normalizeIntArg = (value, fallback, minValue) => {
110
+ const parsed = Number(value);
111
+ if (!Number.isFinite(parsed) || parsed < minValue) return fallback;
112
+ return Math.floor(parsed);
113
+ };
114
+
115
+ const resolveBaseUrl = (args) => {
116
+ const env = args["env"];
117
+ if (env && !ENV_BASE_URLS[env]) {
118
+ throw new Error(
119
+ `Unknown env '${env}'. Use one of: ${Object.keys(ENV_BASE_URLS).join(", ")}`,
120
+ );
121
+ }
122
+ return (
123
+ args["base-url"] ||
124
+ (env ? ENV_BASE_URLS[env] : null) ||
125
+ DEFAULT_BASE_URL
126
+ );
127
+ return normalizeHttpBaseUrl(rawBaseUrl);
128
+ };
129
+
130
+ const isExcludedPath = (relPath, entryName, excludes) => {
131
+ if (!relPath) return true;
132
+ if (excludes.has(entryName)) return true;
133
+ if (entryName.startsWith(".")) return true;
134
+ if (DEFAULT_SNAPSHOT_EXCLUDE_PREFIXES.has(relPath)) return true;
135
+ if (SNAPSHOT_EXCLUDE_PREFIXES.some((prefix) => relPath.startsWith(`${prefix}/`))) {
136
+ return true;
137
+ }
138
+ return false;
139
+ };
140
+
141
+ const collectSnapshotPaths = (rootDir, excludes) => {
142
+ const results = [];
143
+ const queue = [rootDir];
144
+ while (queue.length) {
145
+ const current = queue.pop();
146
+ if (!current) continue;
147
+ let entries = [];
148
+ try {
149
+ entries = fs.readdirSync(current, { withFileTypes: true });
150
+ } catch (error) {
151
+ log(`snapshot read failed: ${current} (${error})`);
152
+ continue;
153
+ }
154
+ for (const entry of entries) {
155
+ const fullPath = path.join(current, entry.name);
156
+ const relPath = path.relative(rootDir, fullPath).replace(/\\/g, "/");
157
+ if (isExcludedPath(relPath, entry.name, excludes)) continue;
158
+ if (entry.isSymbolicLink && entry.isSymbolicLink()) continue;
159
+ if (entry.isDirectory()) {
160
+ queue.push(fullPath);
161
+ continue;
162
+ }
163
+ if (entry.isFile()) {
164
+ const ext = path.extname(entry.name).toLowerCase();
165
+ if (PROJECT_TREE_EXTENSIONS.has(ext)) {
166
+ results.push(relPath);
167
+ }
168
+ }
169
+ }
170
+ }
171
+ return results.sort();
172
+ };
173
+
174
+ const extractApiEndpointItems = (jsonText) => {
175
+ if (!jsonText) return [];
176
+ let parsed = null;
177
+ try {
178
+ parsed = JSON.parse(jsonText);
179
+ } catch (error) {
180
+ return [];
181
+ }
182
+ const raw = Array.isArray(parsed) ? parsed : parsed?.endpoints;
183
+ if (!Array.isArray(raw)) return [];
184
+ const endpoints = [];
185
+ for (const item of raw) {
186
+ if (typeof item === "string") {
187
+ const trimmed = item.trim();
188
+ if (trimmed) endpoints.push(trimmed);
189
+ continue;
190
+ }
191
+ if (!item || typeof item !== "object") continue;
192
+ const method = String(item.method || "").trim().toUpperCase();
193
+ const pathValue = String(item.path || "").trim();
194
+ if (!pathValue) continue;
195
+ endpoints.push(method ? `${method} ${pathValue}` : pathValue);
196
+ }
197
+ return endpoints;
198
+ };
199
+
200
+ const isBinaryBuffer = (buffer) => buffer.includes(0);
201
+
202
+ const buildProjectTreeLines = (
203
+ rootDir,
204
+ excludes,
205
+ maxFiles,
206
+ includeContents,
207
+ maxBytes,
208
+ ) => {
209
+ const paths = collectSnapshotPaths(rootDir, excludes);
210
+ const limited = paths.slice(0, maxFiles);
211
+ const lines = [
212
+ "[project-tree] listing files",
213
+ `[project-tree] root=${rootDir}`,
214
+ `[project-tree] total=${paths.length} shown=${limited.length}`,
215
+ ];
216
+ for (const relPath of limited) {
217
+ lines.push(`- ${relPath}`);
218
+ if (!includeContents) {
219
+ continue;
220
+ }
221
+ const fullPath = path.join(rootDir, relPath);
222
+ let content = "";
223
+ try {
224
+ const buffer = fs.readFileSync(fullPath);
225
+ if (isBinaryBuffer(buffer)) {
226
+ lines.push(`(binary skipped) ${relPath}`);
227
+ continue;
228
+ }
229
+ content = buffer.toString("utf8");
230
+ } catch (error) {
231
+ lines.push(`(read failed) ${relPath}: ${error}`);
232
+ continue;
233
+ }
234
+ if (!content) {
235
+ lines.push(`(empty) ${relPath}`);
236
+ continue;
237
+ }
238
+ const slice = content.slice(0, maxBytes);
239
+ lines.push(`----- ${relPath} (first ${slice.length} chars) -----`);
240
+ lines.push(slice);
241
+ if (content.length > maxBytes) {
242
+ lines.push(`----- ${relPath} (truncated) -----`);
243
+ }
244
+ }
245
+ if (paths.length > limited.length) {
246
+ lines.push(`[project-tree] truncated: ${paths.length - limited.length} more`);
247
+ }
248
+ return lines;
249
+ };
250
+
71
251
  const args = parseArgs(process.argv.slice(2));
72
252
  const sessionId = args["session-id"];
73
253
  const projectKey = args["project-key"];
74
- const env = args["env"];
75
- const baseUrl =
76
- args["base-url"] ||
77
- (env && ENV_BASE_URLS[env] ? ENV_BASE_URLS[env] : DEFAULT_BASE_URL);
254
+ let baseUrl = "";
255
+ try {
256
+ baseUrl = resolveBaseUrl(args);
257
+ } catch (error) {
258
+ log(error.message || String(error));
259
+ process.exit(1);
260
+ }
78
261
  const token = args["token"];
79
- const retryWaitMs = Number(args["retry-wait"] || DEFAULT_RETRY_WAIT_MS);
262
+ const retryWaitMs = normalizeIntArg(
263
+ args["retry-wait"],
264
+ DEFAULT_RETRY_WAIT_MS,
265
+ 0,
266
+ );
80
267
  const logPath = args["log-path"];
268
+ const projectRoot = path.resolve(args["project-root"] || process.cwd());
269
+ const sendProjectTree = normalizeBoolArg(args["project-tree"], true);
270
+ const includeProjectTreeContents = normalizeTrueArg(
271
+ args["project-tree-contents"],
272
+ );
273
+ const projectTreeMaxFiles = normalizeIntArg(
274
+ args["project-tree-max-files"],
275
+ DEFAULT_PROJECT_TREE_MAX_FILES,
276
+ 1,
277
+ );
278
+ const projectTreeMaxBytes = normalizeIntArg(
279
+ args["project-tree-max-bytes"],
280
+ DEFAULT_PROJECT_TREE_MAX_BYTES,
281
+ 1,
282
+ );
283
+ const enableApiCheck = normalizeBoolArg(args["api-llm-check"], true);
284
+ const enableScreenCheck = normalizeBoolArg(args["screen-check"], true);
285
+ const dotenvPath = args["env-file"] || path.join(projectRoot, ".env");
286
+ const loadEnvFile = (filePath) => {
287
+ try {
288
+ const raw = fs.readFileSync(filePath, "utf8");
289
+ const entries = {};
290
+ raw.split(/\r?\n/).forEach((line) => {
291
+ const trimmed = line.trim();
292
+ if (!trimmed || trimmed.startsWith("#")) return;
293
+ const idx = trimmed.indexOf("=");
294
+ if (idx <= 0) return;
295
+ const key = trimmed.slice(0, idx).trim();
296
+ let value = trimmed.slice(idx + 1).trim();
297
+ if (
298
+ (value.startsWith('"') && value.endsWith('"')) ||
299
+ (value.startsWith("'") && value.endsWith("'"))
300
+ ) {
301
+ value = value.slice(1, -1);
302
+ }
303
+ entries[key] = value;
304
+ });
305
+ return entries;
306
+ } catch (error) {
307
+ return {};
308
+ }
309
+ };
310
+ const envFromFile = loadEnvFile(dotenvPath);
311
+ const openaiApiKey =
312
+ args["openai-api-key"] ||
313
+ process.env.OPENAI_API_KEY ||
314
+ envFromFile.OPENAI_API_KEY ||
315
+ "";
316
+ const openaiModel =
317
+ args["openai-model"] ||
318
+ process.env.OPENAI_MODEL ||
319
+ envFromFile.OPENAI_MODEL ||
320
+ DEFAULT_OPENAI_MODEL;
321
+ const apiEndpointUrl = `${baseUrl.replace(/\/$/, "")}/api/v1/projects/${encodeURIComponent(
322
+ projectKey,
323
+ )}/api_endpoint_list`;
324
+ let apiEndpointCandidatesCached = [];
81
325
 
82
326
  if (!sessionId || !projectKey) {
83
327
  log("Missing required args: --session-id and --project-key");
84
328
  process.exit(1);
85
329
  }
86
330
 
87
- if (env && !ENV_BASE_URLS[env]) {
88
- log(
89
- `Unknown env '${env}'. Use one of: ${Object.keys(ENV_BASE_URLS).join(", ")}`,
90
- );
91
- process.exit(1);
92
- }
93
-
94
331
  const wsUrl = buildWsUrl(baseUrl, sessionId, projectKey, token);
95
332
  let socket = null;
96
333
  let isOpen = false;
97
334
  const queue = [];
98
335
  let retryCount = 0;
99
336
  let lastErrorMessage = "";
337
+ let apiCheckInFlight = false;
100
338
 
101
339
  const attachHandler = (target, event, handler) => {
102
340
  if (typeof target.on === "function") {
@@ -132,6 +370,20 @@ const connect = () => {
132
370
  isOpen = true;
133
371
  retryCount = 0;
134
372
  log(`connected: ${wsUrl}`);
373
+ if (sendProjectTree) {
374
+ emitProjectTree();
375
+ }
376
+ if (enableApiCheck) {
377
+ void (async () => {
378
+ if (!apiEndpointCandidatesCached.length) {
379
+ await fetchApiEndpointList();
380
+ }
381
+ logApiCheckStatus();
382
+ if (apiEndpointCandidatesCached.length) {
383
+ await runApiCheck();
384
+ }
385
+ })();
386
+ }
135
387
  while (queue.length) {
136
388
  const payload = queue.shift();
137
389
  if (!isSocketOpen()) {
@@ -147,6 +399,38 @@ const connect = () => {
147
399
  }
148
400
  });
149
401
 
402
+ attachHandler(socket, "message", (event) => {
403
+ const raw =
404
+ event && typeof event === "object" && "data" in event ? event.data : event;
405
+ if (!raw) return;
406
+ const text =
407
+ typeof raw === "string"
408
+ ? raw
409
+ : typeof raw.toString === "function"
410
+ ? raw.toString()
411
+ : "";
412
+ if (!text || !text.trim().startsWith("{")) return;
413
+ let payload = null;
414
+ try {
415
+ payload = JSON.parse(text);
416
+ } catch (error) {
417
+ return;
418
+ }
419
+ if (!payload || typeof payload !== "object") return;
420
+ if (payload.type !== "control") return;
421
+ if (payload.action === "api_check") {
422
+ void triggerApiCheck("control");
423
+ return;
424
+ }
425
+ if (payload.action === "screen_check") {
426
+ if (enableScreenCheck) {
427
+ void triggerScreenCheck("control");
428
+ } else {
429
+ sendScreenCheckLine("screen check disabled; skipping.");
430
+ }
431
+ }
432
+ });
433
+
150
434
  attachHandler(socket, "close", (event) => {
151
435
  isOpen = false;
152
436
  const code =
@@ -196,6 +480,38 @@ const enqueueOrSend = (payload) => {
196
480
  queue.push(payload);
197
481
  };
198
482
 
483
+ const sendLogLine = (line) => {
484
+ const payload = JSON.stringify({
485
+ type: "log",
486
+ content: line,
487
+ timestamp: new Date().toISOString(),
488
+ });
489
+ enqueueOrSend(payload);
490
+ };
491
+
492
+ const sendApiCheckLine = (line) => {
493
+ const tagged = `[api-check] ${line}`;
494
+ log(tagged);
495
+ sendLogLine(tagged);
496
+ };
497
+
498
+ const sendScreenCheckLine = (line) => {
499
+ const tagged = `[screen-check] ${line}`;
500
+ log(tagged);
501
+ sendLogLine(tagged);
502
+ };
503
+
504
+ const logApiCheckStatus = () => {
505
+ const maskedKey = openaiApiKey ? `${openaiApiKey.slice(0, 6)}...` : "";
506
+ sendApiCheckLine(
507
+ `必須チェック: api_endpoint_list=${
508
+ apiEndpointCandidatesCached.length ? "OK" : "NG"
509
+ } openai_api_key=${openaiApiKey ? "OK" : "NG"} ${
510
+ maskedKey ? `(${maskedKey})` : ""
511
+ } openai_model=${openaiModel ? "OK" : "NG"}`,
512
+ );
513
+ };
514
+
199
515
  const handleLine = (line) => {
200
516
  const payload = JSON.stringify({
201
517
  type: "log",
@@ -213,6 +529,269 @@ const handleLine = (line) => {
213
529
  }
214
530
  };
215
531
 
532
+ const emitProjectTree = () => {
533
+ const lines = buildProjectTreeLines(
534
+ projectRoot,
535
+ DEFAULT_SNAPSHOT_EXCLUDES,
536
+ projectTreeMaxFiles,
537
+ includeProjectTreeContents,
538
+ projectTreeMaxBytes,
539
+ );
540
+ lines.forEach((line) => {
541
+ log(line);
542
+ sendLogLine(line);
543
+ });
544
+ };
545
+
546
+ const buildApiCheckPrompt = (endpoints, treeLines) => {
547
+ return [
548
+ "You are a senior backend engineer. Determine which API endpoints are NOT implemented in the codebase.",
549
+ "Use the file tree (and optional file excerpts) to infer whether each endpoint is implemented.",
550
+ "If unsure, mark as 'unknown'.",
551
+ "Return JSON only: {\"missing\": [{\"endpoint\": \"METHOD /path\", \"reason\": \"...\", \"confidence\": 0-1}], \"unknown\": [\"METHOD /path\"], \"implemented\": [\"METHOD /path\"]}",
552
+ "",
553
+ "API endpoints:",
554
+ ...endpoints.map((item) => `- ${item}`),
555
+ "",
556
+ "Project files:",
557
+ ...treeLines,
558
+ ].join("\n");
559
+ };
560
+
561
+ const collectTreeForApiCheck = () => {
562
+ return buildProjectTreeLines(
563
+ projectRoot,
564
+ DEFAULT_SNAPSHOT_EXCLUDES,
565
+ projectTreeMaxFiles,
566
+ includeProjectTreeContents,
567
+ projectTreeMaxBytes,
568
+ );
569
+ };
570
+
571
+ const runApiCheck = async () => {
572
+ if (!enableApiCheck) return;
573
+ if (!apiEndpointCandidatesCached.length) return;
574
+ if (!openaiApiKey) {
575
+ sendApiCheckLine("OPENAI_API_KEY is not set; skipping API check.");
576
+ return;
577
+ }
578
+ if (!openaiModel) {
579
+ sendApiCheckLine("OPENAI_MODEL is not set; skipping API check.");
580
+ return;
581
+ }
582
+ if (typeof fetch !== "function") {
583
+ sendApiCheckLine("fetch is not available in this runtime; skipping API check.");
584
+ return;
585
+ }
586
+ const treeLines = collectTreeForApiCheck();
587
+ const prompt = buildApiCheckPrompt(apiEndpointCandidatesCached, treeLines);
588
+ const payload = {
589
+ model: openaiModel,
590
+ messages: [
591
+ {
592
+ role: "system",
593
+ content:
594
+ "You return strictly valid JSON. Do not wrap in markdown or add commentary.",
595
+ },
596
+ { role: "user", content: prompt },
597
+ ],
598
+ temperature: 0.2,
599
+ };
600
+ let response = null;
601
+ try {
602
+ response = await fetch(OPENAI_CHAT_API_URL, {
603
+ method: "POST",
604
+ headers: {
605
+ Authorization: `Bearer ${openaiApiKey}`,
606
+ "Content-Type": "application/json",
607
+ },
608
+ body: JSON.stringify(payload),
609
+ });
610
+ } catch (error) {
611
+ sendApiCheckLine(`OpenAI request failed: ${error}`);
612
+ return;
613
+ }
614
+ if (!response.ok) {
615
+ const text = await response.text();
616
+ sendApiCheckLine(
617
+ `OpenAI request failed: ${response.status} ${response.statusText}`,
618
+ );
619
+ sendApiCheckLine(text.slice(0, 1000));
620
+ return;
621
+ }
622
+ let data = null;
623
+ try {
624
+ data = await response.json();
625
+ } catch (error) {
626
+ sendApiCheckLine(`OpenAI response parse failed: ${error}`);
627
+ return;
628
+ }
629
+ const content = data?.choices?.[0]?.message?.content?.trim();
630
+ if (!content) {
631
+ sendApiCheckLine("OpenAI response was empty.");
632
+ return;
633
+ }
634
+ try {
635
+ const parsed = JSON.parse(content);
636
+ const missing = Array.isArray(parsed?.missing) ? parsed.missing : [];
637
+ const unknown = Array.isArray(parsed?.unknown) ? parsed.unknown : [];
638
+ const implemented = Array.isArray(parsed?.implemented)
639
+ ? parsed.implemented
640
+ : [];
641
+ sendApiCheckLine(
642
+ `missing=${missing.length} unknown=${unknown.length} implemented=${implemented.length}`,
643
+ );
644
+ missing.forEach((item) => {
645
+ if (item && typeof item === "object") {
646
+ sendApiCheckLine(
647
+ `missing: ${item.endpoint || ""} (${item.reason || "no reason"})`,
648
+ );
649
+ } else {
650
+ sendApiCheckLine(`missing: ${String(item)}`);
651
+ }
652
+ });
653
+ unknown.forEach((item) => sendApiCheckLine(`unknown: ${String(item)}`));
654
+ implemented.forEach((item) =>
655
+ sendApiCheckLine(`implemented: ${String(item)}`),
656
+ );
657
+ } catch (error) {
658
+ sendApiCheckLine("OpenAI output was not valid JSON. Raw output:");
659
+ sendApiCheckLine(content.slice(0, 2000));
660
+ }
661
+ };
662
+
663
+ const screenCheckUrl = `${normalizeBaseHttpUrl(baseUrl)}/api/v1/project_snapshot/check`;
664
+
665
+ const runScreenCheck = async () => {
666
+ if (!projectKey) {
667
+ sendScreenCheckLine("project_key is missing; skipping screen check.");
668
+ return;
669
+ }
670
+ if (typeof fetch !== "function") {
671
+ sendScreenCheckLine("fetch is not available in this runtime; skipping screen check.");
672
+ return;
673
+ }
674
+ const paths = collectSnapshotPaths(projectRoot, DEFAULT_SNAPSHOT_EXCLUDES);
675
+ const limitedPaths = paths.slice(0, projectTreeMaxFiles);
676
+ const payload = {
677
+ projectPublicId: projectKey,
678
+ paths: limitedPaths,
679
+ };
680
+ let response = null;
681
+ try {
682
+ response = await fetch(screenCheckUrl, {
683
+ method: "POST",
684
+ headers: { "Content-Type": "application/json" },
685
+ body: JSON.stringify(payload),
686
+ });
687
+ } catch (error) {
688
+ sendScreenCheckLine(`screen check request failed: ${error}`);
689
+ return;
690
+ }
691
+ if (!response.ok) {
692
+ const text = await response.text();
693
+ sendScreenCheckLine(
694
+ `screen check failed: ${response.status} ${response.statusText}`,
695
+ );
696
+ sendScreenCheckLine(text.slice(0, 1000));
697
+ return;
698
+ }
699
+ let data = null;
700
+ try {
701
+ data = await response.json();
702
+ } catch (error) {
703
+ sendScreenCheckLine(`screen check response parse failed: ${error}`);
704
+ return;
705
+ }
706
+ const expected = Array.isArray(data?.expectedScreens) ? data.expectedScreens : [];
707
+ const missing = Array.isArray(data?.check?.missingScreens)
708
+ ? data.check.missingScreens
709
+ : [];
710
+ const implemented = expected.filter((item) => !missing.includes(item));
711
+ sendScreenCheckLine(
712
+ `missing_screens=${missing.length} expected=${expected.length} implemented=${implemented.length}`,
713
+ );
714
+ missing.forEach((item) => sendScreenCheckLine(`missing: ${String(item)}`));
715
+ implemented.forEach((item) =>
716
+ sendScreenCheckLine(`implemented: ${String(item)}`),
717
+ );
718
+ if (data?.check?.notes) {
719
+ sendScreenCheckLine(`note: ${String(data.check.notes)}`);
720
+ }
721
+ };
722
+
723
+ const triggerApiCheck = async (reason) => {
724
+ if (apiCheckInFlight) return;
725
+ apiCheckInFlight = true;
726
+ try {
727
+ sendApiCheckLine(`api-check start (${reason})`);
728
+ if (!apiEndpointCandidatesCached.length) {
729
+ await fetchApiEndpointList();
730
+ }
731
+ logApiCheckStatus();
732
+ if (!apiEndpointCandidatesCached.length) {
733
+ sendApiCheckLine("api_endpoint_list is empty; skipping api-check.");
734
+ return;
735
+ }
736
+ await runApiCheck();
737
+ } finally {
738
+ apiCheckInFlight = false;
739
+ }
740
+ };
741
+
742
+ const triggerScreenCheck = async (reason) => {
743
+ try {
744
+ sendScreenCheckLine(`screen-check start (${reason})`);
745
+ await runScreenCheck();
746
+ } catch (error) {
747
+ sendScreenCheckLine(`screen-check failed: ${error}`);
748
+ }
749
+ };
750
+
751
+ const fetchApiEndpointList = async () => {
752
+ if (!apiEndpointUrl) return [];
753
+ if (typeof fetch !== "function") {
754
+ sendApiCheckLine("fetch is not available in this runtime; skipping fetch.");
755
+ return [];
756
+ }
757
+ try {
758
+ const response = await fetch(apiEndpointUrl, {
759
+ method: "GET",
760
+ headers: { "Content-Type": "application/json" },
761
+ });
762
+ if (!response.ok) {
763
+ let detail = "";
764
+ try {
765
+ detail = await response.text();
766
+ } catch (error) {
767
+ detail = "";
768
+ }
769
+ const detailSnippet = detail ? ` detail=${detail.slice(0, 200)}` : "";
770
+ sendApiCheckLine(
771
+ `api_endpoint_list取得失敗: ${response.status} ${response.statusText} (${apiEndpointUrl})${detailSnippet}`,
772
+ );
773
+ return [];
774
+ }
775
+ const data = await response.json();
776
+ if (data?.api_endpoint_list) {
777
+ apiEndpointCandidatesCached = extractApiEndpointItems(
778
+ data.api_endpoint_list,
779
+ );
780
+ return apiEndpointCandidatesCached;
781
+ }
782
+ if (Array.isArray(data?.endpoints)) {
783
+ apiEndpointCandidatesCached = extractApiEndpointItems(
784
+ JSON.stringify({ endpoints: data.endpoints }),
785
+ );
786
+ return apiEndpointCandidatesCached;
787
+ }
788
+ return [];
789
+ } catch (error) {
790
+ sendApiCheckLine(`api_endpoint_list fetch error: ${error}`);
791
+ return [];
792
+ }
793
+ };
794
+
216
795
  const startFileTailer = (targetPath) => {
217
796
  let fileOffset = 0;
218
797
  let remainder = "";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "coderail-watch",
3
- "version": "0.1.7",
3
+ "version": "0.1.8",
4
4
  "description": "Stream terminal output to CodeRail backend over WebSocket.",
5
5
  "bin": {
6
6
  "coderail-watch": "bin/coderail-watch.js"