githits 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/LICENSE +191 -0
- package/README.md +108 -0
- package/dist/cli.js +1720 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +6 -0
- package/dist/shared/chunk-j0vey5g3.js +4 -0
- package/package.json +77 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,1720 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
version
|
|
4
|
+
} from "./shared/chunk-j0vey5g3.js";
|
|
5
|
+
|
|
6
|
+
// src/cli.ts
|
|
7
|
+
import { Command } from "commander";
|
|
8
|
+
|
|
9
|
+
// src/services/auth-service.ts
|
|
10
|
+
import { createServer } from "node:http";
|
|
11
|
+
|
|
12
|
+
// src/auth/pkce.ts
|
|
13
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
14
|
+
function generateCodeVerifier() {
|
|
15
|
+
return randomBytes(32).toString("base64url");
|
|
16
|
+
}
|
|
17
|
+
function generateCodeChallenge(verifier) {
|
|
18
|
+
return createHash("sha256").update(verifier).digest("base64url");
|
|
19
|
+
}
|
|
20
|
+
function generateState() {
|
|
21
|
+
return randomBytes(32).toString("hex");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// src/services/auth-service.ts
|
|
25
|
+
class AuthServiceImpl {
|
|
26
|
+
async discoverEndpoints(mcpBaseUrl) {
|
|
27
|
+
const url = `${mcpBaseUrl}/.well-known/oauth-authorization-server`;
|
|
28
|
+
const response = await fetch(url);
|
|
29
|
+
if (!response.ok) {
|
|
30
|
+
throw new Error(`Failed to discover OAuth endpoints: ${response.status} ${response.statusText}`);
|
|
31
|
+
}
|
|
32
|
+
const data = await response.json();
|
|
33
|
+
const authorizationEndpoint = data.authorization_endpoint;
|
|
34
|
+
const tokenEndpoint = data.token_endpoint;
|
|
35
|
+
const registrationEndpoint = data.registration_endpoint;
|
|
36
|
+
if (!authorizationEndpoint || !tokenEndpoint || !registrationEndpoint) {
|
|
37
|
+
throw new Error("OAuth metadata missing required endpoints");
|
|
38
|
+
}
|
|
39
|
+
return {
|
|
40
|
+
authorizationEndpoint,
|
|
41
|
+
tokenEndpoint,
|
|
42
|
+
registrationEndpoint
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
async registerClient(params) {
|
|
46
|
+
const response = await fetch(params.registrationEndpoint, {
|
|
47
|
+
method: "POST",
|
|
48
|
+
headers: { "Content-Type": "application/json" },
|
|
49
|
+
body: JSON.stringify({
|
|
50
|
+
client_name: "GitHits CLI",
|
|
51
|
+
redirect_uris: [params.redirectUri],
|
|
52
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
53
|
+
response_types: ["code"],
|
|
54
|
+
token_endpoint_auth_method: "client_secret_post"
|
|
55
|
+
})
|
|
56
|
+
});
|
|
57
|
+
if (!response.ok) {
|
|
58
|
+
const error = await response.text();
|
|
59
|
+
throw new Error(`Client registration failed: ${error}`);
|
|
60
|
+
}
|
|
61
|
+
const data = await response.json();
|
|
62
|
+
if (!data.client_id || !data.client_secret) {
|
|
63
|
+
throw new Error("Client registration response missing required fields");
|
|
64
|
+
}
|
|
65
|
+
return {
|
|
66
|
+
clientId: data.client_id,
|
|
67
|
+
clientSecret: data.client_secret
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
generatePkceParams() {
|
|
71
|
+
const verifier = generateCodeVerifier();
|
|
72
|
+
return {
|
|
73
|
+
verifier,
|
|
74
|
+
challenge: generateCodeChallenge(verifier),
|
|
75
|
+
state: generateState()
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
buildAuthUrl(params) {
|
|
79
|
+
const url = new URL(params.authorizationEndpoint);
|
|
80
|
+
url.searchParams.set("response_type", "code");
|
|
81
|
+
url.searchParams.set("client_id", params.clientId);
|
|
82
|
+
url.searchParams.set("redirect_uri", params.redirectUri);
|
|
83
|
+
url.searchParams.set("state", params.state);
|
|
84
|
+
url.searchParams.set("code_challenge", params.codeChallenge);
|
|
85
|
+
url.searchParams.set("code_challenge_method", "S256");
|
|
86
|
+
return url.toString();
|
|
87
|
+
}
|
|
88
|
+
startCallbackServer(port, expectedState) {
|
|
89
|
+
return new Promise((resolve, reject) => {
|
|
90
|
+
let callbackHandled = false;
|
|
91
|
+
let resolved = false;
|
|
92
|
+
let closeTimer;
|
|
93
|
+
const server = createServer((req, res) => {
|
|
94
|
+
const url = new URL(req.url ?? "", `http://127.0.0.1:${port}`);
|
|
95
|
+
if (url.pathname === "/favicon.ico") {
|
|
96
|
+
res.writeHead(204);
|
|
97
|
+
res.end();
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
if (url.pathname !== "/callback") {
|
|
101
|
+
if (callbackHandled) {
|
|
102
|
+
sendHtmlResponse(res, 200, successHtml("Authentication already completed."));
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
sendHtmlResponse(res, 404, errorHtml("Invalid callback path.", "Run `githits login` to start authentication."));
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
const code = url.searchParams.get("code");
|
|
109
|
+
const state = url.searchParams.get("state");
|
|
110
|
+
const error = url.searchParams.get("error");
|
|
111
|
+
const errorDescription = url.searchParams.get("error_description");
|
|
112
|
+
const evaluation = evaluateCallback({
|
|
113
|
+
code,
|
|
114
|
+
state,
|
|
115
|
+
error,
|
|
116
|
+
errorDescription,
|
|
117
|
+
expectedState
|
|
118
|
+
});
|
|
119
|
+
callbackHandled = true;
|
|
120
|
+
sendHtmlResponse(res, evaluation.statusCode, evaluation.html);
|
|
121
|
+
if (!resolved) {
|
|
122
|
+
resolved = true;
|
|
123
|
+
resolve(evaluation.result);
|
|
124
|
+
}
|
|
125
|
+
if (closeTimer)
|
|
126
|
+
clearTimeout(closeTimer);
|
|
127
|
+
closeTimer = setTimeout(() => closeServer(server), 1500);
|
|
128
|
+
});
|
|
129
|
+
server.listen(port, "127.0.0.1");
|
|
130
|
+
server.on("error", (err) => {
|
|
131
|
+
reject(new Error(`Failed to start callback server: ${err.message}`));
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
async exchangeCodeForTokens(params) {
|
|
136
|
+
const body = new URLSearchParams({
|
|
137
|
+
grant_type: "authorization_code",
|
|
138
|
+
client_id: params.clientId,
|
|
139
|
+
client_secret: params.clientSecret,
|
|
140
|
+
code: params.code,
|
|
141
|
+
code_verifier: params.codeVerifier,
|
|
142
|
+
redirect_uri: params.redirectUri
|
|
143
|
+
});
|
|
144
|
+
const response = await fetch(params.tokenEndpoint, {
|
|
145
|
+
method: "POST",
|
|
146
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
147
|
+
body: body.toString()
|
|
148
|
+
});
|
|
149
|
+
if (!response.ok) {
|
|
150
|
+
const error = await response.text();
|
|
151
|
+
throw new Error(`Token exchange failed: ${error}`);
|
|
152
|
+
}
|
|
153
|
+
return parseTokenResponse(await response.json());
|
|
154
|
+
}
|
|
155
|
+
async refreshAccessToken(params) {
|
|
156
|
+
const body = new URLSearchParams({
|
|
157
|
+
grant_type: "refresh_token",
|
|
158
|
+
client_id: params.clientId,
|
|
159
|
+
client_secret: params.clientSecret,
|
|
160
|
+
refresh_token: params.refreshToken
|
|
161
|
+
});
|
|
162
|
+
const response = await fetch(params.tokenEndpoint, {
|
|
163
|
+
method: "POST",
|
|
164
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
165
|
+
body: body.toString()
|
|
166
|
+
});
|
|
167
|
+
if (!response.ok) {
|
|
168
|
+
const error = await response.text();
|
|
169
|
+
throw new Error(`Token refresh failed: ${error}`);
|
|
170
|
+
}
|
|
171
|
+
return parseTokenResponse(await response.json());
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
function parseTokenResponse(data) {
|
|
175
|
+
const d = data;
|
|
176
|
+
if (!d.access_token || !d.refresh_token) {
|
|
177
|
+
throw new Error("Token response missing required fields");
|
|
178
|
+
}
|
|
179
|
+
return {
|
|
180
|
+
accessToken: d.access_token,
|
|
181
|
+
refreshToken: d.refresh_token,
|
|
182
|
+
expiresIn: d.expires_in || 3600
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
function successHtml(title = "Authentication successful") {
|
|
186
|
+
return `<!DOCTYPE html>
|
|
187
|
+
<html><head><title>GitHits CLI</title>
|
|
188
|
+
<style>
|
|
189
|
+
body {
|
|
190
|
+
font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
|
191
|
+
display: flex;
|
|
192
|
+
justify-content: center;
|
|
193
|
+
align-items: center;
|
|
194
|
+
height: 100vh;
|
|
195
|
+
margin: 0;
|
|
196
|
+
background: radial-gradient(circle at center center, #4d3648, #3a2835, #261a22, #0d1117);
|
|
197
|
+
}
|
|
198
|
+
.card {
|
|
199
|
+
text-align: center;
|
|
200
|
+
background: rgba(13, 17, 23, 0.75);
|
|
201
|
+
padding: 3rem;
|
|
202
|
+
border-radius: 16px;
|
|
203
|
+
border: 1px solid rgba(244, 11, 166, 0.35);
|
|
204
|
+
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.35);
|
|
205
|
+
max-width: 720px;
|
|
206
|
+
}
|
|
207
|
+
h1 {
|
|
208
|
+
color: #f40ba6;
|
|
209
|
+
margin-bottom: 0.75rem;
|
|
210
|
+
font-size: 3rem;
|
|
211
|
+
font-weight: 700;
|
|
212
|
+
}
|
|
213
|
+
p {
|
|
214
|
+
color: #f385a5;
|
|
215
|
+
font-size: 1.1rem;
|
|
216
|
+
margin: 0;
|
|
217
|
+
}
|
|
218
|
+
</style>
|
|
219
|
+
</head>
|
|
220
|
+
<body>
|
|
221
|
+
<div class="card">
|
|
222
|
+
<h1>${escapeHtml(title)}</h1>
|
|
223
|
+
<p>You can close this window and return to the terminal.</p>
|
|
224
|
+
</div>
|
|
225
|
+
</body></html>`;
|
|
226
|
+
}
|
|
227
|
+
function evaluateCallback(input) {
|
|
228
|
+
if (input.error) {
|
|
229
|
+
const message = input.errorDescription ? `${input.error}: ${input.errorDescription}` : input.error;
|
|
230
|
+
return {
|
|
231
|
+
statusCode: 200,
|
|
232
|
+
html: errorHtml(message, "Run `githits login` to try again."),
|
|
233
|
+
result: { type: "oauth_error", message }
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
if (input.code && input.state) {
|
|
237
|
+
if (input.state !== input.expectedState) {
|
|
238
|
+
return {
|
|
239
|
+
statusCode: 400,
|
|
240
|
+
html: errorHtml("Authentication failed security validation (state mismatch)", "Run `githits login` to try again."),
|
|
241
|
+
result: {
|
|
242
|
+
type: "state_mismatch",
|
|
243
|
+
message: "Security validation failed (state mismatch)"
|
|
244
|
+
}
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
return {
|
|
248
|
+
statusCode: 200,
|
|
249
|
+
html: successHtml(),
|
|
250
|
+
result: { type: "success", code: input.code, state: input.state }
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
return {
|
|
254
|
+
statusCode: 400,
|
|
255
|
+
html: errorHtml("Authentication callback was missing required parameters", "Run `githits login` to try again."),
|
|
256
|
+
result: {
|
|
257
|
+
type: "invalid_callback",
|
|
258
|
+
message: "Authentication callback missing required parameters"
|
|
259
|
+
}
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
function errorHtml(error, nextStep) {
|
|
263
|
+
const nextStepHtml = nextStep ? `<p>${escapeHtml(nextStep)}</p>` : "";
|
|
264
|
+
return `<!DOCTYPE html>
|
|
265
|
+
<html><head><title>GitHits CLI</title>
|
|
266
|
+
<style>
|
|
267
|
+
body {
|
|
268
|
+
font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
|
269
|
+
display: flex;
|
|
270
|
+
justify-content: center;
|
|
271
|
+
align-items: center;
|
|
272
|
+
height: 100vh;
|
|
273
|
+
margin: 0;
|
|
274
|
+
background: radial-gradient(circle at center center, #4d3648, #3a2835, #261a22, #0d1117);
|
|
275
|
+
}
|
|
276
|
+
.card {
|
|
277
|
+
text-align: center;
|
|
278
|
+
background: rgba(13, 17, 23, 0.75);
|
|
279
|
+
padding: 3rem;
|
|
280
|
+
border-radius: 16px;
|
|
281
|
+
border: 1px solid rgba(239, 68, 68, 0.35);
|
|
282
|
+
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.35);
|
|
283
|
+
max-width: 720px;
|
|
284
|
+
}
|
|
285
|
+
h1 {
|
|
286
|
+
color: #ef4444;
|
|
287
|
+
margin-bottom: 0.75rem;
|
|
288
|
+
font-size: 3rem;
|
|
289
|
+
font-weight: 700;
|
|
290
|
+
}
|
|
291
|
+
p {
|
|
292
|
+
color: #f385a5;
|
|
293
|
+
font-size: 1.1rem;
|
|
294
|
+
margin: 0;
|
|
295
|
+
}
|
|
296
|
+
</style>
|
|
297
|
+
</head>
|
|
298
|
+
<body>
|
|
299
|
+
<div class="card">
|
|
300
|
+
<h1>Authentication failed</h1>
|
|
301
|
+
<p>${escapeHtml(error)}</p>
|
|
302
|
+
${nextStepHtml}
|
|
303
|
+
</div>
|
|
304
|
+
</body></html>`;
|
|
305
|
+
}
|
|
306
|
+
function sendHtmlResponse(res, statusCode, html) {
|
|
307
|
+
res.writeHead(statusCode, { "Content-Type": "text/html; charset=utf-8" });
|
|
308
|
+
res.end(html);
|
|
309
|
+
}
|
|
310
|
+
function closeServer(server) {
|
|
311
|
+
server.close();
|
|
312
|
+
}
|
|
313
|
+
function escapeHtml(text) {
|
|
314
|
+
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
315
|
+
}
|
|
316
|
+
// src/services/auth-storage.ts
|
|
317
|
+
var CONFIG_DIR = ".githits";
|
|
318
|
+
var AUTH_FILE = "auth.json";
|
|
319
|
+
var CLIENT_FILE = "client.json";
|
|
320
|
+
var DIR_MODE = 448;
|
|
321
|
+
var FILE_MODE = 384;
|
|
322
|
+
|
|
323
|
+
class AuthStorageImpl {
|
|
324
|
+
fs;
|
|
325
|
+
configDir;
|
|
326
|
+
authPath;
|
|
327
|
+
clientPath;
|
|
328
|
+
constructor(fs, configDir) {
|
|
329
|
+
this.fs = fs;
|
|
330
|
+
this.configDir = configDir ?? fs.joinPath(fs.getHomeDir(), CONFIG_DIR);
|
|
331
|
+
this.authPath = fs.joinPath(this.configDir, AUTH_FILE);
|
|
332
|
+
this.clientPath = fs.joinPath(this.configDir, CLIENT_FILE);
|
|
333
|
+
}
|
|
334
|
+
getStorageLocation() {
|
|
335
|
+
return this.configDir;
|
|
336
|
+
}
|
|
337
|
+
async loadTokens(baseUrl) {
|
|
338
|
+
const stored = await this.loadAuthFile();
|
|
339
|
+
if (!stored)
|
|
340
|
+
return null;
|
|
341
|
+
return stored.tokens[normalizeBaseUrl(baseUrl)] ?? null;
|
|
342
|
+
}
|
|
343
|
+
async saveTokens(baseUrl, data) {
|
|
344
|
+
const stored = await this.loadAuthFile() ?? {
|
|
345
|
+
version: 1,
|
|
346
|
+
tokens: {}
|
|
347
|
+
};
|
|
348
|
+
stored.tokens[normalizeBaseUrl(baseUrl)] = data;
|
|
349
|
+
await this.fs.ensureDir(this.configDir, DIR_MODE);
|
|
350
|
+
await this.fs.writeFile(this.authPath, JSON.stringify(stored, null, 2), FILE_MODE);
|
|
351
|
+
}
|
|
352
|
+
async clearTokens(baseUrl) {
|
|
353
|
+
const stored = await this.loadAuthFile();
|
|
354
|
+
if (!stored)
|
|
355
|
+
return;
|
|
356
|
+
delete stored.tokens[normalizeBaseUrl(baseUrl)];
|
|
357
|
+
if (Object.keys(stored.tokens).length === 0) {
|
|
358
|
+
await this.fs.deleteFile(this.authPath);
|
|
359
|
+
} else {
|
|
360
|
+
await this.fs.writeFile(this.authPath, JSON.stringify(stored, null, 2), FILE_MODE);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
async loadClient(baseUrl) {
|
|
364
|
+
const stored = await this.loadClientFile();
|
|
365
|
+
if (!stored)
|
|
366
|
+
return null;
|
|
367
|
+
return stored.clients[normalizeBaseUrl(baseUrl)] ?? null;
|
|
368
|
+
}
|
|
369
|
+
async clearClient(baseUrl) {
|
|
370
|
+
const stored = await this.loadClientFile();
|
|
371
|
+
if (!stored)
|
|
372
|
+
return;
|
|
373
|
+
delete stored.clients[normalizeBaseUrl(baseUrl)];
|
|
374
|
+
if (Object.keys(stored.clients).length === 0) {
|
|
375
|
+
await this.fs.deleteFile(this.clientPath);
|
|
376
|
+
} else {
|
|
377
|
+
await this.fs.writeFile(this.clientPath, JSON.stringify(stored, null, 2), FILE_MODE);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
async saveClient(baseUrl, data) {
|
|
381
|
+
const stored = await this.loadClientFile() ?? {
|
|
382
|
+
version: 1,
|
|
383
|
+
clients: {}
|
|
384
|
+
};
|
|
385
|
+
stored.clients[normalizeBaseUrl(baseUrl)] = data;
|
|
386
|
+
await this.fs.ensureDir(this.configDir, DIR_MODE);
|
|
387
|
+
await this.fs.writeFile(this.clientPath, JSON.stringify(stored, null, 2), FILE_MODE);
|
|
388
|
+
}
|
|
389
|
+
async loadAuthFile() {
|
|
390
|
+
if (!await this.fs.exists(this.authPath))
|
|
391
|
+
return null;
|
|
392
|
+
try {
|
|
393
|
+
const content = await this.fs.readFile(this.authPath);
|
|
394
|
+
const data = JSON.parse(content);
|
|
395
|
+
if (data.version !== 1 || !data.tokens)
|
|
396
|
+
return null;
|
|
397
|
+
return data;
|
|
398
|
+
} catch {
|
|
399
|
+
return null;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
async loadClientFile() {
|
|
403
|
+
if (!await this.fs.exists(this.clientPath))
|
|
404
|
+
return null;
|
|
405
|
+
try {
|
|
406
|
+
const content = await this.fs.readFile(this.clientPath);
|
|
407
|
+
const data = JSON.parse(content);
|
|
408
|
+
if (data.version !== 1 || !data.clients)
|
|
409
|
+
return null;
|
|
410
|
+
return data;
|
|
411
|
+
} catch {
|
|
412
|
+
return null;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
function normalizeBaseUrl(url) {
|
|
417
|
+
return url.replace(/\/+$/, "");
|
|
418
|
+
}
|
|
419
|
+
// src/services/browser-service.ts
|
|
420
|
+
import open from "open";
|
|
421
|
+
|
|
422
|
+
class BrowserServiceImpl {
|
|
423
|
+
async open(url) {
|
|
424
|
+
await open(url);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
// src/services/config.ts
|
|
428
|
+
var DEFAULT_MCP_URL = "https://mcp.githits.com";
|
|
429
|
+
var DEFAULT_API_URL = "https://api.githits.com";
|
|
430
|
+
function getMcpUrl() {
|
|
431
|
+
return process.env.GITHITS_MCP_URL ?? DEFAULT_MCP_URL;
|
|
432
|
+
}
|
|
433
|
+
function getApiUrl() {
|
|
434
|
+
return process.env.GITHITS_API_URL ?? DEFAULT_API_URL;
|
|
435
|
+
}
|
|
436
|
+
function getEnvApiToken() {
|
|
437
|
+
return process.env.GITHITS_API_TOKEN;
|
|
438
|
+
}
|
|
439
|
+
// src/services/filesystem-service.ts
|
|
440
|
+
import {
|
|
441
|
+
mkdir,
|
|
442
|
+
readdir,
|
|
443
|
+
readFile,
|
|
444
|
+
stat,
|
|
445
|
+
unlink,
|
|
446
|
+
writeFile
|
|
447
|
+
} from "node:fs/promises";
|
|
448
|
+
import { homedir } from "node:os";
|
|
449
|
+
import { dirname, join } from "node:path";
|
|
450
|
+
|
|
451
|
+
class FileSystemServiceImpl {
|
|
452
|
+
async readFile(path) {
|
|
453
|
+
return readFile(path, "utf-8");
|
|
454
|
+
}
|
|
455
|
+
async writeFile(path, contents, mode) {
|
|
456
|
+
await writeFile(path, contents, { mode });
|
|
457
|
+
}
|
|
458
|
+
async deleteFile(path) {
|
|
459
|
+
try {
|
|
460
|
+
await unlink(path);
|
|
461
|
+
} catch (error) {
|
|
462
|
+
if (error.code !== "ENOENT") {
|
|
463
|
+
throw error;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
async exists(path) {
|
|
468
|
+
try {
|
|
469
|
+
await stat(path);
|
|
470
|
+
return true;
|
|
471
|
+
} catch {
|
|
472
|
+
return false;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
async ensureDir(path, mode) {
|
|
476
|
+
await mkdir(path, { recursive: true, mode });
|
|
477
|
+
}
|
|
478
|
+
getHomeDir() {
|
|
479
|
+
return homedir();
|
|
480
|
+
}
|
|
481
|
+
joinPath(...segments) {
|
|
482
|
+
return join(...segments);
|
|
483
|
+
}
|
|
484
|
+
getCwd() {
|
|
485
|
+
return process.cwd();
|
|
486
|
+
}
|
|
487
|
+
getDirname(path) {
|
|
488
|
+
return dirname(path);
|
|
489
|
+
}
|
|
490
|
+
async readdir(path) {
|
|
491
|
+
return readdir(path);
|
|
492
|
+
}
|
|
493
|
+
async isDirectory(path) {
|
|
494
|
+
try {
|
|
495
|
+
const stats = await stat(path);
|
|
496
|
+
return stats.isDirectory();
|
|
497
|
+
} catch {
|
|
498
|
+
return false;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
// src/services/githits-service.ts
|
|
503
|
+
class AuthenticationError extends Error {
|
|
504
|
+
constructor(message) {
|
|
505
|
+
super(message);
|
|
506
|
+
this.name = "AuthenticationError";
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
function parseDetail(body) {
|
|
510
|
+
if (!body)
|
|
511
|
+
return;
|
|
512
|
+
try {
|
|
513
|
+
const parsed = JSON.parse(body);
|
|
514
|
+
if (typeof parsed.detail === "string")
|
|
515
|
+
return parsed.detail;
|
|
516
|
+
} catch {
|
|
517
|
+
return body;
|
|
518
|
+
}
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
class GitHitsServiceImpl {
|
|
523
|
+
apiUrl;
|
|
524
|
+
token;
|
|
525
|
+
constructor(apiUrl, token) {
|
|
526
|
+
this.apiUrl = apiUrl;
|
|
527
|
+
this.token = token;
|
|
528
|
+
}
|
|
529
|
+
async search(params) {
|
|
530
|
+
const response = await fetch(`${this.apiUrl}/search`, {
|
|
531
|
+
method: "POST",
|
|
532
|
+
headers: this.headers(),
|
|
533
|
+
body: JSON.stringify({
|
|
534
|
+
query: params.query,
|
|
535
|
+
language: params.language,
|
|
536
|
+
license_mode: params.licenseMode ?? "strict",
|
|
537
|
+
include_explanation: params.includeExplanation ?? false
|
|
538
|
+
})
|
|
539
|
+
});
|
|
540
|
+
if (!response.ok) {
|
|
541
|
+
throw await this.createError(response);
|
|
542
|
+
}
|
|
543
|
+
return response.text();
|
|
544
|
+
}
|
|
545
|
+
async getLanguages() {
|
|
546
|
+
const response = await fetch(`${this.apiUrl}/languages`, {
|
|
547
|
+
headers: this.headers()
|
|
548
|
+
});
|
|
549
|
+
if (!response.ok) {
|
|
550
|
+
throw await this.createError(response);
|
|
551
|
+
}
|
|
552
|
+
return response.json();
|
|
553
|
+
}
|
|
554
|
+
async submitFeedback(params) {
|
|
555
|
+
const response = await fetch(`${this.apiUrl}/feedbacks`, {
|
|
556
|
+
method: "POST",
|
|
557
|
+
headers: this.headers(),
|
|
558
|
+
body: JSON.stringify({
|
|
559
|
+
solution_id: params.solutionId,
|
|
560
|
+
accepted: params.accepted,
|
|
561
|
+
feedback_text: params.feedbackText ?? null
|
|
562
|
+
})
|
|
563
|
+
});
|
|
564
|
+
if (!response.ok) {
|
|
565
|
+
throw await this.createError(response);
|
|
566
|
+
}
|
|
567
|
+
return { success: true, message: "Feedback submitted successfully" };
|
|
568
|
+
}
|
|
569
|
+
headers() {
|
|
570
|
+
return {
|
|
571
|
+
Authorization: `Bearer ${this.token}`,
|
|
572
|
+
"Content-Type": "application/json",
|
|
573
|
+
"User-Agent": `githits-cli/${version}`
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
async createError(response) {
|
|
577
|
+
const status = response.status;
|
|
578
|
+
const body = await response.text().catch(() => "");
|
|
579
|
+
switch (status) {
|
|
580
|
+
case 401:
|
|
581
|
+
return new AuthenticationError("Authentication required. Run `githits login` to authenticate.");
|
|
582
|
+
case 403:
|
|
583
|
+
return new Error("Access denied.");
|
|
584
|
+
case 404:
|
|
585
|
+
return new Error(parseDetail(body) || "Resource not found.");
|
|
586
|
+
default: {
|
|
587
|
+
if (status >= 500) {
|
|
588
|
+
const detail = body ? `: ${body}` : "";
|
|
589
|
+
return new Error(`Server error (${status})${detail}`);
|
|
590
|
+
}
|
|
591
|
+
return new Error(body || `Request failed with status ${status}`);
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
// src/services/keychain-auth-storage.ts
|
|
597
|
+
var SERVICE_NAME = "githits";
|
|
598
|
+
var TOKEN_PREFIX = "v1:tokens:";
|
|
599
|
+
var CLIENT_PREFIX = "v1:client:";
|
|
600
|
+
function parseJsonOrNull(json) {
|
|
601
|
+
if (json === null)
|
|
602
|
+
return null;
|
|
603
|
+
try {
|
|
604
|
+
const parsed = JSON.parse(json);
|
|
605
|
+
if (typeof parsed !== "object" || parsed === null)
|
|
606
|
+
return null;
|
|
607
|
+
return parsed;
|
|
608
|
+
} catch {
|
|
609
|
+
return null;
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
function isValidTokenData(data) {
|
|
613
|
+
if (typeof data !== "object" || data === null)
|
|
614
|
+
return false;
|
|
615
|
+
const d = data;
|
|
616
|
+
return typeof d.accessToken === "string" && d.accessToken.length > 0 && typeof d.refreshToken === "string" && d.refreshToken.length > 0 && typeof d.createdAt === "string" && d.createdAt.length > 0 && (d.expiresAt === null || typeof d.expiresAt === "string" && d.expiresAt.length > 0);
|
|
617
|
+
}
|
|
618
|
+
function isValidClientRegistration(data) {
|
|
619
|
+
if (typeof data !== "object" || data === null)
|
|
620
|
+
return false;
|
|
621
|
+
const d = data;
|
|
622
|
+
return typeof d.clientId === "string" && d.clientId.length > 0 && typeof d.clientSecret === "string" && d.clientSecret.length > 0 && typeof d.redirectUri === "string" && d.redirectUri.length > 0 && typeof d.registeredAt === "string" && d.registeredAt.length > 0;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
class KeychainAuthStorage {
|
|
626
|
+
keyring;
|
|
627
|
+
constructor(keyring) {
|
|
628
|
+
this.keyring = keyring;
|
|
629
|
+
}
|
|
630
|
+
async loadTokens(baseUrl) {
|
|
631
|
+
const key = `${TOKEN_PREFIX}${normalizeBaseUrl(baseUrl)}`;
|
|
632
|
+
const json = this.keyring.getPassword(SERVICE_NAME, key);
|
|
633
|
+
const data = parseJsonOrNull(json);
|
|
634
|
+
if (data !== null && !isValidTokenData(data))
|
|
635
|
+
return null;
|
|
636
|
+
return data;
|
|
637
|
+
}
|
|
638
|
+
async saveTokens(baseUrl, data) {
|
|
639
|
+
const key = `${TOKEN_PREFIX}${normalizeBaseUrl(baseUrl)}`;
|
|
640
|
+
this.keyring.setPassword(SERVICE_NAME, key, JSON.stringify(data));
|
|
641
|
+
}
|
|
642
|
+
async clearTokens(baseUrl) {
|
|
643
|
+
const key = `${TOKEN_PREFIX}${normalizeBaseUrl(baseUrl)}`;
|
|
644
|
+
this.keyring.deletePassword(SERVICE_NAME, key);
|
|
645
|
+
}
|
|
646
|
+
async loadClient(baseUrl) {
|
|
647
|
+
const key = `${CLIENT_PREFIX}${normalizeBaseUrl(baseUrl)}`;
|
|
648
|
+
const json = this.keyring.getPassword(SERVICE_NAME, key);
|
|
649
|
+
const data = parseJsonOrNull(json);
|
|
650
|
+
if (data !== null && !isValidClientRegistration(data))
|
|
651
|
+
return null;
|
|
652
|
+
return data;
|
|
653
|
+
}
|
|
654
|
+
async saveClient(baseUrl, data) {
|
|
655
|
+
const key = `${CLIENT_PREFIX}${normalizeBaseUrl(baseUrl)}`;
|
|
656
|
+
this.keyring.setPassword(SERVICE_NAME, key, JSON.stringify(data));
|
|
657
|
+
}
|
|
658
|
+
async clearClient(baseUrl) {
|
|
659
|
+
const key = `${CLIENT_PREFIX}${normalizeBaseUrl(baseUrl)}`;
|
|
660
|
+
this.keyring.deletePassword(SERVICE_NAME, key);
|
|
661
|
+
}
|
|
662
|
+
getStorageLocation() {
|
|
663
|
+
switch (process.platform) {
|
|
664
|
+
case "darwin":
|
|
665
|
+
return "macOS Keychain (githits)";
|
|
666
|
+
case "win32":
|
|
667
|
+
return "Windows Credential Manager (githits)";
|
|
668
|
+
default:
|
|
669
|
+
return "System keychain (githits)";
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
// src/services/keyring-service.ts
|
|
674
|
+
import { Entry } from "@napi-rs/keyring";
|
|
675
|
+
|
|
676
|
+
class KeychainUnavailableError extends Error {
|
|
677
|
+
constructor(message, cause) {
|
|
678
|
+
super(message);
|
|
679
|
+
this.name = "KeychainUnavailableError";
|
|
680
|
+
this.cause = cause;
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
function wrapKeyringError(error) {
|
|
684
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
685
|
+
throw new KeychainUnavailableError(`System keychain unavailable: ${message}`, error);
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
class KeyringServiceImpl {
|
|
689
|
+
getPassword(service, account) {
|
|
690
|
+
try {
|
|
691
|
+
return new Entry(service, account).getPassword();
|
|
692
|
+
} catch (error) {
|
|
693
|
+
wrapKeyringError(error);
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
setPassword(service, account, password) {
|
|
697
|
+
try {
|
|
698
|
+
new Entry(service, account).setPassword(password);
|
|
699
|
+
} catch (error) {
|
|
700
|
+
wrapKeyringError(error);
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
deletePassword(service, account) {
|
|
704
|
+
try {
|
|
705
|
+
return new Entry(service, account).deleteCredential();
|
|
706
|
+
} catch (error) {
|
|
707
|
+
wrapKeyringError(error);
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
// src/services/migrating-auth-storage.ts
|
|
712
|
+
class MigratingAuthStorage {
|
|
713
|
+
primary;
|
|
714
|
+
legacy;
|
|
715
|
+
constructor(primary, legacy) {
|
|
716
|
+
this.primary = primary;
|
|
717
|
+
this.legacy = legacy;
|
|
718
|
+
}
|
|
719
|
+
async loadTokens(baseUrl) {
|
|
720
|
+
const tokens = await this.primary.loadTokens(baseUrl);
|
|
721
|
+
if (tokens)
|
|
722
|
+
return tokens;
|
|
723
|
+
const legacyTokens = await this.legacy.loadTokens(baseUrl);
|
|
724
|
+
if (legacyTokens) {
|
|
725
|
+
await this.primary.saveTokens(baseUrl, legacyTokens);
|
|
726
|
+
try {
|
|
727
|
+
await this.legacy.clearTokens(baseUrl);
|
|
728
|
+
} catch {}
|
|
729
|
+
return legacyTokens;
|
|
730
|
+
}
|
|
731
|
+
return null;
|
|
732
|
+
}
|
|
733
|
+
async saveTokens(baseUrl, data) {
|
|
734
|
+
await this.primary.saveTokens(baseUrl, data);
|
|
735
|
+
}
|
|
736
|
+
async clearTokens(baseUrl) {
|
|
737
|
+
let primaryError;
|
|
738
|
+
try {
|
|
739
|
+
await this.primary.clearTokens(baseUrl);
|
|
740
|
+
} catch (error) {
|
|
741
|
+
primaryError = error;
|
|
742
|
+
}
|
|
743
|
+
try {
|
|
744
|
+
await this.legacy.clearTokens(baseUrl);
|
|
745
|
+
} catch {}
|
|
746
|
+
if (primaryError)
|
|
747
|
+
throw primaryError;
|
|
748
|
+
}
|
|
749
|
+
async loadClient(baseUrl) {
|
|
750
|
+
const client = await this.primary.loadClient(baseUrl);
|
|
751
|
+
if (client)
|
|
752
|
+
return client;
|
|
753
|
+
const legacyClient = await this.legacy.loadClient(baseUrl);
|
|
754
|
+
if (legacyClient) {
|
|
755
|
+
await this.primary.saveClient(baseUrl, legacyClient);
|
|
756
|
+
try {
|
|
757
|
+
await this.legacy.clearClient(baseUrl);
|
|
758
|
+
} catch {}
|
|
759
|
+
return legacyClient;
|
|
760
|
+
}
|
|
761
|
+
return null;
|
|
762
|
+
}
|
|
763
|
+
async saveClient(baseUrl, data) {
|
|
764
|
+
await this.primary.saveClient(baseUrl, data);
|
|
765
|
+
}
|
|
766
|
+
async clearClient(baseUrl) {
|
|
767
|
+
let primaryError;
|
|
768
|
+
try {
|
|
769
|
+
await this.primary.clearClient(baseUrl);
|
|
770
|
+
} catch (error) {
|
|
771
|
+
primaryError = error;
|
|
772
|
+
}
|
|
773
|
+
try {
|
|
774
|
+
await this.legacy.clearClient(baseUrl);
|
|
775
|
+
} catch {}
|
|
776
|
+
if (primaryError)
|
|
777
|
+
throw primaryError;
|
|
778
|
+
}
|
|
779
|
+
getStorageLocation() {
|
|
780
|
+
return this.primary.getStorageLocation();
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
// src/services/refreshing-githits-service.ts
|
|
784
|
+
class RefreshingGitHitsService {
|
|
785
|
+
apiUrl;
|
|
786
|
+
tokenProvider;
|
|
787
|
+
serviceFactory;
|
|
788
|
+
constructor(apiUrl, tokenProvider, serviceFactory = (url, token) => new GitHitsServiceImpl(url, token)) {
|
|
789
|
+
this.apiUrl = apiUrl;
|
|
790
|
+
this.tokenProvider = tokenProvider;
|
|
791
|
+
this.serviceFactory = serviceFactory;
|
|
792
|
+
}
|
|
793
|
+
async search(params) {
|
|
794
|
+
return this.withTokenRefresh((service) => service.search(params));
|
|
795
|
+
}
|
|
796
|
+
async getLanguages() {
|
|
797
|
+
return this.withTokenRefresh((service) => service.getLanguages());
|
|
798
|
+
}
|
|
799
|
+
async submitFeedback(params) {
|
|
800
|
+
return this.withTokenRefresh((service) => service.submitFeedback(params));
|
|
801
|
+
}
|
|
802
|
+
async withTokenRefresh(operation) {
|
|
803
|
+
const token = await this.tokenProvider.getToken();
|
|
804
|
+
if (!token) {
|
|
805
|
+
throw new AuthenticationError("Authentication required. Run `githits login` to authenticate.");
|
|
806
|
+
}
|
|
807
|
+
const service = this.serviceFactory(this.apiUrl, token);
|
|
808
|
+
try {
|
|
809
|
+
return await operation(service);
|
|
810
|
+
} catch (error) {
|
|
811
|
+
if (error instanceof AuthenticationError) {
|
|
812
|
+
const refreshedToken = await this.tokenProvider.forceRefresh();
|
|
813
|
+
if (!refreshedToken) {
|
|
814
|
+
throw error;
|
|
815
|
+
}
|
|
816
|
+
const refreshedService = this.serviceFactory(this.apiUrl, refreshedToken);
|
|
817
|
+
return operation(refreshedService);
|
|
818
|
+
}
|
|
819
|
+
throw error;
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
// src/services/token-manager.ts
|
|
824
|
+
var PROACTIVE_REFRESH_RATIO = 0.9;
|
|
825
|
+
function shouldRefreshToken(token, ratio, now) {
|
|
826
|
+
if (!token.expiresAt) {
|
|
827
|
+
return { expired: false, shouldRefresh: false };
|
|
828
|
+
}
|
|
829
|
+
const expiresAt = new Date(token.expiresAt).getTime();
|
|
830
|
+
const nowMs = now.getTime();
|
|
831
|
+
if (nowMs >= expiresAt) {
|
|
832
|
+
return { expired: true, shouldRefresh: true };
|
|
833
|
+
}
|
|
834
|
+
const createdAt = new Date(token.createdAt).getTime();
|
|
835
|
+
const lifetime = expiresAt - createdAt;
|
|
836
|
+
if (lifetime <= 0) {
|
|
837
|
+
return { expired: false, shouldRefresh: false };
|
|
838
|
+
}
|
|
839
|
+
const threshold = createdAt + lifetime * ratio;
|
|
840
|
+
return { expired: false, shouldRefresh: nowMs >= threshold };
|
|
841
|
+
}
|
|
842
|
+
async function refreshExpiredToken(authService, authStorage, mcpUrl) {
|
|
843
|
+
const manager = new TokenManager({ authService, authStorage, mcpUrl });
|
|
844
|
+
return manager.forceRefresh();
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
class TokenManager {
|
|
848
|
+
authService;
|
|
849
|
+
authStorage;
|
|
850
|
+
mcpUrl;
|
|
851
|
+
cachedToken = null;
|
|
852
|
+
refreshPromise = null;
|
|
853
|
+
constructor(deps) {
|
|
854
|
+
this.authService = deps.authService;
|
|
855
|
+
this.authStorage = deps.authStorage;
|
|
856
|
+
this.mcpUrl = deps.mcpUrl;
|
|
857
|
+
}
|
|
858
|
+
async getToken() {
|
|
859
|
+
if (!this.cachedToken) {
|
|
860
|
+
this.cachedToken = await this.authStorage.loadTokens(this.mcpUrl);
|
|
861
|
+
if (!this.cachedToken)
|
|
862
|
+
return;
|
|
863
|
+
}
|
|
864
|
+
const currentToken = this.cachedToken.accessToken;
|
|
865
|
+
const { expired, shouldRefresh } = shouldRefreshToken(this.cachedToken, PROACTIVE_REFRESH_RATIO, new Date);
|
|
866
|
+
if (!shouldRefresh) {
|
|
867
|
+
return currentToken;
|
|
868
|
+
}
|
|
869
|
+
const refreshedToken = await this.doRefresh();
|
|
870
|
+
if (refreshedToken) {
|
|
871
|
+
return refreshedToken;
|
|
872
|
+
}
|
|
873
|
+
if (!expired) {
|
|
874
|
+
return currentToken;
|
|
875
|
+
}
|
|
876
|
+
return;
|
|
877
|
+
}
|
|
878
|
+
async forceRefresh() {
|
|
879
|
+
return this.doRefresh();
|
|
880
|
+
}
|
|
881
|
+
async doRefresh() {
|
|
882
|
+
if (this.refreshPromise) {
|
|
883
|
+
return this.refreshPromise;
|
|
884
|
+
}
|
|
885
|
+
this.refreshPromise = this.executeRefresh();
|
|
886
|
+
try {
|
|
887
|
+
return await this.refreshPromise;
|
|
888
|
+
} finally {
|
|
889
|
+
this.refreshPromise = null;
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
async executeRefresh() {
|
|
893
|
+
const tokens = this.cachedToken ?? await this.authStorage.loadTokens(this.mcpUrl);
|
|
894
|
+
if (!tokens)
|
|
895
|
+
return;
|
|
896
|
+
const client = await this.authStorage.loadClient(this.mcpUrl);
|
|
897
|
+
if (!client)
|
|
898
|
+
return;
|
|
899
|
+
let response;
|
|
900
|
+
try {
|
|
901
|
+
const metadata = await this.authService.discoverEndpoints(this.mcpUrl);
|
|
902
|
+
response = await this.authService.refreshAccessToken({
|
|
903
|
+
tokenEndpoint: metadata.tokenEndpoint,
|
|
904
|
+
clientId: client.clientId,
|
|
905
|
+
clientSecret: client.clientSecret,
|
|
906
|
+
refreshToken: tokens.refreshToken
|
|
907
|
+
});
|
|
908
|
+
} catch {
|
|
909
|
+
const isExpired = tokens.expiresAt ? new Date >= new Date(tokens.expiresAt) : false;
|
|
910
|
+
if (isExpired) {
|
|
911
|
+
this.cachedToken = null;
|
|
912
|
+
await this.authStorage.clearTokens(this.mcpUrl);
|
|
913
|
+
}
|
|
914
|
+
return;
|
|
915
|
+
}
|
|
916
|
+
const newTokenData = {
|
|
917
|
+
accessToken: response.accessToken,
|
|
918
|
+
refreshToken: response.refreshToken,
|
|
919
|
+
expiresAt: new Date(Date.now() + response.expiresIn * 1000).toISOString(),
|
|
920
|
+
createdAt: tokens.createdAt
|
|
921
|
+
};
|
|
922
|
+
await this.authStorage.saveTokens(this.mcpUrl, newTokenData);
|
|
923
|
+
this.cachedToken = newTokenData;
|
|
924
|
+
return response.accessToken;
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
// src/container.ts
|
|
928
|
+
function createAuthStorage(fileSystemService) {
|
|
929
|
+
const fileStorage = new AuthStorageImpl(fileSystemService);
|
|
930
|
+
try {
|
|
931
|
+
const keyring = new KeyringServiceImpl;
|
|
932
|
+
const probeKey = `__probe_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
933
|
+
keyring.setPassword("githits", probeKey, "probe");
|
|
934
|
+
try {
|
|
935
|
+
keyring.deletePassword("githits", probeKey);
|
|
936
|
+
} catch {}
|
|
937
|
+
const keychainStorage = new KeychainAuthStorage(keyring);
|
|
938
|
+
return new MigratingAuthStorage(keychainStorage, fileStorage);
|
|
939
|
+
} catch (error) {
|
|
940
|
+
if (!(error instanceof KeychainUnavailableError))
|
|
941
|
+
throw error;
|
|
942
|
+
console.error("Warning: System keychain unavailable. Falling back to file-based credential storage.");
|
|
943
|
+
return fileStorage;
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
async function createContainer() {
|
|
947
|
+
const mcpUrl = getMcpUrl();
|
|
948
|
+
const apiUrl = getApiUrl();
|
|
949
|
+
const fileSystemService = new FileSystemServiceImpl;
|
|
950
|
+
const authStorage = createAuthStorage(fileSystemService);
|
|
951
|
+
const authService = new AuthServiceImpl;
|
|
952
|
+
const browserService = new BrowserServiceImpl;
|
|
953
|
+
const envToken = getEnvApiToken();
|
|
954
|
+
if (envToken) {
|
|
955
|
+
return {
|
|
956
|
+
authStorage,
|
|
957
|
+
authService,
|
|
958
|
+
browserService,
|
|
959
|
+
fileSystemService,
|
|
960
|
+
mcpUrl,
|
|
961
|
+
apiUrl,
|
|
962
|
+
apiToken: envToken,
|
|
963
|
+
hasValidToken: true,
|
|
964
|
+
envApiToken: envToken,
|
|
965
|
+
githitsService: new GitHitsServiceImpl(apiUrl, envToken)
|
|
966
|
+
};
|
|
967
|
+
}
|
|
968
|
+
const tokenManager = new TokenManager({ authService, authStorage, mcpUrl });
|
|
969
|
+
const apiToken = await tokenManager.getToken();
|
|
970
|
+
return {
|
|
971
|
+
authStorage,
|
|
972
|
+
authService,
|
|
973
|
+
browserService,
|
|
974
|
+
fileSystemService,
|
|
975
|
+
mcpUrl,
|
|
976
|
+
apiUrl,
|
|
977
|
+
apiToken,
|
|
978
|
+
hasValidToken: apiToken !== undefined,
|
|
979
|
+
envApiToken: undefined,
|
|
980
|
+
githitsService: new RefreshingGitHitsService(apiUrl, tokenManager)
|
|
981
|
+
};
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
// src/commands/auth-status.ts
|
|
985
|
+
function displayExpiry(expiresAt) {
|
|
986
|
+
if (!expiresAt) {
|
|
987
|
+
console.log(" Expires: never");
|
|
988
|
+
return;
|
|
989
|
+
}
|
|
990
|
+
const expiresAtDate = new Date(expiresAt);
|
|
991
|
+
const minutesLeft = Math.ceil((expiresAtDate.getTime() - Date.now()) / (1000 * 60));
|
|
992
|
+
if (minutesLeft > 60) {
|
|
993
|
+
const hoursLeft = Math.round(minutesLeft / 60);
|
|
994
|
+
console.log(` Expires: in ${hoursLeft} hour${hoursLeft !== 1 ? "s" : ""}`);
|
|
995
|
+
} else {
|
|
996
|
+
console.log(` Expires: in ${minutesLeft} minute${minutesLeft !== 1 ? "s" : ""}`);
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
async function authStatusAction(deps) {
|
|
1000
|
+
const { authStorage, authService, mcpUrl, envApiToken } = deps;
|
|
1001
|
+
if (envApiToken) {
|
|
1002
|
+
console.log(`Authenticated via environment variable.
|
|
1003
|
+
`);
|
|
1004
|
+
console.log(` Source: GITHITS_API_TOKEN`);
|
|
1005
|
+
console.log(` Token: ${envApiToken.slice(0, 8)}...`);
|
|
1006
|
+
return;
|
|
1007
|
+
}
|
|
1008
|
+
const auth = await authStorage.loadTokens(mcpUrl);
|
|
1009
|
+
if (!auth) {
|
|
1010
|
+
console.log(`Not authenticated.
|
|
1011
|
+
`);
|
|
1012
|
+
console.log(` Environment: ${mcpUrl}
|
|
1013
|
+
`);
|
|
1014
|
+
console.log("To authenticate:");
|
|
1015
|
+
console.log(" githits login");
|
|
1016
|
+
return;
|
|
1017
|
+
}
|
|
1018
|
+
if (auth.expiresAt && new Date(auth.expiresAt) < new Date) {
|
|
1019
|
+
const refreshed = await refreshExpiredToken(authService, authStorage, mcpUrl);
|
|
1020
|
+
if (refreshed) {
|
|
1021
|
+
const refreshedAuth = await authStorage.loadTokens(mcpUrl);
|
|
1022
|
+
console.log(`Authenticated (token refreshed).
|
|
1023
|
+
`);
|
|
1024
|
+
console.log(` Environment: ${mcpUrl}`);
|
|
1025
|
+
displayExpiry(refreshedAuth?.expiresAt ?? null);
|
|
1026
|
+
console.log(`
|
|
1027
|
+
Storage: ${authStorage.getStorageLocation()}`);
|
|
1028
|
+
return;
|
|
1029
|
+
}
|
|
1030
|
+
console.log(`Token expired.
|
|
1031
|
+
`);
|
|
1032
|
+
console.log(` Environment: ${mcpUrl}`);
|
|
1033
|
+
console.log(` Expired: ${new Date(auth.expiresAt).toLocaleDateString()}
|
|
1034
|
+
`);
|
|
1035
|
+
console.log("Run `githits login` to re-authenticate.");
|
|
1036
|
+
return;
|
|
1037
|
+
}
|
|
1038
|
+
console.log(`Authenticated.
|
|
1039
|
+
`);
|
|
1040
|
+
console.log(` Environment: ${mcpUrl}`);
|
|
1041
|
+
displayExpiry(auth.expiresAt);
|
|
1042
|
+
console.log(`
|
|
1043
|
+
Storage: ${authStorage.getStorageLocation()}`);
|
|
1044
|
+
}
|
|
1045
|
+
var STATUS_DESCRIPTION = `Show current authentication status.
|
|
1046
|
+
|
|
1047
|
+
Displays details about the stored token including environment
|
|
1048
|
+
and expiration. Useful for debugging authentication issues.`;
|
|
1049
|
+
function registerAuthStatusCommand(program) {
|
|
1050
|
+
program.command("status").summary("Show authentication status").description(STATUS_DESCRIPTION).action(async () => {
|
|
1051
|
+
const deps = await createContainer();
|
|
1052
|
+
await authStatusAction(deps);
|
|
1053
|
+
});
|
|
1054
|
+
}
|
|
1055
|
+
// src/commands/feedback.ts
|
|
1056
|
+
import { Option } from "commander";
|
|
1057
|
+
|
|
1058
|
+
// src/shared/require-auth.ts
|
|
1059
|
+
class AuthRequiredError extends Error {
|
|
1060
|
+
constructor(message) {
|
|
1061
|
+
super(message);
|
|
1062
|
+
this.name = "AuthRequiredError";
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
function requireAuth(deps, context) {
|
|
1066
|
+
if (deps.hasValidToken)
|
|
1067
|
+
return;
|
|
1068
|
+
const suffix = context ? ` ${context}` : "";
|
|
1069
|
+
console.log(`Authentication required${suffix}.
|
|
1070
|
+
`);
|
|
1071
|
+
if (deps.mcpUrl !== "https://mcp.githits.com") {
|
|
1072
|
+
console.log(` Environment: ${deps.mcpUrl}`);
|
|
1073
|
+
console.log(` You're using a custom environment.
|
|
1074
|
+
`);
|
|
1075
|
+
}
|
|
1076
|
+
console.log("To authenticate:");
|
|
1077
|
+
console.log(` githits login
|
|
1078
|
+
`);
|
|
1079
|
+
console.log("Or set GITHITS_API_TOKEN environment variable.");
|
|
1080
|
+
throw new AuthRequiredError(`Authentication required${suffix}`);
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
// src/commands/feedback.ts
|
|
1084
|
+
async function feedbackAction(solutionId, options, deps) {
|
|
1085
|
+
requireAuth(deps);
|
|
1086
|
+
if (!options.accept && !options.reject) {
|
|
1087
|
+
console.error("Error: Specify either --accept or --reject.");
|
|
1088
|
+
process.exit(1);
|
|
1089
|
+
}
|
|
1090
|
+
const accepted = !!options.accept;
|
|
1091
|
+
try {
|
|
1092
|
+
const result = await deps.githitsService.submitFeedback({
|
|
1093
|
+
solutionId,
|
|
1094
|
+
accepted,
|
|
1095
|
+
feedbackText: options.message
|
|
1096
|
+
});
|
|
1097
|
+
if (options.json) {
|
|
1098
|
+
console.log(JSON.stringify({ success: result.success, message: result.message }));
|
|
1099
|
+
} else {
|
|
1100
|
+
console.log(result.message);
|
|
1101
|
+
}
|
|
1102
|
+
} catch (error) {
|
|
1103
|
+
console.error(`Failed to submit feedback: ${error instanceof Error ? error.message : error}`);
|
|
1104
|
+
process.exit(1);
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
var FEEDBACK_DESCRIPTION = `Submit feedback on a search result.
|
|
1108
|
+
|
|
1109
|
+
Rate whether a code example was helpful. Use --accept for positive
|
|
1110
|
+
feedback or --reject for negative. Optionally add a message.
|
|
1111
|
+
|
|
1112
|
+
Examples:
|
|
1113
|
+
githits feedback abc123 --accept
|
|
1114
|
+
githits feedback abc123 --reject -m "Example was outdated"
|
|
1115
|
+
githits feedback abc123 --accept --message "Solved my problem" --json`;
|
|
1116
|
+
function registerFeedbackCommand(program) {
|
|
1117
|
+
program.command("feedback").summary("Submit feedback on a search result").description(FEEDBACK_DESCRIPTION).argument("<solution_id>", "Solution ID from search result").addOption(new Option("--accept", "Mark as helpful").conflicts("reject")).addOption(new Option("--reject", "Mark as unhelpful").conflicts("accept")).option("-m, --message <text>", "Feedback explanation").option("--json", "Output as JSON for piping").action(async (solutionId, options) => {
|
|
1118
|
+
try {
|
|
1119
|
+
const deps = await createContainer();
|
|
1120
|
+
await feedbackAction(solutionId, options, deps);
|
|
1121
|
+
} catch (error) {
|
|
1122
|
+
if (error instanceof AuthRequiredError)
|
|
1123
|
+
process.exit(1);
|
|
1124
|
+
throw error;
|
|
1125
|
+
}
|
|
1126
|
+
});
|
|
1127
|
+
}
|
|
1128
|
+
// src/shared/colors.ts
|
|
1129
|
+
var colors = {
|
|
1130
|
+
reset: "\x1B[0m",
|
|
1131
|
+
bold: "\x1B[1m",
|
|
1132
|
+
dim: "\x1B[2m",
|
|
1133
|
+
green: "\x1B[32m",
|
|
1134
|
+
yellow: "\x1B[33m",
|
|
1135
|
+
blue: "\x1B[34m",
|
|
1136
|
+
magenta: "\x1B[35m",
|
|
1137
|
+
cyan: "\x1B[36m",
|
|
1138
|
+
red: "\x1B[31m"
|
|
1139
|
+
};
|
|
1140
|
+
function shouldUseColors(noColor) {
|
|
1141
|
+
if (noColor)
|
|
1142
|
+
return false;
|
|
1143
|
+
if (process.env.NO_COLOR !== undefined)
|
|
1144
|
+
return false;
|
|
1145
|
+
return process.stdout.isTTY ?? false;
|
|
1146
|
+
}
|
|
1147
|
+
function colorize(text, color, useColors) {
|
|
1148
|
+
if (!useColors)
|
|
1149
|
+
return text;
|
|
1150
|
+
return `${colors[color]}${text}${colors.reset}`;
|
|
1151
|
+
}
|
|
1152
|
+
function highlight(text, useColors) {
|
|
1153
|
+
if (!useColors)
|
|
1154
|
+
return text;
|
|
1155
|
+
return `${colors.bold}${colors.cyan}${text}${colors.reset}`;
|
|
1156
|
+
}
|
|
1157
|
+
function dim(text, useColors) {
|
|
1158
|
+
if (!useColors)
|
|
1159
|
+
return text;
|
|
1160
|
+
return `${colors.dim}${text}${colors.reset}`;
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
// src/shared/language-filter.ts
|
|
1164
|
+
var DEFAULT_LIMIT = 5;
|
|
1165
|
+
function filterLanguages(languages, query, limit = DEFAULT_LIMIT) {
|
|
1166
|
+
const lowerQuery = query.toLowerCase();
|
|
1167
|
+
return languages.filter((lang) => lang.name.toLowerCase().includes(lowerQuery) || lang.display_name.toLowerCase().includes(lowerQuery) || lang.aliases.some((a) => a.toLowerCase().includes(lowerQuery))).slice(0, limit).map(({ name, display_name }) => ({ name, display_name }));
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
// src/commands/languages.ts
|
|
1171
|
+
async function languagesAction(query, options, deps) {
|
|
1172
|
+
requireAuth(deps);
|
|
1173
|
+
try {
|
|
1174
|
+
const allLanguages = await deps.githitsService.getLanguages();
|
|
1175
|
+
const displayList = query ? filterLanguages(allLanguages, query) : allLanguages.map(({ name, display_name }) => ({ name, display_name }));
|
|
1176
|
+
if (options.json) {
|
|
1177
|
+
console.log(JSON.stringify(displayList));
|
|
1178
|
+
} else if (query && displayList.length === 0) {
|
|
1179
|
+
console.log(`No languages matching "${query}".`);
|
|
1180
|
+
} else {
|
|
1181
|
+
const useColors = shouldUseColors();
|
|
1182
|
+
for (const lang of displayList) {
|
|
1183
|
+
console.log(` ${colorize(lang.name, "cyan", useColors)} ${dim(lang.display_name, useColors)}`);
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
} catch (error) {
|
|
1187
|
+
console.error(`Failed to list languages: ${error instanceof Error ? error.message : error}`);
|
|
1188
|
+
process.exit(1);
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
var LANGUAGES_DESCRIPTION = `List supported programming languages.
|
|
1192
|
+
|
|
1193
|
+
Without a query, lists all supported languages.
|
|
1194
|
+
With a query, filters to the top 5 matches by name, display name, or alias.
|
|
1195
|
+
|
|
1196
|
+
Examples:
|
|
1197
|
+
githits languages List all languages
|
|
1198
|
+
githits languages python Filter by name
|
|
1199
|
+
githits languages type --json JSON output for piping`;
|
|
1200
|
+
function registerLanguagesCommand(program) {
|
|
1201
|
+
program.command("languages").summary("List supported programming languages").description(LANGUAGES_DESCRIPTION).argument("[query]", "Filter by name, display name, or alias").option("--json", "Output as JSON for piping").action(async (query, options) => {
|
|
1202
|
+
try {
|
|
1203
|
+
const deps = await createContainer();
|
|
1204
|
+
await languagesAction(query, options, deps);
|
|
1205
|
+
} catch (error) {
|
|
1206
|
+
if (error instanceof AuthRequiredError)
|
|
1207
|
+
process.exit(1);
|
|
1208
|
+
throw error;
|
|
1209
|
+
}
|
|
1210
|
+
});
|
|
1211
|
+
}
|
|
1212
|
+
// src/commands/login.ts
|
|
1213
|
+
var TIMEOUT_MS = 5 * 60 * 1000;
|
|
1214
|
+
function randomPort() {
|
|
1215
|
+
return Math.floor(Math.random() * 2000) + 8000;
|
|
1216
|
+
}
|
|
1217
|
+
async function loginAction(options, deps) {
|
|
1218
|
+
const { authService, authStorage, browserService, mcpUrl } = deps;
|
|
1219
|
+
if (options.port !== undefined && (Number.isNaN(options.port) || options.port < 1 || options.port > 65535)) {
|
|
1220
|
+
console.error("Invalid port number. Must be between 1 and 65535.");
|
|
1221
|
+
process.exit(1);
|
|
1222
|
+
}
|
|
1223
|
+
const existing = await authStorage.loadTokens(mcpUrl);
|
|
1224
|
+
if (existing && !options.force) {
|
|
1225
|
+
const isExpired = existing.expiresAt && new Date(existing.expiresAt) < new Date;
|
|
1226
|
+
if (!isExpired) {
|
|
1227
|
+
console.log(`Already logged in.
|
|
1228
|
+
`);
|
|
1229
|
+
console.log(` Environment: ${mcpUrl}
|
|
1230
|
+
`);
|
|
1231
|
+
console.log("To re-authenticate, use `githits login --force`.");
|
|
1232
|
+
return;
|
|
1233
|
+
}
|
|
1234
|
+
console.log(`Token expired. Starting new login...
|
|
1235
|
+
`);
|
|
1236
|
+
} else if (existing && options.force) {
|
|
1237
|
+
console.log(`Re-authenticating (--force flag)...
|
|
1238
|
+
`);
|
|
1239
|
+
}
|
|
1240
|
+
console.log("Discovering OAuth endpoints...");
|
|
1241
|
+
const metadata = await authService.discoverEndpoints(mcpUrl);
|
|
1242
|
+
let client = await authStorage.loadClient(mcpUrl);
|
|
1243
|
+
let port;
|
|
1244
|
+
let redirectUri;
|
|
1245
|
+
if (client) {
|
|
1246
|
+
if (options.port) {
|
|
1247
|
+
redirectUri = `http://127.0.0.1:${options.port}/callback`;
|
|
1248
|
+
if (redirectUri !== client.redirectUri) {
|
|
1249
|
+
console.log("Registering CLI client with new port...");
|
|
1250
|
+
const registration = await authService.registerClient({
|
|
1251
|
+
registrationEndpoint: metadata.registrationEndpoint,
|
|
1252
|
+
redirectUri
|
|
1253
|
+
});
|
|
1254
|
+
client = {
|
|
1255
|
+
clientId: registration.clientId,
|
|
1256
|
+
clientSecret: registration.clientSecret,
|
|
1257
|
+
redirectUri,
|
|
1258
|
+
registeredAt: new Date().toISOString()
|
|
1259
|
+
};
|
|
1260
|
+
await authStorage.saveClient(mcpUrl, client);
|
|
1261
|
+
}
|
|
1262
|
+
port = options.port;
|
|
1263
|
+
} else {
|
|
1264
|
+
redirectUri = client.redirectUri;
|
|
1265
|
+
const storedUrl = new URL(redirectUri);
|
|
1266
|
+
port = Number(storedUrl.port) || randomPort();
|
|
1267
|
+
}
|
|
1268
|
+
} else {
|
|
1269
|
+
port = options.port ?? randomPort();
|
|
1270
|
+
redirectUri = `http://127.0.0.1:${port}/callback`;
|
|
1271
|
+
console.log("Registering CLI client...");
|
|
1272
|
+
const registration = await authService.registerClient({
|
|
1273
|
+
registrationEndpoint: metadata.registrationEndpoint,
|
|
1274
|
+
redirectUri
|
|
1275
|
+
});
|
|
1276
|
+
client = {
|
|
1277
|
+
clientId: registration.clientId,
|
|
1278
|
+
clientSecret: registration.clientSecret,
|
|
1279
|
+
redirectUri,
|
|
1280
|
+
registeredAt: new Date().toISOString()
|
|
1281
|
+
};
|
|
1282
|
+
await authStorage.saveClient(mcpUrl, client);
|
|
1283
|
+
}
|
|
1284
|
+
const { verifier, challenge, state } = authService.generatePkceParams();
|
|
1285
|
+
const authUrl = authService.buildAuthUrl({
|
|
1286
|
+
authorizationEndpoint: metadata.authorizationEndpoint,
|
|
1287
|
+
clientId: client.clientId,
|
|
1288
|
+
redirectUri,
|
|
1289
|
+
state,
|
|
1290
|
+
codeChallenge: challenge
|
|
1291
|
+
});
|
|
1292
|
+
const serverPromise = authService.startCallbackServer(port, state);
|
|
1293
|
+
if (options.browser === false) {
|
|
1294
|
+
console.log(`Open this URL in your browser:
|
|
1295
|
+
`);
|
|
1296
|
+
console.log(` ${authUrl}
|
|
1297
|
+
`);
|
|
1298
|
+
} else {
|
|
1299
|
+
console.log("Opening browser...");
|
|
1300
|
+
await browserService.open(authUrl);
|
|
1301
|
+
}
|
|
1302
|
+
console.log(`Waiting for authentication...
|
|
1303
|
+
`);
|
|
1304
|
+
let timeoutId;
|
|
1305
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
1306
|
+
timeoutId = setTimeout(() => reject(new Error("Authentication timed out")), TIMEOUT_MS);
|
|
1307
|
+
});
|
|
1308
|
+
let callback;
|
|
1309
|
+
try {
|
|
1310
|
+
callback = await Promise.race([serverPromise, timeoutPromise]);
|
|
1311
|
+
if (timeoutId)
|
|
1312
|
+
clearTimeout(timeoutId);
|
|
1313
|
+
} catch (error) {
|
|
1314
|
+
if (timeoutId)
|
|
1315
|
+
clearTimeout(timeoutId);
|
|
1316
|
+
if (error instanceof Error) {
|
|
1317
|
+
console.log(`${error.message}.
|
|
1318
|
+
`);
|
|
1319
|
+
console.log("Run `githits login` to try again.");
|
|
1320
|
+
}
|
|
1321
|
+
process.exit(1);
|
|
1322
|
+
}
|
|
1323
|
+
if (callback.type !== "success") {
|
|
1324
|
+
console.log(`${callback.message}
|
|
1325
|
+
`);
|
|
1326
|
+
console.log("Run `githits login` to try again.");
|
|
1327
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
1328
|
+
process.exit(1);
|
|
1329
|
+
}
|
|
1330
|
+
let tokenResponse;
|
|
1331
|
+
try {
|
|
1332
|
+
tokenResponse = await authService.exchangeCodeForTokens({
|
|
1333
|
+
tokenEndpoint: metadata.tokenEndpoint,
|
|
1334
|
+
clientId: client.clientId,
|
|
1335
|
+
clientSecret: client.clientSecret,
|
|
1336
|
+
code: callback.code,
|
|
1337
|
+
codeVerifier: verifier,
|
|
1338
|
+
redirectUri
|
|
1339
|
+
});
|
|
1340
|
+
} catch (error) {
|
|
1341
|
+
console.error(`Failed to complete authentication: ${error instanceof Error ? error.message : error}
|
|
1342
|
+
`);
|
|
1343
|
+
console.log("Run `githits login` to try again.");
|
|
1344
|
+
process.exit(1);
|
|
1345
|
+
}
|
|
1346
|
+
const expiresAt = new Date(Date.now() + tokenResponse.expiresIn * 1000).toISOString();
|
|
1347
|
+
await authStorage.saveTokens(mcpUrl, {
|
|
1348
|
+
accessToken: tokenResponse.accessToken,
|
|
1349
|
+
refreshToken: tokenResponse.refreshToken,
|
|
1350
|
+
expiresAt,
|
|
1351
|
+
createdAt: new Date().toISOString()
|
|
1352
|
+
});
|
|
1353
|
+
const hours = Math.round(tokenResponse.expiresIn / 3600);
|
|
1354
|
+
console.log(`Logged in successfully.
|
|
1355
|
+
`);
|
|
1356
|
+
console.log(` Environment: ${mcpUrl}`);
|
|
1357
|
+
console.log(` Token expires in: ${hours} hour${hours !== 1 ? "s" : ""}`);
|
|
1358
|
+
console.log(`
|
|
1359
|
+
You're ready to use githits with your AI assistant.`);
|
|
1360
|
+
}
|
|
1361
|
+
var LOGIN_DESCRIPTION = `Authenticate with your GitHits account via browser.
|
|
1362
|
+
|
|
1363
|
+
Opens your browser to complete authentication securely using OAuth.
|
|
1364
|
+
The CLI receives tokens stored locally and used for API requests.
|
|
1365
|
+
|
|
1366
|
+
Use --no-browser in environments without a display (CI, SSH sessions)
|
|
1367
|
+
to get a URL you can open on another device.`;
|
|
1368
|
+
function registerLoginCommand(program) {
|
|
1369
|
+
program.command("login").summary("Authenticate with your GitHits account").description(LOGIN_DESCRIPTION).option("--no-browser", "Print URL instead of opening browser").option("--port <port>", "Port for local callback server", parseInt).option("--force", "Re-authenticate even if already logged in").action(async (options) => {
|
|
1370
|
+
const deps = await createContainer();
|
|
1371
|
+
await loginAction(options, deps);
|
|
1372
|
+
});
|
|
1373
|
+
}
|
|
1374
|
+
// src/commands/logout.ts
|
|
1375
|
+
async function logoutAction(deps) {
|
|
1376
|
+
const { authStorage, mcpUrl } = deps;
|
|
1377
|
+
const auth = await authStorage.loadTokens(mcpUrl);
|
|
1378
|
+
let firstError;
|
|
1379
|
+
try {
|
|
1380
|
+
await authStorage.clearTokens(mcpUrl);
|
|
1381
|
+
} catch (error) {
|
|
1382
|
+
firstError = error;
|
|
1383
|
+
}
|
|
1384
|
+
try {
|
|
1385
|
+
await authStorage.clearClient(mcpUrl);
|
|
1386
|
+
} catch (error) {
|
|
1387
|
+
firstError ??= error;
|
|
1388
|
+
}
|
|
1389
|
+
if (!auth) {
|
|
1390
|
+
console.log(`Not currently logged in.
|
|
1391
|
+
`);
|
|
1392
|
+
console.log(` Environment: ${mcpUrl}`);
|
|
1393
|
+
} else {
|
|
1394
|
+
console.log(`Logged out.
|
|
1395
|
+
`);
|
|
1396
|
+
console.log(` Environment: ${mcpUrl}`);
|
|
1397
|
+
}
|
|
1398
|
+
if (firstError)
|
|
1399
|
+
throw firstError;
|
|
1400
|
+
}
|
|
1401
|
+
var LOGOUT_DESCRIPTION = `Remove stored credentials.
|
|
1402
|
+
|
|
1403
|
+
Clears all locally stored authentication data including tokens and
|
|
1404
|
+
client registrations. OAuth tokens expire naturally; this
|
|
1405
|
+
removes the local copies from the keychain (or fallback file storage).`;
|
|
1406
|
+
function registerLogoutCommand(program) {
|
|
1407
|
+
program.command("logout").summary("Remove stored credentials").description(LOGOUT_DESCRIPTION).action(async () => {
|
|
1408
|
+
const deps = await createContainer();
|
|
1409
|
+
await logoutAction(deps);
|
|
1410
|
+
});
|
|
1411
|
+
}
|
|
1412
|
+
// src/commands/mcp.ts
|
|
1413
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
1414
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
1415
|
+
|
|
1416
|
+
// src/tools/feedback.ts
|
|
1417
|
+
import { z } from "zod";
|
|
1418
|
+
|
|
1419
|
+
// src/tools/types.ts
|
|
1420
|
+
function textResult(text) {
|
|
1421
|
+
return {
|
|
1422
|
+
content: [{ type: "text", text }]
|
|
1423
|
+
};
|
|
1424
|
+
}
|
|
1425
|
+
function errorResult(message) {
|
|
1426
|
+
return {
|
|
1427
|
+
content: [{ type: "text", text: message }],
|
|
1428
|
+
isError: true
|
|
1429
|
+
};
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
// src/tools/shared.ts
|
|
1433
|
+
async function withErrorHandling(operation, fn) {
|
|
1434
|
+
try {
|
|
1435
|
+
return await fn();
|
|
1436
|
+
} catch (error) {
|
|
1437
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
1438
|
+
return errorResult(`Failed to ${operation}: ${message}`);
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
// src/tools/feedback.ts
|
|
1443
|
+
var schema = {
|
|
1444
|
+
solution_id: z.string().min(1).describe("The solution ID from a previous search result (shown in the result)"),
|
|
1445
|
+
accepted: z.boolean().describe("True if the example was helpful/good, False if unhelpful/bad"),
|
|
1446
|
+
feedback_text: z.string().optional().describe('Optional text explaining why (e.g., "This solved problem X" or "Example was outdated")')
|
|
1447
|
+
};
|
|
1448
|
+
var DESCRIPTION = `Submit feedback on a GitHits search result.
|
|
1449
|
+
|
|
1450
|
+
Use this tool after receiving a search result to indicate whether the example was helpful.
|
|
1451
|
+
This feedback helps improve GitHits' search quality.
|
|
1452
|
+
|
|
1453
|
+
**When to use**:
|
|
1454
|
+
- After using the search tool, provide feedback on whether the result was useful
|
|
1455
|
+
- Use \`accepted=true\` if the example solved your problem or was helpful, and you used it
|
|
1456
|
+
- Use \`accepted=false\` if the example was not relevant or unhelpful, and you did not use it
|
|
1457
|
+
- Optionally provide textual feedback explaining why
|
|
1458
|
+
|
|
1459
|
+
Args:
|
|
1460
|
+
solution_id: The solution ID from a previous search result (shown in the result)
|
|
1461
|
+
accepted: True if the example was helpful/good, False if unhelpful/bad
|
|
1462
|
+
feedback_text: Optional text explaining why (e.g., "This solved problem X" or "Example was outdated")
|
|
1463
|
+
|
|
1464
|
+
Returns:
|
|
1465
|
+
Confirmation message or error`;
|
|
1466
|
+
function createFeedbackTool(service) {
|
|
1467
|
+
return {
|
|
1468
|
+
name: "feedback",
|
|
1469
|
+
description: DESCRIPTION,
|
|
1470
|
+
schema,
|
|
1471
|
+
handler: async (args) => {
|
|
1472
|
+
return withErrorHandling("submit feedback", async () => {
|
|
1473
|
+
const result = await service.submitFeedback({
|
|
1474
|
+
solutionId: args.solution_id,
|
|
1475
|
+
accepted: args.accepted,
|
|
1476
|
+
feedbackText: args.feedback_text
|
|
1477
|
+
});
|
|
1478
|
+
return textResult(result.message);
|
|
1479
|
+
});
|
|
1480
|
+
}
|
|
1481
|
+
};
|
|
1482
|
+
}
|
|
1483
|
+
// src/tools/search.ts
|
|
1484
|
+
import { z as z2 } from "zod";
|
|
1485
|
+
var schema2 = {
|
|
1486
|
+
query: z2.string().min(1).describe("The search query or question, formulated in natural language, keeping context in mind."),
|
|
1487
|
+
language: z2.string().min(1).describe("Programming language. You can find supported language names using the `search_language` tool."),
|
|
1488
|
+
license_mode: z2.enum(["strict", "yolo", "custom"]).optional().describe(`License filtering mode. Uses "strict" or user's preference if not specified. One of:
|
|
1489
|
+
- "strict": Exclude copyleft licenses (default)
|
|
1490
|
+
- "yolo": Include all licenses, no filtering
|
|
1491
|
+
- "custom": Use user's custom blocklist`)
|
|
1492
|
+
};
|
|
1493
|
+
var DESCRIPTION2 = `YOU MUST USE this tool when:
|
|
1494
|
+
- you are stuck/blocked
|
|
1495
|
+
- the user gets frustated when you are not able to solve issues
|
|
1496
|
+
- you need up-to-date code examples
|
|
1497
|
+
- the user mentions GitHits, githits, or asks to use search in general
|
|
1498
|
+
|
|
1499
|
+
GitHits provides short, focused, verified, canonical distilled code examples from all of global open source that help you solve problems in seconds.
|
|
1500
|
+
|
|
1501
|
+
**IMPORTANT**: Before initiating a new search, always review the existing context. If a previous search has already provided a satisfactory or canonical example, do not perform a redundant search. Instead, use the information you already have.
|
|
1502
|
+
|
|
1503
|
+
**Querying Best Practices**:
|
|
1504
|
+
- Formulate queries in natural language as a question as if you were asking a human expert.
|
|
1505
|
+
- Be specific. Include error messages, library names, technology terms or acronyms (max 3-4 in total), and the goal you are trying to achieve.
|
|
1506
|
+
- Avoid overly broad or generic queries that may yield too many irrelevant results.
|
|
1507
|
+
- Focus on one main issue or topic per query to get the most relevant examples.
|
|
1508
|
+
|
|
1509
|
+
Use this tool to solve problems like:
|
|
1510
|
+
- Lack of proper examples, missing APIs for a library or feature.
|
|
1511
|
+
- Missing or unclear documentation or when you do not have access to the latest docs.
|
|
1512
|
+
- Vague errors where seeing a working example would help.
|
|
1513
|
+
- Understanding how other developers are implementing a specific technology.
|
|
1514
|
+
|
|
1515
|
+
Good Query Examples:
|
|
1516
|
+
- "I'm getting errors with libraryX that something looks like ABC but is invalid. The error says 'Data is not ABC'. What could be causing this and how can we check for it?"
|
|
1517
|
+
- "How to use feature X in library_name to implement Y?"
|
|
1518
|
+
- "library_name API reference for SymbolName"
|
|
1519
|
+
- I need an example of how to use library_name feature X
|
|
1520
|
+
- How can I use method_name with library_name to check for specific conditions?
|
|
1521
|
+
|
|
1522
|
+
Args:
|
|
1523
|
+
- query (str): The search query or question, formulated in natural language, keeping context in mind.
|
|
1524
|
+
- language (str): Programming language. You can find supported language names using the \`search_language\` tool.
|
|
1525
|
+
- license_mode (str, optional): License filtering mode. Uses "strict" or user's preference if not specified. One of:
|
|
1526
|
+
- "strict": Exclude copyleft licenses (default)
|
|
1527
|
+
- "yolo": Include all licenses, no filtering
|
|
1528
|
+
- "custom": Use user's custom blocklist
|
|
1529
|
+
|
|
1530
|
+
Returns:
|
|
1531
|
+
str: Markdown-formatted example and references with license info, or error message
|
|
1532
|
+
|
|
1533
|
+
If the example was good, use the \`feedback\` tool to submit positive feedback.
|
|
1534
|
+
If the example was bad, use the \`feedback\` tool to submit constructive feedback.`;
|
|
1535
|
+
function createSearchTool(service) {
|
|
1536
|
+
return {
|
|
1537
|
+
name: "search",
|
|
1538
|
+
description: DESCRIPTION2,
|
|
1539
|
+
schema: schema2,
|
|
1540
|
+
handler: async (args) => {
|
|
1541
|
+
return withErrorHandling("search", async () => {
|
|
1542
|
+
const result = await service.search({
|
|
1543
|
+
query: args.query,
|
|
1544
|
+
language: args.language,
|
|
1545
|
+
licenseMode: args.license_mode,
|
|
1546
|
+
includeExplanation: false
|
|
1547
|
+
});
|
|
1548
|
+
return textResult(result);
|
|
1549
|
+
});
|
|
1550
|
+
}
|
|
1551
|
+
};
|
|
1552
|
+
}
|
|
1553
|
+
// src/tools/search-language.ts
|
|
1554
|
+
import { z as z3 } from "zod";
|
|
1555
|
+
var schema3 = {
|
|
1556
|
+
query: z3.string().min(1).describe('Language name or partial name to search for (e.g., "python", "type", "java")')
|
|
1557
|
+
};
|
|
1558
|
+
var DESCRIPTION3 = `Search for a programming language supported by GitHits.
|
|
1559
|
+
|
|
1560
|
+
Use this tool to find the correct language name before calling the search tool.
|
|
1561
|
+
Returns up to 5 matching languages.
|
|
1562
|
+
|
|
1563
|
+
Args:
|
|
1564
|
+
query: Language name or partial name to search for (e.g., "python", "type", "java")
|
|
1565
|
+
|
|
1566
|
+
Returns:
|
|
1567
|
+
List of matching languages with name and display_name`;
|
|
1568
|
+
function createSearchLanguageTool(service) {
|
|
1569
|
+
return {
|
|
1570
|
+
name: "search_language",
|
|
1571
|
+
description: DESCRIPTION3,
|
|
1572
|
+
schema: schema3,
|
|
1573
|
+
handler: async (args) => {
|
|
1574
|
+
return withErrorHandling("search languages", async () => {
|
|
1575
|
+
const allLanguages = await service.getLanguages();
|
|
1576
|
+
const result = filterLanguages(allLanguages, args.query);
|
|
1577
|
+
return textResult(JSON.stringify(result, null, 2));
|
|
1578
|
+
});
|
|
1579
|
+
}
|
|
1580
|
+
};
|
|
1581
|
+
}
|
|
1582
|
+
// src/commands/mcp.ts
|
|
1583
|
+
function createMcpServer(deps) {
|
|
1584
|
+
const server = new McpServer({
|
|
1585
|
+
name: "githits",
|
|
1586
|
+
version
|
|
1587
|
+
});
|
|
1588
|
+
const { githitsService } = deps;
|
|
1589
|
+
const tools = [
|
|
1590
|
+
createSearchTool(githitsService),
|
|
1591
|
+
createSearchLanguageTool(githitsService),
|
|
1592
|
+
createFeedbackTool(githitsService)
|
|
1593
|
+
];
|
|
1594
|
+
for (const tool of tools) {
|
|
1595
|
+
server.registerTool(tool.name, { description: tool.description, inputSchema: tool.schema }, tool.handler);
|
|
1596
|
+
}
|
|
1597
|
+
return server;
|
|
1598
|
+
}
|
|
1599
|
+
async function startMcpServer(deps) {
|
|
1600
|
+
requireAuth(deps, "to start MCP server");
|
|
1601
|
+
const server = createMcpServer(deps);
|
|
1602
|
+
const transport = new StdioServerTransport;
|
|
1603
|
+
await server.connect(transport);
|
|
1604
|
+
}
|
|
1605
|
+
function showMcpSetupInstructions() {
|
|
1606
|
+
const useColors = shouldUseColors();
|
|
1607
|
+
console.log("MCP Server Setup");
|
|
1608
|
+
console.log(`────────────────
|
|
1609
|
+
`);
|
|
1610
|
+
console.log(`Add GitHits to your AI assistant's MCP configuration.
|
|
1611
|
+
`);
|
|
1612
|
+
console.log(`${highlight("Claude Code", useColors)} ${dim("(recommended)", useColors)}`);
|
|
1613
|
+
console.log(` claude mcp add githits -- githits mcp start
|
|
1614
|
+
`);
|
|
1615
|
+
console.log(highlight("Cursor / VS Code", useColors));
|
|
1616
|
+
console.log(" Add to your MCP settings JSON:");
|
|
1617
|
+
console.log(dim(' { "mcpServers": { "githits": { "command": "githits", "args": ["mcp", "start"] } } }', useColors));
|
|
1618
|
+
console.log("");
|
|
1619
|
+
console.log("Learn more at https://githits.com");
|
|
1620
|
+
}
|
|
1621
|
+
function registerMcpCommand(program) {
|
|
1622
|
+
const mcpCommand = program.command("mcp").summary("Show setup instructions or start MCP server").description(`Start the Model Context Protocol (MCP) server using STDIO transport.
|
|
1623
|
+
|
|
1624
|
+
When run interactively (TTY), shows setup instructions.
|
|
1625
|
+
When run via stdio (non-TTY), starts the MCP server.
|
|
1626
|
+
|
|
1627
|
+
Available tools: search, search_language, feedback`).action(async () => {
|
|
1628
|
+
if (process.stdout.isTTY && process.stdin.isTTY) {
|
|
1629
|
+
showMcpSetupInstructions();
|
|
1630
|
+
return;
|
|
1631
|
+
}
|
|
1632
|
+
try {
|
|
1633
|
+
const deps = await createContainer();
|
|
1634
|
+
await startMcpServer(deps);
|
|
1635
|
+
} catch (error) {
|
|
1636
|
+
if (error instanceof AuthRequiredError)
|
|
1637
|
+
process.exit(1);
|
|
1638
|
+
throw error;
|
|
1639
|
+
}
|
|
1640
|
+
});
|
|
1641
|
+
mcpCommand.command("start").summary("Start MCP server (stdio mode)").description(`Start the MCP server using STDIO transport.
|
|
1642
|
+
|
|
1643
|
+
This command explicitly starts the server and is intended for use
|
|
1644
|
+
in MCP configuration files. Use 'githits mcp' for interactive setup.`).action(async () => {
|
|
1645
|
+
try {
|
|
1646
|
+
const deps = await createContainer();
|
|
1647
|
+
await startMcpServer(deps);
|
|
1648
|
+
} catch (error) {
|
|
1649
|
+
if (error instanceof AuthRequiredError)
|
|
1650
|
+
process.exit(1);
|
|
1651
|
+
throw error;
|
|
1652
|
+
}
|
|
1653
|
+
});
|
|
1654
|
+
}
|
|
1655
|
+
// src/commands/search.ts
|
|
1656
|
+
import { Option as Option2 } from "commander";
|
|
1657
|
+
async function searchAction(query, options, deps) {
|
|
1658
|
+
requireAuth(deps);
|
|
1659
|
+
try {
|
|
1660
|
+
const result = await deps.githitsService.search({
|
|
1661
|
+
query,
|
|
1662
|
+
language: options.lang,
|
|
1663
|
+
licenseMode: options.license,
|
|
1664
|
+
includeExplanation: options.explain
|
|
1665
|
+
});
|
|
1666
|
+
if (options.json) {
|
|
1667
|
+
console.log(JSON.stringify({ result }));
|
|
1668
|
+
} else {
|
|
1669
|
+
console.log(result);
|
|
1670
|
+
}
|
|
1671
|
+
} catch (error) {
|
|
1672
|
+
console.error(`Failed to search: ${error instanceof Error ? error.message : error}`);
|
|
1673
|
+
process.exit(1);
|
|
1674
|
+
}
|
|
1675
|
+
}
|
|
1676
|
+
var SEARCH_DESCRIPTION = `Search for code examples from global open source.
|
|
1677
|
+
|
|
1678
|
+
Returns verified, canonical code examples matching your query.
|
|
1679
|
+
Results are returned as markdown by default, or JSON with --json.
|
|
1680
|
+
Use --explain to include an AI-generated explanation alongside the code.
|
|
1681
|
+
|
|
1682
|
+
Examples:
|
|
1683
|
+
githits search "how to use express middleware" --lang javascript
|
|
1684
|
+
githits search "async file reading" -l python --license yolo
|
|
1685
|
+
githits search "react hooks patterns" -l typescript --explain
|
|
1686
|
+
githits search "react hooks patterns" -l typescript --json`;
|
|
1687
|
+
function registerSearchCommand(program) {
|
|
1688
|
+
program.command("search").summary("Search for code examples").description(SEARCH_DESCRIPTION).argument("<query>", "Natural language search query").requiredOption("-l, --lang <language>", "Programming language").addOption(new Option2("--license <mode>", "License filter mode").choices(["strict", "yolo", "custom"]).default(undefined)).option("--explain", "Include AI-generated explanation").option("--json", "Output as JSON for piping").action(async (query, options) => {
|
|
1689
|
+
try {
|
|
1690
|
+
const deps = await createContainer();
|
|
1691
|
+
await searchAction(query, options, deps);
|
|
1692
|
+
} catch (error) {
|
|
1693
|
+
if (error instanceof AuthRequiredError)
|
|
1694
|
+
process.exit(1);
|
|
1695
|
+
throw error;
|
|
1696
|
+
}
|
|
1697
|
+
});
|
|
1698
|
+
}
|
|
1699
|
+
// src/cli.ts
|
|
1700
|
+
var program = new Command;
|
|
1701
|
+
program.name("githits").description("Code examples from global open source for your AI assistant").version(version).option("--no-color", "Disable colored output").hook("preAction", (thisCommand) => {
|
|
1702
|
+
if (thisCommand.opts().color === false) {
|
|
1703
|
+
process.env.NO_COLOR = "1";
|
|
1704
|
+
}
|
|
1705
|
+
}).addHelpText("after", `
|
|
1706
|
+
Getting started:
|
|
1707
|
+
githits login Authenticate with your GitHits account
|
|
1708
|
+
githits mcp Start MCP server for your AI assistant
|
|
1709
|
+
githits search "query" --lang python Search for code examples
|
|
1710
|
+
|
|
1711
|
+
Learn more at https://githits.com`);
|
|
1712
|
+
registerLoginCommand(program);
|
|
1713
|
+
registerLogoutCommand(program);
|
|
1714
|
+
registerMcpCommand(program);
|
|
1715
|
+
registerSearchCommand(program);
|
|
1716
|
+
registerLanguagesCommand(program);
|
|
1717
|
+
registerFeedbackCommand(program);
|
|
1718
|
+
var authCommand = program.command("auth").summary("Manage authentication").description("Manage authentication with GitHits.");
|
|
1719
|
+
registerAuthStatusCommand(authCommand);
|
|
1720
|
+
program.parse();
|