ghcrawl 0.0.1 → 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/bin/ghcrawl.js +28 -0
- package/package.json +31 -16
- package/src/init-wizard.test.ts +185 -0
- package/src/init-wizard.ts +323 -0
- package/src/main.test.ts +181 -0
- package/src/main.ts +447 -0
- package/src/neo-blessed.d.ts +4 -0
- package/src/tui/app.test.ts +164 -0
- package/src/tui/app.ts +1210 -0
- package/src/tui/layout.test.ts +19 -0
- package/src/tui/layout.ts +53 -0
- package/src/tui/state.test.ts +116 -0
- package/src/tui/state.ts +121 -0
- package/README.md +0 -9
- package/index.mjs +0 -12
package/bin/ghcrawl.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { createRequire } from 'node:module';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
|
|
7
|
+
const binDir = path.dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
const entrypoint = path.join(binDir, '..', 'src', 'main.ts');
|
|
9
|
+
const require = createRequire(import.meta.url);
|
|
10
|
+
const tsxLoader = require.resolve('tsx');
|
|
11
|
+
|
|
12
|
+
const child = spawn(process.execPath, ['--import', tsxLoader, entrypoint, ...process.argv.slice(2)], {
|
|
13
|
+
stdio: 'inherit',
|
|
14
|
+
env: process.env,
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
child.on('exit', (code, signal) => {
|
|
18
|
+
if (signal) {
|
|
19
|
+
process.kill(process.pid, signal);
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
process.exit(code ?? 0);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
child.on('error', (error) => {
|
|
26
|
+
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
|
27
|
+
process.exit(1);
|
|
28
|
+
});
|
package/package.json
CHANGED
|
@@ -1,35 +1,50 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ghcrawl",
|
|
3
|
-
"version": "0.0
|
|
4
|
-
"
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "ghcrawl terminal UI and CLI runtime",
|
|
5
6
|
"author": "PwrDrvr LLC <harold@pwrdrvr.com>",
|
|
6
7
|
"license": "MIT",
|
|
7
|
-
"
|
|
8
|
-
"homepage": "https://github.com/pwrdrvr/gitcrawl",
|
|
8
|
+
"homepage": "https://github.com/pwrdrvr/ghcrawl",
|
|
9
9
|
"bugs": {
|
|
10
|
-
"url": "https://github.com/pwrdrvr/
|
|
10
|
+
"url": "https://github.com/pwrdrvr/ghcrawl/issues"
|
|
11
11
|
},
|
|
12
12
|
"repository": {
|
|
13
13
|
"type": "git",
|
|
14
|
-
"url": "git+https://github.com/pwrdrvr/
|
|
14
|
+
"url": "git+https://github.com/pwrdrvr/ghcrawl.git"
|
|
15
15
|
},
|
|
16
16
|
"keywords": [
|
|
17
17
|
"ghcrawl",
|
|
18
18
|
"github",
|
|
19
|
-
"cli"
|
|
19
|
+
"cli",
|
|
20
|
+
"tui",
|
|
21
|
+
"sqlite",
|
|
22
|
+
"embeddings"
|
|
20
23
|
],
|
|
21
|
-
"
|
|
22
|
-
"
|
|
24
|
+
"engines": {
|
|
25
|
+
"node": ">=22"
|
|
23
26
|
},
|
|
24
27
|
"files": [
|
|
25
|
-
"
|
|
26
|
-
"
|
|
27
|
-
"LICENSE"
|
|
28
|
+
"bin",
|
|
29
|
+
"src"
|
|
28
30
|
],
|
|
29
|
-
"engines": {
|
|
30
|
-
"node": ">=18"
|
|
31
|
-
},
|
|
32
31
|
"publishConfig": {
|
|
33
32
|
"access": "public"
|
|
33
|
+
},
|
|
34
|
+
"bin": {
|
|
35
|
+
"ghcrawl": "./bin/ghcrawl.js",
|
|
36
|
+
"gitcrawl": "./bin/ghcrawl.js"
|
|
37
|
+
},
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"@clack/prompts": "^0.11.0",
|
|
40
|
+
"@types/blessed": "^0.1.27",
|
|
41
|
+
"neo-blessed": "^0.2.0",
|
|
42
|
+
"tsx": "^4.20.5",
|
|
43
|
+
"@ghcrawl/api-core": "0.1.0"
|
|
44
|
+
},
|
|
45
|
+
"scripts": {
|
|
46
|
+
"cli": "tsx src/main.ts",
|
|
47
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
48
|
+
"test": "tsx --test src/*.test.ts src/**/*.test.ts"
|
|
34
49
|
}
|
|
35
|
-
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
|
|
7
|
+
import { readPersistedConfig, writePersistedConfig } from '@ghcrawl/api-core';
|
|
8
|
+
|
|
9
|
+
import { runInitWizard, type InitPrompter } from './init-wizard.js';
|
|
10
|
+
|
|
11
|
+
function makeTestEnv(overrides: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv {
|
|
12
|
+
return {
|
|
13
|
+
...process.env,
|
|
14
|
+
XDG_CONFIG_HOME: undefined,
|
|
15
|
+
APPDATA: undefined,
|
|
16
|
+
...overrides,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function makePrompter(overrides: Partial<InitPrompter> = {}): InitPrompter {
|
|
21
|
+
return {
|
|
22
|
+
intro: async () => undefined,
|
|
23
|
+
note: async () => undefined,
|
|
24
|
+
select: async () => 'plaintext',
|
|
25
|
+
text: async () => {
|
|
26
|
+
throw new Error('unexpected text prompt');
|
|
27
|
+
},
|
|
28
|
+
confirm: async () => true,
|
|
29
|
+
password: async () => {
|
|
30
|
+
throw new Error('unexpected password prompt');
|
|
31
|
+
},
|
|
32
|
+
outro: async () => undefined,
|
|
33
|
+
cancel: () => undefined,
|
|
34
|
+
...overrides,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
test('runInitWizard skips prompting when config already has both API keys', async () => {
|
|
39
|
+
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'ghcrawl-init-test-'));
|
|
40
|
+
const env = makeTestEnv({ HOME: home });
|
|
41
|
+
writePersistedConfig(
|
|
42
|
+
{
|
|
43
|
+
githubToken: 'ghp_testtoken1234567890',
|
|
44
|
+
openaiApiKey: 'sk-proj-testkey1234567890',
|
|
45
|
+
},
|
|
46
|
+
{ env },
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
const result = await runInitWizard({
|
|
50
|
+
env,
|
|
51
|
+
prompter: makePrompter(),
|
|
52
|
+
isInteractive: true,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
assert.equal(result.changed, false);
|
|
56
|
+
assert.equal(fs.existsSync(result.configPath), true);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('runInitWizard prompts for missing keys and writes the config file', async () => {
|
|
60
|
+
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'ghcrawl-init-test-'));
|
|
61
|
+
const env = makeTestEnv({ HOME: home });
|
|
62
|
+
const prompts: string[] = [];
|
|
63
|
+
|
|
64
|
+
const result = await runInitWizard({
|
|
65
|
+
env,
|
|
66
|
+
prompter: makePrompter({
|
|
67
|
+
select: async () => 'plaintext',
|
|
68
|
+
password: async ({ message }) => {
|
|
69
|
+
prompts.push(message);
|
|
70
|
+
return message.includes('GitHub') ? 'ghp_testtoken1234567890' : 'sk-proj-testkey1234567890';
|
|
71
|
+
},
|
|
72
|
+
}),
|
|
73
|
+
isInteractive: true,
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
assert.equal(result.changed, true);
|
|
77
|
+
assert.deepEqual(prompts, ['GitHub personal access token', 'OpenAI API key']);
|
|
78
|
+
|
|
79
|
+
const persisted = readPersistedConfig({ env });
|
|
80
|
+
assert.equal(persisted.data.githubToken, 'ghp_testtoken1234567890');
|
|
81
|
+
assert.equal(persisted.data.openaiApiKey, 'sk-proj-testkey1234567890');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('runInitWizard can persist detected environment keys without prompting for secrets', async () => {
|
|
85
|
+
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'ghcrawl-init-test-'));
|
|
86
|
+
const env = makeTestEnv({
|
|
87
|
+
HOME: home,
|
|
88
|
+
GITHUB_TOKEN: 'ghp_envtoken1234567890',
|
|
89
|
+
OPENAI_API_KEY: 'sk-proj-envkey1234567890',
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const result = await runInitWizard({
|
|
93
|
+
env,
|
|
94
|
+
prompter: makePrompter({
|
|
95
|
+
select: async () => 'plaintext',
|
|
96
|
+
confirm: async () => true,
|
|
97
|
+
}),
|
|
98
|
+
isInteractive: true,
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
assert.equal(result.changed, true);
|
|
102
|
+
const persisted = readPersistedConfig({ env });
|
|
103
|
+
assert.equal(persisted.data.githubToken, 'ghp_envtoken1234567890');
|
|
104
|
+
assert.equal(persisted.data.openaiApiKey, 'sk-proj-envkey1234567890');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('runInitWizard can configure 1Password CLI metadata without persisting plaintext keys', async () => {
|
|
108
|
+
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'ghcrawl-init-test-'));
|
|
109
|
+
const env = makeTestEnv({ HOME: home });
|
|
110
|
+
const notes: Array<{ title?: string; message: string }> = [];
|
|
111
|
+
const confirms: string[] = [];
|
|
112
|
+
|
|
113
|
+
const result = await runInitWizard({
|
|
114
|
+
env,
|
|
115
|
+
prompter: makePrompter({
|
|
116
|
+
select: async () => 'op',
|
|
117
|
+
text: async ({ message }) => (message.includes('vault') ? 'Private' : 'ghcrawl'),
|
|
118
|
+
note: async (message, title) => {
|
|
119
|
+
notes.push({ title, message });
|
|
120
|
+
},
|
|
121
|
+
confirm: async ({ message }) => {
|
|
122
|
+
confirms.push(message);
|
|
123
|
+
return true;
|
|
124
|
+
},
|
|
125
|
+
}),
|
|
126
|
+
isInteractive: true,
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
assert.equal(result.changed, true);
|
|
130
|
+
const persisted = readPersistedConfig({ env });
|
|
131
|
+
assert.equal(persisted.data.secretProvider, 'op');
|
|
132
|
+
assert.equal(persisted.data.opVaultName, 'Private');
|
|
133
|
+
assert.equal(persisted.data.opItemName, 'ghcrawl');
|
|
134
|
+
assert.equal(persisted.data.githubToken, undefined);
|
|
135
|
+
assert.equal(persisted.data.openaiApiKey, undefined);
|
|
136
|
+
assert.equal(
|
|
137
|
+
notes.some((entry) => entry.title === '1Password Setup' && entry.message.includes('op://Private/ghcrawl/GITHUB_TOKEN')),
|
|
138
|
+
true,
|
|
139
|
+
);
|
|
140
|
+
assert.equal(notes.some((entry) => entry.title === 'Next Commands' && entry.message.includes('ghcrawl-op()')), true);
|
|
141
|
+
assert.equal(notes.some((entry) => entry.title === 'Next Commands' && entry.message.includes('ghcrawl-op doctor')), true);
|
|
142
|
+
assert.equal(notes.some((entry) => entry.title === 'Next Commands' && entry.message.includes('ghcrawl-op sync org/repo')), true);
|
|
143
|
+
assert.equal(notes.some((entry) => entry.title === 'Responsibility' && entry.message.includes('accept no liability')), true);
|
|
144
|
+
assert.equal(confirms.some((message) => message.includes('I created the Secure Note')), true);
|
|
145
|
+
assert.equal(confirms.some((message) => message.includes('I copied those commands')), true);
|
|
146
|
+
assert.equal(confirms.some((message) => message.includes('accept full responsibility')), true);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test('runInitWizard accepts empty 1Password vault and item input as defaults', async () => {
|
|
150
|
+
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'ghcrawl-init-test-'));
|
|
151
|
+
const env = makeTestEnv({ HOME: home });
|
|
152
|
+
|
|
153
|
+
await runInitWizard({
|
|
154
|
+
env,
|
|
155
|
+
prompter: makePrompter({
|
|
156
|
+
select: async () => 'op',
|
|
157
|
+
text: async () => '',
|
|
158
|
+
confirm: async () => true,
|
|
159
|
+
}),
|
|
160
|
+
isInteractive: true,
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
const persisted = readPersistedConfig({ env });
|
|
164
|
+
assert.equal(persisted.data.opVaultName, 'Private');
|
|
165
|
+
assert.equal(persisted.data.opItemName, 'ghcrawl');
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('runInitWizard accepts undefined 1Password text responses as defaults', async () => {
|
|
169
|
+
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'ghcrawl-init-test-'));
|
|
170
|
+
const env = makeTestEnv({ HOME: home });
|
|
171
|
+
|
|
172
|
+
await runInitWizard({
|
|
173
|
+
env,
|
|
174
|
+
prompter: makePrompter({
|
|
175
|
+
select: async () => 'op',
|
|
176
|
+
text: async () => undefined,
|
|
177
|
+
confirm: async () => true,
|
|
178
|
+
}),
|
|
179
|
+
isInteractive: true,
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
const persisted = readPersistedConfig({ env });
|
|
183
|
+
assert.equal(persisted.data.opVaultName, 'Private');
|
|
184
|
+
assert.equal(persisted.data.opItemName, 'ghcrawl');
|
|
185
|
+
});
|
|
@@ -0,0 +1,323 @@
|
|
|
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
|
+
}
|