ghcrawl 0.1.0 → 0.2.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/README.md +32 -0
- package/bin/ghcrawl.js +29 -19
- package/dist/init-wizard.d.ts +41 -0
- package/dist/init-wizard.d.ts.map +1 -0
- package/dist/init-wizard.js +255 -0
- package/dist/init-wizard.js.map +1 -0
- package/dist/main.d.ts +18 -0
- package/dist/main.d.ts.map +1 -0
- package/dist/main.js +400 -0
- package/dist/main.js.map +1 -0
- package/dist/tui/app.d.ts +37 -0
- package/dist/tui/app.d.ts.map +1 -0
- package/dist/tui/app.js +1055 -0
- package/dist/tui/app.js.map +1 -0
- package/dist/tui/layout.d.ts +17 -0
- package/dist/tui/layout.d.ts.map +1 -0
- package/dist/tui/layout.js +34 -0
- package/dist/tui/layout.js.map +1 -0
- package/dist/tui/state.d.ts +30 -0
- package/dist/tui/state.d.ts.map +1 -0
- package/dist/tui/state.js +101 -0
- package/dist/tui/state.js.map +1 -0
- package/package.json +6 -5
- package/src/init-wizard.test.ts +0 -185
- package/src/init-wizard.ts +0 -323
- package/src/main.test.ts +0 -181
- package/src/main.ts +0 -447
- package/src/neo-blessed.d.ts +0 -4
- package/src/tui/app.test.ts +0 -164
- package/src/tui/app.ts +0 -1210
- package/src/tui/layout.test.ts +0 -19
- package/src/tui/layout.ts +0 -53
- package/src/tui/state.test.ts +0 -116
- package/src/tui/state.ts +0 -121
package/src/main.ts
DELETED
|
@@ -1,447 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
import { once } from 'node:events';
|
|
3
|
-
import { parseArgs } from 'node:util';
|
|
4
|
-
|
|
5
|
-
import { createApiServer, GHCrawlService } from '@ghcrawl/api-core';
|
|
6
|
-
import { runInitWizard } from './init-wizard.js';
|
|
7
|
-
import { startTui } from './tui/app.js';
|
|
8
|
-
|
|
9
|
-
type CommandName =
|
|
10
|
-
| 'init'
|
|
11
|
-
| 'doctor'
|
|
12
|
-
| 'sync'
|
|
13
|
-
| 'refresh'
|
|
14
|
-
| 'summarize'
|
|
15
|
-
| 'purge-comments'
|
|
16
|
-
| 'embed'
|
|
17
|
-
| 'cluster'
|
|
18
|
-
| 'clusters'
|
|
19
|
-
| 'cluster-detail'
|
|
20
|
-
| 'search'
|
|
21
|
-
| 'neighbors'
|
|
22
|
-
| 'tui'
|
|
23
|
-
| 'serve';
|
|
24
|
-
|
|
25
|
-
type DoctorResult = Awaited<ReturnType<GHCrawlService['doctor']>>;
|
|
26
|
-
|
|
27
|
-
function usage(devMode = false): string {
|
|
28
|
-
const lines = [
|
|
29
|
-
'ghcrawl <command> [options]',
|
|
30
|
-
'',
|
|
31
|
-
'Commands:',
|
|
32
|
-
' init [--reconfigure]',
|
|
33
|
-
' doctor',
|
|
34
|
-
' sync <owner/repo> [--since <iso|duration>] [--limit <count>] [--include-comments]',
|
|
35
|
-
' refresh <owner/repo> [--no-sync] [--no-embed] [--no-cluster]',
|
|
36
|
-
' embed <owner/repo> [--number <thread>]',
|
|
37
|
-
' cluster <owner/repo> [--k <count>] [--threshold <score>]',
|
|
38
|
-
' clusters <owner/repo> [--min-size <count>] [--limit <count>] [--sort recent|size] [--search <text>]',
|
|
39
|
-
' cluster-detail <owner/repo> --id <cluster-id> [--member-limit <count>] [--body-chars <count>]',
|
|
40
|
-
' search <owner/repo> --query <text> [--mode keyword|semantic|hybrid]',
|
|
41
|
-
' neighbors <owner/repo> --number <thread> [--limit <count>] [--threshold <score>]',
|
|
42
|
-
' tui [owner/repo]',
|
|
43
|
-
' serve',
|
|
44
|
-
];
|
|
45
|
-
if (devMode) {
|
|
46
|
-
lines.push('', 'Advanced Commands:', ' summarize <owner/repo> [--number <thread>] [--include-comments]', ' purge-comments <owner/repo> [--number <thread>]');
|
|
47
|
-
}
|
|
48
|
-
return `${lines.join('\n')}\n`;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
function parseGlobalFlags(argv: string[], env: NodeJS.ProcessEnv = process.env): { argv: string[]; devMode: boolean } {
|
|
52
|
-
let devMode = env.GHCRAWL_DEV_MODE === '1' || env.GHCRAWL_DEV_MODE === '1';
|
|
53
|
-
const filtered: string[] = [];
|
|
54
|
-
for (const arg of argv) {
|
|
55
|
-
if (arg === '--dev') {
|
|
56
|
-
devMode = true;
|
|
57
|
-
continue;
|
|
58
|
-
}
|
|
59
|
-
filtered.push(arg);
|
|
60
|
-
}
|
|
61
|
-
return { argv: filtered, devMode };
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
export function parseOwnerRepo(value: string): { owner: string; repo: string } {
|
|
65
|
-
const trimmed = value.trim();
|
|
66
|
-
const parts = trimmed.split('/');
|
|
67
|
-
if (parts.length !== 2 || !parts[0] || !parts[1]) {
|
|
68
|
-
throw new Error(`Expected owner/repo, received: ${value}`);
|
|
69
|
-
}
|
|
70
|
-
return { owner: parts[0], repo: parts[1] };
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
export function parseRepoFlags(args: string[]): { owner: string; repo: string; values: Record<string, string | boolean> } {
|
|
74
|
-
const parsed = parseArgs({
|
|
75
|
-
args,
|
|
76
|
-
allowPositionals: true,
|
|
77
|
-
options: {
|
|
78
|
-
owner: { type: 'string' },
|
|
79
|
-
repo: { type: 'string' },
|
|
80
|
-
since: { type: 'string' },
|
|
81
|
-
limit: { type: 'string' },
|
|
82
|
-
'include-comments': { type: 'boolean' },
|
|
83
|
-
number: { type: 'string' },
|
|
84
|
-
query: { type: 'string' },
|
|
85
|
-
mode: { type: 'string' },
|
|
86
|
-
k: { type: 'string' },
|
|
87
|
-
threshold: { type: 'string' },
|
|
88
|
-
port: { type: 'string' },
|
|
89
|
-
id: { type: 'string' },
|
|
90
|
-
sort: { type: 'string' },
|
|
91
|
-
search: { type: 'string' },
|
|
92
|
-
'min-size': { type: 'string' },
|
|
93
|
-
'member-limit': { type: 'string' },
|
|
94
|
-
'body-chars': { type: 'string' },
|
|
95
|
-
'no-sync': { type: 'boolean' },
|
|
96
|
-
'no-embed': { type: 'boolean' },
|
|
97
|
-
'no-cluster': { type: 'boolean' },
|
|
98
|
-
},
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
if (typeof parsed.values.repo === 'string' && parsed.values.repo.includes('/')) {
|
|
102
|
-
const target = parseOwnerRepo(parsed.values.repo);
|
|
103
|
-
return { ...target, values: parsed.values };
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
if (parsed.positionals.length > 0) {
|
|
107
|
-
const target = parseOwnerRepo(parsed.positionals[0]);
|
|
108
|
-
return { ...target, values: parsed.values };
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
const owner = parsed.values.owner;
|
|
112
|
-
const repo = parsed.values.repo;
|
|
113
|
-
if (typeof owner === 'string' && typeof repo === 'string') {
|
|
114
|
-
return { owner, repo, values: parsed.values };
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
throw new Error('Use --repo owner/repo or provide owner/repo as the first positional argument');
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
export function resolveSinceValue(value: string, now: Date = new Date()): string {
|
|
121
|
-
const trimmed = value.trim();
|
|
122
|
-
const absolute = new Date(trimmed);
|
|
123
|
-
if (!Number.isNaN(absolute.getTime())) {
|
|
124
|
-
return absolute.toISOString();
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
const match = trimmed.match(/^(\d+)(s|m|h|d|w|mo|y)$/i);
|
|
128
|
-
if (!match) {
|
|
129
|
-
throw new Error(`Invalid --since value: ${value}. Use an ISO timestamp or duration like 15m, 2h, 7d, or 1mo.`);
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
const amount = Number(match[1]);
|
|
133
|
-
const unit = match[2].toLowerCase();
|
|
134
|
-
const resolved = new Date(now);
|
|
135
|
-
|
|
136
|
-
switch (unit) {
|
|
137
|
-
case 's':
|
|
138
|
-
resolved.setTime(resolved.getTime() - amount * 1000);
|
|
139
|
-
break;
|
|
140
|
-
case 'm':
|
|
141
|
-
resolved.setTime(resolved.getTime() - amount * 60 * 1000);
|
|
142
|
-
break;
|
|
143
|
-
case 'h':
|
|
144
|
-
resolved.setTime(resolved.getTime() - amount * 60 * 60 * 1000);
|
|
145
|
-
break;
|
|
146
|
-
case 'd':
|
|
147
|
-
resolved.setTime(resolved.getTime() - amount * 24 * 60 * 60 * 1000);
|
|
148
|
-
break;
|
|
149
|
-
case 'w':
|
|
150
|
-
resolved.setTime(resolved.getTime() - amount * 7 * 24 * 60 * 60 * 1000);
|
|
151
|
-
break;
|
|
152
|
-
case 'mo':
|
|
153
|
-
resolved.setUTCMonth(resolved.getUTCMonth() - amount);
|
|
154
|
-
break;
|
|
155
|
-
case 'y':
|
|
156
|
-
resolved.setUTCFullYear(resolved.getUTCFullYear() - amount);
|
|
157
|
-
break;
|
|
158
|
-
default:
|
|
159
|
-
throw new Error(`Unsupported --since unit: ${unit}`);
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
return resolved.toISOString();
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
export function formatLogLine(message: string, now: Date = new Date()): string {
|
|
166
|
-
return `[${now.toISOString()}] ${message}`;
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
function writeProgress(message: string): void {
|
|
170
|
-
process.stderr.write(`${formatLogLine(message)}\n`);
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
function formatBooleanStatus(value: boolean): string {
|
|
174
|
-
return value ? 'yes' : 'no';
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
function parsePositiveInteger(name: string, value: string): number {
|
|
178
|
-
const parsed = Number(value);
|
|
179
|
-
if (!Number.isSafeInteger(parsed) || parsed <= 0) {
|
|
180
|
-
throw new Error(`Invalid ${name}: ${value}`);
|
|
181
|
-
}
|
|
182
|
-
return parsed;
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
export function formatDoctorReport(result: DoctorResult): string {
|
|
186
|
-
const lines = [
|
|
187
|
-
'ghcrawl doctor',
|
|
188
|
-
'',
|
|
189
|
-
'Health',
|
|
190
|
-
` ok: ${formatBooleanStatus(result.health.ok)}`,
|
|
191
|
-
` config path: ${result.health.configPath}`,
|
|
192
|
-
` config file exists: ${formatBooleanStatus(result.health.configFileExists)}`,
|
|
193
|
-
` db path: ${result.health.dbPath}`,
|
|
194
|
-
` api port: ${result.health.apiPort}`,
|
|
195
|
-
'',
|
|
196
|
-
'GitHub',
|
|
197
|
-
` configured: ${formatBooleanStatus(result.github.configured)}`,
|
|
198
|
-
` source: ${result.github.source}`,
|
|
199
|
-
` format ok: ${formatBooleanStatus(result.github.formatOk)}`,
|
|
200
|
-
` auth ok: ${formatBooleanStatus(result.github.authOk)}`,
|
|
201
|
-
];
|
|
202
|
-
if (result.github.error) {
|
|
203
|
-
lines.push(` note: ${result.github.error}`);
|
|
204
|
-
}
|
|
205
|
-
lines.push(
|
|
206
|
-
'',
|
|
207
|
-
'OpenAI',
|
|
208
|
-
` configured: ${formatBooleanStatus(result.openai.configured)}`,
|
|
209
|
-
` source: ${result.openai.source}`,
|
|
210
|
-
` format ok: ${formatBooleanStatus(result.openai.formatOk)}`,
|
|
211
|
-
` auth ok: ${formatBooleanStatus(result.openai.authOk)}`,
|
|
212
|
-
);
|
|
213
|
-
if (result.openai.error) {
|
|
214
|
-
lines.push(` note: ${result.openai.error}`);
|
|
215
|
-
}
|
|
216
|
-
return `${lines.join('\n')}\n`;
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
function closeService(service: GHCrawlService | null): void {
|
|
220
|
-
if (service) {
|
|
221
|
-
service.close();
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
export async function run(argv: string[], stdout: NodeJS.WritableStream = process.stdout): Promise<void> {
|
|
226
|
-
const parsedGlobals = parseGlobalFlags(argv);
|
|
227
|
-
const [commandRaw, ...rest] = parsedGlobals.argv;
|
|
228
|
-
const command = commandRaw as CommandName | undefined;
|
|
229
|
-
if (!command || commandRaw === '--help' || commandRaw === '-h' || commandRaw === 'help') {
|
|
230
|
-
stdout.write(usage(parsedGlobals.devMode));
|
|
231
|
-
return;
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
let service: GHCrawlService | null = null;
|
|
235
|
-
const getService = (): GHCrawlService => {
|
|
236
|
-
service ??= new GHCrawlService();
|
|
237
|
-
return service;
|
|
238
|
-
};
|
|
239
|
-
try {
|
|
240
|
-
switch (command) {
|
|
241
|
-
case 'init': {
|
|
242
|
-
const parsed = parseArgs({
|
|
243
|
-
args: rest,
|
|
244
|
-
options: {
|
|
245
|
-
reconfigure: { type: 'boolean' },
|
|
246
|
-
},
|
|
247
|
-
});
|
|
248
|
-
await runInitWizard({ reconfigure: parsed.values.reconfigure === true });
|
|
249
|
-
stdout.write(`${JSON.stringify(getService().init(), null, 2)}\n`);
|
|
250
|
-
return;
|
|
251
|
-
}
|
|
252
|
-
case 'doctor': {
|
|
253
|
-
const parsed = parseArgs({
|
|
254
|
-
args: rest,
|
|
255
|
-
options: {
|
|
256
|
-
json: { type: 'boolean' },
|
|
257
|
-
},
|
|
258
|
-
});
|
|
259
|
-
const result = await getService().doctor();
|
|
260
|
-
const shouldWriteJson = parsed.values.json === true || (stdout as NodeJS.WriteStream).isTTY !== true;
|
|
261
|
-
stdout.write(shouldWriteJson ? `${JSON.stringify(result, null, 2)}\n` : formatDoctorReport(result));
|
|
262
|
-
return;
|
|
263
|
-
}
|
|
264
|
-
case 'sync': {
|
|
265
|
-
const { owner, repo, values } = parseRepoFlags(rest);
|
|
266
|
-
const result = await getService().syncRepository({
|
|
267
|
-
owner,
|
|
268
|
-
repo,
|
|
269
|
-
since: typeof values.since === 'string' ? resolveSinceValue(values.since) : undefined,
|
|
270
|
-
limit: typeof values.limit === 'string' ? Number(values.limit) : undefined,
|
|
271
|
-
includeComments: values['include-comments'] === true,
|
|
272
|
-
onProgress: writeProgress,
|
|
273
|
-
});
|
|
274
|
-
stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
275
|
-
return;
|
|
276
|
-
}
|
|
277
|
-
case 'refresh': {
|
|
278
|
-
const { owner, repo, values } = parseRepoFlags(rest);
|
|
279
|
-
const result = await getService().refreshRepository({
|
|
280
|
-
owner,
|
|
281
|
-
repo,
|
|
282
|
-
sync: values['no-sync'] === true ? false : undefined,
|
|
283
|
-
embed: values['no-embed'] === true ? false : undefined,
|
|
284
|
-
cluster: values['no-cluster'] === true ? false : undefined,
|
|
285
|
-
onProgress: writeProgress,
|
|
286
|
-
});
|
|
287
|
-
stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
288
|
-
return;
|
|
289
|
-
}
|
|
290
|
-
case 'summarize': {
|
|
291
|
-
const { owner, repo, values } = parseRepoFlags(rest);
|
|
292
|
-
const result = await getService().summarizeRepository({
|
|
293
|
-
owner,
|
|
294
|
-
repo,
|
|
295
|
-
threadNumber: typeof values.number === 'string' ? Number(values.number) : undefined,
|
|
296
|
-
includeComments: values['include-comments'] === true,
|
|
297
|
-
onProgress: writeProgress,
|
|
298
|
-
});
|
|
299
|
-
stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
300
|
-
return;
|
|
301
|
-
}
|
|
302
|
-
case 'purge-comments': {
|
|
303
|
-
const { owner, repo, values } = parseRepoFlags(rest);
|
|
304
|
-
const result = getService().purgeComments({
|
|
305
|
-
owner,
|
|
306
|
-
repo,
|
|
307
|
-
threadNumber: typeof values.number === 'string' ? Number(values.number) : undefined,
|
|
308
|
-
onProgress: writeProgress,
|
|
309
|
-
});
|
|
310
|
-
stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
311
|
-
return;
|
|
312
|
-
}
|
|
313
|
-
case 'embed': {
|
|
314
|
-
const { owner, repo, values } = parseRepoFlags(rest);
|
|
315
|
-
const result = await getService().embedRepository({
|
|
316
|
-
owner,
|
|
317
|
-
repo,
|
|
318
|
-
threadNumber: typeof values.number === 'string' ? Number(values.number) : undefined,
|
|
319
|
-
onProgress: writeProgress,
|
|
320
|
-
});
|
|
321
|
-
stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
322
|
-
return;
|
|
323
|
-
}
|
|
324
|
-
case 'cluster': {
|
|
325
|
-
const { owner, repo, values } = parseRepoFlags(rest);
|
|
326
|
-
const result = getService().clusterRepository({
|
|
327
|
-
owner,
|
|
328
|
-
repo,
|
|
329
|
-
k: typeof values.k === 'string' ? Number(values.k) : undefined,
|
|
330
|
-
minScore: typeof values.threshold === 'string' ? Number(values.threshold) : undefined,
|
|
331
|
-
onProgress: writeProgress,
|
|
332
|
-
});
|
|
333
|
-
stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
334
|
-
return;
|
|
335
|
-
}
|
|
336
|
-
case 'clusters': {
|
|
337
|
-
const { owner, repo, values } = parseRepoFlags(rest);
|
|
338
|
-
const sort = values.sort === 'recent' || values.sort === 'size' ? values.sort : undefined;
|
|
339
|
-
const result = getService().listClusterSummaries({
|
|
340
|
-
owner,
|
|
341
|
-
repo,
|
|
342
|
-
minSize: typeof values['min-size'] === 'string' ? parsePositiveInteger('min-size', values['min-size']) : undefined,
|
|
343
|
-
limit: typeof values.limit === 'string' ? parsePositiveInteger('limit', values.limit) : undefined,
|
|
344
|
-
sort,
|
|
345
|
-
search: typeof values.search === 'string' ? values.search : undefined,
|
|
346
|
-
});
|
|
347
|
-
stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
348
|
-
return;
|
|
349
|
-
}
|
|
350
|
-
case 'cluster-detail': {
|
|
351
|
-
const { owner, repo, values } = parseRepoFlags(rest);
|
|
352
|
-
if (typeof values.id !== 'string') {
|
|
353
|
-
throw new Error('Missing --id');
|
|
354
|
-
}
|
|
355
|
-
const result = getService().getClusterDetailDump({
|
|
356
|
-
owner,
|
|
357
|
-
repo,
|
|
358
|
-
clusterId: parsePositiveInteger('id', values.id),
|
|
359
|
-
memberLimit:
|
|
360
|
-
typeof values['member-limit'] === 'string'
|
|
361
|
-
? parsePositiveInteger('member-limit', values['member-limit'])
|
|
362
|
-
: undefined,
|
|
363
|
-
bodyChars:
|
|
364
|
-
typeof values['body-chars'] === 'string'
|
|
365
|
-
? parsePositiveInteger('body-chars', values['body-chars'])
|
|
366
|
-
: undefined,
|
|
367
|
-
});
|
|
368
|
-
stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
369
|
-
return;
|
|
370
|
-
}
|
|
371
|
-
case 'search': {
|
|
372
|
-
const { owner, repo, values } = parseRepoFlags(rest);
|
|
373
|
-
if (typeof values.query !== 'string') {
|
|
374
|
-
throw new Error('Missing --query');
|
|
375
|
-
}
|
|
376
|
-
const mode =
|
|
377
|
-
values.mode === 'keyword' || values.mode === 'semantic' || values.mode === 'hybrid'
|
|
378
|
-
? values.mode
|
|
379
|
-
: undefined;
|
|
380
|
-
const result = await getService().searchRepository({
|
|
381
|
-
owner,
|
|
382
|
-
repo,
|
|
383
|
-
query: values.query,
|
|
384
|
-
mode,
|
|
385
|
-
});
|
|
386
|
-
stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
387
|
-
return;
|
|
388
|
-
}
|
|
389
|
-
case 'neighbors': {
|
|
390
|
-
const { owner, repo, values } = parseRepoFlags(rest);
|
|
391
|
-
if (typeof values.number !== 'string') {
|
|
392
|
-
throw new Error('Missing --number');
|
|
393
|
-
}
|
|
394
|
-
const result = getService().listNeighbors({
|
|
395
|
-
owner,
|
|
396
|
-
repo,
|
|
397
|
-
threadNumber: Number(values.number),
|
|
398
|
-
limit: typeof values.limit === 'string' ? Number(values.limit) : undefined,
|
|
399
|
-
minScore: typeof values.threshold === 'string' ? Number(values.threshold) : undefined,
|
|
400
|
-
});
|
|
401
|
-
stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
402
|
-
return;
|
|
403
|
-
}
|
|
404
|
-
case 'tui': {
|
|
405
|
-
if (rest.length === 0) {
|
|
406
|
-
await startTui({ service: getService() });
|
|
407
|
-
return;
|
|
408
|
-
}
|
|
409
|
-
const { owner, repo } = parseRepoFlags(rest);
|
|
410
|
-
await startTui({ service: getService(), owner, repo });
|
|
411
|
-
return;
|
|
412
|
-
}
|
|
413
|
-
case 'serve': {
|
|
414
|
-
const serviceForServe = getService();
|
|
415
|
-
const server = createApiServer(serviceForServe);
|
|
416
|
-
const parsed = parseArgs({
|
|
417
|
-
args: rest,
|
|
418
|
-
options: { port: { type: 'string' } },
|
|
419
|
-
});
|
|
420
|
-
const port = typeof parsed.values.port === 'string' ? Number(parsed.values.port) : serviceForServe.config.apiPort;
|
|
421
|
-
server.listen(port, '127.0.0.1');
|
|
422
|
-
stdout.write(`ghcrawl API listening on http://127.0.0.1:${port}\n`);
|
|
423
|
-
const stop = async () => {
|
|
424
|
-
server.close();
|
|
425
|
-
serviceForServe.close();
|
|
426
|
-
};
|
|
427
|
-
process.once('SIGINT', () => void stop());
|
|
428
|
-
process.once('SIGTERM', () => void stop());
|
|
429
|
-
await once(server, 'close');
|
|
430
|
-
return;
|
|
431
|
-
}
|
|
432
|
-
default:
|
|
433
|
-
throw new Error(`Unknown command: ${command}`);
|
|
434
|
-
}
|
|
435
|
-
} finally {
|
|
436
|
-
if (command !== 'serve') {
|
|
437
|
-
closeService(service);
|
|
438
|
-
}
|
|
439
|
-
}
|
|
440
|
-
}
|
|
441
|
-
|
|
442
|
-
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
443
|
-
run(process.argv.slice(2)).catch((error) => {
|
|
444
|
-
writeProgress(error instanceof Error ? error.message : String(error));
|
|
445
|
-
process.exit(1);
|
|
446
|
-
});
|
|
447
|
-
}
|
package/src/neo-blessed.d.ts
DELETED
package/src/tui/app.test.ts
DELETED
|
@@ -1,164 +0,0 @@
|
|
|
1
|
-
import test from 'node:test';
|
|
2
|
-
import assert from 'node:assert/strict';
|
|
3
|
-
|
|
4
|
-
import type { TuiClusterDetail, TuiRepoStats, TuiThreadDetail } from '@ghcrawl/api-core';
|
|
5
|
-
|
|
6
|
-
import {
|
|
7
|
-
buildUpdatePipelineLabels,
|
|
8
|
-
describeUpdateTask,
|
|
9
|
-
escapeBlessedText,
|
|
10
|
-
getRepositoryChoices,
|
|
11
|
-
parseOwnerRepoValue,
|
|
12
|
-
renderDetailPane,
|
|
13
|
-
resolveBlessedTerminal,
|
|
14
|
-
} from './app.js';
|
|
15
|
-
|
|
16
|
-
test('escapeBlessedText escapes blessed tag delimiters', () => {
|
|
17
|
-
assert.equal(escapeBlessedText('{bold}wow{/bold}'), '\\{bold\\}wow\\{/bold\\}');
|
|
18
|
-
assert.equal(escapeBlessedText('path\\name'), 'path\\\\name');
|
|
19
|
-
});
|
|
20
|
-
|
|
21
|
-
test('renderDetailPane escapes user-provided text before rendering into a tags-enabled box', () => {
|
|
22
|
-
const cluster: TuiClusterDetail = {
|
|
23
|
-
clusterId: 1,
|
|
24
|
-
displayTitle: 'Cluster {red-fg}boom{/red-fg}',
|
|
25
|
-
totalCount: 1,
|
|
26
|
-
issueCount: 1,
|
|
27
|
-
pullRequestCount: 0,
|
|
28
|
-
latestUpdatedAt: '2026-03-09T00:00:00Z',
|
|
29
|
-
representativeThreadId: 1,
|
|
30
|
-
representativeNumber: 42,
|
|
31
|
-
representativeKind: 'issue',
|
|
32
|
-
members: [],
|
|
33
|
-
};
|
|
34
|
-
const detail: TuiThreadDetail = {
|
|
35
|
-
thread: {
|
|
36
|
-
id: 1,
|
|
37
|
-
repoId: 1,
|
|
38
|
-
number: 42,
|
|
39
|
-
kind: 'issue',
|
|
40
|
-
state: 'open',
|
|
41
|
-
title: 'Bad {bold}title{/bold}',
|
|
42
|
-
body: 'Body with {red-fg}tags{/red-fg}',
|
|
43
|
-
authorLogin: 'dev{cyan-fg}',
|
|
44
|
-
htmlUrl: 'https://example.com/{oops}',
|
|
45
|
-
labels: ['bug{green-fg}'],
|
|
46
|
-
updatedAtGh: '2026-03-09T00:00:00Z',
|
|
47
|
-
clusterId: 1,
|
|
48
|
-
},
|
|
49
|
-
summaries: {
|
|
50
|
-
dedupe_summary: 'Summary {yellow-fg}text{/yellow-fg}',
|
|
51
|
-
},
|
|
52
|
-
neighbors: [
|
|
53
|
-
{
|
|
54
|
-
threadId: 2,
|
|
55
|
-
number: 43,
|
|
56
|
-
kind: 'pull_request',
|
|
57
|
-
title: 'Neighbor {blue-fg}title{/blue-fg}',
|
|
58
|
-
score: 0.9,
|
|
59
|
-
},
|
|
60
|
-
],
|
|
61
|
-
};
|
|
62
|
-
|
|
63
|
-
const rendered = renderDetailPane(detail, cluster, 'detail');
|
|
64
|
-
assert.match(rendered, /Bad \\{bold\\}title\\{\/bold\\}/);
|
|
65
|
-
assert.match(rendered, /Body with \\{red-fg\\}tags\\{\/red-fg\\}/);
|
|
66
|
-
assert.match(rendered, /Summary \\{yellow-fg\\}text\\{\/yellow-fg\\}/);
|
|
67
|
-
assert.match(rendered, /Neighbor \\{blue-fg\\}title\\{\/blue-fg\\}/);
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
test('parseOwnerRepoValue accepts owner slash repo values and rejects invalid ones', () => {
|
|
71
|
-
assert.deepEqual(parseOwnerRepoValue('openclaw/openclaw'), { owner: 'openclaw', repo: 'openclaw' });
|
|
72
|
-
assert.equal(parseOwnerRepoValue('openclaw'), null);
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
test('resolveBlessedTerminal normalizes ghostty to xterm-256color', () => {
|
|
76
|
-
assert.equal(resolveBlessedTerminal({ TERM: 'xterm-ghostty' } as NodeJS.ProcessEnv), 'xterm-256color');
|
|
77
|
-
assert.equal(resolveBlessedTerminal({ TERM: 'xterm-256color' } as NodeJS.ProcessEnv), 'xterm-256color');
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
test('getRepositoryChoices sorts by most recent update and includes the new-repo action', () => {
|
|
81
|
-
const service = {
|
|
82
|
-
listRepositories() {
|
|
83
|
-
return {
|
|
84
|
-
repositories: [
|
|
85
|
-
{
|
|
86
|
-
id: 1,
|
|
87
|
-
owner: 'older',
|
|
88
|
-
name: 'repo',
|
|
89
|
-
fullName: 'older/repo',
|
|
90
|
-
githubRepoId: '1',
|
|
91
|
-
updatedAt: '2026-03-08T12:00:00Z',
|
|
92
|
-
},
|
|
93
|
-
{
|
|
94
|
-
id: 2,
|
|
95
|
-
owner: 'newer',
|
|
96
|
-
name: 'repo',
|
|
97
|
-
fullName: 'newer/repo',
|
|
98
|
-
githubRepoId: '2',
|
|
99
|
-
updatedAt: '2026-03-09T12:00:00Z',
|
|
100
|
-
},
|
|
101
|
-
],
|
|
102
|
-
};
|
|
103
|
-
},
|
|
104
|
-
};
|
|
105
|
-
|
|
106
|
-
const choices = getRepositoryChoices(service, new Date('2026-03-09T12:30:00Z'));
|
|
107
|
-
assert.equal(choices[0]?.kind, 'existing');
|
|
108
|
-
assert.equal(choices[0]?.target.owner, 'newer');
|
|
109
|
-
assert.match(choices[0]?.label ?? '', /newer\/repo/);
|
|
110
|
-
assert.equal(choices.at(-1)?.kind, 'new');
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
test('describeUpdateTask reports stale embeddings relative to GitHub sync', () => {
|
|
114
|
-
const stats: TuiRepoStats = {
|
|
115
|
-
openIssueCount: 10,
|
|
116
|
-
openPullRequestCount: 5,
|
|
117
|
-
lastGithubReconciliationAt: '2026-03-09T14:00:00Z',
|
|
118
|
-
lastEmbedRefreshAt: '2026-03-09T12:00:00Z',
|
|
119
|
-
staleEmbedThreadCount: 0,
|
|
120
|
-
staleEmbedSourceCount: 0,
|
|
121
|
-
latestClusterRunId: 7,
|
|
122
|
-
latestClusterRunFinishedAt: '2026-03-09T14:30:00Z',
|
|
123
|
-
};
|
|
124
|
-
|
|
125
|
-
assert.equal(describeUpdateTask('embed', stats, new Date('2026-03-09T15:00:00Z')), 'outdated: GitHub is newer by 2h');
|
|
126
|
-
});
|
|
127
|
-
|
|
128
|
-
test('describeUpdateTask reports stale clusters relative to embed refresh', () => {
|
|
129
|
-
const stats: TuiRepoStats = {
|
|
130
|
-
openIssueCount: 10,
|
|
131
|
-
openPullRequestCount: 5,
|
|
132
|
-
lastGithubReconciliationAt: '2026-03-09T14:00:00Z',
|
|
133
|
-
lastEmbedRefreshAt: '2026-03-09T15:00:00Z',
|
|
134
|
-
staleEmbedThreadCount: 0,
|
|
135
|
-
staleEmbedSourceCount: 0,
|
|
136
|
-
latestClusterRunId: 7,
|
|
137
|
-
latestClusterRunFinishedAt: '2026-03-09T12:00:00Z',
|
|
138
|
-
};
|
|
139
|
-
|
|
140
|
-
assert.equal(describeUpdateTask('cluster', stats, new Date('2026-03-09T16:00:00Z')), 'outdated: embeddings are newer by 3h');
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
test('buildUpdatePipelineLabels marks the selected tasks and includes task guidance', () => {
|
|
144
|
-
const stats: TuiRepoStats = {
|
|
145
|
-
openIssueCount: 10,
|
|
146
|
-
openPullRequestCount: 5,
|
|
147
|
-
lastGithubReconciliationAt: '2026-03-09T14:00:00Z',
|
|
148
|
-
lastEmbedRefreshAt: '2026-03-09T15:00:00Z',
|
|
149
|
-
staleEmbedThreadCount: 2,
|
|
150
|
-
staleEmbedSourceCount: 4,
|
|
151
|
-
latestClusterRunId: 7,
|
|
152
|
-
latestClusterRunFinishedAt: '2026-03-09T12:00:00Z',
|
|
153
|
-
};
|
|
154
|
-
|
|
155
|
-
const labels = buildUpdatePipelineLabels(
|
|
156
|
-
stats,
|
|
157
|
-
{ sync: true, embed: true, cluster: false },
|
|
158
|
-
new Date('2026-03-09T16:00:00Z'),
|
|
159
|
-
);
|
|
160
|
-
|
|
161
|
-
assert.match(labels[0] ?? '', /^\[x\] GitHub sync\/reconcile up to date, last 2h ago$/);
|
|
162
|
-
assert.match(labels[1] ?? '', /^\[x\] Embed refresh outdated: 2 stale, last 1h ago$/);
|
|
163
|
-
assert.match(labels[2] ?? '', /^\[ \] Cluster rebuild outdated: embeddings are newer by 3h$/);
|
|
164
|
-
});
|