hamster-wheel-cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/ISSUE_TEMPLATE/bug_report.yml +107 -0
- package/.github/ISSUE_TEMPLATE/config.yml +15 -0
- package/.github/ISSUE_TEMPLATE/feature_request.yml +56 -0
- package/.github/workflows/ci-pr.yml +50 -0
- package/.github/workflows/publish.yml +121 -0
- package/.github/workflows/sync-master-to-dev.yml +100 -0
- package/AGENTS.md +20 -0
- package/CHANGELOG.md +12 -0
- package/LICENSE +21 -0
- package/README.md +90 -0
- package/dist/cli.d.ts +6 -0
- package/dist/cli.js +2678 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.d.ts +80 -0
- package/dist/index.js +2682 -0
- package/dist/index.js.map +1 -0
- package/docs/ai-workflow.md +58 -0
- package/package.json +44 -0
- package/src/ai.ts +173 -0
- package/src/cli.ts +189 -0
- package/src/config.ts +134 -0
- package/src/deps.ts +210 -0
- package/src/gh.ts +228 -0
- package/src/git.ts +285 -0
- package/src/global-config.ts +296 -0
- package/src/index.ts +3 -0
- package/src/logger.ts +122 -0
- package/src/logs-viewer.ts +420 -0
- package/src/logs.ts +132 -0
- package/src/loop.ts +422 -0
- package/src/monitor.ts +291 -0
- package/src/runtime-tracker.ts +65 -0
- package/src/summary.ts +255 -0
- package/src/types.ts +176 -0
- package/src/utils.ts +179 -0
- package/src/webhook.ts +107 -0
- package/tests/deps.test.ts +72 -0
- package/tests/e2e/cli.e2e.test.ts +77 -0
- package/tests/e2e/gh-pr-create.e2e.test.ts +55 -0
- package/tests/e2e/gh-run-list.e2e.test.ts +47 -0
- package/tests/gh-pr-create.test.ts +55 -0
- package/tests/gh-run-list.test.ts +35 -0
- package/tests/global-config.test.ts +52 -0
- package/tests/logger-file.test.ts +56 -0
- package/tests/logger.test.ts +72 -0
- package/tests/logs-viewer.test.ts +57 -0
- package/tests/logs.test.ts +33 -0
- package/tests/prompt.test.ts +20 -0
- package/tests/run-command-stream.test.ts +60 -0
- package/tests/summary.test.ts +58 -0
- package/tests/token-usage.test.ts +33 -0
- package/tests/utils.test.ts +8 -0
- package/tests/webhook.test.ts +89 -0
- package/tsconfig.json +18 -0
- package/tsup.config.ts +18 -0
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
import fs from 'fs-extra';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { CurrentRegistry, RunMetadata, getLogsDir, readCurrentRegistry } from './logs';
|
|
4
|
+
import { pad2 } from './utils';
|
|
5
|
+
|
|
6
|
+
export interface LogEntry {
|
|
7
|
+
readonly fileName: string;
|
|
8
|
+
readonly filePath: string;
|
|
9
|
+
readonly size: number;
|
|
10
|
+
readonly mtimeMs: number;
|
|
11
|
+
readonly meta?: RunMetadata;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface ViewState {
|
|
15
|
+
entry: LogEntry;
|
|
16
|
+
lines: string[];
|
|
17
|
+
pageOffset: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface LogsViewerState {
|
|
21
|
+
mode: 'list' | 'view';
|
|
22
|
+
logs: LogEntry[];
|
|
23
|
+
selectedIndex: number;
|
|
24
|
+
listOffset: number;
|
|
25
|
+
view?: ViewState;
|
|
26
|
+
lastError?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function isRunMetadata(value: unknown): value is RunMetadata {
|
|
30
|
+
if (!value || typeof value !== 'object') return false;
|
|
31
|
+
const record = value as Record<string, unknown>;
|
|
32
|
+
return (
|
|
33
|
+
typeof record.command === 'string' &&
|
|
34
|
+
typeof record.round === 'number' &&
|
|
35
|
+
typeof record.tokenUsed === 'number' &&
|
|
36
|
+
typeof record.path === 'string'
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function buildLogMetaPath(logsDir: string, logFile: string): string {
|
|
41
|
+
const baseName = path.basename(logFile, path.extname(logFile));
|
|
42
|
+
return path.join(logsDir, `${baseName}.json`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function readLogMetadata(logsDir: string, logFile: string): Promise<RunMetadata | undefined> {
|
|
46
|
+
const metaPath = buildLogMetaPath(logsDir, logFile);
|
|
47
|
+
const exists = await fs.pathExists(metaPath);
|
|
48
|
+
if (!exists) return undefined;
|
|
49
|
+
try {
|
|
50
|
+
const content = await fs.readFile(metaPath, 'utf8');
|
|
51
|
+
const parsed = JSON.parse(content) as unknown;
|
|
52
|
+
return isRunMetadata(parsed) ? parsed : undefined;
|
|
53
|
+
} catch {
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function buildRunningLogKeys(registry: CurrentRegistry): Set<string> {
|
|
59
|
+
const keys = new Set<string>();
|
|
60
|
+
for (const [key, entry] of Object.entries(registry)) {
|
|
61
|
+
keys.add(key);
|
|
62
|
+
if (entry.logFile) {
|
|
63
|
+
keys.add(path.basename(entry.logFile));
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return keys;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function loadLogEntries(logsDir: string, registry: CurrentRegistry): Promise<LogEntry[]> {
|
|
70
|
+
const exists = await fs.pathExists(logsDir);
|
|
71
|
+
if (!exists) return [];
|
|
72
|
+
const running = buildRunningLogKeys(registry);
|
|
73
|
+
const names = await fs.readdir(logsDir);
|
|
74
|
+
const entries: LogEntry[] = [];
|
|
75
|
+
for (const name of names) {
|
|
76
|
+
if (path.extname(name).toLowerCase() !== '.log') continue;
|
|
77
|
+
if (running.has(name)) continue;
|
|
78
|
+
const filePath = path.join(logsDir, name);
|
|
79
|
+
let stat: fs.Stats;
|
|
80
|
+
try {
|
|
81
|
+
stat = await fs.stat(filePath);
|
|
82
|
+
} catch {
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (!stat.isFile()) continue;
|
|
86
|
+
const meta = await readLogMetadata(logsDir, filePath);
|
|
87
|
+
entries.push({
|
|
88
|
+
fileName: name,
|
|
89
|
+
filePath,
|
|
90
|
+
size: stat.size,
|
|
91
|
+
mtimeMs: stat.mtimeMs,
|
|
92
|
+
meta
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
return entries.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function getTerminalSize(): { rows: number; columns: number } {
|
|
99
|
+
const rows = process.stdout.rows ?? 24;
|
|
100
|
+
const columns = process.stdout.columns ?? 80;
|
|
101
|
+
return { rows, columns };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function truncateLine(line: string, width: number): string {
|
|
105
|
+
if (width <= 0) return '';
|
|
106
|
+
if (line.length <= width) return line;
|
|
107
|
+
return line.slice(0, width);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function formatTimestamp(ms: number): string {
|
|
111
|
+
const date = new Date(ms);
|
|
112
|
+
const year = date.getFullYear();
|
|
113
|
+
const month = pad2(date.getMonth() + 1);
|
|
114
|
+
const day = pad2(date.getDate());
|
|
115
|
+
const hours = pad2(date.getHours());
|
|
116
|
+
const minutes = pad2(date.getMinutes());
|
|
117
|
+
const seconds = pad2(date.getSeconds());
|
|
118
|
+
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function formatBytes(size: number): string {
|
|
122
|
+
if (size < 1024) return `${size}B`;
|
|
123
|
+
const kb = size / 1024;
|
|
124
|
+
if (kb < 1024) return `${kb.toFixed(1)}KB`;
|
|
125
|
+
const mb = kb / 1024;
|
|
126
|
+
return `${mb.toFixed(1)}MB`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function getPageSize(rows: number): number {
|
|
130
|
+
return Math.max(1, rows - 2);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function readLogLines(logFile: string): Promise<string[]> {
|
|
134
|
+
try {
|
|
135
|
+
const content = await fs.readFile(logFile, 'utf8');
|
|
136
|
+
const normalized = content.replace(/\r\n?/g, '\n');
|
|
137
|
+
const lines = normalized.split('\n');
|
|
138
|
+
return lines.length > 0 ? lines : [''];
|
|
139
|
+
} catch (error) {
|
|
140
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
141
|
+
return [`(无法读取日志文件:${message})`];
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function buildListHeader(state: LogsViewerState, columns: number): string {
|
|
146
|
+
const total = state.logs.length;
|
|
147
|
+
const title = `日志列表(${total} 条)|↑/↓ 选择 Enter 查看 q 退出`;
|
|
148
|
+
return truncateLine(title, columns);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function buildListStatus(state: LogsViewerState, columns: number): string {
|
|
152
|
+
if (state.logs.length === 0) {
|
|
153
|
+
const text = state.lastError ? `加载失败:${state.lastError}` : '暂无可查看的日志';
|
|
154
|
+
return truncateLine(text, columns);
|
|
155
|
+
}
|
|
156
|
+
const entry = state.logs[state.selectedIndex];
|
|
157
|
+
const meta = entry.meta;
|
|
158
|
+
const detail = meta ? `项目 ${meta.path}` : `文件 ${entry.fileName}`;
|
|
159
|
+
const suffix = state.lastError ? ` | 加载失败:${state.lastError}` : '';
|
|
160
|
+
return truncateLine(`${detail}${suffix}`, columns);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function buildListLine(entry: LogEntry, selected: boolean, columns: number): string {
|
|
164
|
+
const marker = selected ? '>' : ' ';
|
|
165
|
+
const time = formatTimestamp(entry.mtimeMs);
|
|
166
|
+
const metaInfo = entry.meta
|
|
167
|
+
? `轮次 ${entry.meta.round} | Token ${entry.meta.tokenUsed}`
|
|
168
|
+
: `大小 ${formatBytes(entry.size)}`;
|
|
169
|
+
return truncateLine(`${marker} ${entry.fileName} | ${time} | ${metaInfo}`, columns);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function buildViewHeader(entry: LogEntry, columns: number): string {
|
|
173
|
+
const title = `日志查看|${entry.fileName}|↑/↓ 翻页 b 返回 q 退出`;
|
|
174
|
+
return truncateLine(title, columns);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function buildViewStatus(entry: LogEntry, page: { current: number; total: number }, columns: number): string {
|
|
178
|
+
const meta = entry.meta;
|
|
179
|
+
const metaInfo = meta
|
|
180
|
+
? `轮次 ${meta.round} | Token ${meta.tokenUsed} | 项目 ${meta.path}`
|
|
181
|
+
: `文件 ${entry.fileName}`;
|
|
182
|
+
const status = `页 ${page.current}/${page.total} | ${metaInfo}`;
|
|
183
|
+
return truncateLine(status, columns);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function ensureListOffset(state: LogsViewerState, pageSize: number): void {
|
|
187
|
+
const total = state.logs.length;
|
|
188
|
+
if (total === 0) {
|
|
189
|
+
state.listOffset = 0;
|
|
190
|
+
state.selectedIndex = 0;
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
const maxOffset = Math.max(0, total - pageSize);
|
|
194
|
+
if (state.selectedIndex < state.listOffset) {
|
|
195
|
+
state.listOffset = state.selectedIndex;
|
|
196
|
+
}
|
|
197
|
+
if (state.selectedIndex >= state.listOffset + pageSize) {
|
|
198
|
+
state.listOffset = state.selectedIndex - pageSize + 1;
|
|
199
|
+
}
|
|
200
|
+
state.listOffset = Math.min(Math.max(state.listOffset, 0), maxOffset);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function renderList(state: LogsViewerState): void {
|
|
204
|
+
const { rows, columns } = getTerminalSize();
|
|
205
|
+
const pageSize = getPageSize(rows);
|
|
206
|
+
const header = buildListHeader(state, columns);
|
|
207
|
+
ensureListOffset(state, pageSize);
|
|
208
|
+
|
|
209
|
+
if (state.logs.length === 0) {
|
|
210
|
+
const filler = Array.from({ length: pageSize }, () => '');
|
|
211
|
+
const status = buildListStatus(state, columns);
|
|
212
|
+
const content = [header, ...filler, status].join('\n');
|
|
213
|
+
process.stdout.write(`\u001b[2J\u001b[H${content}`);
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const start = state.listOffset;
|
|
218
|
+
const slice = state.logs.slice(start, start + pageSize);
|
|
219
|
+
const lines = slice.map((entry, index) => {
|
|
220
|
+
const selected = start + index === state.selectedIndex;
|
|
221
|
+
return buildListLine(entry, selected, columns);
|
|
222
|
+
});
|
|
223
|
+
while (lines.length < pageSize) {
|
|
224
|
+
lines.push('');
|
|
225
|
+
}
|
|
226
|
+
const status = buildListStatus(state, columns);
|
|
227
|
+
const content = [header, ...lines, status].join('\n');
|
|
228
|
+
process.stdout.write(`\u001b[2J\u001b[H${content}`);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function renderView(view: ViewState): void {
|
|
232
|
+
const { rows, columns } = getTerminalSize();
|
|
233
|
+
const pageSize = getPageSize(rows);
|
|
234
|
+
const header = buildViewHeader(view.entry, columns);
|
|
235
|
+
const maxOffset = Math.max(0, Math.ceil(view.lines.length / pageSize) - 1);
|
|
236
|
+
view.pageOffset = Math.min(Math.max(view.pageOffset, 0), maxOffset);
|
|
237
|
+
|
|
238
|
+
const start = view.pageOffset * pageSize;
|
|
239
|
+
const pageLines = view.lines.slice(start, start + pageSize).map(line => truncateLine(line, columns));
|
|
240
|
+
while (pageLines.length < pageSize) {
|
|
241
|
+
pageLines.push('');
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const status = buildViewStatus(view.entry, { current: view.pageOffset + 1, total: Math.max(1, maxOffset + 1) }, columns);
|
|
245
|
+
const content = [header, ...pageLines, status].join('\n');
|
|
246
|
+
process.stdout.write(`\u001b[2J\u001b[H${content}`);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function render(state: LogsViewerState): void {
|
|
250
|
+
if (state.mode === 'view' && state.view) {
|
|
251
|
+
renderView(state.view);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
renderList(state);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function shouldExit(input: string): boolean {
|
|
258
|
+
if (input === '\u0003') return true;
|
|
259
|
+
if (input.toLowerCase() === 'q') return true;
|
|
260
|
+
return false;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function isEnter(input: string): boolean {
|
|
264
|
+
return input.includes('\r') || input.includes('\n');
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function isArrowUp(input: string): boolean {
|
|
268
|
+
return input.includes('\u001b[A');
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function isArrowDown(input: string): boolean {
|
|
272
|
+
return input.includes('\u001b[B');
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function isEscape(input: string): boolean {
|
|
276
|
+
return input === '\u001b';
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function setupCleanup(cleanup: () => void): void {
|
|
280
|
+
const exitHandler = (): void => {
|
|
281
|
+
cleanup();
|
|
282
|
+
};
|
|
283
|
+
const signalHandler = (): void => {
|
|
284
|
+
cleanup();
|
|
285
|
+
process.exit(0);
|
|
286
|
+
};
|
|
287
|
+
process.on('SIGINT', signalHandler);
|
|
288
|
+
process.on('SIGTERM', signalHandler);
|
|
289
|
+
process.on('exit', exitHandler);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function clampIndex(value: number, total: number): number {
|
|
293
|
+
if (total <= 0) return 0;
|
|
294
|
+
return Math.min(Math.max(value, 0), total - 1);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* 启动日志列表查看界面。
|
|
299
|
+
*/
|
|
300
|
+
export async function runLogsViewer(): Promise<void> {
|
|
301
|
+
if (!process.stdout.isTTY || !process.stdin.isTTY) {
|
|
302
|
+
console.log('当前终端不支持交互式 logs。');
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const logsDir = getLogsDir();
|
|
307
|
+
const state: LogsViewerState = {
|
|
308
|
+
mode: 'list',
|
|
309
|
+
logs: [],
|
|
310
|
+
selectedIndex: 0,
|
|
311
|
+
listOffset: 0
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
let cleaned = false;
|
|
315
|
+
const cleanup = (): void => {
|
|
316
|
+
if (cleaned) return;
|
|
317
|
+
cleaned = true;
|
|
318
|
+
if (process.stdin.isTTY) {
|
|
319
|
+
process.stdin.setRawMode(false);
|
|
320
|
+
process.stdin.pause();
|
|
321
|
+
}
|
|
322
|
+
process.stdout.write('\u001b[?25h');
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
setupCleanup(cleanup);
|
|
326
|
+
process.stdout.write('\u001b[?25l');
|
|
327
|
+
process.stdin.setRawMode(true);
|
|
328
|
+
process.stdin.resume();
|
|
329
|
+
|
|
330
|
+
let loading = false;
|
|
331
|
+
|
|
332
|
+
const loadLogs = async (): Promise<void> => {
|
|
333
|
+
try {
|
|
334
|
+
const registry = await readCurrentRegistry();
|
|
335
|
+
state.logs = await loadLogEntries(logsDir, registry);
|
|
336
|
+
state.selectedIndex = clampIndex(state.selectedIndex, state.logs.length);
|
|
337
|
+
state.lastError = undefined;
|
|
338
|
+
} catch (error) {
|
|
339
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
340
|
+
state.lastError = message;
|
|
341
|
+
state.logs = [];
|
|
342
|
+
state.selectedIndex = 0;
|
|
343
|
+
}
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
const openView = async (): Promise<void> => {
|
|
347
|
+
if (loading || state.logs.length === 0) return;
|
|
348
|
+
loading = true;
|
|
349
|
+
const entry = state.logs[state.selectedIndex];
|
|
350
|
+
state.mode = 'view';
|
|
351
|
+
state.view = {
|
|
352
|
+
entry,
|
|
353
|
+
lines: ['加载中…'],
|
|
354
|
+
pageOffset: 0
|
|
355
|
+
};
|
|
356
|
+
render(state);
|
|
357
|
+
const lines = await readLogLines(entry.filePath);
|
|
358
|
+
const pageSize = getPageSize(getTerminalSize().rows);
|
|
359
|
+
const maxOffset = Math.max(0, Math.ceil(lines.length / pageSize) - 1);
|
|
360
|
+
state.view = {
|
|
361
|
+
entry,
|
|
362
|
+
lines,
|
|
363
|
+
pageOffset: maxOffset
|
|
364
|
+
};
|
|
365
|
+
loading = false;
|
|
366
|
+
render(state);
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
await loadLogs();
|
|
370
|
+
render(state);
|
|
371
|
+
|
|
372
|
+
process.stdin.on('data', (data: Buffer) => {
|
|
373
|
+
const input = data.toString('utf8');
|
|
374
|
+
if (shouldExit(input)) {
|
|
375
|
+
cleanup();
|
|
376
|
+
process.exit(0);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
if (state.mode === 'list') {
|
|
380
|
+
if (isArrowUp(input)) {
|
|
381
|
+
state.selectedIndex = clampIndex(state.selectedIndex - 1, state.logs.length);
|
|
382
|
+
render(state);
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
if (isArrowDown(input)) {
|
|
386
|
+
state.selectedIndex = clampIndex(state.selectedIndex + 1, state.logs.length);
|
|
387
|
+
render(state);
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
if (isEnter(input)) {
|
|
391
|
+
void openView();
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
if (state.mode === 'view' && state.view) {
|
|
398
|
+
if (isArrowUp(input)) {
|
|
399
|
+
state.view.pageOffset -= 1;
|
|
400
|
+
render(state);
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
if (isArrowDown(input)) {
|
|
404
|
+
state.view.pageOffset += 1;
|
|
405
|
+
render(state);
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
if (input.toLowerCase() === 'b' || isEscape(input)) {
|
|
409
|
+
state.mode = 'list';
|
|
410
|
+
state.view = undefined;
|
|
411
|
+
render(state);
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
process.stdout.on('resize', () => {
|
|
418
|
+
render(state);
|
|
419
|
+
});
|
|
420
|
+
}
|
package/src/logs.ts
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import os from 'node:os';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import fs from 'fs-extra';
|
|
4
|
+
import { pad2 } from './utils';
|
|
5
|
+
|
|
6
|
+
export interface RunMetadata {
|
|
7
|
+
readonly command: string;
|
|
8
|
+
readonly round: number;
|
|
9
|
+
readonly tokenUsed: number;
|
|
10
|
+
readonly path: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface CurrentRegistryEntry extends RunMetadata {
|
|
14
|
+
readonly logFile?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type CurrentRegistry = Record<string, CurrentRegistryEntry>;
|
|
18
|
+
|
|
19
|
+
const LOGS_DIR = path.join(os.homedir(), '.wheel-ai', 'logs');
|
|
20
|
+
|
|
21
|
+
export function getLogsDir(): string {
|
|
22
|
+
return LOGS_DIR;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function getCurrentRegistryPath(): string {
|
|
26
|
+
return path.join(LOGS_DIR, 'current.json');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function ensureLogsDir(): Promise<void> {
|
|
30
|
+
await fs.mkdirp(LOGS_DIR);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* 生成时间字符串(YYYYMMDDHHmmss)。
|
|
35
|
+
*/
|
|
36
|
+
export function formatTimeString(date: Date = new Date()): string {
|
|
37
|
+
const year = date.getFullYear();
|
|
38
|
+
const month = pad2(date.getMonth() + 1);
|
|
39
|
+
const day = pad2(date.getDate());
|
|
40
|
+
const hours = pad2(date.getHours());
|
|
41
|
+
const minutes = pad2(date.getMinutes());
|
|
42
|
+
const seconds = pad2(date.getSeconds());
|
|
43
|
+
return `${year}${month}${day}${hours}${minutes}${seconds}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* 清理分支名中的非法字符。
|
|
48
|
+
*/
|
|
49
|
+
export function sanitizeBranchName(branchName: string): string {
|
|
50
|
+
const normalized = branchName.trim();
|
|
51
|
+
if (!normalized) return '';
|
|
52
|
+
const replaced = normalized.replace(/[\\/:*?"<>|\s]+/g, '-');
|
|
53
|
+
return replaced.replace(/-+/g, '-').replace(/^-+|-+$/g, '');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function buildAutoLogFilePath(branchName: string, date: Date = new Date()): string {
|
|
57
|
+
const safeBranch = sanitizeBranchName(branchName) || 'unknown';
|
|
58
|
+
return path.join(LOGS_DIR, `wheel-ai-auto-log-${formatTimeString(date)}-${safeBranch}.log`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function getLogMetaPath(logFile: string): string {
|
|
62
|
+
const baseName = path.basename(logFile, path.extname(logFile));
|
|
63
|
+
return path.join(LOGS_DIR, `${baseName}.json`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function buildLogKey(logFile: string): string {
|
|
67
|
+
return path.basename(logFile);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* 格式化命令行字符串(用于写入元信息)。
|
|
72
|
+
*/
|
|
73
|
+
export function formatCommandLine(argv: string[]): string {
|
|
74
|
+
const quote = (value: string): string => {
|
|
75
|
+
if (/[\s"'\\]/.test(value)) {
|
|
76
|
+
return JSON.stringify(value);
|
|
77
|
+
}
|
|
78
|
+
return value;
|
|
79
|
+
};
|
|
80
|
+
return argv.map(quote).join(' ').trim();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function writeJsonFile(filePath: string, data: unknown): Promise<void> {
|
|
84
|
+
await ensureLogsDir();
|
|
85
|
+
await fs.writeFile(filePath, `${JSON.stringify(data, null, 2)}\n`, 'utf8');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* 写入单次运行的元信息。
|
|
90
|
+
*/
|
|
91
|
+
export async function writeRunMetadata(logFile: string, metadata: RunMetadata): Promise<void> {
|
|
92
|
+
const metaPath = getLogMetaPath(logFile);
|
|
93
|
+
await writeJsonFile(metaPath, metadata);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* 读取 current.json 注册表。
|
|
98
|
+
*/
|
|
99
|
+
export async function readCurrentRegistry(): Promise<CurrentRegistry> {
|
|
100
|
+
const filePath = getCurrentRegistryPath();
|
|
101
|
+
const exists = await fs.pathExists(filePath);
|
|
102
|
+
if (!exists) return {};
|
|
103
|
+
try {
|
|
104
|
+
const content = await fs.readFile(filePath, 'utf8');
|
|
105
|
+
const parsed = JSON.parse(content) as CurrentRegistry;
|
|
106
|
+
if (!parsed || typeof parsed !== 'object') return {};
|
|
107
|
+
return parsed;
|
|
108
|
+
} catch {
|
|
109
|
+
return {};
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* 更新 current.json 中的运行记录。
|
|
115
|
+
*/
|
|
116
|
+
export async function upsertCurrentRegistry(logFile: string, metadata: RunMetadata): Promise<void> {
|
|
117
|
+
const registry = await readCurrentRegistry();
|
|
118
|
+
const key = buildLogKey(logFile);
|
|
119
|
+
registry[key] = { ...metadata, logFile };
|
|
120
|
+
await writeJsonFile(getCurrentRegistryPath(), registry);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* 从 current.json 中移除运行记录。
|
|
125
|
+
*/
|
|
126
|
+
export async function removeCurrentRegistry(logFile: string): Promise<void> {
|
|
127
|
+
const registry = await readCurrentRegistry();
|
|
128
|
+
const key = buildLogKey(logFile);
|
|
129
|
+
if (!(key in registry)) return;
|
|
130
|
+
delete registry[key];
|
|
131
|
+
await writeJsonFile(getCurrentRegistryPath(), registry);
|
|
132
|
+
}
|