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.
@@ -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
- const equalIndex = findUnquotedIndex(line, '=');
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
+ }
@@ -14,7 +14,7 @@ export interface LogEntry {
14
14
  interface ViewState {
15
15
  entry: LogEntry;
16
16
  lines: string[];
17
- pageOffset: number;
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, Math.ceil(view.lines.length / pageSize) - 1);
236
- view.pageOffset = Math.min(Math.max(view.pageOffset, 0), maxOffset);
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.pageOffset * pageSize;
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 status = buildViewStatus(view.entry, { current: view.pageOffset + 1, total: Math.max(1, maxOffset + 1) }, columns);
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
- pageOffset: 0
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, Math.ceil(lines.length / pageSize) - 1);
369
+ const maxOffset = Math.max(0, lines.length - pageSize);
360
370
  state.view = {
361
371
  entry,
362
372
  lines,
363
- pageOffset: maxOffset
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.pageOffset -= 1;
409
+ state.view.lineOffset -= 1;
400
410
  render(state);
401
411
  return;
402
412
  }
403
413
  if (isArrowDown(input)) {
404
- state.view.pageOffset += 1;
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
  }
package/src/logs.ts CHANGED
@@ -8,6 +8,7 @@ export interface RunMetadata {
8
8
  readonly round: number;
9
9
  readonly tokenUsed: number;
10
10
  readonly path: string;
11
+ readonly pid?: number;
11
12
  }
12
13
 
13
14
  export interface CurrentRegistryEntry extends RunMetadata {