hamster-wheel-cli 0.1.0 → 0.2.0-beta.1
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/CHANGELOG.md +27 -1
- package/README.md +29 -0
- package/dist/cli.js +1747 -309
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +9 -1
- package/dist/index.js +1747 -309
- package/dist/index.js.map +1 -1
- package/docs/ai-workflow.md +27 -36
- package/package.json +1 -1
- package/src/ai.ts +346 -1
- package/src/alias-viewer.ts +221 -0
- package/src/cli.ts +263 -29
- package/src/config.ts +6 -2
- package/src/gh.ts +20 -0
- package/src/git.ts +38 -3
- package/src/global-config.ts +149 -11
- package/src/log-tailer.ts +103 -0
- package/src/logs-viewer.ts +33 -11
- package/src/logs.ts +1 -0
- package/src/loop.ts +458 -120
- package/src/monitor.ts +240 -23
- package/src/multi-task.ts +117 -0
- package/src/plan.ts +61 -0
- package/src/quality.ts +48 -0
- package/src/runtime-tracker.ts +2 -1
- package/src/types.ts +23 -0
- package/tests/branch-name.test.ts +28 -0
- package/tests/e2e/cli.e2e.test.ts +41 -0
- package/tests/global-config.test.ts +52 -1
- package/tests/monitor.test.ts +17 -0
- package/tests/multi-task.test.ts +77 -0
package/src/global-config.ts
CHANGED
|
@@ -11,6 +11,15 @@ export interface ShortcutConfig {
|
|
|
11
11
|
readonly command: string;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* 全局 alias 配置条目。
|
|
16
|
+
*/
|
|
17
|
+
export interface AliasEntry {
|
|
18
|
+
readonly name: string;
|
|
19
|
+
readonly command: string;
|
|
20
|
+
readonly source: 'alias' | 'shortcut';
|
|
21
|
+
}
|
|
22
|
+
|
|
14
23
|
/**
|
|
15
24
|
* 全局配置结构。
|
|
16
25
|
*/
|
|
@@ -140,6 +149,20 @@ function parseTomlString(raw: string): string | null {
|
|
|
140
149
|
return null;
|
|
141
150
|
}
|
|
142
151
|
|
|
152
|
+
function parseTomlKeyValue(line: string): { key: string; value: string } | null {
|
|
153
|
+
const equalIndex = findUnquotedIndex(line, '=');
|
|
154
|
+
if (equalIndex <= 0) return null;
|
|
155
|
+
|
|
156
|
+
const key = line.slice(0, equalIndex).trim();
|
|
157
|
+
const valuePart = line.slice(equalIndex + 1).trim();
|
|
158
|
+
if (!key || !valuePart) return null;
|
|
159
|
+
|
|
160
|
+
const parsedValue = parseTomlString(valuePart);
|
|
161
|
+
if (parsedValue === null) return null;
|
|
162
|
+
|
|
163
|
+
return { key, value: parsedValue };
|
|
164
|
+
}
|
|
165
|
+
|
|
143
166
|
function normalizeShortcutName(name: string): string | null {
|
|
144
167
|
const trimmed = name.trim();
|
|
145
168
|
if (!trimmed) return null;
|
|
@@ -147,6 +170,81 @@ function normalizeShortcutName(name: string): string | null {
|
|
|
147
170
|
return trimmed;
|
|
148
171
|
}
|
|
149
172
|
|
|
173
|
+
/**
|
|
174
|
+
* 规范化 alias 名称。
|
|
175
|
+
*/
|
|
176
|
+
export function normalizeAliasName(name: string): string | null {
|
|
177
|
+
return normalizeShortcutName(name);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function formatTomlString(value: string): string {
|
|
181
|
+
const escaped = value
|
|
182
|
+
.replace(/\\/g, '\\\\')
|
|
183
|
+
.replace(/"/g, '\\"')
|
|
184
|
+
.replace(/\n/g, '\\n')
|
|
185
|
+
.replace(/\r/g, '\\r')
|
|
186
|
+
.replace(/\t/g, '\\t');
|
|
187
|
+
return `"${escaped}"`;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* 更新 alias 配置内容,返回新文本。
|
|
192
|
+
*/
|
|
193
|
+
export function updateAliasContent(content: string, name: string, command: string): string {
|
|
194
|
+
const lines = content.split(/\r?\n/);
|
|
195
|
+
const entryLine = `${name} = ${formatTomlString(command)}`;
|
|
196
|
+
let currentSection: string | null = null;
|
|
197
|
+
let aliasStart = -1;
|
|
198
|
+
let aliasEnd = lines.length;
|
|
199
|
+
|
|
200
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
201
|
+
const match = /^\s*\[(.+?)\]\s*$/.exec(lines[i]);
|
|
202
|
+
if (!match) continue;
|
|
203
|
+
if (currentSection === 'alias' && aliasStart >= 0 && aliasEnd === lines.length) {
|
|
204
|
+
aliasEnd = i;
|
|
205
|
+
}
|
|
206
|
+
currentSection = match[1].trim();
|
|
207
|
+
if (currentSection === 'alias') {
|
|
208
|
+
aliasStart = i;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (aliasStart < 0) {
|
|
213
|
+
const trimmed = content.trimEnd();
|
|
214
|
+
const prefix = trimmed.length > 0 ? `${trimmed}\n\n` : '';
|
|
215
|
+
return `${prefix}[alias]\n${entryLine}\n`;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
let replaced = false;
|
|
219
|
+
for (let i = aliasStart + 1; i < aliasEnd; i += 1) {
|
|
220
|
+
const parsed = parseTomlKeyValue(stripTomlComment(lines[i]).trim());
|
|
221
|
+
if (!parsed) continue;
|
|
222
|
+
if (parsed.key === name) {
|
|
223
|
+
lines[i] = entryLine;
|
|
224
|
+
replaced = true;
|
|
225
|
+
break;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (!replaced) {
|
|
230
|
+
lines.splice(aliasEnd, 0, entryLine);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const output = lines.join('\n');
|
|
234
|
+
return output.endsWith('\n') ? output : `${output}\n`;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* 写入或更新 alias 配置。
|
|
239
|
+
*/
|
|
240
|
+
export async function upsertAliasEntry(name: string, command: string, filePath: string = getGlobalConfigPath()): Promise<void> {
|
|
241
|
+
const exists = await fs.pathExists(filePath);
|
|
242
|
+
const content = exists ? await fs.readFile(filePath, 'utf8') : '';
|
|
243
|
+
const nextContent = updateAliasContent(content, name, command);
|
|
244
|
+
await fs.mkdirp(path.dirname(filePath));
|
|
245
|
+
await fs.writeFile(filePath, nextContent, 'utf8');
|
|
246
|
+
}
|
|
247
|
+
|
|
150
248
|
/**
|
|
151
249
|
* 解析全局 TOML 配置文本。
|
|
152
250
|
*/
|
|
@@ -166,18 +264,10 @@ export function parseGlobalConfig(content: string): GlobalConfig {
|
|
|
166
264
|
}
|
|
167
265
|
|
|
168
266
|
if (currentSection !== 'shortcut') continue;
|
|
267
|
+
const parsed = parseTomlKeyValue(line);
|
|
268
|
+
if (!parsed) continue;
|
|
169
269
|
|
|
170
|
-
|
|
171
|
-
if (equalIndex <= 0) continue;
|
|
172
|
-
|
|
173
|
-
const key = line.slice(0, equalIndex).trim();
|
|
174
|
-
const valuePart = line.slice(equalIndex + 1).trim();
|
|
175
|
-
if (!key || !valuePart) continue;
|
|
176
|
-
|
|
177
|
-
const parsedValue = parseTomlString(valuePart);
|
|
178
|
-
if (parsedValue === null) continue;
|
|
179
|
-
|
|
180
|
-
shortcut[key] = parsedValue;
|
|
270
|
+
shortcut[parsed.key] = parsed.value;
|
|
181
271
|
}
|
|
182
272
|
|
|
183
273
|
const name = normalizeShortcutName(shortcut.name ?? '');
|
|
@@ -194,6 +284,54 @@ export function parseGlobalConfig(content: string): GlobalConfig {
|
|
|
194
284
|
};
|
|
195
285
|
}
|
|
196
286
|
|
|
287
|
+
/**
|
|
288
|
+
* 解析 alias 配置条目(含 shortcut 作为补充来源)。
|
|
289
|
+
*/
|
|
290
|
+
export function parseAliasEntries(content: string): AliasEntry[] {
|
|
291
|
+
const lines = content.split(/\r?\n/);
|
|
292
|
+
let currentSection: string | null = null;
|
|
293
|
+
const entries: AliasEntry[] = [];
|
|
294
|
+
const names = new Set<string>();
|
|
295
|
+
|
|
296
|
+
for (const rawLine of lines) {
|
|
297
|
+
const line = stripTomlComment(rawLine).trim();
|
|
298
|
+
if (!line) continue;
|
|
299
|
+
|
|
300
|
+
const sectionMatch = /^\[(.+)\]$/.exec(line);
|
|
301
|
+
if (sectionMatch) {
|
|
302
|
+
currentSection = sectionMatch[1].trim();
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (currentSection !== 'alias') continue;
|
|
307
|
+
const parsed = parseTomlKeyValue(line);
|
|
308
|
+
if (!parsed) continue;
|
|
309
|
+
|
|
310
|
+
const name = normalizeShortcutName(parsed.key);
|
|
311
|
+
const command = parsed.value.trim();
|
|
312
|
+
if (!name || !command) continue;
|
|
313
|
+
if (names.has(name)) continue;
|
|
314
|
+
|
|
315
|
+
names.add(name);
|
|
316
|
+
entries.push({
|
|
317
|
+
name,
|
|
318
|
+
command,
|
|
319
|
+
source: 'alias'
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const shortcut = parseGlobalConfig(content).shortcut;
|
|
324
|
+
if (shortcut && !names.has(shortcut.name)) {
|
|
325
|
+
entries.push({
|
|
326
|
+
name: shortcut.name,
|
|
327
|
+
command: shortcut.command,
|
|
328
|
+
source: 'shortcut'
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
return entries;
|
|
333
|
+
}
|
|
334
|
+
|
|
197
335
|
/**
|
|
198
336
|
* 读取用户目录下的全局配置。
|
|
199
337
|
*/
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { open as openFile } from 'node:fs/promises';
|
|
2
|
+
import fs from 'fs-extra';
|
|
3
|
+
|
|
4
|
+
export interface LogTailOptions {
|
|
5
|
+
readonly filePath: string;
|
|
6
|
+
readonly startFromEnd?: boolean;
|
|
7
|
+
readonly pollIntervalMs?: number;
|
|
8
|
+
readonly onLine: (line: string) => void;
|
|
9
|
+
readonly onError?: (message: string) => void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface LogTailHandle {
|
|
13
|
+
stop: () => Promise<void>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function normalizeChunk(chunk: string): string {
|
|
17
|
+
return chunk.replace(/\r\n?/g, '\n');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* 轮询尾读日志文件,按行输出。
|
|
22
|
+
*/
|
|
23
|
+
export async function tailLogFile(options: LogTailOptions): Promise<LogTailHandle> {
|
|
24
|
+
const intervalMs = options.pollIntervalMs ?? 200;
|
|
25
|
+
let offset = 0;
|
|
26
|
+
let buffer = '';
|
|
27
|
+
let reading = false;
|
|
28
|
+
let stopped = false;
|
|
29
|
+
|
|
30
|
+
if (options.startFromEnd) {
|
|
31
|
+
try {
|
|
32
|
+
const stat = await fs.stat(options.filePath);
|
|
33
|
+
offset = stat.size;
|
|
34
|
+
} catch {
|
|
35
|
+
offset = 0;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const flushBuffer = (): void => {
|
|
40
|
+
if (!buffer) return;
|
|
41
|
+
options.onLine(buffer);
|
|
42
|
+
buffer = '';
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const emitChunk = (chunk: string): void => {
|
|
46
|
+
buffer += normalizeChunk(chunk);
|
|
47
|
+
const parts = buffer.split('\n');
|
|
48
|
+
buffer = parts.pop() ?? '';
|
|
49
|
+
for (const line of parts) {
|
|
50
|
+
options.onLine(line);
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const readNew = async (): Promise<void> => {
|
|
55
|
+
if (reading || stopped) return;
|
|
56
|
+
reading = true;
|
|
57
|
+
try {
|
|
58
|
+
const stat = await fs.stat(options.filePath);
|
|
59
|
+
if (stat.size < offset) {
|
|
60
|
+
offset = stat.size;
|
|
61
|
+
buffer = '';
|
|
62
|
+
}
|
|
63
|
+
if (stat.size > offset) {
|
|
64
|
+
const length = stat.size - offset;
|
|
65
|
+
const handle = await openFile(options.filePath, 'r');
|
|
66
|
+
try {
|
|
67
|
+
const payload = Buffer.alloc(length);
|
|
68
|
+
const result = await handle.read(payload, 0, length, offset);
|
|
69
|
+
offset += result.bytesRead;
|
|
70
|
+
if (result.bytesRead > 0) {
|
|
71
|
+
const text = payload.subarray(0, result.bytesRead).toString('utf8');
|
|
72
|
+
emitChunk(text);
|
|
73
|
+
}
|
|
74
|
+
} finally {
|
|
75
|
+
await handle.close();
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
} catch (error) {
|
|
79
|
+
const err = error as NodeJS.ErrnoException;
|
|
80
|
+
if (err?.code !== 'ENOENT') {
|
|
81
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
82
|
+
options.onError?.(message);
|
|
83
|
+
}
|
|
84
|
+
} finally {
|
|
85
|
+
reading = false;
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const timer = setInterval(() => {
|
|
90
|
+
void readNew();
|
|
91
|
+
}, intervalMs);
|
|
92
|
+
|
|
93
|
+
await readNew();
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
stop: async () => {
|
|
97
|
+
if (stopped) return;
|
|
98
|
+
stopped = true;
|
|
99
|
+
clearInterval(timer);
|
|
100
|
+
flushBuffer();
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
}
|
package/src/logs-viewer.ts
CHANGED
|
@@ -14,7 +14,7 @@ export interface LogEntry {
|
|
|
14
14
|
interface ViewState {
|
|
15
15
|
entry: LogEntry;
|
|
16
16
|
lines: string[];
|
|
17
|
-
|
|
17
|
+
lineOffset: number;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
interface LogsViewerState {
|
|
@@ -170,7 +170,7 @@ function buildListLine(entry: LogEntry, selected: boolean, columns: number): str
|
|
|
170
170
|
}
|
|
171
171
|
|
|
172
172
|
function buildViewHeader(entry: LogEntry, columns: number): string {
|
|
173
|
-
const title = `日志查看|${entry.fileName}|↑/↓ 翻页 b 返回 q 退出`;
|
|
173
|
+
const title = `日志查看|${entry.fileName}|↑/↓ 上下 1 行 PageUp/PageDown 翻页 b 返回 q 退出`;
|
|
174
174
|
return truncateLine(title, columns);
|
|
175
175
|
}
|
|
176
176
|
|
|
@@ -232,16 +232,18 @@ function renderView(view: ViewState): void {
|
|
|
232
232
|
const { rows, columns } = getTerminalSize();
|
|
233
233
|
const pageSize = getPageSize(rows);
|
|
234
234
|
const header = buildViewHeader(view.entry, columns);
|
|
235
|
-
const maxOffset = Math.max(0,
|
|
236
|
-
view.
|
|
235
|
+
const maxOffset = Math.max(0, view.lines.length - pageSize);
|
|
236
|
+
view.lineOffset = Math.min(Math.max(view.lineOffset, 0), maxOffset);
|
|
237
237
|
|
|
238
|
-
const start = view.
|
|
238
|
+
const start = view.lineOffset;
|
|
239
239
|
const pageLines = view.lines.slice(start, start + pageSize).map(line => truncateLine(line, columns));
|
|
240
240
|
while (pageLines.length < pageSize) {
|
|
241
241
|
pageLines.push('');
|
|
242
242
|
}
|
|
243
243
|
|
|
244
|
-
const
|
|
244
|
+
const totalPages = Math.max(1, Math.ceil(view.lines.length / pageSize));
|
|
245
|
+
const currentPage = Math.min(totalPages, Math.floor(view.lineOffset / pageSize) + 1);
|
|
246
|
+
const status = buildViewStatus(view.entry, { current: currentPage, total: totalPages }, columns);
|
|
245
247
|
const content = [header, ...pageLines, status].join('\n');
|
|
246
248
|
process.stdout.write(`\u001b[2J\u001b[H${content}`);
|
|
247
249
|
}
|
|
@@ -272,6 +274,14 @@ function isArrowDown(input: string): boolean {
|
|
|
272
274
|
return input.includes('\u001b[B');
|
|
273
275
|
}
|
|
274
276
|
|
|
277
|
+
function isPageUp(input: string): boolean {
|
|
278
|
+
return input.includes('\u001b[5~');
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function isPageDown(input: string): boolean {
|
|
282
|
+
return input.includes('\u001b[6~');
|
|
283
|
+
}
|
|
284
|
+
|
|
275
285
|
function isEscape(input: string): boolean {
|
|
276
286
|
return input === '\u001b';
|
|
277
287
|
}
|
|
@@ -351,16 +361,16 @@ export async function runLogsViewer(): Promise<void> {
|
|
|
351
361
|
state.view = {
|
|
352
362
|
entry,
|
|
353
363
|
lines: ['加载中…'],
|
|
354
|
-
|
|
364
|
+
lineOffset: 0
|
|
355
365
|
};
|
|
356
366
|
render(state);
|
|
357
367
|
const lines = await readLogLines(entry.filePath);
|
|
358
368
|
const pageSize = getPageSize(getTerminalSize().rows);
|
|
359
|
-
const maxOffset = Math.max(0,
|
|
369
|
+
const maxOffset = Math.max(0, lines.length - pageSize);
|
|
360
370
|
state.view = {
|
|
361
371
|
entry,
|
|
362
372
|
lines,
|
|
363
|
-
|
|
373
|
+
lineOffset: maxOffset
|
|
364
374
|
};
|
|
365
375
|
loading = false;
|
|
366
376
|
render(state);
|
|
@@ -396,12 +406,24 @@ export async function runLogsViewer(): Promise<void> {
|
|
|
396
406
|
|
|
397
407
|
if (state.mode === 'view' && state.view) {
|
|
398
408
|
if (isArrowUp(input)) {
|
|
399
|
-
state.view.
|
|
409
|
+
state.view.lineOffset -= 1;
|
|
400
410
|
render(state);
|
|
401
411
|
return;
|
|
402
412
|
}
|
|
403
413
|
if (isArrowDown(input)) {
|
|
404
|
-
state.view.
|
|
414
|
+
state.view.lineOffset += 1;
|
|
415
|
+
render(state);
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
if (isPageUp(input)) {
|
|
419
|
+
const pageSize = getPageSize(getTerminalSize().rows);
|
|
420
|
+
state.view.lineOffset -= pageSize;
|
|
421
|
+
render(state);
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
if (isPageDown(input)) {
|
|
425
|
+
const pageSize = getPageSize(getTerminalSize().rows);
|
|
426
|
+
state.view.lineOffset += pageSize;
|
|
405
427
|
render(state);
|
|
406
428
|
return;
|
|
407
429
|
}
|