ghcrawl 0.1.0 → 0.1.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/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 +398 -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 +4 -3
- 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/init-wizard.ts
DELETED
|
@@ -1,323 +0,0 @@
|
|
|
1
|
-
import { cancel, confirm, intro, isCancel, note, outro, password, select, text } from '@clack/prompts';
|
|
2
|
-
import {
|
|
3
|
-
loadConfig,
|
|
4
|
-
readPersistedConfig,
|
|
5
|
-
writePersistedConfig,
|
|
6
|
-
isLikelyGitHubToken,
|
|
7
|
-
isLikelyOpenAiApiKey,
|
|
8
|
-
} from '@ghcrawl/api-core';
|
|
9
|
-
|
|
10
|
-
type InitSecretMode = 'plaintext' | 'op';
|
|
11
|
-
|
|
12
|
-
export type InitWizardResult = {
|
|
13
|
-
configPath: string;
|
|
14
|
-
changed: boolean;
|
|
15
|
-
};
|
|
16
|
-
|
|
17
|
-
export type InitPrompter = {
|
|
18
|
-
intro: (message: string) => Promise<void> | void;
|
|
19
|
-
note: (message: string, title?: string) => Promise<void> | void;
|
|
20
|
-
select: (options: {
|
|
21
|
-
message: string;
|
|
22
|
-
initialValue?: string;
|
|
23
|
-
options: Array<{ value: string; label: string; hint?: string }>;
|
|
24
|
-
}) => Promise<string | symbol>;
|
|
25
|
-
text: (options: {
|
|
26
|
-
message: string;
|
|
27
|
-
placeholder?: string;
|
|
28
|
-
validate?: (value: string) => string | undefined;
|
|
29
|
-
}) => Promise<string | symbol | undefined>;
|
|
30
|
-
confirm: (options: { message: string; initialValue?: boolean }) => Promise<boolean | symbol>;
|
|
31
|
-
password: (options: { message: string; validate?: (value: string) => string | undefined }) => Promise<string | symbol>;
|
|
32
|
-
outro: (message: string) => Promise<void> | void;
|
|
33
|
-
cancel: (message: string) => void;
|
|
34
|
-
};
|
|
35
|
-
|
|
36
|
-
function resolveTextValue(value: string | symbol | undefined, fallback: string): string | symbol {
|
|
37
|
-
if (isCancel(value)) {
|
|
38
|
-
return value;
|
|
39
|
-
}
|
|
40
|
-
if (typeof value !== 'string') {
|
|
41
|
-
return fallback;
|
|
42
|
-
}
|
|
43
|
-
const trimmed = value.trim();
|
|
44
|
-
return trimmed.length > 0 ? trimmed : fallback;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
export function createClackInitPrompter(): InitPrompter {
|
|
48
|
-
return {
|
|
49
|
-
intro,
|
|
50
|
-
note,
|
|
51
|
-
select,
|
|
52
|
-
text,
|
|
53
|
-
confirm,
|
|
54
|
-
password,
|
|
55
|
-
outro,
|
|
56
|
-
cancel,
|
|
57
|
-
};
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
export async function runInitWizard(
|
|
61
|
-
options: {
|
|
62
|
-
cwd?: string;
|
|
63
|
-
env?: NodeJS.ProcessEnv;
|
|
64
|
-
reconfigure?: boolean;
|
|
65
|
-
prompter?: InitPrompter;
|
|
66
|
-
isInteractive?: boolean;
|
|
67
|
-
} = {},
|
|
68
|
-
): Promise<InitWizardResult> {
|
|
69
|
-
const cwd = options.cwd ?? process.cwd();
|
|
70
|
-
const env = options.env ?? process.env;
|
|
71
|
-
const reconfigure = options.reconfigure ?? false;
|
|
72
|
-
const prompter = options.prompter ?? createClackInitPrompter();
|
|
73
|
-
const current = loadConfig({ cwd, env });
|
|
74
|
-
const stored = readPersistedConfig({ cwd, env });
|
|
75
|
-
|
|
76
|
-
const hasStoredGithub = Boolean(stored.data.githubToken);
|
|
77
|
-
const hasStoredOpenAi = Boolean(stored.data.openaiApiKey);
|
|
78
|
-
if (!reconfigure && hasStoredGithub && hasStoredOpenAi) {
|
|
79
|
-
return { configPath: current.configPath, changed: false };
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
const isInteractive = options.isInteractive ?? (process.stdin.isTTY && process.stdout.isTTY);
|
|
83
|
-
if (!isInteractive) {
|
|
84
|
-
throw new Error(`ghcrawl init requires a TTY. Create ${current.configPath} manually or set environment variables first.`);
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
await prompter.intro('ghcrawl init');
|
|
88
|
-
await prompter.note(
|
|
89
|
-
[
|
|
90
|
-
`Config file: ${current.configPath}`,
|
|
91
|
-
'',
|
|
92
|
-
'Secret storage modes:',
|
|
93
|
-
'- Plaintext config: writes both keys to ~/.config/ghcrawl/config.json',
|
|
94
|
-
'- 1Password CLI: keeps keys out of the config file and expects you to run ghcrawl through an op wrapper',
|
|
95
|
-
'',
|
|
96
|
-
'GitHub token recommendation:',
|
|
97
|
-
'- Fine-grained PAT scoped to the repos you want to crawl',
|
|
98
|
-
'- Repository permissions: Metadata (read), Issues (read), Pull requests (read)',
|
|
99
|
-
'- For private repos with a classic PAT, repo is the safe fallback',
|
|
100
|
-
'',
|
|
101
|
-
'OpenAI key recommendation:',
|
|
102
|
-
'- Standard API key for the project/account you want to bill',
|
|
103
|
-
].join('\n'),
|
|
104
|
-
'Setup',
|
|
105
|
-
);
|
|
106
|
-
|
|
107
|
-
const nextConfig = { ...stored.data };
|
|
108
|
-
let changed = false;
|
|
109
|
-
|
|
110
|
-
const secretMode = await prompter.select({
|
|
111
|
-
message: 'How should ghcrawl get your GitHub and OpenAI secrets?',
|
|
112
|
-
initialValue: stored.data.secretProvider ?? (hasStoredGithub && hasStoredOpenAi ? 'plaintext' : 'op'),
|
|
113
|
-
options: [
|
|
114
|
-
{
|
|
115
|
-
value: 'plaintext',
|
|
116
|
-
label: 'Store plaintext keys in ~/.config/ghcrawl/config.json',
|
|
117
|
-
hint: 'simpler, but you are responsible for any bills caused by misuse',
|
|
118
|
-
},
|
|
119
|
-
{
|
|
120
|
-
value: 'op',
|
|
121
|
-
label: 'Keep keys in 1Password CLI and run through op',
|
|
122
|
-
hint: 'recommended if you already use op',
|
|
123
|
-
},
|
|
124
|
-
],
|
|
125
|
-
});
|
|
126
|
-
if (isCancel(secretMode) || (secretMode !== 'plaintext' && secretMode !== 'op')) {
|
|
127
|
-
prompter.cancel('init cancelled');
|
|
128
|
-
throw new Error('init cancelled');
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
if (secretMode === 'plaintext') {
|
|
132
|
-
await prompter.note(
|
|
133
|
-
[
|
|
134
|
-
'Plaintext storage warning:',
|
|
135
|
-
'- ghcrawl will write both API keys to ~/.config/ghcrawl/config.json',
|
|
136
|
-
'- anyone who can read that file can use your keys',
|
|
137
|
-
'- any OpenAI/API bills caused by misuse are your responsibility',
|
|
138
|
-
].join('\n'),
|
|
139
|
-
'Security',
|
|
140
|
-
);
|
|
141
|
-
|
|
142
|
-
if (reconfigure || !hasStoredGithub) {
|
|
143
|
-
const detectedGithub = env.GITHUB_TOKEN;
|
|
144
|
-
let githubToken = stored.data.githubToken;
|
|
145
|
-
let usedDetectedGithub = false;
|
|
146
|
-
if (detectedGithub && (!githubToken || reconfigure)) {
|
|
147
|
-
const useDetected = await prompter.confirm({
|
|
148
|
-
message: 'Persist the detected GITHUB_TOKEN environment value to the ghcrawl config file?',
|
|
149
|
-
initialValue: true,
|
|
150
|
-
});
|
|
151
|
-
if (isCancel(useDetected)) {
|
|
152
|
-
prompter.cancel('init cancelled');
|
|
153
|
-
throw new Error('init cancelled');
|
|
154
|
-
}
|
|
155
|
-
if (useDetected) {
|
|
156
|
-
if (isLikelyGitHubToken(detectedGithub)) {
|
|
157
|
-
githubToken = detectedGithub;
|
|
158
|
-
usedDetectedGithub = true;
|
|
159
|
-
} else {
|
|
160
|
-
await prompter.note('The detected GITHUB_TOKEN value does not look like a GitHub PAT, so init will prompt for it instead.', 'GitHub token');
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
if (!githubToken || (reconfigure && !usedDetectedGithub)) {
|
|
165
|
-
const value = await prompter.password({
|
|
166
|
-
message: 'GitHub personal access token',
|
|
167
|
-
validate: (candidate) => (isLikelyGitHubToken(candidate) ? undefined : 'Enter a GitHub PAT like ghp_... or github_pat_...'),
|
|
168
|
-
});
|
|
169
|
-
if (isCancel(value)) {
|
|
170
|
-
prompter.cancel('init cancelled');
|
|
171
|
-
throw new Error('init cancelled');
|
|
172
|
-
}
|
|
173
|
-
githubToken = value;
|
|
174
|
-
}
|
|
175
|
-
nextConfig.githubToken = githubToken;
|
|
176
|
-
changed = true;
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
if (reconfigure || !hasStoredOpenAi) {
|
|
180
|
-
const detectedOpenAi = env.OPENAI_API_KEY;
|
|
181
|
-
let openaiApiKey = stored.data.openaiApiKey;
|
|
182
|
-
let usedDetectedOpenAi = false;
|
|
183
|
-
if (detectedOpenAi && (!openaiApiKey || reconfigure)) {
|
|
184
|
-
const useDetected = await prompter.confirm({
|
|
185
|
-
message: 'Persist the detected OPENAI_API_KEY environment value to the ghcrawl config file?',
|
|
186
|
-
initialValue: true,
|
|
187
|
-
});
|
|
188
|
-
if (isCancel(useDetected)) {
|
|
189
|
-
prompter.cancel('init cancelled');
|
|
190
|
-
throw new Error('init cancelled');
|
|
191
|
-
}
|
|
192
|
-
if (useDetected) {
|
|
193
|
-
if (isLikelyOpenAiApiKey(detectedOpenAi)) {
|
|
194
|
-
openaiApiKey = detectedOpenAi;
|
|
195
|
-
usedDetectedOpenAi = true;
|
|
196
|
-
} else {
|
|
197
|
-
await prompter.note('The detected OPENAI_API_KEY value does not look like an OpenAI API key, so init will prompt for it instead.', 'OpenAI key');
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
if (!openaiApiKey || (reconfigure && !usedDetectedOpenAi)) {
|
|
202
|
-
const value = await prompter.password({
|
|
203
|
-
message: 'OpenAI API key',
|
|
204
|
-
validate: (candidate) => (isLikelyOpenAiApiKey(candidate) ? undefined : 'Enter an OpenAI API key like sk-...'),
|
|
205
|
-
});
|
|
206
|
-
if (isCancel(value)) {
|
|
207
|
-
prompter.cancel('init cancelled');
|
|
208
|
-
throw new Error('init cancelled');
|
|
209
|
-
}
|
|
210
|
-
openaiApiKey = value;
|
|
211
|
-
}
|
|
212
|
-
nextConfig.openaiApiKey = openaiApiKey;
|
|
213
|
-
changed = true;
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
nextConfig.secretProvider = 'plaintext';
|
|
217
|
-
nextConfig.opVaultName = undefined;
|
|
218
|
-
nextConfig.opItemName = undefined;
|
|
219
|
-
} else {
|
|
220
|
-
const defaultVaultName = stored.data.opVaultName ?? 'Private';
|
|
221
|
-
const vaultNameInput = await prompter.text({
|
|
222
|
-
message: '1Password vault name',
|
|
223
|
-
placeholder: defaultVaultName,
|
|
224
|
-
});
|
|
225
|
-
const vaultName = resolveTextValue(vaultNameInput, defaultVaultName);
|
|
226
|
-
if (isCancel(vaultName)) {
|
|
227
|
-
prompter.cancel('init cancelled');
|
|
228
|
-
throw new Error('init cancelled');
|
|
229
|
-
}
|
|
230
|
-
const defaultItemName = stored.data.opItemName ?? 'ghcrawl';
|
|
231
|
-
const itemNameInput = await prompter.text({
|
|
232
|
-
message: '1Password item name',
|
|
233
|
-
placeholder: defaultItemName,
|
|
234
|
-
});
|
|
235
|
-
const itemName = resolveTextValue(itemNameInput, defaultItemName);
|
|
236
|
-
if (isCancel(itemName)) {
|
|
237
|
-
prompter.cancel('init cancelled');
|
|
238
|
-
throw new Error('init cancelled');
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
nextConfig.secretProvider = 'op';
|
|
242
|
-
nextConfig.opVaultName = vaultName.trim();
|
|
243
|
-
nextConfig.opItemName = itemName.trim();
|
|
244
|
-
nextConfig.githubToken = undefined;
|
|
245
|
-
nextConfig.openaiApiKey = undefined;
|
|
246
|
-
changed = true;
|
|
247
|
-
|
|
248
|
-
const opReferenceBase = `op://${nextConfig.opVaultName}/${nextConfig.opItemName}`;
|
|
249
|
-
await prompter.note(
|
|
250
|
-
[
|
|
251
|
-
'Create a 1Password Secure Note with:',
|
|
252
|
-
`- Vault: ${nextConfig.opVaultName}`,
|
|
253
|
-
`- Item: ${nextConfig.opItemName}`,
|
|
254
|
-
'',
|
|
255
|
-
'Add concealed fields named exactly:',
|
|
256
|
-
'- GITHUB_TOKEN',
|
|
257
|
-
'- OPENAI_API_KEY',
|
|
258
|
-
'',
|
|
259
|
-
'Secret refs:',
|
|
260
|
-
`- ${opReferenceBase}/GITHUB_TOKEN`,
|
|
261
|
-
`- ${opReferenceBase}/OPENAI_API_KEY`,
|
|
262
|
-
].join('\n'),
|
|
263
|
-
'1Password Setup',
|
|
264
|
-
);
|
|
265
|
-
const readyNote = await prompter.confirm({
|
|
266
|
-
message: 'I created the Secure Note with those exact field names and secret refs.',
|
|
267
|
-
initialValue: true,
|
|
268
|
-
});
|
|
269
|
-
if (isCancel(readyNote) || readyNote !== true) {
|
|
270
|
-
prompter.cancel('init cancelled');
|
|
271
|
-
throw new Error('init cancelled');
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
await prompter.note(
|
|
275
|
-
[
|
|
276
|
-
'After saving that Secure Note, run ghcrawl through an op-backed shell helper:',
|
|
277
|
-
'',
|
|
278
|
-
'ghcrawl-op() {',
|
|
279
|
-
` env GITHUB_TOKEN=\"$(op read '${opReferenceBase}/GITHUB_TOKEN')\" \\`,
|
|
280
|
-
` OPENAI_API_KEY=\"$(op read '${opReferenceBase}/OPENAI_API_KEY')\" \\`,
|
|
281
|
-
' ghcrawl "$@"',
|
|
282
|
-
'}',
|
|
283
|
-
'',
|
|
284
|
-
'Examples:',
|
|
285
|
-
'- ghcrawl-op doctor',
|
|
286
|
-
'- ghcrawl-op tui',
|
|
287
|
-
'- ghcrawl-op sync org/repo',
|
|
288
|
-
].join('\n'),
|
|
289
|
-
'Next Commands',
|
|
290
|
-
);
|
|
291
|
-
const readyCommands = await prompter.confirm({
|
|
292
|
-
message: 'I copied those commands and I am ready to save this ghcrawl config.',
|
|
293
|
-
initialValue: true,
|
|
294
|
-
});
|
|
295
|
-
if (isCancel(readyCommands) || readyCommands !== true) {
|
|
296
|
-
prompter.cancel('init cancelled');
|
|
297
|
-
throw new Error('init cancelled');
|
|
298
|
-
}
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
await prompter.note(
|
|
302
|
-
[
|
|
303
|
-
'Responsibility attestation:',
|
|
304
|
-
'- You are responsible for obtaining and using GitHub and OpenAI API keys in compliance with the agreements and usage policies for those platforms.',
|
|
305
|
-
'- You and any employer or organization you operate this tool for accept full responsibility for monitoring API usage, spend, and access.',
|
|
306
|
-
'- You are fully responsible for storing your API keys securely and for any misuse, theft, or unexpected spend caused by those keys.',
|
|
307
|
-
'- The creators and contributors of ghcrawl accept no liability for API charges, account actions, data loss, or misuse resulting from operation of this tool.',
|
|
308
|
-
].join('\n'),
|
|
309
|
-
'Responsibility',
|
|
310
|
-
);
|
|
311
|
-
const acceptResponsibility = await prompter.confirm({
|
|
312
|
-
message: 'I understand and accept full responsibility for using ghcrawl and for securing any API keys it uses.',
|
|
313
|
-
initialValue: false,
|
|
314
|
-
});
|
|
315
|
-
if (isCancel(acceptResponsibility) || acceptResponsibility !== true) {
|
|
316
|
-
prompter.cancel('init cancelled');
|
|
317
|
-
throw new Error('init cancelled');
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
const result = writePersistedConfig(nextConfig, { cwd, env });
|
|
321
|
-
await prompter.outro(`Saved ghcrawl config to ${result.configPath}`);
|
|
322
|
-
return { configPath: result.configPath, changed };
|
|
323
|
-
}
|
package/src/main.test.ts
DELETED
|
@@ -1,181 +0,0 @@
|
|
|
1
|
-
import test from 'node:test';
|
|
2
|
-
import assert from 'node:assert/strict';
|
|
3
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
4
|
-
import path from 'node:path';
|
|
5
|
-
import { fileURLToPath } from 'node:url';
|
|
6
|
-
|
|
7
|
-
import { formatDoctorReport, formatLogLine, parseOwnerRepo, parseRepoFlags, resolveSinceValue, run } from './main.js';
|
|
8
|
-
|
|
9
|
-
test('run prints usage with no command', async () => {
|
|
10
|
-
let output = '';
|
|
11
|
-
const stdout = {
|
|
12
|
-
write(chunk: string) {
|
|
13
|
-
output += chunk;
|
|
14
|
-
return true;
|
|
15
|
-
},
|
|
16
|
-
} as unknown as NodeJS.WritableStream;
|
|
17
|
-
|
|
18
|
-
await run([], stdout);
|
|
19
|
-
assert.match(output, /ghcrawl <command>/);
|
|
20
|
-
assert.match(output, /refresh <owner\/repo>/);
|
|
21
|
-
assert.match(output, /clusters <owner\/repo>/);
|
|
22
|
-
assert.match(output, /cluster-detail <owner\/repo>/);
|
|
23
|
-
assert.match(output, /tui \[owner\/repo\]/);
|
|
24
|
-
assert.doesNotMatch(output, /summarize <owner\/repo>/);
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
test('run prints usage for help flag', async () => {
|
|
28
|
-
let output = '';
|
|
29
|
-
const stdout = {
|
|
30
|
-
write(chunk: string) {
|
|
31
|
-
output += chunk;
|
|
32
|
-
return true;
|
|
33
|
-
},
|
|
34
|
-
} as unknown as NodeJS.WritableStream;
|
|
35
|
-
|
|
36
|
-
await run(['--help'], stdout);
|
|
37
|
-
assert.match(output, /ghcrawl <command>/);
|
|
38
|
-
assert.match(output, /refresh <owner\/repo>/);
|
|
39
|
-
assert.match(output, /tui \[owner\/repo\]/);
|
|
40
|
-
assert.doesNotMatch(output, /summarize <owner\/repo>/);
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
test('run prints advanced commands when dev mode is enabled', async () => {
|
|
44
|
-
let output = '';
|
|
45
|
-
const stdout = {
|
|
46
|
-
write(chunk: string) {
|
|
47
|
-
output += chunk;
|
|
48
|
-
return true;
|
|
49
|
-
},
|
|
50
|
-
} as unknown as NodeJS.WritableStream;
|
|
51
|
-
|
|
52
|
-
await run(['--dev', '--help'], stdout);
|
|
53
|
-
assert.match(output, /Advanced Commands:/);
|
|
54
|
-
assert.match(output, /summarize <owner\/repo>/);
|
|
55
|
-
assert.match(output, /purge-comments <owner\/repo>/);
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
test('run prints pretty doctor output on a tty', async () => {
|
|
59
|
-
let output = '';
|
|
60
|
-
const stdout = {
|
|
61
|
-
isTTY: true,
|
|
62
|
-
write(chunk: string) {
|
|
63
|
-
output += chunk;
|
|
64
|
-
return true;
|
|
65
|
-
},
|
|
66
|
-
} as unknown as NodeJS.WritableStream;
|
|
67
|
-
|
|
68
|
-
await run(['doctor'], stdout);
|
|
69
|
-
assert.match(output, /ghcrawl doctor/);
|
|
70
|
-
assert.match(output, /Health/);
|
|
71
|
-
assert.doesNotMatch(output, /^\s*\{/m);
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
test('run prints json doctor output when explicitly requested', async () => {
|
|
75
|
-
let output = '';
|
|
76
|
-
const stdout = {
|
|
77
|
-
isTTY: true,
|
|
78
|
-
write(chunk: string) {
|
|
79
|
-
output += chunk;
|
|
80
|
-
return true;
|
|
81
|
-
},
|
|
82
|
-
} as unknown as NodeJS.WritableStream;
|
|
83
|
-
|
|
84
|
-
await run(['doctor', '--json'], stdout);
|
|
85
|
-
assert.match(output, /"health"/);
|
|
86
|
-
assert.match(output, /"github"/);
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
test('parseOwnerRepo accepts owner slash repo syntax', () => {
|
|
90
|
-
assert.deepEqual(parseOwnerRepo('openclaw/openclaw'), { owner: 'openclaw', repo: 'openclaw' });
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
test('parseRepoFlags accepts repo flag with owner slash repo syntax', () => {
|
|
94
|
-
const parsed = parseRepoFlags(['--repo', 'openclaw/openclaw', '--limit', '1']);
|
|
95
|
-
assert.equal(parsed.owner, 'openclaw');
|
|
96
|
-
assert.equal(parsed.repo, 'openclaw');
|
|
97
|
-
assert.equal(parsed.values.limit, '1');
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
test('parseRepoFlags accepts positional owner slash repo syntax', () => {
|
|
101
|
-
const parsed = parseRepoFlags(['openclaw/openclaw', '--limit', '2']);
|
|
102
|
-
assert.equal(parsed.owner, 'openclaw');
|
|
103
|
-
assert.equal(parsed.repo, 'openclaw');
|
|
104
|
-
assert.equal(parsed.values.limit, '2');
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
test('parseRepoFlags accepts include-comments boolean flag', () => {
|
|
108
|
-
const parsed = parseRepoFlags(['openclaw/openclaw', '--include-comments']);
|
|
109
|
-
assert.equal(parsed.owner, 'openclaw');
|
|
110
|
-
assert.equal(parsed.repo, 'openclaw');
|
|
111
|
-
assert.equal(parsed.values['include-comments'], true);
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
test('resolveSinceValue keeps ISO timestamps', () => {
|
|
115
|
-
assert.equal(resolveSinceValue('2026-03-01T00:00:00Z'), '2026-03-01T00:00:00.000Z');
|
|
116
|
-
});
|
|
117
|
-
|
|
118
|
-
test('resolveSinceValue parses minute duration shorthand', () => {
|
|
119
|
-
const now = new Date('2026-03-09T12:00:00Z');
|
|
120
|
-
assert.equal(resolveSinceValue('15m', now), '2026-03-09T11:45:00.000Z');
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
test('resolveSinceValue parses month duration shorthand', () => {
|
|
124
|
-
const now = new Date('2026-03-09T12:00:00Z');
|
|
125
|
-
assert.equal(resolveSinceValue('1mo', now), '2026-02-09T12:00:00.000Z');
|
|
126
|
-
});
|
|
127
|
-
|
|
128
|
-
test('resolveSinceValue rejects unsupported syntax', () => {
|
|
129
|
-
assert.throws(() => resolveSinceValue('yesterday'), /Invalid --since value/);
|
|
130
|
-
});
|
|
131
|
-
|
|
132
|
-
test('formatLogLine prefixes ISO timestamps with millisecond resolution', () => {
|
|
133
|
-
assert.equal(formatLogLine('[sync] hello', new Date('2026-03-09T12:34:56.789Z')), '[2026-03-09T12:34:56.789Z] [sync] hello');
|
|
134
|
-
});
|
|
135
|
-
|
|
136
|
-
test('formatDoctorReport renders a human-readable health summary', () => {
|
|
137
|
-
const rendered = formatDoctorReport({
|
|
138
|
-
health: {
|
|
139
|
-
ok: true,
|
|
140
|
-
configPath: '/tmp/config.json',
|
|
141
|
-
configFileExists: true,
|
|
142
|
-
dbPath: '/tmp/ghcrawl.db',
|
|
143
|
-
apiPort: 5179,
|
|
144
|
-
githubConfigured: true,
|
|
145
|
-
openaiConfigured: true,
|
|
146
|
-
},
|
|
147
|
-
github: {
|
|
148
|
-
configured: true,
|
|
149
|
-
source: 'config',
|
|
150
|
-
formatOk: true,
|
|
151
|
-
authOk: true,
|
|
152
|
-
error: null,
|
|
153
|
-
},
|
|
154
|
-
openai: {
|
|
155
|
-
configured: false,
|
|
156
|
-
source: 'none',
|
|
157
|
-
formatOk: false,
|
|
158
|
-
authOk: false,
|
|
159
|
-
error: 'missing',
|
|
160
|
-
},
|
|
161
|
-
});
|
|
162
|
-
|
|
163
|
-
assert.match(rendered, /config path: \/tmp\/config\.json/);
|
|
164
|
-
assert.match(rendered, /GitHub/);
|
|
165
|
-
assert.match(rendered, /OpenAI/);
|
|
166
|
-
assert.match(rendered, /note: missing/);
|
|
167
|
-
});
|
|
168
|
-
|
|
169
|
-
test('published cli package exposes ghcrawl and compatibility gitcrawl bin shims', () => {
|
|
170
|
-
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
171
|
-
const packageJsonPath = path.resolve(here, '..', 'package.json');
|
|
172
|
-
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')) as { bin?: Record<string, string> };
|
|
173
|
-
const ghcrawlBinPath = packageJson.bin?.ghcrawl;
|
|
174
|
-
const gitcrawlBinPath = packageJson.bin?.gitcrawl;
|
|
175
|
-
|
|
176
|
-
assert.equal(typeof ghcrawlBinPath, 'string');
|
|
177
|
-
assert.equal(typeof gitcrawlBinPath, 'string');
|
|
178
|
-
assert.equal(ghcrawlBinPath, './bin/ghcrawl.js');
|
|
179
|
-
assert.equal(gitcrawlBinPath, './bin/ghcrawl.js');
|
|
180
|
-
assert.equal(existsSync(path.resolve(here, '..', ghcrawlBinPath)), true);
|
|
181
|
-
});
|