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 +17 -0
- package/bin/coderail-watch.js +595 -16
- package/package.json +1 -1
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.
|
package/bin/coderail-watch.js
CHANGED
|
@@ -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 = "
|
|
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;
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
args
|
|
77
|
-
|
|
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 =
|
|
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 = "";
|