coderail-watch 0.1.7 → 0.1.11
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 +17 -0
- package/bin/coderail-watch.js +1218 -43
- package/package.json +1 -1
package/bin/coderail-watch.js
CHANGED
|
@@ -2,17 +2,41 @@
|
|
|
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 = "
|
|
8
|
+
const DEFAULT_BASE_URL = "https://api.dev-coderail.local";
|
|
8
9
|
const ENV_BASE_URLS = {
|
|
9
|
-
local: "
|
|
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;
|
|
15
|
+
const DEFAULT_LOG_BATCH_MS = 300;
|
|
16
|
+
const DEFAULT_LOG_BATCH_MAX_LINES = 50;
|
|
14
17
|
const ALERT_PREFIX = "[[CODERAIL_ERROR]] ";
|
|
15
18
|
const ERROR_PATTERN = /(^|[\s:])(?:error|exception|traceback|panic|fatal)(?=[\s:])/i;
|
|
19
|
+
const DEFAULT_PROJECT_TREE_MAX_FILES = 1000;
|
|
20
|
+
const DEFAULT_PROJECT_TREE_MAX_BYTES = 4096;
|
|
21
|
+
const PROJECT_TREE_EXTENSIONS = new Set([".py", ".js"]);
|
|
22
|
+
const OPENAI_CHAT_API_URL = "https://api.openai.com/v1/chat/completions";
|
|
23
|
+
const DEFAULT_OPENAI_MODEL = "gpt-4o-mini";
|
|
24
|
+
const DEFAULT_SNAPSHOT_EXCLUDES = new Set([
|
|
25
|
+
".git",
|
|
26
|
+
"node_modules",
|
|
27
|
+
".venv",
|
|
28
|
+
"venv",
|
|
29
|
+
".next",
|
|
30
|
+
"dist",
|
|
31
|
+
"build",
|
|
32
|
+
"tests",
|
|
33
|
+
]);
|
|
34
|
+
const DEFAULT_SNAPSHOT_EXCLUDE_PREFIXES = new Set([
|
|
35
|
+
"frontend/.next",
|
|
36
|
+
"backend/.venv",
|
|
37
|
+
"backend/.pytest_cache",
|
|
38
|
+
]);
|
|
39
|
+
const SNAPSHOT_EXCLUDE_PREFIXES = Array.from(DEFAULT_SNAPSHOT_EXCLUDE_PREFIXES);
|
|
16
40
|
|
|
17
41
|
const isErrorLine = (line) => ERROR_PATTERN.test(line);
|
|
18
42
|
|
|
@@ -33,7 +57,21 @@ const parseArgs = (argv) => {
|
|
|
33
57
|
return args;
|
|
34
58
|
};
|
|
35
59
|
|
|
36
|
-
const
|
|
60
|
+
const LOG_LEVELS = {
|
|
61
|
+
error: 0,
|
|
62
|
+
warn: 1,
|
|
63
|
+
info: 2,
|
|
64
|
+
debug: 3,
|
|
65
|
+
};
|
|
66
|
+
let currentLogLevel = LOG_LEVELS.warn;
|
|
67
|
+
|
|
68
|
+
const shouldLog = (level) => {
|
|
69
|
+
const resolved = LOG_LEVELS[level] ?? LOG_LEVELS.info;
|
|
70
|
+
return currentLogLevel >= resolved;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const log = (message, level = "info") => {
|
|
74
|
+
if (!shouldLog(level)) return;
|
|
37
75
|
process.stderr.write(`[coderail-watch] ${message}\n`);
|
|
38
76
|
};
|
|
39
77
|
|
|
@@ -44,18 +82,28 @@ if (!WebSocketImpl) {
|
|
|
44
82
|
} catch (error) {
|
|
45
83
|
log(
|
|
46
84
|
"Missing dependency 'ws'. Run `cd tools/coderail-watch && npm install` or use the published package via `npx coderail-watch@latest ...`.",
|
|
85
|
+
"error",
|
|
47
86
|
);
|
|
48
87
|
process.exit(1);
|
|
49
88
|
}
|
|
50
89
|
}
|
|
51
90
|
const WebSocket = WebSocketImpl;
|
|
52
91
|
|
|
53
|
-
const
|
|
92
|
+
const normalizeHttpBaseUrl = (baseUrl) => {
|
|
54
93
|
let normalized = baseUrl.trim();
|
|
55
94
|
if (!normalized.startsWith("http://") && !normalized.startsWith("https://")) {
|
|
56
95
|
normalized = `http://${normalized}`;
|
|
57
96
|
}
|
|
58
|
-
|
|
97
|
+
return normalized;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const normalizeBaseHttpUrl = (baseUrl) => {
|
|
101
|
+
const normalized = normalizeHttpBaseUrl(baseUrl);
|
|
102
|
+
return normalized.replace(/\/$/, "");
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const buildWsUrl = (baseUrl, sessionId, projectKey, token, flowId) => {
|
|
106
|
+
const url = new URL(normalizeHttpBaseUrl(baseUrl));
|
|
59
107
|
const wsProtocol = url.protocol === "https:" ? "wss:" : "ws:";
|
|
60
108
|
url.protocol = wsProtocol;
|
|
61
109
|
url.pathname = `${url.pathname.replace(/\/$/, "")}/api/v1/terminal_watch/ws`;
|
|
@@ -65,38 +113,296 @@ const buildWsUrl = (baseUrl, sessionId, projectKey, token) => {
|
|
|
65
113
|
if (token) {
|
|
66
114
|
url.searchParams.set("token", token);
|
|
67
115
|
}
|
|
116
|
+
if (flowId) {
|
|
117
|
+
url.searchParams.set("flow_id", flowId);
|
|
118
|
+
}
|
|
68
119
|
return url.toString();
|
|
69
120
|
};
|
|
70
121
|
|
|
122
|
+
const normalizeBoolArg = (value, defaultValue) => {
|
|
123
|
+
if (value === undefined) return defaultValue;
|
|
124
|
+
return value !== "false";
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const normalizeTrueArg = (value) => value === "true";
|
|
128
|
+
|
|
129
|
+
const normalizeIntArg = (value, fallback, minValue) => {
|
|
130
|
+
const parsed = Number(value);
|
|
131
|
+
if (!Number.isFinite(parsed) || parsed < minValue) return fallback;
|
|
132
|
+
return Math.floor(parsed);
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const resolveBaseUrl = (args) => {
|
|
136
|
+
const env = args["env"];
|
|
137
|
+
if (env && !ENV_BASE_URLS[env]) {
|
|
138
|
+
throw new Error(
|
|
139
|
+
`Unknown env '${env}'. Use one of: ${Object.keys(ENV_BASE_URLS).join(", ")}`,
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
return (
|
|
143
|
+
args["base-url"] ||
|
|
144
|
+
(env ? ENV_BASE_URLS[env] : null) ||
|
|
145
|
+
DEFAULT_BASE_URL
|
|
146
|
+
);
|
|
147
|
+
return normalizeHttpBaseUrl(rawBaseUrl);
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const isExcludedPath = (relPath, entryName, excludes) => {
|
|
151
|
+
if (!relPath) return true;
|
|
152
|
+
if (excludes.has(entryName)) return true;
|
|
153
|
+
if (entryName.startsWith(".")) return true;
|
|
154
|
+
if (DEFAULT_SNAPSHOT_EXCLUDE_PREFIXES.has(relPath)) return true;
|
|
155
|
+
if (SNAPSHOT_EXCLUDE_PREFIXES.some((prefix) => relPath.startsWith(`${prefix}/`))) {
|
|
156
|
+
return true;
|
|
157
|
+
}
|
|
158
|
+
return false;
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
const collectSnapshotPaths = (rootDir, excludes) => {
|
|
162
|
+
const results = [];
|
|
163
|
+
const queue = [rootDir];
|
|
164
|
+
while (queue.length) {
|
|
165
|
+
const current = queue.pop();
|
|
166
|
+
if (!current) continue;
|
|
167
|
+
let entries = [];
|
|
168
|
+
try {
|
|
169
|
+
entries = fs.readdirSync(current, { withFileTypes: true });
|
|
170
|
+
} catch (error) {
|
|
171
|
+
log(`snapshot read failed: ${current} (${error})`, "warn");
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
for (const entry of entries) {
|
|
175
|
+
const fullPath = path.join(current, entry.name);
|
|
176
|
+
const relPath = path.relative(rootDir, fullPath).replace(/\\/g, "/");
|
|
177
|
+
if (isExcludedPath(relPath, entry.name, excludes)) continue;
|
|
178
|
+
if (entry.isSymbolicLink && entry.isSymbolicLink()) continue;
|
|
179
|
+
if (entry.isDirectory()) {
|
|
180
|
+
queue.push(fullPath);
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
if (entry.isFile()) {
|
|
184
|
+
const ext = path.extname(entry.name).toLowerCase();
|
|
185
|
+
if (PROJECT_TREE_EXTENSIONS.has(ext)) {
|
|
186
|
+
results.push(relPath);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return results.sort();
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
const extractApiEndpointItems = (jsonText) => {
|
|
195
|
+
if (!jsonText) return [];
|
|
196
|
+
let parsed = null;
|
|
197
|
+
try {
|
|
198
|
+
parsed = JSON.parse(jsonText);
|
|
199
|
+
} catch (error) {
|
|
200
|
+
return [];
|
|
201
|
+
}
|
|
202
|
+
const raw = Array.isArray(parsed) ? parsed : parsed?.endpoints;
|
|
203
|
+
if (!Array.isArray(raw)) return [];
|
|
204
|
+
const endpoints = [];
|
|
205
|
+
for (const item of raw) {
|
|
206
|
+
if (typeof item === "string") {
|
|
207
|
+
const trimmed = item.trim();
|
|
208
|
+
if (trimmed) endpoints.push(trimmed);
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
if (!item || typeof item !== "object") continue;
|
|
212
|
+
const method = String(item.method || "").trim().toUpperCase();
|
|
213
|
+
const pathValue = String(item.path || "").trim();
|
|
214
|
+
if (!pathValue) continue;
|
|
215
|
+
endpoints.push(method ? `${method} ${pathValue}` : pathValue);
|
|
216
|
+
}
|
|
217
|
+
return endpoints;
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
const isBinaryBuffer = (buffer) => buffer.includes(0);
|
|
221
|
+
|
|
222
|
+
const buildProjectTreeLines = (
|
|
223
|
+
rootDir,
|
|
224
|
+
excludes,
|
|
225
|
+
maxFiles,
|
|
226
|
+
includeContents,
|
|
227
|
+
maxBytes,
|
|
228
|
+
) => {
|
|
229
|
+
const paths = collectSnapshotPaths(rootDir, excludes);
|
|
230
|
+
const limited = paths.slice(0, maxFiles);
|
|
231
|
+
const lines = [
|
|
232
|
+
"[project-tree] listing files",
|
|
233
|
+
`[project-tree] root=${rootDir}`,
|
|
234
|
+
`[project-tree] total=${paths.length} shown=${limited.length}`,
|
|
235
|
+
];
|
|
236
|
+
for (const relPath of limited) {
|
|
237
|
+
lines.push(`- ${relPath}`);
|
|
238
|
+
if (!includeContents) {
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
const fullPath = path.join(rootDir, relPath);
|
|
242
|
+
let content = "";
|
|
243
|
+
try {
|
|
244
|
+
const buffer = fs.readFileSync(fullPath);
|
|
245
|
+
if (isBinaryBuffer(buffer)) {
|
|
246
|
+
lines.push(`(binary skipped) ${relPath}`);
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
content = buffer.toString("utf8");
|
|
250
|
+
} catch (error) {
|
|
251
|
+
lines.push(`(read failed) ${relPath}: ${error}`);
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
if (!content) {
|
|
255
|
+
lines.push(`(empty) ${relPath}`);
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
const slice = content.slice(0, maxBytes);
|
|
259
|
+
lines.push(`----- ${relPath} (first ${slice.length} chars) -----`);
|
|
260
|
+
lines.push(slice);
|
|
261
|
+
if (content.length > maxBytes) {
|
|
262
|
+
lines.push(`----- ${relPath} (truncated) -----`);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
if (paths.length > limited.length) {
|
|
266
|
+
lines.push(`[project-tree] truncated: ${paths.length - limited.length} more`);
|
|
267
|
+
}
|
|
268
|
+
return lines;
|
|
269
|
+
};
|
|
270
|
+
|
|
71
271
|
const args = parseArgs(process.argv.slice(2));
|
|
72
272
|
const sessionId = args["session-id"];
|
|
73
273
|
const projectKey = args["project-key"];
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
args
|
|
77
|
-
|
|
274
|
+
let baseUrl = "";
|
|
275
|
+
try {
|
|
276
|
+
baseUrl = resolveBaseUrl(args);
|
|
277
|
+
} catch (error) {
|
|
278
|
+
log(error.message || String(error), "error");
|
|
279
|
+
process.exit(1);
|
|
280
|
+
}
|
|
78
281
|
const token = args["token"];
|
|
79
|
-
const retryWaitMs =
|
|
282
|
+
const retryWaitMs = normalizeIntArg(
|
|
283
|
+
args["retry-wait"],
|
|
284
|
+
DEFAULT_RETRY_WAIT_MS,
|
|
285
|
+
0,
|
|
286
|
+
);
|
|
287
|
+
const logBatchMs = normalizeIntArg(
|
|
288
|
+
args["log-batch-ms"],
|
|
289
|
+
DEFAULT_LOG_BATCH_MS,
|
|
290
|
+
0,
|
|
291
|
+
);
|
|
292
|
+
const logBatchMaxLines = normalizeIntArg(
|
|
293
|
+
args["log-batch-lines"],
|
|
294
|
+
DEFAULT_LOG_BATCH_MAX_LINES,
|
|
295
|
+
1,
|
|
296
|
+
);
|
|
80
297
|
const logPath = args["log-path"];
|
|
298
|
+
const projectRoot = path.resolve(args["project-root"] || process.cwd());
|
|
299
|
+
const sendProjectTree = normalizeBoolArg(args["project-tree"], true);
|
|
300
|
+
const includeProjectTreeContents = normalizeTrueArg(
|
|
301
|
+
args["project-tree-contents"],
|
|
302
|
+
);
|
|
303
|
+
const projectTreeMaxFiles = normalizeIntArg(
|
|
304
|
+
args["project-tree-max-files"],
|
|
305
|
+
DEFAULT_PROJECT_TREE_MAX_FILES,
|
|
306
|
+
1,
|
|
307
|
+
);
|
|
308
|
+
const projectTreeMaxBytes = normalizeIntArg(
|
|
309
|
+
args["project-tree-max-bytes"],
|
|
310
|
+
DEFAULT_PROJECT_TREE_MAX_BYTES,
|
|
311
|
+
1,
|
|
312
|
+
);
|
|
313
|
+
const runMode = args["mode"] || "default";
|
|
314
|
+
const enableApiCheck = true;
|
|
315
|
+
const enableScreenCheck = normalizeBoolArg(args["screen-check"], true);
|
|
316
|
+
const dotenvPath = args["env-file"] || path.join(projectRoot, ".env");
|
|
317
|
+
const loadEnvFile = (filePath) => {
|
|
318
|
+
try {
|
|
319
|
+
const raw = fs.readFileSync(filePath, "utf8");
|
|
320
|
+
const entries = {};
|
|
321
|
+
raw.split(/\r?\n/).forEach((line) => {
|
|
322
|
+
const trimmed = line.trim();
|
|
323
|
+
if (!trimmed || trimmed.startsWith("#")) return;
|
|
324
|
+
const idx = trimmed.indexOf("=");
|
|
325
|
+
if (idx <= 0) return;
|
|
326
|
+
const key = trimmed.slice(0, idx).trim();
|
|
327
|
+
let value = trimmed.slice(idx + 1).trim();
|
|
328
|
+
if (
|
|
329
|
+
(value.startsWith('"') && value.endsWith('"')) ||
|
|
330
|
+
(value.startsWith("'") && value.endsWith("'"))
|
|
331
|
+
) {
|
|
332
|
+
value = value.slice(1, -1);
|
|
333
|
+
}
|
|
334
|
+
entries[key] = value;
|
|
335
|
+
});
|
|
336
|
+
return entries;
|
|
337
|
+
} catch (error) {
|
|
338
|
+
return {};
|
|
339
|
+
}
|
|
340
|
+
};
|
|
341
|
+
const envFromFile = loadEnvFile(dotenvPath);
|
|
342
|
+
const openaiApiKey =
|
|
343
|
+
args["openai-api-key"] ||
|
|
344
|
+
process.env.OPENAI_API_KEY ||
|
|
345
|
+
envFromFile.OPENAI_API_KEY ||
|
|
346
|
+
"";
|
|
347
|
+
const openaiModel =
|
|
348
|
+
args["openai-model"] ||
|
|
349
|
+
process.env.OPENAI_MODEL ||
|
|
350
|
+
envFromFile.OPENAI_MODEL ||
|
|
351
|
+
DEFAULT_OPENAI_MODEL;
|
|
352
|
+
const isNumericProjectKey = /^[0-9]+$/.test(projectKey);
|
|
353
|
+
const apiEndpointUrl = isNumericProjectKey
|
|
354
|
+
? `${baseUrl.replace(/\/$/, "")}/api/v1/projects/${encodeURIComponent(
|
|
355
|
+
projectKey,
|
|
356
|
+
)}/api_endpoint_list`
|
|
357
|
+
: `${baseUrl.replace(/\/$/, "")}/api/v1/projects/by_public/${encodeURIComponent(
|
|
358
|
+
projectKey,
|
|
359
|
+
)}/api_endpoint_list`;
|
|
360
|
+
const screenListUrl = isNumericProjectKey
|
|
361
|
+
? `${baseUrl.replace(/\/$/, "")}/api/v1/projects/${encodeURIComponent(
|
|
362
|
+
projectKey,
|
|
363
|
+
)}/screen_list`
|
|
364
|
+
: `${baseUrl.replace(/\/$/, "")}/api/v1/projects/by_public/${encodeURIComponent(
|
|
365
|
+
projectKey,
|
|
366
|
+
)}/screen_list`;
|
|
367
|
+
let apiEndpointCandidatesCached = [];
|
|
368
|
+
let screenCandidatesCached = [];
|
|
369
|
+
const toScreenName = (item) =>
|
|
370
|
+
typeof item === "object" && item !== null ? item.name || JSON.stringify(item) : String(item);
|
|
371
|
+
let apiEndpointFetchStatus = "unknown";
|
|
372
|
+
let apiEndpointFetchDetail = "";
|
|
373
|
+
let screenListFetchStatus = "unknown";
|
|
374
|
+
let screenListFetchDetail = "";
|
|
81
375
|
|
|
82
376
|
if (!sessionId || !projectKey) {
|
|
83
|
-
log("Missing required args: --session-id and --project-key");
|
|
377
|
+
log("Missing required args: --session-id and --project-key", "error");
|
|
84
378
|
process.exit(1);
|
|
85
379
|
}
|
|
86
380
|
|
|
87
|
-
|
|
88
|
-
log
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
381
|
+
const requestedLogLevel = String(
|
|
382
|
+
args["log-level"] || args["loglevel"] || "",
|
|
383
|
+
).toLowerCase();
|
|
384
|
+
if (requestedLogLevel in LOG_LEVELS) {
|
|
385
|
+
currentLogLevel = LOG_LEVELS[requestedLogLevel];
|
|
386
|
+
}
|
|
387
|
+
if (args["quiet"] === "true") {
|
|
388
|
+
currentLogLevel = -1;
|
|
389
|
+
}
|
|
390
|
+
// --mode security はデフォルトで info レベル以上を表示
|
|
391
|
+
if (runMode === "security" && !requestedLogLevel && args["quiet"] !== "true") {
|
|
392
|
+
currentLogLevel = LOG_LEVELS.info;
|
|
92
393
|
}
|
|
93
394
|
|
|
94
|
-
const
|
|
395
|
+
const MODE_FLOW_IDS = { security: "security_check" };
|
|
396
|
+
const resolvedFlowId = args["flow-id"] || MODE_FLOW_IDS[runMode] || null;
|
|
397
|
+
const wsUrl = buildWsUrl(baseUrl, sessionId, projectKey, token, resolvedFlowId);
|
|
95
398
|
let socket = null;
|
|
96
399
|
let isOpen = false;
|
|
97
400
|
const queue = [];
|
|
401
|
+
const logBuffer = [];
|
|
402
|
+
let logFlushTimer = null;
|
|
98
403
|
let retryCount = 0;
|
|
99
404
|
let lastErrorMessage = "";
|
|
405
|
+
let apiCheckInFlight = false;
|
|
100
406
|
|
|
101
407
|
const attachHandler = (target, event, handler) => {
|
|
102
408
|
if (typeof target.on === "function") {
|
|
@@ -116,22 +422,89 @@ const isSocketOpen = () => {
|
|
|
116
422
|
return isOpen;
|
|
117
423
|
};
|
|
118
424
|
|
|
425
|
+
const CONNECT_TIMEOUT_MS = 10000;
|
|
426
|
+
|
|
119
427
|
const connect = () => {
|
|
120
428
|
retryCount += 1;
|
|
121
|
-
|
|
429
|
+
const attempt = retryCount;
|
|
430
|
+
log(`connecting to ${wsUrl} (#${attempt})...`, "warn");
|
|
122
431
|
try {
|
|
123
|
-
|
|
432
|
+
const wsOptions = wsUrl.startsWith("wss://")
|
|
433
|
+
? { rejectUnauthorized: false }
|
|
434
|
+
: undefined;
|
|
435
|
+
socket = new WebSocket(wsUrl, wsOptions);
|
|
124
436
|
} catch (error) {
|
|
125
437
|
lastErrorMessage = String(error);
|
|
126
|
-
log(`connect failed: ${lastErrorMessage}
|
|
438
|
+
log(`connect failed: ${lastErrorMessage}`, "error");
|
|
127
439
|
setTimeout(connect, retryWaitMs);
|
|
128
440
|
return;
|
|
129
441
|
}
|
|
130
442
|
|
|
443
|
+
// 接続タイムアウト検知
|
|
444
|
+
const connectTimer = setTimeout(() => {
|
|
445
|
+
if (socket && !isOpen) {
|
|
446
|
+
log(`connection timeout (${CONNECT_TIMEOUT_MS}ms). 接続先を確認してください: ${wsUrl}`, "error");
|
|
447
|
+
try { socket.close(); } catch (_e) { /* ignore */ }
|
|
448
|
+
}
|
|
449
|
+
}, CONNECT_TIMEOUT_MS);
|
|
450
|
+
|
|
131
451
|
attachHandler(socket, "open", () => {
|
|
452
|
+
clearTimeout(connectTimer);
|
|
132
453
|
isOpen = true;
|
|
133
454
|
retryCount = 0;
|
|
134
|
-
log(`connected: ${wsUrl}
|
|
455
|
+
log(`connected: ${wsUrl}`, "warn");
|
|
456
|
+
|
|
457
|
+
// --mode security: run security check only, then exit
|
|
458
|
+
if (runMode === "security") {
|
|
459
|
+
void (async () => {
|
|
460
|
+
try {
|
|
461
|
+
const result = await triggerSecurityCheck("mode:security");
|
|
462
|
+
flushLogBuffer();
|
|
463
|
+
// Send done summary as code-analysis done for backend pipeline
|
|
464
|
+
sendCodeAnalysisLine(`done ${JSON.stringify({
|
|
465
|
+
api_missing_count: 0, api_missing_items: [],
|
|
466
|
+
api_implemented_count: 0, api_implemented_items: [],
|
|
467
|
+
screen_missing_count: 0, screen_missing_items: [],
|
|
468
|
+
screen_implemented_count: 0, screen_implemented_items: [],
|
|
469
|
+
...result,
|
|
470
|
+
})}`);
|
|
471
|
+
flushLogBuffer();
|
|
472
|
+
// Give time for send to complete
|
|
473
|
+
setTimeout(() => {
|
|
474
|
+
log("security mode complete, exiting.", "warn");
|
|
475
|
+
if (socket) socket.close();
|
|
476
|
+
process.exit(0);
|
|
477
|
+
}, 500);
|
|
478
|
+
} catch (error) {
|
|
479
|
+
log(`security mode error: ${error}`, "error");
|
|
480
|
+
process.exit(1);
|
|
481
|
+
}
|
|
482
|
+
})();
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
if (sendProjectTree) {
|
|
487
|
+
emitProjectTree();
|
|
488
|
+
}
|
|
489
|
+
if (enableApiCheck) {
|
|
490
|
+
void (async () => {
|
|
491
|
+
if (!apiEndpointCandidatesCached.length) {
|
|
492
|
+
await fetchApiEndpointList();
|
|
493
|
+
}
|
|
494
|
+
logApiCheckStatus();
|
|
495
|
+
if (apiEndpointCandidatesCached.length) {
|
|
496
|
+
await runApiCheck();
|
|
497
|
+
}
|
|
498
|
+
})();
|
|
499
|
+
}
|
|
500
|
+
if (enableScreenCheck) {
|
|
501
|
+
void (async () => {
|
|
502
|
+
if (!screenCandidatesCached.length) {
|
|
503
|
+
await fetchScreenList();
|
|
504
|
+
}
|
|
505
|
+
logScreenCheckStatus();
|
|
506
|
+
})();
|
|
507
|
+
}
|
|
135
508
|
while (queue.length) {
|
|
136
509
|
const payload = queue.shift();
|
|
137
510
|
if (!isSocketOpen()) {
|
|
@@ -147,7 +520,52 @@ const connect = () => {
|
|
|
147
520
|
}
|
|
148
521
|
});
|
|
149
522
|
|
|
523
|
+
attachHandler(socket, "message", (event) => {
|
|
524
|
+
const raw =
|
|
525
|
+
event && typeof event === "object" && "data" in event ? event.data : event;
|
|
526
|
+
if (!raw) return;
|
|
527
|
+
const text =
|
|
528
|
+
typeof raw === "string"
|
|
529
|
+
? raw
|
|
530
|
+
: typeof raw.toString === "function"
|
|
531
|
+
? raw.toString()
|
|
532
|
+
: "";
|
|
533
|
+
if (!text || !text.trim().startsWith("{")) return;
|
|
534
|
+
let payload = null;
|
|
535
|
+
try {
|
|
536
|
+
payload = JSON.parse(text);
|
|
537
|
+
} catch (error) {
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
if (!payload || typeof payload !== "object") return;
|
|
541
|
+
if (payload.type !== "control") return;
|
|
542
|
+
log(`[control] received action=${payload.action}`, "info");
|
|
543
|
+
if (payload.action === "api_check") {
|
|
544
|
+
log("[control] triggering api_check", "info");
|
|
545
|
+
void triggerApiCheck("control");
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
if (payload.action === "code_analysis") {
|
|
549
|
+
const role = payload.role || null;
|
|
550
|
+
log(`[control] triggering code_analysis role=${role}`, "info");
|
|
551
|
+
void triggerCodeAnalysis("control", role);
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
if (payload.action === "screen_check") {
|
|
555
|
+
if (enableScreenCheck) {
|
|
556
|
+
void triggerScreenCheck("control");
|
|
557
|
+
} else {
|
|
558
|
+
sendScreenCheckLine("screen check disabled; skipping.");
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
if (payload.action === "security_check") {
|
|
562
|
+
log("[control] triggering security_check", "info");
|
|
563
|
+
void triggerSecurityCheck("control");
|
|
564
|
+
}
|
|
565
|
+
});
|
|
566
|
+
|
|
150
567
|
attachHandler(socket, "close", (event) => {
|
|
568
|
+
clearTimeout(connectTimer);
|
|
151
569
|
isOpen = false;
|
|
152
570
|
const code =
|
|
153
571
|
event && typeof event === "object" && "code" in event ? event.code : null;
|
|
@@ -155,13 +573,23 @@ const connect = () => {
|
|
|
155
573
|
event && typeof event === "object" && "reason" in event
|
|
156
574
|
? event.reason
|
|
157
575
|
: "";
|
|
158
|
-
const
|
|
576
|
+
const reasonStr = reason ? String(reason) : "";
|
|
577
|
+
const detail = [code ? `code=${code}` : null, reasonStr || null]
|
|
159
578
|
.filter(Boolean)
|
|
160
579
|
.join(" ");
|
|
580
|
+
if (code === 1008) {
|
|
581
|
+
const hint = reasonStr === "project_key_mismatch"
|
|
582
|
+
? "同じ session_id で別の project_key が既に登録されています。バックエンドサーバーを再起動してください。"
|
|
583
|
+
: reasonStr
|
|
584
|
+
? `サーバーが接続を拒否しました: ${reasonStr}`
|
|
585
|
+
: "サーバーが接続を拒否しました (code=1008)。session_id / project_key を確認してください。";
|
|
586
|
+
log(`error: ${hint}`, "error");
|
|
587
|
+
}
|
|
161
588
|
log(
|
|
162
589
|
`disconnected; retrying in ${retryWaitMs}ms${
|
|
163
590
|
detail ? ` (${detail})` : ""
|
|
164
591
|
}`,
|
|
592
|
+
"warn",
|
|
165
593
|
);
|
|
166
594
|
setTimeout(connect, retryWaitMs);
|
|
167
595
|
});
|
|
@@ -172,7 +600,7 @@ const connect = () => {
|
|
|
172
600
|
? error.message
|
|
173
601
|
: String(error);
|
|
174
602
|
lastErrorMessage = message;
|
|
175
|
-
log(`error: ${message}
|
|
603
|
+
log(`error: ${message}`, "warn");
|
|
176
604
|
});
|
|
177
605
|
};
|
|
178
606
|
|
|
@@ -196,13 +624,96 @@ const enqueueOrSend = (payload) => {
|
|
|
196
624
|
queue.push(payload);
|
|
197
625
|
};
|
|
198
626
|
|
|
199
|
-
const
|
|
627
|
+
const flushLogBuffer = () => {
|
|
628
|
+
if (logFlushTimer) {
|
|
629
|
+
clearTimeout(logFlushTimer);
|
|
630
|
+
logFlushTimer = null;
|
|
631
|
+
}
|
|
632
|
+
if (!logBuffer.length) return;
|
|
633
|
+
const lines = logBuffer.splice(0, logBuffer.length);
|
|
200
634
|
const payload = JSON.stringify({
|
|
201
635
|
type: "log",
|
|
202
|
-
content:
|
|
636
|
+
content: lines.join("\n"),
|
|
203
637
|
timestamp: new Date().toISOString(),
|
|
204
638
|
});
|
|
205
639
|
enqueueOrSend(payload);
|
|
640
|
+
};
|
|
641
|
+
|
|
642
|
+
const enqueueLogLine = (line) => {
|
|
643
|
+
if (!line) return;
|
|
644
|
+
logBuffer.push(line);
|
|
645
|
+
if (logBatchMs === 0 || logBuffer.length >= logBatchMaxLines) {
|
|
646
|
+
flushLogBuffer();
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
if (!logFlushTimer) {
|
|
650
|
+
logFlushTimer = setTimeout(flushLogBuffer, logBatchMs);
|
|
651
|
+
}
|
|
652
|
+
};
|
|
653
|
+
|
|
654
|
+
const sendLogLine = (line) => {
|
|
655
|
+
enqueueLogLine(line);
|
|
656
|
+
};
|
|
657
|
+
|
|
658
|
+
const sendApiCheckLine = (line) => {
|
|
659
|
+
const tagged = `[api-check] ${line}`;
|
|
660
|
+
log(tagged, "debug");
|
|
661
|
+
sendLogLine(tagged);
|
|
662
|
+
};
|
|
663
|
+
|
|
664
|
+
const sendCodeAnalysisLine = (line) => {
|
|
665
|
+
const tagged = `[code-analysis] ${line}`;
|
|
666
|
+
log(tagged, "info");
|
|
667
|
+
sendLogLine(tagged);
|
|
668
|
+
};
|
|
669
|
+
|
|
670
|
+
const sendScreenCheckLine = (line) => {
|
|
671
|
+
const tagged = `[screen-check] ${line}`;
|
|
672
|
+
log(tagged, "debug");
|
|
673
|
+
sendLogLine(tagged);
|
|
674
|
+
};
|
|
675
|
+
|
|
676
|
+
const sendSecurityCheckLine = (line) => {
|
|
677
|
+
const tagged = `[security-check] ${line}`;
|
|
678
|
+
log(tagged, "info");
|
|
679
|
+
sendLogLine(tagged);
|
|
680
|
+
};
|
|
681
|
+
|
|
682
|
+
const logApiCheckStatus = () => {
|
|
683
|
+
const maskedKey = openaiApiKey ? `${openaiApiKey.slice(0, 6)}...` : "";
|
|
684
|
+
const statusLabel =
|
|
685
|
+
apiEndpointFetchStatus === "error"
|
|
686
|
+
? `NG(fetch_failed${apiEndpointFetchDetail ? `: ${apiEndpointFetchDetail}` : ""})`
|
|
687
|
+
: apiEndpointFetchStatus === "ok_empty"
|
|
688
|
+
? "NG(empty)"
|
|
689
|
+
: apiEndpointCandidatesCached.length
|
|
690
|
+
? "OK"
|
|
691
|
+
: "NG(unknown)";
|
|
692
|
+
sendApiCheckLine(
|
|
693
|
+
`必須チェック: api_endpoint_list=${statusLabel} openai_api_key=${
|
|
694
|
+
openaiApiKey ? "OK" : "NG"
|
|
695
|
+
} ${
|
|
696
|
+
maskedKey ? `(${maskedKey})` : ""
|
|
697
|
+
} openai_model=${openaiModel ? "OK" : "NG"}`,
|
|
698
|
+
);
|
|
699
|
+
};
|
|
700
|
+
|
|
701
|
+
const logScreenCheckStatus = () => {
|
|
702
|
+
const statusLabel =
|
|
703
|
+
screenListFetchStatus === "error"
|
|
704
|
+
? `NG(fetch_failed${screenListFetchDetail ? `: ${screenListFetchDetail}` : ""})`
|
|
705
|
+
: screenListFetchStatus === "ok_empty"
|
|
706
|
+
? "NG(empty)"
|
|
707
|
+
: screenCandidatesCached.length
|
|
708
|
+
? "OK"
|
|
709
|
+
: "NG(unknown)";
|
|
710
|
+
sendScreenCheckLine(
|
|
711
|
+
`必須チェック: screen_list=${statusLabel}`,
|
|
712
|
+
);
|
|
713
|
+
};
|
|
714
|
+
|
|
715
|
+
const handleLine = (line) => {
|
|
716
|
+
sendLogLine(line);
|
|
206
717
|
if (isErrorLine(line)) {
|
|
207
718
|
const alertPayload = JSON.stringify({
|
|
208
719
|
type: "alert",
|
|
@@ -213,6 +724,667 @@ const handleLine = (line) => {
|
|
|
213
724
|
}
|
|
214
725
|
};
|
|
215
726
|
|
|
727
|
+
const emitProjectTree = () => {
|
|
728
|
+
const lines = buildProjectTreeLines(
|
|
729
|
+
projectRoot,
|
|
730
|
+
DEFAULT_SNAPSHOT_EXCLUDES,
|
|
731
|
+
projectTreeMaxFiles,
|
|
732
|
+
includeProjectTreeContents,
|
|
733
|
+
projectTreeMaxBytes,
|
|
734
|
+
);
|
|
735
|
+
lines.forEach((line) => {
|
|
736
|
+
log(line, "debug");
|
|
737
|
+
sendLogLine(line);
|
|
738
|
+
});
|
|
739
|
+
};
|
|
740
|
+
|
|
741
|
+
process.on("SIGINT", () => {
|
|
742
|
+
flushLogBuffer();
|
|
743
|
+
process.exit(0);
|
|
744
|
+
});
|
|
745
|
+
|
|
746
|
+
process.on("SIGTERM", () => {
|
|
747
|
+
flushLogBuffer();
|
|
748
|
+
process.exit(0);
|
|
749
|
+
});
|
|
750
|
+
|
|
751
|
+
const buildApiCheckPrompt = (endpoints, treeLines) => {
|
|
752
|
+
return [
|
|
753
|
+
"You are a senior backend engineer. Determine which API endpoints are NOT implemented in the codebase.",
|
|
754
|
+
"Use the file tree (and optional file excerpts) to infer whether each endpoint is implemented.",
|
|
755
|
+
"If unsure, mark as 'unknown'.",
|
|
756
|
+
"Return JSON only: {\"missing\": [{\"endpoint\": \"METHOD /path\", \"reason\": \"...\", \"confidence\": 0-1}], \"unknown\": [\"METHOD /path\"], \"implemented\": [\"METHOD /path\"]}",
|
|
757
|
+
"",
|
|
758
|
+
"API endpoints:",
|
|
759
|
+
...endpoints.map((item) => `- ${item}`),
|
|
760
|
+
"",
|
|
761
|
+
"Project files:",
|
|
762
|
+
...treeLines,
|
|
763
|
+
].join("\n");
|
|
764
|
+
};
|
|
765
|
+
|
|
766
|
+
const collectTreeForApiCheck = () => {
|
|
767
|
+
return buildProjectTreeLines(
|
|
768
|
+
projectRoot,
|
|
769
|
+
DEFAULT_SNAPSHOT_EXCLUDES,
|
|
770
|
+
projectTreeMaxFiles,
|
|
771
|
+
includeProjectTreeContents,
|
|
772
|
+
projectTreeMaxBytes,
|
|
773
|
+
);
|
|
774
|
+
};
|
|
775
|
+
|
|
776
|
+
const runApiCheck = async () => {
|
|
777
|
+
const allMissingResult = () => {
|
|
778
|
+
const items = apiEndpointCandidatesCached.map(String);
|
|
779
|
+
return { api_missing_count: items.length, api_missing_items: items, api_implemented_count: 0, api_implemented_items: [] };
|
|
780
|
+
};
|
|
781
|
+
if (!enableApiCheck) return allMissingResult();
|
|
782
|
+
if (!apiEndpointCandidatesCached.length) {
|
|
783
|
+
return { api_missing_count: 0, api_missing_items: [], api_implemented_count: 0, api_implemented_items: [] };
|
|
784
|
+
}
|
|
785
|
+
if (!openaiApiKey) {
|
|
786
|
+
sendApiCheckLine("OPENAI_API_KEY is not set; すべて未実装として扱います。");
|
|
787
|
+
return allMissingResult();
|
|
788
|
+
}
|
|
789
|
+
if (!openaiModel) {
|
|
790
|
+
sendApiCheckLine("OPENAI_MODEL is not set; すべて未実装として扱います。");
|
|
791
|
+
return allMissingResult();
|
|
792
|
+
}
|
|
793
|
+
if (typeof fetch !== "function") {
|
|
794
|
+
sendApiCheckLine("fetch is not available in this runtime; すべて未実装として扱います。");
|
|
795
|
+
return allMissingResult();
|
|
796
|
+
}
|
|
797
|
+
const treeLines = collectTreeForApiCheck();
|
|
798
|
+
const prompt = buildApiCheckPrompt(apiEndpointCandidatesCached, treeLines);
|
|
799
|
+
const payload = {
|
|
800
|
+
model: openaiModel,
|
|
801
|
+
messages: [
|
|
802
|
+
{
|
|
803
|
+
role: "system",
|
|
804
|
+
content:
|
|
805
|
+
"You return strictly valid JSON. Do not wrap in markdown or add commentary.",
|
|
806
|
+
},
|
|
807
|
+
{ role: "user", content: prompt },
|
|
808
|
+
],
|
|
809
|
+
temperature: 0.2,
|
|
810
|
+
};
|
|
811
|
+
let response = null;
|
|
812
|
+
try {
|
|
813
|
+
response = await fetch(OPENAI_CHAT_API_URL, {
|
|
814
|
+
method: "POST",
|
|
815
|
+
headers: {
|
|
816
|
+
Authorization: `Bearer ${openaiApiKey}`,
|
|
817
|
+
"Content-Type": "application/json",
|
|
818
|
+
},
|
|
819
|
+
body: JSON.stringify(payload),
|
|
820
|
+
});
|
|
821
|
+
} catch (error) {
|
|
822
|
+
sendApiCheckLine(`OpenAI request failed: ${error}`);
|
|
823
|
+
return allMissingResult();
|
|
824
|
+
}
|
|
825
|
+
if (!response.ok) {
|
|
826
|
+
const text = await response.text();
|
|
827
|
+
sendApiCheckLine(
|
|
828
|
+
`OpenAI request failed: ${response.status} ${response.statusText}`,
|
|
829
|
+
);
|
|
830
|
+
sendApiCheckLine(text.slice(0, 1000));
|
|
831
|
+
return allMissingResult();
|
|
832
|
+
}
|
|
833
|
+
let data = null;
|
|
834
|
+
try {
|
|
835
|
+
data = await response.json();
|
|
836
|
+
} catch (error) {
|
|
837
|
+
sendApiCheckLine(`OpenAI response parse failed: ${error}`);
|
|
838
|
+
return allMissingResult();
|
|
839
|
+
}
|
|
840
|
+
const content = data?.choices?.[0]?.message?.content?.trim();
|
|
841
|
+
if (!content) {
|
|
842
|
+
sendApiCheckLine("OpenAI response was empty.");
|
|
843
|
+
return allMissingResult();
|
|
844
|
+
}
|
|
845
|
+
try {
|
|
846
|
+
const parsed = JSON.parse(content);
|
|
847
|
+
const missing = Array.isArray(parsed?.missing) ? parsed.missing : [];
|
|
848
|
+
const unknown = Array.isArray(parsed?.unknown) ? parsed.unknown : [];
|
|
849
|
+
const implemented = Array.isArray(parsed?.implemented)
|
|
850
|
+
? parsed.implemented
|
|
851
|
+
: [];
|
|
852
|
+
sendApiCheckLine(
|
|
853
|
+
`missing=${missing.length} unknown=${unknown.length} implemented=${implemented.length}`,
|
|
854
|
+
);
|
|
855
|
+
const missingItems = [];
|
|
856
|
+
missing.forEach((item) => {
|
|
857
|
+
if (item && typeof item === "object") {
|
|
858
|
+
const label = item.endpoint || "";
|
|
859
|
+
missingItems.push(label);
|
|
860
|
+
sendApiCheckLine(
|
|
861
|
+
`missing: ${label} (${item.reason || "no reason"})`,
|
|
862
|
+
);
|
|
863
|
+
} else {
|
|
864
|
+
missingItems.push(String(item));
|
|
865
|
+
sendApiCheckLine(`missing: ${String(item)}`);
|
|
866
|
+
}
|
|
867
|
+
});
|
|
868
|
+
const implementedItems = [];
|
|
869
|
+
unknown.forEach((item) => sendApiCheckLine(`unknown: ${String(item)}`));
|
|
870
|
+
implemented.forEach((item) => {
|
|
871
|
+
implementedItems.push(String(item));
|
|
872
|
+
sendApiCheckLine(`implemented: ${String(item)}`);
|
|
873
|
+
});
|
|
874
|
+
return {
|
|
875
|
+
api_missing_count: missing.length,
|
|
876
|
+
api_missing_items: missingItems,
|
|
877
|
+
api_implemented_count: implemented.length,
|
|
878
|
+
api_implemented_items: implementedItems,
|
|
879
|
+
};
|
|
880
|
+
} catch (error) {
|
|
881
|
+
sendApiCheckLine("OpenAI output was not valid JSON. Raw output:");
|
|
882
|
+
sendApiCheckLine(content.slice(0, 2000));
|
|
883
|
+
return allMissingResult();
|
|
884
|
+
}
|
|
885
|
+
};
|
|
886
|
+
|
|
887
|
+
const screenCheckUrl = `${normalizeBaseHttpUrl(baseUrl)}/api/v1/project_snapshot/check`;
|
|
888
|
+
|
|
889
|
+
const runScreenCheck = async () => {
|
|
890
|
+
const allMissingResult = () => {
|
|
891
|
+
const items = screenCandidatesCached.map(toScreenName);
|
|
892
|
+
return { screen_missing_count: items.length, screen_missing_items: items, screen_implemented_count: 0, screen_implemented_items: [] };
|
|
893
|
+
};
|
|
894
|
+
if (!projectKey) {
|
|
895
|
+
sendScreenCheckLine("project_key is missing; すべて未実装として扱います。");
|
|
896
|
+
return allMissingResult();
|
|
897
|
+
}
|
|
898
|
+
if (typeof fetch !== "function") {
|
|
899
|
+
sendScreenCheckLine("fetch is not available in this runtime; すべて未実装として扱います。");
|
|
900
|
+
return allMissingResult();
|
|
901
|
+
}
|
|
902
|
+
const paths = collectSnapshotPaths(projectRoot, DEFAULT_SNAPSHOT_EXCLUDES).filter(
|
|
903
|
+
(item) => item.startsWith("frontend/"),
|
|
904
|
+
);
|
|
905
|
+
const limitedPaths = paths.slice(0, projectTreeMaxFiles);
|
|
906
|
+
if (!limitedPaths.length) {
|
|
907
|
+
sendScreenCheckLine("frontend/ 配下のファイルが見つからないため、すべて未実装として扱います。");
|
|
908
|
+
const allMissing = screenCandidatesCached.map(toScreenName);
|
|
909
|
+
allMissing.forEach((item) => sendScreenCheckLine(`missing: ${item}`));
|
|
910
|
+
return {
|
|
911
|
+
screen_missing_count: allMissing.length,
|
|
912
|
+
screen_missing_items: allMissing,
|
|
913
|
+
screen_implemented_count: 0,
|
|
914
|
+
screen_implemented_items: [],
|
|
915
|
+
};
|
|
916
|
+
}
|
|
917
|
+
const payload = {
|
|
918
|
+
projectPublicId: projectKey,
|
|
919
|
+
paths: limitedPaths,
|
|
920
|
+
};
|
|
921
|
+
let response = null;
|
|
922
|
+
try {
|
|
923
|
+
response = await fetch(screenCheckUrl, {
|
|
924
|
+
method: "POST",
|
|
925
|
+
headers: { "Content-Type": "application/json" },
|
|
926
|
+
body: JSON.stringify(payload),
|
|
927
|
+
});
|
|
928
|
+
} catch (error) {
|
|
929
|
+
sendScreenCheckLine(`screen check request failed: ${error}`);
|
|
930
|
+
return allMissingResult();
|
|
931
|
+
}
|
|
932
|
+
if (!response.ok) {
|
|
933
|
+
const text = await response.text();
|
|
934
|
+
sendScreenCheckLine(
|
|
935
|
+
`screen check failed: ${response.status} ${response.statusText}`,
|
|
936
|
+
);
|
|
937
|
+
sendScreenCheckLine(text.slice(0, 1000));
|
|
938
|
+
return allMissingResult();
|
|
939
|
+
}
|
|
940
|
+
let data = null;
|
|
941
|
+
try {
|
|
942
|
+
data = await response.json();
|
|
943
|
+
} catch (error) {
|
|
944
|
+
sendScreenCheckLine(`screen check response parse failed: ${error}`);
|
|
945
|
+
return allMissingResult();
|
|
946
|
+
}
|
|
947
|
+
const expected = screenCandidatesCached.length
|
|
948
|
+
? screenCandidatesCached.map(toScreenName)
|
|
949
|
+
: (Array.isArray(data?.expectedScreens) ? data.expectedScreens : []);
|
|
950
|
+
const rawMissing = Array.isArray(data?.check?.missingScreens)
|
|
951
|
+
? data.check.missingScreens
|
|
952
|
+
: [];
|
|
953
|
+
// expected をキャッシュに揃えたので、missing も expected に含まれるもののみに絞る
|
|
954
|
+
const expectedSet = new Set(expected.map(String));
|
|
955
|
+
const missing = rawMissing.filter((item) => expectedSet.has(String(item)));
|
|
956
|
+
const implemented = expected.filter((item) => !missing.includes(item));
|
|
957
|
+
sendScreenCheckLine(
|
|
958
|
+
`missing_screens=${missing.length} expected=${expected.length} implemented=${implemented.length}`,
|
|
959
|
+
);
|
|
960
|
+
missing.forEach((item) => sendScreenCheckLine(`missing: ${String(item)}`));
|
|
961
|
+
implemented.forEach((item) =>
|
|
962
|
+
sendScreenCheckLine(`implemented: ${String(item)}`),
|
|
963
|
+
);
|
|
964
|
+
if (data?.check?.notes) {
|
|
965
|
+
sendScreenCheckLine(`note: ${String(data.check.notes)}`);
|
|
966
|
+
}
|
|
967
|
+
return {
|
|
968
|
+
screen_missing_count: missing.length,
|
|
969
|
+
screen_missing_items: missing.map(String),
|
|
970
|
+
screen_implemented_count: implemented.length,
|
|
971
|
+
screen_implemented_items: implemented.map(String),
|
|
972
|
+
};
|
|
973
|
+
};
|
|
974
|
+
|
|
975
|
+
const triggerApiCheck = async (reason) => {
|
|
976
|
+
const emptyResult = { api_missing_count: 0, api_missing_items: [], api_implemented_count: 0, api_implemented_items: [] };
|
|
977
|
+
if (apiCheckInFlight) return emptyResult;
|
|
978
|
+
apiCheckInFlight = true;
|
|
979
|
+
try {
|
|
980
|
+
sendApiCheckLine(`api-check start (${reason})`);
|
|
981
|
+
if (!apiEndpointCandidatesCached.length) {
|
|
982
|
+
await fetchApiEndpointList();
|
|
983
|
+
}
|
|
984
|
+
logApiCheckStatus();
|
|
985
|
+
if (!apiEndpointCandidatesCached.length) {
|
|
986
|
+
sendApiCheckLine("api_endpoint_list is empty; skipping api-check.");
|
|
987
|
+
return emptyResult;
|
|
988
|
+
}
|
|
989
|
+
return await runApiCheck();
|
|
990
|
+
} finally {
|
|
991
|
+
apiCheckInFlight = false;
|
|
992
|
+
}
|
|
993
|
+
};
|
|
994
|
+
|
|
995
|
+
const triggerScreenCheck = async (reason) => {
|
|
996
|
+
const emptyResult = { screen_missing_count: 0, screen_missing_items: [], screen_implemented_count: 0, screen_implemented_items: [] };
|
|
997
|
+
try {
|
|
998
|
+
sendScreenCheckLine(`screen-check start (${reason})`);
|
|
999
|
+
if (!screenCandidatesCached.length) {
|
|
1000
|
+
await fetchScreenList();
|
|
1001
|
+
}
|
|
1002
|
+
logScreenCheckStatus();
|
|
1003
|
+
if (!screenCandidatesCached.length) {
|
|
1004
|
+
sendScreenCheckLine("screen_list is empty; skipping screen-check.");
|
|
1005
|
+
return emptyResult;
|
|
1006
|
+
}
|
|
1007
|
+
return await runScreenCheck();
|
|
1008
|
+
} catch (error) {
|
|
1009
|
+
sendScreenCheckLine(`screen-check failed: ${error}`);
|
|
1010
|
+
return emptyResult;
|
|
1011
|
+
}
|
|
1012
|
+
};
|
|
1013
|
+
|
|
1014
|
+
// ---------------------------------------------------------------------------
|
|
1015
|
+
// Security Check (static regex-based)
|
|
1016
|
+
// ---------------------------------------------------------------------------
|
|
1017
|
+
|
|
1018
|
+
const SECURITY_RULES = [
|
|
1019
|
+
{
|
|
1020
|
+
id: "raw_sql",
|
|
1021
|
+
label: "SQLインジェクション",
|
|
1022
|
+
pattern: /(?:execute\s*\(\s*f["']|\.raw\s*\(|cursor\.execute\s*\(\s*f["'])/,
|
|
1023
|
+
extensions: new Set([".py", ".js", ".ts"]),
|
|
1024
|
+
},
|
|
1025
|
+
{
|
|
1026
|
+
id: "xss",
|
|
1027
|
+
label: "XSS",
|
|
1028
|
+
pattern: /(?:dangerouslySetInnerHTML|v-html)/,
|
|
1029
|
+
extensions: new Set([".js", ".jsx", ".ts", ".tsx", ".vue"]),
|
|
1030
|
+
},
|
|
1031
|
+
{
|
|
1032
|
+
id: "hardcoded_secrets",
|
|
1033
|
+
label: "秘密情報ハードコード",
|
|
1034
|
+
pattern: /(?:api_key|apikey|secret_key|password|aws_secret)\s*=\s*["'][^"']{4,}["']/i,
|
|
1035
|
+
extensions: new Set([".py", ".js", ".ts", ".env", ".yaml", ".yml", ".json"]),
|
|
1036
|
+
},
|
|
1037
|
+
{
|
|
1038
|
+
id: "stack_trace_leak",
|
|
1039
|
+
label: "スタックトレース漏洩",
|
|
1040
|
+
pattern: /(?:traceback\.format_exc|import\s+traceback)/,
|
|
1041
|
+
extensions: new Set([".py"]),
|
|
1042
|
+
},
|
|
1043
|
+
{
|
|
1044
|
+
id: "log_secrets",
|
|
1045
|
+
label: "ログ秘密情報",
|
|
1046
|
+
pattern: /(?:logger\.\w+.*(?:password|token|secret)|print\s*\(.*(?:password|token|secret))/i,
|
|
1047
|
+
extensions: new Set([".py", ".js", ".ts"]),
|
|
1048
|
+
},
|
|
1049
|
+
{
|
|
1050
|
+
id: "cors_wildcard",
|
|
1051
|
+
label: "CORSワイルドカード",
|
|
1052
|
+
pattern: /allow_origins\s*=\s*\[\s*["']\*["']\s*\]/,
|
|
1053
|
+
extensions: new Set([".py"]),
|
|
1054
|
+
},
|
|
1055
|
+
];
|
|
1056
|
+
|
|
1057
|
+
const SECURITY_EXTENSIONS = new Set([".py", ".js", ".jsx", ".ts", ".tsx", ".vue", ".yaml", ".yml", ".json"]);
|
|
1058
|
+
|
|
1059
|
+
const collectSecurityTargetPaths = (rootDir, excludes) => {
|
|
1060
|
+
const results = [];
|
|
1061
|
+
const queue = [rootDir];
|
|
1062
|
+
while (queue.length) {
|
|
1063
|
+
const current = queue.pop();
|
|
1064
|
+
if (!current) continue;
|
|
1065
|
+
let entries = [];
|
|
1066
|
+
try {
|
|
1067
|
+
entries = fs.readdirSync(current, { withFileTypes: true });
|
|
1068
|
+
} catch (error) {
|
|
1069
|
+
continue;
|
|
1070
|
+
}
|
|
1071
|
+
for (const entry of entries) {
|
|
1072
|
+
const fullPath = path.join(current, entry.name);
|
|
1073
|
+
const relPath = path.relative(rootDir, fullPath).replace(/\\/g, "/");
|
|
1074
|
+
if (isExcludedPath(relPath, entry.name, excludes)) continue;
|
|
1075
|
+
if (entry.isSymbolicLink && entry.isSymbolicLink()) continue;
|
|
1076
|
+
if (entry.isDirectory()) {
|
|
1077
|
+
queue.push(fullPath);
|
|
1078
|
+
continue;
|
|
1079
|
+
}
|
|
1080
|
+
if (entry.isFile()) {
|
|
1081
|
+
const ext = path.extname(entry.name).toLowerCase();
|
|
1082
|
+
if (SECURITY_EXTENSIONS.has(ext)) {
|
|
1083
|
+
results.push({ relPath, fullPath, ext });
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
return results;
|
|
1089
|
+
};
|
|
1090
|
+
|
|
1091
|
+
const SECURITY_CHECK_STEP_DELAY_MS = 400;
|
|
1092
|
+
const SECURITY_DETAIL_MAX_MATCHES = 5;
|
|
1093
|
+
|
|
1094
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
1095
|
+
|
|
1096
|
+
const collectRuleMatches = (rule, files) => {
|
|
1097
|
+
const matches = [];
|
|
1098
|
+
for (const file of files) {
|
|
1099
|
+
if (!rule.extensions.has(file.ext)) continue;
|
|
1100
|
+
let content = "";
|
|
1101
|
+
try {
|
|
1102
|
+
const buffer = fs.readFileSync(file.fullPath);
|
|
1103
|
+
if (isBinaryBuffer(buffer)) continue;
|
|
1104
|
+
content = buffer.toString("utf8");
|
|
1105
|
+
} catch (error) {
|
|
1106
|
+
continue;
|
|
1107
|
+
}
|
|
1108
|
+
const lines = content.split("\n");
|
|
1109
|
+
const re = new RegExp(rule.pattern.source, rule.pattern.flags);
|
|
1110
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1111
|
+
if (!re.test(lines[i])) continue;
|
|
1112
|
+
const contextStart = Math.max(0, i - 3);
|
|
1113
|
+
const contextEnd = Math.min(lines.length, i + 4);
|
|
1114
|
+
matches.push({
|
|
1115
|
+
file: file.relPath,
|
|
1116
|
+
line: i + 1,
|
|
1117
|
+
code: lines[i].trim(),
|
|
1118
|
+
context: lines.slice(contextStart, contextEnd).join("\n"),
|
|
1119
|
+
});
|
|
1120
|
+
if (matches.length >= SECURITY_DETAIL_MAX_MATCHES) return matches;
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
return matches;
|
|
1124
|
+
};
|
|
1125
|
+
|
|
1126
|
+
const runSecurityCheck = async () => {
|
|
1127
|
+
const files = collectSecurityTargetPaths(projectRoot, DEFAULT_SNAPSHOT_EXCLUDES);
|
|
1128
|
+
const issueMap = {}; // id -> count
|
|
1129
|
+
const passedIds = [];
|
|
1130
|
+
const issueDetails = []; // { id, label, count }
|
|
1131
|
+
const total = SECURITY_RULES.length + 1; // +1 for .env check
|
|
1132
|
+
|
|
1133
|
+
// Check each rule
|
|
1134
|
+
let ruleIndex = 0;
|
|
1135
|
+
for (const rule of SECURITY_RULES) {
|
|
1136
|
+
ruleIndex += 1;
|
|
1137
|
+
// Collect detailed match info
|
|
1138
|
+
const ruleMatches = collectRuleMatches(rule, files);
|
|
1139
|
+
const hitCount = ruleMatches.length;
|
|
1140
|
+
if (hitCount > 0) {
|
|
1141
|
+
issueMap[rule.id] = hitCount;
|
|
1142
|
+
issueDetails.push({ id: rule.id, label: rule.label, count: hitCount });
|
|
1143
|
+
sendSecurityCheckLine(`progress ${ruleIndex}/${total} ${rule.id} issue ${hitCount}`);
|
|
1144
|
+
// Send detail line with match locations
|
|
1145
|
+
sendSecurityCheckLine(`detail ${JSON.stringify({ rule_id: rule.id, label: rule.label, matches: ruleMatches })}`);
|
|
1146
|
+
} else {
|
|
1147
|
+
passedIds.push(rule.id);
|
|
1148
|
+
sendSecurityCheckLine(`progress ${ruleIndex}/${total} ${rule.id} passed`);
|
|
1149
|
+
}
|
|
1150
|
+
// 1項目ずつ送信してバルーン更新を可視化
|
|
1151
|
+
flushLogBuffer();
|
|
1152
|
+
await sleep(SECURITY_CHECK_STEP_DELAY_MS);
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
// Check .env in .gitignore
|
|
1156
|
+
let envGitignored = false;
|
|
1157
|
+
try {
|
|
1158
|
+
const gitignore = fs.readFileSync(path.join(projectRoot, ".gitignore"), "utf8");
|
|
1159
|
+
envGitignored = /^\.env$/m.test(gitignore);
|
|
1160
|
+
} catch (error) {
|
|
1161
|
+
// no .gitignore
|
|
1162
|
+
}
|
|
1163
|
+
if (!envGitignored) {
|
|
1164
|
+
issueDetails.push({ id: "env_not_gitignored", label: ".env未gitignore", count: 1 });
|
|
1165
|
+
issueMap["env_not_gitignored"] = 1;
|
|
1166
|
+
sendSecurityCheckLine(`progress ${total}/${total} env_not_gitignored issue 1`);
|
|
1167
|
+
} else {
|
|
1168
|
+
passedIds.push("env_not_gitignored");
|
|
1169
|
+
sendSecurityCheckLine(`progress ${total}/${total} env_not_gitignored passed`);
|
|
1170
|
+
}
|
|
1171
|
+
flushLogBuffer();
|
|
1172
|
+
|
|
1173
|
+
return {
|
|
1174
|
+
security_issue_count: issueDetails.length,
|
|
1175
|
+
security_issue_items: issueDetails.map((d) => `${d.id} (${d.count}箇所)`),
|
|
1176
|
+
security_passed_count: passedIds.length,
|
|
1177
|
+
security_passed_items: passedIds,
|
|
1178
|
+
};
|
|
1179
|
+
};
|
|
1180
|
+
|
|
1181
|
+
const triggerSecurityCheck = async (reason) => {
|
|
1182
|
+
sendSecurityCheckLine(`security-check start (${reason})`);
|
|
1183
|
+
flushLogBuffer();
|
|
1184
|
+
const result = await runSecurityCheck();
|
|
1185
|
+
|
|
1186
|
+
// Emit detail lines
|
|
1187
|
+
for (const item of result.security_issue_items) {
|
|
1188
|
+
sendSecurityCheckLine(`issue: ${item}`);
|
|
1189
|
+
}
|
|
1190
|
+
for (const id of result.security_passed_items) {
|
|
1191
|
+
sendSecurityCheckLine(`passed: ${id}`);
|
|
1192
|
+
}
|
|
1193
|
+
sendSecurityCheckLine(
|
|
1194
|
+
`issues=${result.security_issue_count} passed=${result.security_passed_count}`,
|
|
1195
|
+
);
|
|
1196
|
+
return result;
|
|
1197
|
+
};
|
|
1198
|
+
|
|
1199
|
+
const triggerCodeAnalysis = async (reason, role = null) => {
|
|
1200
|
+
const runApi = !role || role === "api";
|
|
1201
|
+
const runScreen = !role || role === "screen";
|
|
1202
|
+
|
|
1203
|
+
const steps = [];
|
|
1204
|
+
if (runApi) {
|
|
1205
|
+
const apiCount = apiEndpointCandidatesCached.length;
|
|
1206
|
+
const apiSuffix = apiCount ? ` (対象: ${apiCount}件)` : "";
|
|
1207
|
+
steps.push({ label: `API実装チェック中...${apiSuffix}`, fn: () => triggerApiCheck(`code-analysis:${reason}`) });
|
|
1208
|
+
}
|
|
1209
|
+
if (runScreen && enableScreenCheck) {
|
|
1210
|
+
const screenCount = screenCandidatesCached.length;
|
|
1211
|
+
const screenSuffix = screenCount ? ` (対象: ${screenCount}件)` : "";
|
|
1212
|
+
steps.push({ label: `画面実装チェック中...${screenSuffix}`, fn: () => triggerScreenCheck(`code-analysis:${reason}`) });
|
|
1213
|
+
}
|
|
1214
|
+
// Security check is always included
|
|
1215
|
+
steps.push({ label: "セキュリティチェック中...", fn: async () => triggerSecurityCheck(`code-analysis:${reason}`) });
|
|
1216
|
+
|
|
1217
|
+
const totalSteps = steps.length;
|
|
1218
|
+
let currentStep = 0;
|
|
1219
|
+
|
|
1220
|
+
sendCodeAnalysisLine(`code-analysis start (${reason})`);
|
|
1221
|
+
|
|
1222
|
+
// 各ステップの結果を収集
|
|
1223
|
+
const summary = {
|
|
1224
|
+
api_missing_count: 0, api_missing_items: [], api_implemented_count: 0, api_implemented_items: [],
|
|
1225
|
+
screen_missing_count: 0, screen_missing_items: [], screen_implemented_count: 0, screen_implemented_items: [],
|
|
1226
|
+
security_issue_count: 0, security_issue_items: [], security_passed_count: 0, security_passed_items: [],
|
|
1227
|
+
};
|
|
1228
|
+
|
|
1229
|
+
for (const step of steps) {
|
|
1230
|
+
currentStep += 1;
|
|
1231
|
+
sendCodeAnalysisLine(`[${currentStep}/${totalSteps}] ${step.label}`);
|
|
1232
|
+
const result = await step.fn();
|
|
1233
|
+
if (result) {
|
|
1234
|
+
Object.assign(summary, result);
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
const apiExpected = apiEndpointCandidatesCached.map(String);
|
|
1239
|
+
const screenExpected = screenCandidatesCached.map(toScreenName);
|
|
1240
|
+
const apiResultEmpty =
|
|
1241
|
+
summary.api_missing_count === 0 &&
|
|
1242
|
+
summary.api_implemented_count === 0 &&
|
|
1243
|
+
!summary.api_missing_items.length &&
|
|
1244
|
+
!summary.api_implemented_items.length;
|
|
1245
|
+
const screenResultEmpty =
|
|
1246
|
+
summary.screen_missing_count === 0 &&
|
|
1247
|
+
summary.screen_implemented_count === 0 &&
|
|
1248
|
+
!summary.screen_missing_items.length &&
|
|
1249
|
+
!summary.screen_implemented_items.length;
|
|
1250
|
+
|
|
1251
|
+
if (runApi && apiExpected.length && apiResultEmpty) {
|
|
1252
|
+
summary.api_missing_count = apiExpected.length;
|
|
1253
|
+
summary.api_missing_items = apiExpected;
|
|
1254
|
+
summary.api_check_inferred = true;
|
|
1255
|
+
}
|
|
1256
|
+
if (runScreen && enableScreenCheck && screenExpected.length && screenResultEmpty) {
|
|
1257
|
+
summary.screen_missing_count = screenExpected.length;
|
|
1258
|
+
summary.screen_missing_items = screenExpected;
|
|
1259
|
+
summary.screen_check_inferred = true;
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
// done 行にサマリー JSON を埋め込む(バックエンドが確実にパースできるように)
|
|
1263
|
+
flushLogBuffer();
|
|
1264
|
+
sendCodeAnalysisLine(`done ${JSON.stringify(summary)}`);
|
|
1265
|
+
};
|
|
1266
|
+
|
|
1267
|
+
const fetchApiEndpointList = async () => {
|
|
1268
|
+
if (!apiEndpointUrl) return [];
|
|
1269
|
+
if (typeof fetch !== "function") {
|
|
1270
|
+
sendApiCheckLine("fetch is not available in this runtime; skipping fetch.");
|
|
1271
|
+
apiEndpointFetchStatus = "error";
|
|
1272
|
+
apiEndpointFetchDetail = "fetch_unavailable";
|
|
1273
|
+
return [];
|
|
1274
|
+
}
|
|
1275
|
+
try {
|
|
1276
|
+
const response = await fetch(apiEndpointUrl, {
|
|
1277
|
+
method: "GET",
|
|
1278
|
+
headers: { "Content-Type": "application/json" },
|
|
1279
|
+
});
|
|
1280
|
+
if (!response.ok) {
|
|
1281
|
+
let detail = "";
|
|
1282
|
+
try {
|
|
1283
|
+
detail = await response.text();
|
|
1284
|
+
} catch (error) {
|
|
1285
|
+
detail = "";
|
|
1286
|
+
}
|
|
1287
|
+
const detailSnippet = detail ? ` detail=${detail.slice(0, 200)}` : "";
|
|
1288
|
+
sendApiCheckLine(
|
|
1289
|
+
`api_endpoint_list取得失敗: ${response.status} ${response.statusText} (${apiEndpointUrl})${detailSnippet}`,
|
|
1290
|
+
);
|
|
1291
|
+
apiEndpointFetchStatus = "error";
|
|
1292
|
+
apiEndpointFetchDetail = `${response.status}`;
|
|
1293
|
+
return [];
|
|
1294
|
+
}
|
|
1295
|
+
const data = await response.json();
|
|
1296
|
+
if (data?.api_endpoint_list) {
|
|
1297
|
+
apiEndpointCandidatesCached = extractApiEndpointItems(
|
|
1298
|
+
data.api_endpoint_list,
|
|
1299
|
+
);
|
|
1300
|
+
apiEndpointFetchStatus = apiEndpointCandidatesCached.length
|
|
1301
|
+
? "ok"
|
|
1302
|
+
: "ok_empty";
|
|
1303
|
+
apiEndpointFetchDetail = "";
|
|
1304
|
+
return apiEndpointCandidatesCached;
|
|
1305
|
+
}
|
|
1306
|
+
if (Array.isArray(data?.endpoints)) {
|
|
1307
|
+
apiEndpointCandidatesCached = extractApiEndpointItems(
|
|
1308
|
+
JSON.stringify({ endpoints: data.endpoints }),
|
|
1309
|
+
);
|
|
1310
|
+
apiEndpointFetchStatus = apiEndpointCandidatesCached.length
|
|
1311
|
+
? "ok"
|
|
1312
|
+
: "ok_empty";
|
|
1313
|
+
apiEndpointFetchDetail = "";
|
|
1314
|
+
return apiEndpointCandidatesCached;
|
|
1315
|
+
}
|
|
1316
|
+
apiEndpointFetchStatus = "ok_empty";
|
|
1317
|
+
apiEndpointFetchDetail = "";
|
|
1318
|
+
return [];
|
|
1319
|
+
} catch (error) {
|
|
1320
|
+
sendApiCheckLine(`api_endpoint_list fetch error: ${error}`);
|
|
1321
|
+
apiEndpointFetchStatus = "error";
|
|
1322
|
+
apiEndpointFetchDetail = "exception";
|
|
1323
|
+
return [];
|
|
1324
|
+
}
|
|
1325
|
+
};
|
|
1326
|
+
|
|
1327
|
+
const fetchScreenList = async () => {
|
|
1328
|
+
if (!screenListUrl) return [];
|
|
1329
|
+
if (typeof fetch !== "function") {
|
|
1330
|
+
sendScreenCheckLine("fetch is not available in this runtime; skipping fetch.");
|
|
1331
|
+
screenListFetchStatus = "error";
|
|
1332
|
+
screenListFetchDetail = "fetch_unavailable";
|
|
1333
|
+
return [];
|
|
1334
|
+
}
|
|
1335
|
+
try {
|
|
1336
|
+
const response = await fetch(screenListUrl, {
|
|
1337
|
+
method: "GET",
|
|
1338
|
+
headers: { "Content-Type": "application/json" },
|
|
1339
|
+
});
|
|
1340
|
+
if (!response.ok) {
|
|
1341
|
+
let detail = "";
|
|
1342
|
+
try {
|
|
1343
|
+
detail = await response.text();
|
|
1344
|
+
} catch (error) {
|
|
1345
|
+
detail = "";
|
|
1346
|
+
}
|
|
1347
|
+
const detailSnippet = detail ? ` detail=${detail.slice(0, 200)}` : "";
|
|
1348
|
+
sendScreenCheckLine(
|
|
1349
|
+
`screen_list取得失敗: ${response.status} ${response.statusText} (${screenListUrl})${detailSnippet}`,
|
|
1350
|
+
);
|
|
1351
|
+
screenListFetchStatus = "error";
|
|
1352
|
+
screenListFetchDetail = `${response.status}`;
|
|
1353
|
+
return [];
|
|
1354
|
+
}
|
|
1355
|
+
const data = await response.json();
|
|
1356
|
+
if (data?.screen_list) {
|
|
1357
|
+
const raw = Array.isArray(data?.screens) ? data.screens : [];
|
|
1358
|
+
screenCandidatesCached = raw.map((item) =>
|
|
1359
|
+
typeof item === "object" && item !== null ? item.name || String(item) : String(item)
|
|
1360
|
+
);
|
|
1361
|
+
screenListFetchStatus = screenCandidatesCached.length
|
|
1362
|
+
? "ok"
|
|
1363
|
+
: "ok_empty";
|
|
1364
|
+
screenListFetchDetail = "";
|
|
1365
|
+
return screenCandidatesCached;
|
|
1366
|
+
}
|
|
1367
|
+
if (Array.isArray(data?.screens)) {
|
|
1368
|
+
screenCandidatesCached = data.screens.map((item) =>
|
|
1369
|
+
typeof item === "object" && item !== null ? item.name || String(item) : String(item)
|
|
1370
|
+
);
|
|
1371
|
+
screenListFetchStatus = screenCandidatesCached.length
|
|
1372
|
+
? "ok"
|
|
1373
|
+
: "ok_empty";
|
|
1374
|
+
screenListFetchDetail = "";
|
|
1375
|
+
return screenCandidatesCached;
|
|
1376
|
+
}
|
|
1377
|
+
screenListFetchStatus = "ok_empty";
|
|
1378
|
+
screenListFetchDetail = "";
|
|
1379
|
+
return [];
|
|
1380
|
+
} catch (error) {
|
|
1381
|
+
sendScreenCheckLine(`screen_list fetch error: ${error}`);
|
|
1382
|
+
screenListFetchStatus = "error";
|
|
1383
|
+
screenListFetchDetail = "exception";
|
|
1384
|
+
return [];
|
|
1385
|
+
}
|
|
1386
|
+
};
|
|
1387
|
+
|
|
216
1388
|
const startFileTailer = (targetPath) => {
|
|
217
1389
|
let fileOffset = 0;
|
|
218
1390
|
let remainder = "";
|
|
@@ -223,7 +1395,7 @@ const startFileTailer = (targetPath) => {
|
|
|
223
1395
|
fileOffset = stats.size;
|
|
224
1396
|
return true;
|
|
225
1397
|
} catch (error) {
|
|
226
|
-
log(`log-path not found: ${targetPath} (retrying...)
|
|
1398
|
+
log(`log-path not found: ${targetPath} (retrying...)`, "warn");
|
|
227
1399
|
return false;
|
|
228
1400
|
}
|
|
229
1401
|
};
|
|
@@ -242,7 +1414,7 @@ const startFileTailer = (targetPath) => {
|
|
|
242
1414
|
const readNewData = () => {
|
|
243
1415
|
fs.stat(targetPath, (error, stats) => {
|
|
244
1416
|
if (error) {
|
|
245
|
-
log(`log-path read error: ${error.message || String(error)}
|
|
1417
|
+
log(`log-path read error: ${error.message || String(error)}`, "warn");
|
|
246
1418
|
return;
|
|
247
1419
|
}
|
|
248
1420
|
if (stats.size < fileOffset) {
|
|
@@ -265,6 +1437,7 @@ const startFileTailer = (targetPath) => {
|
|
|
265
1437
|
`log-path stream error: ${
|
|
266
1438
|
streamError.message || String(streamError)
|
|
267
1439
|
}`,
|
|
1440
|
+
"warn",
|
|
268
1441
|
);
|
|
269
1442
|
});
|
|
270
1443
|
});
|
|
@@ -278,7 +1451,7 @@ const startFileTailer = (targetPath) => {
|
|
|
278
1451
|
}
|
|
279
1452
|
});
|
|
280
1453
|
} catch (error) {
|
|
281
|
-
log(`log-path watch error: ${error.message || String(error)}
|
|
1454
|
+
log(`log-path watch error: ${error.message || String(error)}`, "warn");
|
|
282
1455
|
}
|
|
283
1456
|
};
|
|
284
1457
|
|
|
@@ -295,21 +1468,23 @@ const startFileTailer = (targetPath) => {
|
|
|
295
1468
|
startWatcher();
|
|
296
1469
|
};
|
|
297
1470
|
|
|
298
|
-
if (
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
1471
|
+
if (runMode !== "security") {
|
|
1472
|
+
if (logPath) {
|
|
1473
|
+
startFileTailer(logPath);
|
|
1474
|
+
} else {
|
|
1475
|
+
const rl = readline.createInterface({
|
|
1476
|
+
input: process.stdin,
|
|
1477
|
+
crlfDelay: Infinity,
|
|
1478
|
+
});
|
|
305
1479
|
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
1480
|
+
rl.on("line", (line) => {
|
|
1481
|
+
handleLine(line);
|
|
1482
|
+
});
|
|
309
1483
|
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
1484
|
+
rl.on("close", () => {
|
|
1485
|
+
if (socket) {
|
|
1486
|
+
socket.close();
|
|
1487
|
+
}
|
|
1488
|
+
});
|
|
1489
|
+
}
|
|
315
1490
|
}
|