@abhaydesu/git-pilot 1.3.1 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +77 -66
- package/bin/git-pilot.js +8 -2
- package/package.json +1 -1
- package/src/cli.js +105 -9
- package/src/commands/auth.js +132 -0
- package/src/commands/branch.js +23 -8
- package/src/commands/commit.js +209 -26
- package/src/commands/run.js +36 -7
- package/src/commands/setup.js +208 -0
- package/src/commands/undo.js +141 -11
- package/src/lib/config.js +191 -0
- package/src/lib/credentials.js +440 -0
- package/src/lib/exec.js +10 -3
- package/src/lib/git.js +127 -9
- package/src/lib/prompts.js +72 -0
- package/src/lib/provider.js +353 -0
- package/src/lib/sanitizer.js +37 -0
- package/src/lib/scanner.js +74 -0
- package/src/lib/ui.js +112 -14
- package/src/lib/api.js +0 -59
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
import axios from "axios";
|
|
2
|
+
import process from "node:process";
|
|
3
|
+
import { sanitizeTerminalText } from "./sanitizer.js";
|
|
4
|
+
import { redactSecretText } from "./credentials.js";
|
|
5
|
+
|
|
6
|
+
export const OFFICIAL_GEMINI_HOST = "https://generativelanguage.googleapis.com";
|
|
7
|
+
export const OFFICIAL_GEMINI_HOSTNAME = "generativelanguage.googleapis.com";
|
|
8
|
+
export const DEFAULT_MODEL = "gemini-3.5-flash-lite";
|
|
9
|
+
export const REQUEST_TIMEOUT_MS = 30_000;
|
|
10
|
+
export const MAX_CONTENT_LENGTH = 1_000_000;
|
|
11
|
+
export const MAX_REQUEST_BYTES = 1_000_000;
|
|
12
|
+
export const MAX_DIFF_CHARS = 800_000;
|
|
13
|
+
export const MAX_SUMMARY_BYTES = 200_000;
|
|
14
|
+
|
|
15
|
+
const SAFE_MODEL_REGEX = /^[a-zA-Z0-9._-]+$/;
|
|
16
|
+
|
|
17
|
+
let defaultHttpClientOverride = null;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Allows test suites to inject an in-process mock HTTP client without exposing
|
|
21
|
+
* any environment-variable endpoint override in published CLI code.
|
|
22
|
+
*/
|
|
23
|
+
export function setDefaultHttpClientForTesting(client) {
|
|
24
|
+
defaultHttpClientOverride = client || null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export class ProviderError extends Error {
|
|
28
|
+
constructor(message, code, status = null) {
|
|
29
|
+
super(message);
|
|
30
|
+
this.name = "ProviderError";
|
|
31
|
+
this.code = code;
|
|
32
|
+
this.status = status;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Validates and resolves the Gemini API base URL.
|
|
38
|
+
* Strictly pins the hostname to generativelanguage.googleapis.com over HTTPS,
|
|
39
|
+
* and rejects embedded credentials, query strings, fragments, custom paths, and local overrides.
|
|
40
|
+
*/
|
|
41
|
+
export function getGeminiBaseUrl(rawOverride = process.env.GEMINI_API_URL) {
|
|
42
|
+
const raw = rawOverride || OFFICIAL_GEMINI_HOST;
|
|
43
|
+
let url;
|
|
44
|
+
try {
|
|
45
|
+
url = new URL(raw);
|
|
46
|
+
} catch {
|
|
47
|
+
throw new ProviderError(
|
|
48
|
+
`Invalid provider URL: ${sanitizeTerminalText(String(raw))}`,
|
|
49
|
+
"INVALID_ENDPOINT"
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (url.username || url.password) {
|
|
54
|
+
throw new ProviderError(
|
|
55
|
+
"Provider URL must not contain embedded credentials.",
|
|
56
|
+
"INVALID_ENDPOINT"
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (url.search || url.hash) {
|
|
61
|
+
throw new ProviderError(
|
|
62
|
+
"Provider URL must not contain query strings or fragments.",
|
|
63
|
+
"INVALID_ENDPOINT"
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (url.pathname && url.pathname !== "/") {
|
|
68
|
+
throw new ProviderError(
|
|
69
|
+
"Provider base URL must not include a custom path.",
|
|
70
|
+
"INVALID_ENDPOINT"
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (url.protocol !== "https:") {
|
|
75
|
+
throw new ProviderError("GEMINI_API_URL must use https.", "INVALID_ENDPOINT");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (url.hostname !== OFFICIAL_GEMINI_HOSTNAME) {
|
|
79
|
+
throw new ProviderError(
|
|
80
|
+
`Refusing to send Gemini API key to untrusted host "${sanitizeTerminalText(url.hostname)}". ` +
|
|
81
|
+
`Personal keys are only transmitted to ${OFFICIAL_GEMINI_HOSTNAME}.`,
|
|
82
|
+
"UNTRUSTED_HOST"
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (url.port && url.port !== "443") {
|
|
87
|
+
throw new ProviderError(
|
|
88
|
+
`Refusing to connect to non-standard port "${url.port}" on ${OFFICIAL_GEMINI_HOSTNAME}.`,
|
|
89
|
+
"INVALID_ENDPOINT"
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return `${url.protocol}//${url.host}`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function buildGeminiRequestBody(prompt) {
|
|
97
|
+
return {
|
|
98
|
+
contents: [
|
|
99
|
+
{
|
|
100
|
+
parts: [{ text: prompt }],
|
|
101
|
+
},
|
|
102
|
+
],
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Checks that both the character length of `rawDiff` and the UTF-8 byte length
|
|
108
|
+
* of the JSON-serialized Gemini request body are within safe limits.
|
|
109
|
+
*/
|
|
110
|
+
export function fitsInGeminiRequest(prompt, rawDiff = "") {
|
|
111
|
+
if (rawDiff && rawDiff.length > MAX_DIFF_CHARS) {
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
const body = buildGeminiRequestBody(prompt);
|
|
115
|
+
const encodedBytes = Buffer.byteLength(JSON.stringify(body), "utf8");
|
|
116
|
+
return encodedBytes <= MAX_REQUEST_BYTES;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Bounds a file-status summary so its UTF-8 JSON-escaped byte size never exceeds `maxBytes`.
|
|
121
|
+
*/
|
|
122
|
+
export function boundSummary(summaryText, maxBytes = MAX_SUMMARY_BYTES) {
|
|
123
|
+
if (typeof summaryText !== "string" || !summaryText) return "";
|
|
124
|
+
if (Buffer.byteLength(JSON.stringify(summaryText), "utf8") <= maxBytes) {
|
|
125
|
+
return summaryText;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const lines = summaryText.split("\n");
|
|
129
|
+
const kept = [];
|
|
130
|
+
let currentBytes = 0;
|
|
131
|
+
const suffix = "\n... [file summary truncated due to size]";
|
|
132
|
+
const budget = Math.max(256, maxBytes - Buffer.byteLength(suffix, "utf8") - 64);
|
|
133
|
+
|
|
134
|
+
for (const line of lines) {
|
|
135
|
+
const lineBytes = Buffer.byteLength(JSON.stringify(line + "\n"), "utf8");
|
|
136
|
+
if (currentBytes + lineBytes > budget) {
|
|
137
|
+
break;
|
|
138
|
+
}
|
|
139
|
+
kept.push(line);
|
|
140
|
+
currentBytes += lineBytes;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (kept.length === 0) {
|
|
144
|
+
return summaryText.slice(0, Math.floor(budget / 4)) + suffix;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return kept.join("\n") + suffix;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Strips code fences, quotes, and backticks */
|
|
151
|
+
export function stripFences(text) {
|
|
152
|
+
if (typeof text !== "string") return "";
|
|
153
|
+
return text
|
|
154
|
+
.replace(/^```\w*\n?/, "")
|
|
155
|
+
.replace(/\n?```$/, "")
|
|
156
|
+
.replace(/^`|`$/g, "")
|
|
157
|
+
.trim();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Direct client for Google Gemini REST API.
|
|
162
|
+
* Uses x-goog-api-key header over HTTPS directly to Google.
|
|
163
|
+
*/
|
|
164
|
+
export class GeminiProvider {
|
|
165
|
+
constructor({ apiKey, model = DEFAULT_MODEL, httpClient = null } = {}) {
|
|
166
|
+
if (!apiKey || typeof apiKey !== "string" || !apiKey.trim()) {
|
|
167
|
+
throw new Error("API key is required for GeminiProvider.");
|
|
168
|
+
}
|
|
169
|
+
if (typeof model !== "string" || !SAFE_MODEL_REGEX.test(model)) {
|
|
170
|
+
throw new ProviderError(
|
|
171
|
+
`Invalid model identifier "${sanitizeTerminalText(String(model))}".`,
|
|
172
|
+
"INVALID_MODEL"
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
this.apiKey = apiKey.trim();
|
|
177
|
+
this.model = model;
|
|
178
|
+
this.baseURL = getGeminiBaseUrl();
|
|
179
|
+
this.client =
|
|
180
|
+
httpClient ||
|
|
181
|
+
defaultHttpClientOverride ||
|
|
182
|
+
axios.create({
|
|
183
|
+
baseURL: this.baseURL,
|
|
184
|
+
timeout: REQUEST_TIMEOUT_MS,
|
|
185
|
+
maxRedirects: 0,
|
|
186
|
+
maxContentLength: MAX_CONTENT_LENGTH,
|
|
187
|
+
maxBodyLength: MAX_REQUEST_BYTES,
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Translates raw HTTP/axios errors into sanitized, stable ProviderErrors.
|
|
193
|
+
* Strips out any credential material or raw payload text.
|
|
194
|
+
*/
|
|
195
|
+
classifyError(err) {
|
|
196
|
+
if (err instanceof ProviderError) return err;
|
|
197
|
+
|
|
198
|
+
if (err.response) {
|
|
199
|
+
const status = err.response.status;
|
|
200
|
+
const rawApiMessage = err.response.data?.error?.message || "";
|
|
201
|
+
const apiMessage = redactSecretText(rawApiMessage, [this.apiKey]);
|
|
202
|
+
|
|
203
|
+
if (
|
|
204
|
+
status === 400 &&
|
|
205
|
+
(apiMessage.includes("API_KEY_INVALID") || apiMessage.includes("API key not valid"))
|
|
206
|
+
) {
|
|
207
|
+
return new ProviderError(
|
|
208
|
+
"Your Gemini API key is invalid. Run `git pilot setup` to configure a valid key.",
|
|
209
|
+
"INVALID_KEY",
|
|
210
|
+
status
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (status === 401 || status === 403) {
|
|
215
|
+
return new ProviderError(
|
|
216
|
+
"Access denied by Gemini API (401/403). Check that your API key is valid and has permissions.",
|
|
217
|
+
"PERMISSION_DENIED",
|
|
218
|
+
status
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (status === 429 || apiMessage.includes("RESOURCE_EXHAUSTED")) {
|
|
223
|
+
return new ProviderError(
|
|
224
|
+
"Gemini API rate limit or quota exceeded. Please check your account quota and billing status.",
|
|
225
|
+
"QUOTA_EXCEEDED",
|
|
226
|
+
status
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (status === 404 || apiMessage.includes("models/")) {
|
|
231
|
+
return new ProviderError(
|
|
232
|
+
`Configured Gemini model "${this.model}" is unavailable to this API key or Google project. ` +
|
|
233
|
+
`Check model access, or change the "model" value in your Git Pilot config and rerun ` +
|
|
234
|
+
"`git pilot setup`.",
|
|
235
|
+
"MODEL_NOT_FOUND",
|
|
236
|
+
status
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return new ProviderError(
|
|
241
|
+
`Gemini API request failed (${status}): ${sanitizeTerminalText(apiMessage || "Unknown error")}`,
|
|
242
|
+
"PROVIDER_ERROR",
|
|
243
|
+
status
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const cleanMsg = redactSecretText(err.message || "Unknown", [this.apiKey]);
|
|
248
|
+
|
|
249
|
+
if (err.code === "ECONNABORTED" || cleanMsg.includes("timeout")) {
|
|
250
|
+
return new ProviderError(
|
|
251
|
+
"Request to Gemini API timed out. Please check your network connection.",
|
|
252
|
+
"TIMEOUT"
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (
|
|
257
|
+
err.code === "ENOTFOUND" ||
|
|
258
|
+
err.code === "ECONNREFUSED" ||
|
|
259
|
+
cleanMsg.includes("Network Error")
|
|
260
|
+
) {
|
|
261
|
+
return new ProviderError(
|
|
262
|
+
"Could not connect to Gemini API. Check your internet connection.",
|
|
263
|
+
"NETWORK_ERROR"
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return new ProviderError(`Unexpected error: ${sanitizeTerminalText(cleanMsg)}`, "UNKNOWN");
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Validates the provided API key with a minimal request without repository data.
|
|
272
|
+
*/
|
|
273
|
+
async validateKey() {
|
|
274
|
+
try {
|
|
275
|
+
const endpoint = `/v1beta/models/${encodeURIComponent(this.model)}:generateContent`;
|
|
276
|
+
const body = {
|
|
277
|
+
contents: [
|
|
278
|
+
{
|
|
279
|
+
parts: [{ text: "ping" }],
|
|
280
|
+
},
|
|
281
|
+
],
|
|
282
|
+
generationConfig: {
|
|
283
|
+
maxOutputTokens: 1,
|
|
284
|
+
},
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
await this.client.post(endpoint, body, {
|
|
288
|
+
headers: {
|
|
289
|
+
"x-goog-api-key": this.apiKey,
|
|
290
|
+
"Content-Type": "application/json",
|
|
291
|
+
},
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
return { valid: true };
|
|
295
|
+
} catch (err) {
|
|
296
|
+
throw this.classifyError(err);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Sends prompt directly to Gemini and returns response text.
|
|
302
|
+
* Enforces serialized UTF-8 byte limit before sending and retries once on transient errors (429/503).
|
|
303
|
+
*/
|
|
304
|
+
async generate(prompt) {
|
|
305
|
+
const endpoint = `/v1beta/models/${encodeURIComponent(this.model)}:generateContent`;
|
|
306
|
+
const body = buildGeminiRequestBody(prompt);
|
|
307
|
+
const serialized = JSON.stringify(body);
|
|
308
|
+
const byteLength = Buffer.byteLength(serialized, "utf8");
|
|
309
|
+
|
|
310
|
+
if (byteLength > MAX_REQUEST_BYTES) {
|
|
311
|
+
throw new ProviderError(
|
|
312
|
+
`Request payload (${byteLength} bytes) exceeds maximum allowed size (${MAX_REQUEST_BYTES} bytes).`,
|
|
313
|
+
"PAYLOAD_TOO_LARGE",
|
|
314
|
+
413
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
let attempts = 0;
|
|
319
|
+
const maxRetries = 1;
|
|
320
|
+
|
|
321
|
+
while (attempts <= maxRetries) {
|
|
322
|
+
attempts++;
|
|
323
|
+
try {
|
|
324
|
+
const response = await this.client.post(endpoint, body, {
|
|
325
|
+
headers: {
|
|
326
|
+
"x-goog-api-key": this.apiKey,
|
|
327
|
+
"Content-Type": "application/json",
|
|
328
|
+
},
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
const text = response.data?.candidates?.[0]?.content?.parts?.[0]?.text;
|
|
332
|
+
if (typeof text !== "string") {
|
|
333
|
+
throw new ProviderError(
|
|
334
|
+
"Gemini response did not contain expected content.",
|
|
335
|
+
"EMPTY_RESPONSE"
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
return sanitizeTerminalText(text);
|
|
340
|
+
} catch (err) {
|
|
341
|
+
const classified = this.classifyError(err);
|
|
342
|
+
const isTransient = classified.status === 429 || classified.status === 503;
|
|
343
|
+
|
|
344
|
+
if (isTransient && attempts <= maxRetries) {
|
|
345
|
+
await new Promise((r) => setTimeout(r, 1000 * attempts));
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
throw classified;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal control sequence sanitizer.
|
|
3
|
+
* Strips ANSI escape sequences, OSC strings, and control characters
|
|
4
|
+
* from untrusted AI output before displaying it in the terminal.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
// Matches ANSI escape codes, OSC sequences, cursor controls
|
|
8
|
+
const ANSI_PATTERN =
|
|
9
|
+
// eslint-disable-next-line no-control-regex
|
|
10
|
+
/[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]|[\u001b\u009b]\].*?(\u0007|\u001b\\)/g;
|
|
11
|
+
|
|
12
|
+
// Strip other non-printable ASCII control characters except newline and tab
|
|
13
|
+
// eslint-disable-next-line no-control-regex
|
|
14
|
+
const CONTROL_CHARS = /[\u0000-\u0008\u000B-\u001A\u001C-\u001F\u007F]/g;
|
|
15
|
+
|
|
16
|
+
export const MAX_MESSAGE_CHARS = 5000;
|
|
17
|
+
export const MAX_MESSAGE_LINES = 100;
|
|
18
|
+
|
|
19
|
+
export function sanitizeTerminalText(input) {
|
|
20
|
+
if (typeof input !== "string") return "";
|
|
21
|
+
let clean = input.replace(ANSI_PATTERN, "").replace(CONTROL_CHARS, "");
|
|
22
|
+
|
|
23
|
+
// Normalize line endings
|
|
24
|
+
clean = clean.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
25
|
+
|
|
26
|
+
// Enforce length and line count bounds
|
|
27
|
+
if (clean.length > MAX_MESSAGE_CHARS) {
|
|
28
|
+
clean = clean.slice(0, MAX_MESSAGE_CHARS) + "\n...[output truncated for safety]";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const lines = clean.split("\n");
|
|
32
|
+
if (lines.length > MAX_MESSAGE_LINES) {
|
|
33
|
+
clean = lines.slice(0, MAX_MESSAGE_LINES).join("\n") + "\n...[output truncated for safety]";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return clean.trim();
|
|
37
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Best-effort heuristic secret scanner for staged diffs.
|
|
3
|
+
* Blocks transmission of high-confidence credential patterns (private keys,
|
|
4
|
+
* well-known provider tokens, and explicit secret assignments) before network requests.
|
|
5
|
+
*
|
|
6
|
+
* NOTE: Heuristic pattern matching cannot guarantee that a diff is free of all
|
|
7
|
+
* sensitive material (such as custom passphrases or unstructured tokens). Users are
|
|
8
|
+
* informed via the privacy notice that staged diffs are transmitted to Google Gemini.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const RULES = [
|
|
12
|
+
{
|
|
13
|
+
name: "Private Key",
|
|
14
|
+
regex: /-----BEGIN [A-Z ]*PRIVATE KEY-----/,
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
name: "AWS Access Key ID",
|
|
18
|
+
regex: /\b(AKIA[0-9A-Z]{16})\b/,
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
name: "GitHub Token",
|
|
22
|
+
regex: /\b((?:ghp|gho|ghu|ghs|ghr)_[a-zA-Z0-9]{36}|github_pat_[a-zA-Z0-9_]{40,})\b/,
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
name: "Slack Token",
|
|
26
|
+
regex: /\b(xox[baprs]-[0-9a-zA-Z-]{10,})\b/,
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
name: "Google API Key",
|
|
30
|
+
regex: /\b(AIza[0-9A-Za-z-_]{30,40})\b/,
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
name: "Generic Secret / Token Assignment",
|
|
34
|
+
regex:
|
|
35
|
+
/(?:api[_-]?key|secret[_-]?key|client[_-]?secret|access[_-]?token|bearer[_-]?token)\s*[:=]\s*['"]([a-zA-Z0-9_-]{20,})['"]/i,
|
|
36
|
+
},
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
function maskMatch(str) {
|
|
40
|
+
if (str.length <= 8) return "****";
|
|
41
|
+
return str.slice(0, 4) + "*".repeat(str.length - 8) + str.slice(-4);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function scanDiffForSecrets(diffText) {
|
|
45
|
+
if (!diffText || typeof diffText !== "string") {
|
|
46
|
+
return { clean: true, findings: [] };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const findings = [];
|
|
50
|
+
const lines = diffText.split("\n");
|
|
51
|
+
|
|
52
|
+
for (let i = 0; i < lines.length; i++) {
|
|
53
|
+
const line = lines[i];
|
|
54
|
+
if (line.startsWith("---") || line.startsWith("+++")) continue;
|
|
55
|
+
|
|
56
|
+
for (const rule of RULES) {
|
|
57
|
+
const match = rule.regex.exec(line);
|
|
58
|
+
if (match) {
|
|
59
|
+
const rawSecret = match[1] || match[0];
|
|
60
|
+
findings.push({
|
|
61
|
+
rule: rule.name,
|
|
62
|
+
line: i + 1,
|
|
63
|
+
preview: maskMatch(rawSecret),
|
|
64
|
+
});
|
|
65
|
+
break;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
clean: findings.length === 0,
|
|
72
|
+
findings,
|
|
73
|
+
};
|
|
74
|
+
}
|
package/src/lib/ui.js
CHANGED
|
@@ -1,28 +1,76 @@
|
|
|
1
1
|
import chalk from "chalk";
|
|
2
2
|
import inquirer from "inquirer";
|
|
3
3
|
import ora from "ora";
|
|
4
|
-
import
|
|
4
|
+
import process from "node:process";
|
|
5
5
|
import { UnsafeCommandError } from "./exec.js";
|
|
6
|
+
import { ProviderError } from "./provider.js";
|
|
7
|
+
import { GitCommitError } from "./git.js";
|
|
8
|
+
import { CredentialStoreUnavailableError, redactSecretText } from "./credentials.js";
|
|
9
|
+
import { sanitizeTerminalText } from "./sanitizer.js";
|
|
6
10
|
|
|
7
|
-
//
|
|
8
|
-
|
|
11
|
+
// Check NO_COLOR standard (https://no-color.org)
|
|
12
|
+
if (process.env.NO_COLOR) {
|
|
13
|
+
chalk.level = 0;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// ora 9.x column fallback
|
|
9
17
|
if (!process.stderr.columns) process.stderr.columns = 80;
|
|
10
18
|
|
|
11
|
-
|
|
12
|
-
export const spin = (text) => ora({ text, discardStdin: false }).start();
|
|
19
|
+
export const spin = (text) => ora({ text, discardStdin: false, stream: process.stderr }).start();
|
|
13
20
|
|
|
14
21
|
export function showBlock(title, body) {
|
|
15
|
-
const bar = "-".repeat(title.length + 14);
|
|
22
|
+
const bar = "-".repeat(Math.max(title.length + 14, 30));
|
|
16
23
|
console.log(chalk.green(`\n------ ${title} ------`));
|
|
17
24
|
console.log(chalk.cyan(body));
|
|
18
25
|
console.log(chalk.green(`${bar}\n`));
|
|
19
26
|
}
|
|
20
27
|
|
|
21
|
-
export
|
|
22
|
-
const
|
|
28
|
+
export function showPanel({ title, details = [], content, stream = process.stdout }) {
|
|
29
|
+
const write = (line) => stream.write(line + "\n");
|
|
30
|
+
write(chalk.bold.cyan(`\n╭─ ${title} ` + "─".repeat(Math.max(0, 50 - title.length))));
|
|
31
|
+
for (const detail of details) {
|
|
32
|
+
write(chalk.dim(`│ ${detail}`));
|
|
33
|
+
}
|
|
34
|
+
if (details.length > 0) {
|
|
35
|
+
write(chalk.dim(`├${"─".repeat(55)}`));
|
|
36
|
+
}
|
|
37
|
+
const lines = (content || "").split("\n");
|
|
38
|
+
for (const line of lines) {
|
|
39
|
+
write(`│ ${chalk.white(line)}`);
|
|
40
|
+
}
|
|
41
|
+
write(chalk.bold.cyan(`╰${"─".repeat(56)}\n`));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function confirm(message, defaultVal = false) {
|
|
45
|
+
const { ok } = await inquirer.prompt([
|
|
46
|
+
{ type: "confirm", name: "ok", message, default: defaultVal },
|
|
47
|
+
]);
|
|
23
48
|
return ok;
|
|
24
49
|
}
|
|
25
50
|
|
|
51
|
+
/**
|
|
52
|
+
* Action menu for commit flow: returns "accept" | "edit" | "regenerate" | "abort".
|
|
53
|
+
* Default selection is "abort" (non-destructive) so pressing Enter accidentally
|
|
54
|
+
* never creates an unintended commit.
|
|
55
|
+
*/
|
|
56
|
+
export async function chooseCommitAction() {
|
|
57
|
+
const { action } = await inquirer.prompt([
|
|
58
|
+
{
|
|
59
|
+
type: "list",
|
|
60
|
+
name: "action",
|
|
61
|
+
message: "What would you like to do?",
|
|
62
|
+
default: "abort",
|
|
63
|
+
choices: [
|
|
64
|
+
{ name: "Accept and commit", value: "accept" },
|
|
65
|
+
{ name: "Edit in $EDITOR", value: "edit" },
|
|
66
|
+
{ name: "Regenerate message", value: "regenerate" },
|
|
67
|
+
{ name: "Abort", value: "abort" },
|
|
68
|
+
],
|
|
69
|
+
},
|
|
70
|
+
]);
|
|
71
|
+
return action;
|
|
72
|
+
}
|
|
73
|
+
|
|
26
74
|
/** Accept / Edit / Abort menu; returns "accept" | "edit" | "abort". */
|
|
27
75
|
export async function chooseAcceptEditAbort() {
|
|
28
76
|
const { choice } = await inquirer.prompt([
|
|
@@ -30,6 +78,7 @@ export async function chooseAcceptEditAbort() {
|
|
|
30
78
|
type: "list",
|
|
31
79
|
name: "choice",
|
|
32
80
|
message: "What would you like to do?",
|
|
81
|
+
default: "abort",
|
|
33
82
|
choices: [
|
|
34
83
|
{ name: "Accept", value: "accept" },
|
|
35
84
|
{ name: "Edit", value: "edit" },
|
|
@@ -54,21 +103,70 @@ export async function editText(message, initial, emptyMessage) {
|
|
|
54
103
|
return text.trim();
|
|
55
104
|
}
|
|
56
105
|
|
|
57
|
-
|
|
58
|
-
export
|
|
106
|
+
/** Non-echoing masked password prompt */
|
|
107
|
+
export async function promptMaskedPassword(message) {
|
|
108
|
+
const { secret } = await inquirer.prompt([
|
|
109
|
+
{
|
|
110
|
+
type: "password",
|
|
111
|
+
name: "secret",
|
|
112
|
+
message,
|
|
113
|
+
mask: "*",
|
|
114
|
+
validate: (input) => (input.trim().length > 0 ? true : "Input cannot be empty."),
|
|
115
|
+
},
|
|
116
|
+
]);
|
|
117
|
+
return secret.trim();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export const success = (text) => console.log(chalk.green(`✔ ${text}`));
|
|
121
|
+
export const warn = (text) => console.error(chalk.yellow(`⚠ ${text}`));
|
|
122
|
+
export const info = (text) => console.error(chalk.blue(`ℹ ${text}`));
|
|
123
|
+
export const aborted = (text) => console.log(chalk.dim(`Cancelled. ${text}`));
|
|
124
|
+
|
|
125
|
+
export function formatSafeErrorText(text) {
|
|
126
|
+
return sanitizeTerminalText(redactSecretText(String(text || "")));
|
|
127
|
+
}
|
|
59
128
|
|
|
60
129
|
/** Prints an error, stops the spinner if it is running, and exits non-zero. */
|
|
61
130
|
export function handleError(error, spinner) {
|
|
62
131
|
if (spinner?.isSpinning) spinner.fail();
|
|
63
132
|
|
|
64
133
|
if (error?.name === "ExitPromptError") {
|
|
65
|
-
console.error(chalk.
|
|
134
|
+
console.error(chalk.dim("\nCancelled by user."));
|
|
66
135
|
process.exit(130);
|
|
67
136
|
}
|
|
137
|
+
|
|
138
|
+
if (error instanceof CredentialStoreUnavailableError) {
|
|
139
|
+
console.error(chalk.red.bold(`\nCredential Store Unavailable:`));
|
|
140
|
+
console.error(chalk.red(formatSafeErrorText(error.message)));
|
|
141
|
+
console.error(
|
|
142
|
+
chalk.yellow(
|
|
143
|
+
"\nTip: For CI or automated environments, you can alternatively set the GEMINI_API_KEY environment variable."
|
|
144
|
+
)
|
|
145
|
+
);
|
|
146
|
+
process.exit(1);
|
|
147
|
+
}
|
|
148
|
+
|
|
68
149
|
if (error instanceof UnsafeCommandError) {
|
|
69
|
-
console.error(chalk.red(`Refusing to run: ${error.message}`));
|
|
70
|
-
|
|
71
|
-
|
|
150
|
+
console.error(chalk.red(`Refusing to run: ${formatSafeErrorText(error.message)}`));
|
|
151
|
+
process.exit(1);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (error instanceof ProviderError) {
|
|
155
|
+
console.error(chalk.red(`Provider Error: ${formatSafeErrorText(error.message)}`));
|
|
156
|
+
process.exit(1);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (error instanceof GitCommitError) {
|
|
160
|
+
console.error(chalk.red(`\nCommit Error: ${formatSafeErrorText(error.message)}`));
|
|
161
|
+
console.error(chalk.yellow(`\nYour generated message was preserved:`));
|
|
162
|
+
console.error(chalk.cyan(sanitizeTerminalText(error.commitMessage)));
|
|
163
|
+
process.exit(1);
|
|
72
164
|
}
|
|
165
|
+
|
|
166
|
+
console.error(
|
|
167
|
+
chalk.red(
|
|
168
|
+
`An unexpected error occurred: ${formatSafeErrorText(error?.message || String(error))}`
|
|
169
|
+
)
|
|
170
|
+
);
|
|
73
171
|
process.exit(1);
|
|
74
172
|
}
|
package/src/lib/api.js
DELETED
|
@@ -1,59 +0,0 @@
|
|
|
1
|
-
import axios from "axios";
|
|
2
|
-
|
|
3
|
-
export const DEFAULT_API_URL = "https://git-pilot-api.vercel.app";
|
|
4
|
-
|
|
5
|
-
// The API rejects request bodies over 1 MB (bytes, after JSON encoding) and diffs over
|
|
6
|
-
// 800,000 characters.
|
|
7
|
-
export const MAX_DIFF_CHARS = 800_000;
|
|
8
|
-
const MAX_BODY_BYTES = 1_000_000;
|
|
9
|
-
|
|
10
|
-
/** True if `body` (containing `diff`) is small enough for the API to accept. */
|
|
11
|
-
export const fitsInRequest = (body, diff) =>
|
|
12
|
-
diff.length <= MAX_DIFF_CHARS && Buffer.byteLength(JSON.stringify(body)) <= MAX_BODY_BYTES;
|
|
13
|
-
|
|
14
|
-
const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]);
|
|
15
|
-
|
|
16
|
-
export function apiBaseUrl() {
|
|
17
|
-
const raw = (process.env.GIT_PILOT_API_URL || DEFAULT_API_URL).replace(/\/+$/, "");
|
|
18
|
-
|
|
19
|
-
let url;
|
|
20
|
-
try {
|
|
21
|
-
url = new URL(raw);
|
|
22
|
-
} catch {
|
|
23
|
-
throw new Error(`GIT_PILOT_API_URL is not a valid URL: ${raw}`);
|
|
24
|
-
}
|
|
25
|
-
// Your staged diffs are sent to this server, so never over plain http except locally.
|
|
26
|
-
const plainHttpOk = url.protocol === "http:" && LOCAL_HOSTS.has(url.hostname);
|
|
27
|
-
if (url.protocol !== "https:" && !plainHttpOk) {
|
|
28
|
-
throw new Error("GIT_PILOT_API_URL must use https (plain http is only allowed for localhost).");
|
|
29
|
-
}
|
|
30
|
-
return raw;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/** POSTs `body` to `/api/<endpoint>` and returns the parsed response data. */
|
|
34
|
-
export async function callApi(endpoint, body) {
|
|
35
|
-
const { data } = await axios.post(`${apiBaseUrl()}/api/${endpoint}`, body, {
|
|
36
|
-
timeout: 30_000,
|
|
37
|
-
maxRedirects: 0, // the API never redirects a POST; don't follow one to somewhere else
|
|
38
|
-
maxContentLength: 1_000_000,
|
|
39
|
-
});
|
|
40
|
-
return data;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/** Returns `data[field]` if it is a non-empty string; otherwise throws a clear error. */
|
|
44
|
-
export function requireString(data, field) {
|
|
45
|
-
const value = data?.[field];
|
|
46
|
-
if (typeof value !== "string" || value === "") {
|
|
47
|
-
throw new Error(`Unexpected response from the API (missing "${field}").`);
|
|
48
|
-
}
|
|
49
|
-
return value;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/** Human-readable message for an axios/network/other error. */
|
|
53
|
-
export function describeError(error) {
|
|
54
|
-
if (error.response) {
|
|
55
|
-
const detail = error.response.data?.error || "No message";
|
|
56
|
-
return `API Error: ${error.response.status} - ${detail}`;
|
|
57
|
-
}
|
|
58
|
-
return `An unexpected error occurred: ${error.message}`;
|
|
59
|
-
}
|