@thegitai/cli 1.0.0-preview.3 → 1.0.0-preview.31
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 +39 -6
- package/dist/bin/ai.js +142 -383
- package/dist/src/agent-mode.js +1 -6
- package/dist/src/api/auth.js +6 -4
- package/dist/src/api/browser-login.js +152 -37
- package/dist/src/api/chat.js +258 -38
- package/dist/src/api/contracts.js +55 -1
- package/dist/src/api/default-host.js +1 -0
- package/dist/src/api/http.js +69 -7
- package/dist/src/api/models.js +19 -10
- package/dist/src/background-jobs.js +2 -2
- package/dist/src/cli-args.js +19 -5
- package/dist/src/core/clipboard.js +7 -13
- package/dist/src/core/image-limits.js +56 -0
- package/dist/src/core/image-path-extractor.js +70 -3
- package/dist/src/core/session-image-store.js +199 -0
- package/dist/src/executor.js +25 -3
- package/dist/src/help-text.js +67 -18
- package/dist/src/permissions.js +243 -0
- package/dist/src/session-safety.js +0 -12
- package/dist/src/session-store.js +121 -20
- package/dist/src/session.js +14 -3
- package/dist/src/signin.js +58 -0
- package/dist/src/tool-executor.js +11 -46
- package/dist/src/tools/delete-file.js +15 -3
- package/dist/src/tools/index.js +13 -10
- package/dist/src/tools/patch-file.js +12 -26
- package/dist/src/tools/read-image-file.js +85 -0
- package/dist/src/tools/replace-document-text.js +28 -18
- package/dist/src/tools/restore-checkpoint.js +0 -1
- package/dist/src/tools/run-command.js +14 -71
- package/dist/src/tools/run-node-script.js +12 -81
- package/dist/src/tools/save-generated-image.js +120 -0
- package/dist/src/tools/str-replace.js +12 -26
- package/dist/src/tools/undo-edit.js +1 -6
- package/dist/src/tools/write-file.js +67 -11
- package/dist/src/turn-failure-marker.js +11 -0
- package/dist/src/ui/prompt-history-store.js +1 -1
- package/dist/src/ui/repl.js +649 -164
- package/dist/src/ui/tui/bridge.js +10 -0
- package/dist/src/ui/tui/build-frame.js +453 -115
- package/dist/src/ui/tui/markdown-render.js +81 -73
- package/dist/src/ui/tui/shell-input.js +206 -63
- package/dist/src/ui/tui/terminal-theme.js +28 -0
- package/dist/src/ui/tui/terminal-title.js +3 -0
- package/dist/src/ui/tui/terminal-writes.js +48 -0
- package/dist/src/ui/tui/text.js +158 -4
- package/dist/src/ui/tui/user-input.js +568 -0
- package/dist/src/utils.js +9 -0
- package/package.json +29 -6
- package/dist/src/markdown-renderer.js +0 -112
- package/dist/src/project-index.js +0 -221
- package/dist/src/tools/code-intel.js +0 -472
- package/dist/src/tools/find-symbol.js +0 -70
- package/dist/src/tools/hover-symbol.js +0 -95
- package/dist/src/tools/list-symbols.js +0 -55
- package/dist/src/tools/search-code.js +0 -37
- package/dist/src/tools/signature-help.js +0 -118
package/dist/src/api/models.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync, } from 'node:fs';
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync, } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { getClientStateDir } from '../client-state.js';
|
|
4
|
-
import { REQUEST_TIMEOUT_MS, ServerApiError, createTraceContext, failureMessage, normalizeServerUrl, readJsonResponse, retryTransient, } from './http.js';
|
|
4
|
+
import { REQUEST_TIMEOUT_MS, ServerApiError, createTraceContext, failureCode, failureMessage, normalizeServerUrl, readJsonResponse, retryTransient, } from './http.js';
|
|
5
5
|
function sanitizeModelInfo(raw) {
|
|
6
6
|
if (!raw || typeof raw !== 'object') {
|
|
7
7
|
return null;
|
|
@@ -58,12 +58,21 @@ export function selectCacheForServer(cached, serverUrl) {
|
|
|
58
58
|
export function writeCachedServerModels(cache, env = process.env) {
|
|
59
59
|
const filePath = getModelsCachePath(env);
|
|
60
60
|
mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
61
|
+
const tempPath = `${filePath}.${process.pid}.tmp`;
|
|
62
|
+
try {
|
|
63
|
+
writeFileSync(tempPath, `${JSON.stringify(cache, null, 2)}\n`, {
|
|
64
|
+
encoding: 'utf8',
|
|
65
|
+
mode: 0o600,
|
|
66
|
+
});
|
|
67
|
+
renameSync(tempPath, filePath);
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
rmSync(tempPath, { force: true });
|
|
71
|
+
throw error;
|
|
72
|
+
}
|
|
65
73
|
}
|
|
66
|
-
export async function fetchServerModels({ config, fetchImpl = globalThis.fetch, }) {
|
|
74
|
+
export async function fetchServerModels({ config, fetchImpl = globalThis.fetch, budget = {}, }) {
|
|
75
|
+
const { timeoutMs = REQUEST_TIMEOUT_MS, ...ladder } = budget;
|
|
67
76
|
return retryTransient(async () => {
|
|
68
77
|
const trace = createTraceContext();
|
|
69
78
|
const response = await fetchImpl(`${normalizeServerUrl(config.serverUrl)}/v1/models`, {
|
|
@@ -71,11 +80,11 @@ export async function fetchServerModels({ config, fetchImpl = globalThis.fetch,
|
|
|
71
80
|
authorization: `Bearer ${config.token}`,
|
|
72
81
|
...trace.headers,
|
|
73
82
|
},
|
|
74
|
-
signal: AbortSignal.timeout(
|
|
83
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
75
84
|
});
|
|
76
85
|
const data = (await readJsonResponse(response));
|
|
77
86
|
if (!response.ok) {
|
|
78
|
-
throw new ServerApiError(failureMessage(data, response.status), response.status, trace.traceId);
|
|
87
|
+
throw new ServerApiError(failureMessage(data, response.status), response.status, trace.traceId, failureCode(data));
|
|
79
88
|
}
|
|
80
89
|
const models = Array.isArray(data?.models)
|
|
81
90
|
? data.models.map(sanitizeModelInfo).filter(Boolean)
|
|
@@ -84,7 +93,7 @@ export async function fetchServerModels({ config, fetchImpl = globalThis.fetch,
|
|
|
84
93
|
throw new Error('Server returned an invalid model list.');
|
|
85
94
|
}
|
|
86
95
|
return { models };
|
|
87
|
-
});
|
|
96
|
+
}, ladder);
|
|
88
97
|
}
|
|
89
98
|
export function selectServerModel({ requestedModelId, cached, serverModels, }) {
|
|
90
99
|
const supportedIds = new Set(serverModels.models.map((model) => model.id));
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import chalk from './colors.js';
|
|
2
2
|
import { spawn } from 'child_process';
|
|
3
|
-
import { buildCommandEnv, commandUsesSudo, sanitizeCommandText, terminateChild, } from './executor.js';
|
|
3
|
+
import { buildCommandEnv, commandUsesSudo, resolveCommandShell, sanitizeCommandText, terminateChild, } from './executor.js';
|
|
4
4
|
import { isTuiMode } from './runtime-mode.js';
|
|
5
5
|
import { redactConnectionStringCredentials } from './secret-preview.js';
|
|
6
6
|
const MAX_RUNNING_JOBS = 8;
|
|
@@ -196,7 +196,7 @@ export async function startBackgroundJob(command, cwd, { startupWaitMs, sessionI
|
|
|
196
196
|
const id = `bg_${++jobCounter}`;
|
|
197
197
|
const child = spawn(command, {
|
|
198
198
|
cwd,
|
|
199
|
-
shell:
|
|
199
|
+
shell: resolveCommandShell(),
|
|
200
200
|
detached: process.platform !== 'win32',
|
|
201
201
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
202
202
|
env: buildCommandEnv(cwd),
|
package/dist/src/cli-args.js
CHANGED
|
@@ -1,9 +1,24 @@
|
|
|
1
1
|
export const AUTH_COMMANDS = new Set(['login', 'whoami', 'logout']);
|
|
2
|
+
function hasPromptWordsAfter(args) {
|
|
3
|
+
for (let i = 1; i < args.length; i++) {
|
|
4
|
+
const arg = args[i];
|
|
5
|
+
if (arg === '--session' || arg === '--resume') {
|
|
6
|
+
i += 1;
|
|
7
|
+
continue;
|
|
8
|
+
}
|
|
9
|
+
if (arg.startsWith('-'))
|
|
10
|
+
continue;
|
|
11
|
+
return true;
|
|
12
|
+
}
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
2
15
|
export function parseArgs(argv) {
|
|
3
16
|
const args = argv.slice(2);
|
|
4
17
|
const firstArg = args[0];
|
|
5
|
-
const command = firstArg && AUTH_COMMANDS.has(firstArg)
|
|
6
|
-
|
|
18
|
+
const command = firstArg && AUTH_COMMANDS.has(firstArg) &&
|
|
19
|
+
(firstArg !== 'login' || !hasPromptWordsAfter(args))
|
|
20
|
+
? firstArg
|
|
21
|
+
: null;
|
|
7
22
|
let autoYes = false;
|
|
8
23
|
let help = false;
|
|
9
24
|
let version = false;
|
|
@@ -12,7 +27,7 @@ export function parseArgs(argv) {
|
|
|
12
27
|
let listSessions = false;
|
|
13
28
|
let unknownOption = null;
|
|
14
29
|
const promptParts = [];
|
|
15
|
-
for (let i = 0; i < args.length; i++) {
|
|
30
|
+
for (let i = command ? 1 : 0; i < args.length; i++) {
|
|
16
31
|
const arg = args[i];
|
|
17
32
|
if (arg === '--yes' || arg === '-y') {
|
|
18
33
|
autoYes = true;
|
|
@@ -39,7 +54,7 @@ export function parseArgs(argv) {
|
|
|
39
54
|
usage = true;
|
|
40
55
|
continue;
|
|
41
56
|
}
|
|
42
|
-
if (
|
|
57
|
+
if (unknownOption === null && /^-/.test(arg)) {
|
|
43
58
|
unknownOption = arg;
|
|
44
59
|
continue;
|
|
45
60
|
}
|
|
@@ -47,7 +62,6 @@ export function parseArgs(argv) {
|
|
|
47
62
|
}
|
|
48
63
|
return {
|
|
49
64
|
command,
|
|
50
|
-
commandArgs,
|
|
51
65
|
autoYes,
|
|
52
66
|
help,
|
|
53
67
|
version,
|
|
@@ -1,15 +1,7 @@
|
|
|
1
1
|
import { execFileSync } from 'node:child_process';
|
|
2
2
|
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
-
|
|
5
|
-
const MIME_BY_EXT = {
|
|
6
|
-
'.png': 'image/png',
|
|
7
|
-
'.jpg': 'image/jpeg',
|
|
8
|
-
'.jpeg': 'image/jpeg',
|
|
9
|
-
'.gif': 'image/gif',
|
|
10
|
-
'.webp': 'image/webp',
|
|
11
|
-
};
|
|
12
|
-
const SUPPORTED_MIME_TYPES = new Set(Object.values(MIME_BY_EXT));
|
|
4
|
+
import { MAX_IMAGE_SIZE_BYTES, SUPPORTED_IMAGE_MIME_TYPES as SUPPORTED_MIME_TYPES, sniffImageMimeType, } from './image-limits.js';
|
|
13
5
|
export class ClipboardError extends Error {
|
|
14
6
|
code;
|
|
15
7
|
constructor(message, code) {
|
|
@@ -273,11 +265,13 @@ export function loadImageFromFile(filePath) {
|
|
|
273
265
|
if (stat.size > MAX_IMAGE_SIZE_BYTES) {
|
|
274
266
|
throw new ClipboardError(`Image file exceeds 10MB limit (${(stat.size / 1024 / 1024).toFixed(1)}MB): ${resolved}`, 'READ_FAILED');
|
|
275
267
|
}
|
|
276
|
-
const
|
|
277
|
-
const mimeType =
|
|
268
|
+
const buf = readFileSync(resolved);
|
|
269
|
+
const mimeType = sniffImageMimeType(buf);
|
|
278
270
|
if (!mimeType) {
|
|
279
|
-
|
|
271
|
+
const ext = path.extname(resolved).toLowerCase();
|
|
272
|
+
throw new ClipboardError(ext
|
|
273
|
+
? `"${path.basename(resolved)}" is named ${ext} but its contents are not a supported image. Supported: PNG, JPEG, GIF, WebP.`
|
|
274
|
+
: `Not a supported image file: ${resolved}. Supported: PNG, JPEG, GIF, WebP.`, 'READ_FAILED');
|
|
280
275
|
}
|
|
281
|
-
const buf = readFileSync(resolved);
|
|
282
276
|
return { base64Data: buf.toString('base64'), mimeType };
|
|
283
277
|
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
export const MAX_IMAGES_PER_MESSAGE = 5;
|
|
2
|
+
export const MAX_IMAGE_SIZE_BYTES = 10 * 1024 * 1024;
|
|
3
|
+
export const MAX_TOTAL_IMAGE_BYTES_PER_MESSAGE = 20 * 1024 * 1024;
|
|
4
|
+
export function approximateBase64DecodedBytes(base64) {
|
|
5
|
+
const padding = base64.endsWith('==') ? 2 : base64.endsWith('=') ? 1 : 0;
|
|
6
|
+
return Math.max(0, Math.floor((base64.length * 3) / 4) - padding);
|
|
7
|
+
}
|
|
8
|
+
export function totalAttachmentBytes(attachments) {
|
|
9
|
+
return attachments.reduce((sum, attachment) => sum + approximateBase64DecodedBytes(attachment.base64Data), 0);
|
|
10
|
+
}
|
|
11
|
+
export const SUPPORTED_IMAGE_MIME_TYPES = new Set([
|
|
12
|
+
'image/png',
|
|
13
|
+
'image/jpeg',
|
|
14
|
+
'image/gif',
|
|
15
|
+
'image/webp',
|
|
16
|
+
]);
|
|
17
|
+
export const IMAGE_MIME_BY_EXT = {
|
|
18
|
+
'.png': 'image/png',
|
|
19
|
+
'.jpg': 'image/jpeg',
|
|
20
|
+
'.jpeg': 'image/jpeg',
|
|
21
|
+
'.gif': 'image/gif',
|
|
22
|
+
'.webp': 'image/webp',
|
|
23
|
+
};
|
|
24
|
+
export const IMAGE_EXT_BY_MIME = {
|
|
25
|
+
'image/png': '.png',
|
|
26
|
+
'image/jpeg': '.jpg',
|
|
27
|
+
'image/gif': '.gif',
|
|
28
|
+
'image/webp': '.webp',
|
|
29
|
+
};
|
|
30
|
+
export function isSupportedImageMimeType(mime) {
|
|
31
|
+
return SUPPORTED_IMAGE_MIME_TYPES.has(mime);
|
|
32
|
+
}
|
|
33
|
+
export function sniffImageMimeType(bytes) {
|
|
34
|
+
if (bytes.length < 12)
|
|
35
|
+
return null;
|
|
36
|
+
if (bytes[0] === 0x89 &&
|
|
37
|
+
bytes[1] === 0x50 &&
|
|
38
|
+
bytes[2] === 0x4e &&
|
|
39
|
+
bytes[3] === 0x47) {
|
|
40
|
+
return 'image/png';
|
|
41
|
+
}
|
|
42
|
+
if (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) {
|
|
43
|
+
return 'image/jpeg';
|
|
44
|
+
}
|
|
45
|
+
if (bytes.subarray(0, 3).toString('latin1') === 'GIF') {
|
|
46
|
+
return 'image/gif';
|
|
47
|
+
}
|
|
48
|
+
if (bytes.subarray(0, 4).toString('latin1') === 'RIFF' &&
|
|
49
|
+
bytes.subarray(8, 12).toString('latin1') === 'WEBP') {
|
|
50
|
+
return 'image/webp';
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
export function imageExtensionForMime(mime) {
|
|
55
|
+
return IMAGE_EXT_BY_MIME[mime] ?? '.png';
|
|
56
|
+
}
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import { existsSync, statSync } from 'node:fs';
|
|
1
|
+
import { closeSync, existsSync, openSync, readSync, statSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
4
|
import { loadImageFromFile } from './clipboard.js';
|
|
5
|
+
import { MAX_IMAGES_PER_MESSAGE, MAX_IMAGE_SIZE_BYTES, MAX_TOTAL_IMAGE_BYTES_PER_MESSAGE, approximateBase64DecodedBytes, sniffImageMimeType, totalAttachmentBytes, } from './image-limits.js';
|
|
6
|
+
import { tryCacheAttachmentBytes } from './session-image-store.js';
|
|
5
7
|
const EXT = '(?:png|jpe?g|gif|webp)';
|
|
6
8
|
const BARE_CHAR = "[^\\s\"'<>,:;!?()\\[\\]{}]";
|
|
7
9
|
const BARE_PATH = `(?:[A-Za-z]:[\\\\/])?(?:\\\\ |${BARE_CHAR})+\\.${EXT}`;
|
|
@@ -110,13 +112,67 @@ function detectImagePaths(input, cwd) {
|
|
|
110
112
|
}
|
|
111
113
|
return rawsByPath;
|
|
112
114
|
}
|
|
115
|
+
const EXTENSIONLESS_CANDIDATE = new RegExp(`"([^"]*[\\\\/][^"]*)"` +
|
|
116
|
+
`|'([^']*[\\\\/][^']*)'` +
|
|
117
|
+
`|((?:[A-Za-z]:[\\\\/])?(?:\\\\ |${BARE_CHAR})*[\\\\/](?:\\\\ |${BARE_CHAR})+)`, 'g');
|
|
118
|
+
function sniffFileHeader(resolvedPath) {
|
|
119
|
+
let fd = null;
|
|
120
|
+
try {
|
|
121
|
+
const stat = statSync(resolvedPath, { throwIfNoEntry: false });
|
|
122
|
+
if (!stat?.isFile() || stat.size > MAX_IMAGE_SIZE_BYTES)
|
|
123
|
+
return null;
|
|
124
|
+
fd = openSync(resolvedPath, 'r');
|
|
125
|
+
const header = Buffer.alloc(12);
|
|
126
|
+
const read = readSync(fd, header, 0, 12, 0);
|
|
127
|
+
return read === 12 ? header : null;
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
finally {
|
|
133
|
+
if (fd !== null) {
|
|
134
|
+
try {
|
|
135
|
+
closeSync(fd);
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
function detectExtensionlessImagePaths(input, cwd, alreadyDetected) {
|
|
143
|
+
const found = new Map();
|
|
144
|
+
const regex = new RegExp(EXTENSIONLESS_CANDIDATE.source, EXTENSIONLESS_CANDIDATE.flags);
|
|
145
|
+
let match;
|
|
146
|
+
while ((match = regex.exec(input)) !== null) {
|
|
147
|
+
const raw = match[0];
|
|
148
|
+
const inner = (match[1] ?? match[2] ?? match[3] ?? '').replace(/\\ /g, ' ');
|
|
149
|
+
if (!inner || inner.includes('://'))
|
|
150
|
+
continue;
|
|
151
|
+
if (/\.(?:png|jpe?g|gif|webp)$/i.test(inner))
|
|
152
|
+
continue;
|
|
153
|
+
const resolvedPath = path.isAbsolute(inner)
|
|
154
|
+
? inner
|
|
155
|
+
: path.resolve(cwd, inner);
|
|
156
|
+
if (alreadyDetected.has(resolvedPath) || found.has(resolvedPath))
|
|
157
|
+
continue;
|
|
158
|
+
const header = sniffFileHeader(resolvedPath);
|
|
159
|
+
if (!header || !sniffImageMimeType(header))
|
|
160
|
+
continue;
|
|
161
|
+
found.set(resolvedPath, [raw]);
|
|
162
|
+
}
|
|
163
|
+
return found;
|
|
164
|
+
}
|
|
113
165
|
export function autoAttachImages(input, cwd, existing = []) {
|
|
114
|
-
const max =
|
|
166
|
+
const max = MAX_IMAGES_PER_MESSAGE;
|
|
115
167
|
const rawsByPath = detectImagePaths(input, cwd);
|
|
168
|
+
for (const [resolvedPath, rawForms] of detectExtensionlessImagePaths(input, cwd, new Set(rawsByPath.keys()))) {
|
|
169
|
+
rawsByPath.set(resolvedPath, rawForms);
|
|
170
|
+
}
|
|
116
171
|
let sanitizedInput = input;
|
|
117
172
|
const attachments = [];
|
|
118
173
|
const errors = [];
|
|
119
174
|
const maxExistingIndex = existing.reduce((highest, a) => Math.max(highest, a.index ?? 0), 0);
|
|
175
|
+
let budgetUsed = totalAttachmentBytes(existing);
|
|
120
176
|
for (const [resolvedPath, rawForms] of rawsByPath) {
|
|
121
177
|
if (existing.length + attachments.length >= max)
|
|
122
178
|
break;
|
|
@@ -124,13 +180,24 @@ export function autoAttachImages(input, cwd, existing = []) {
|
|
|
124
180
|
continue;
|
|
125
181
|
try {
|
|
126
182
|
const loaded = loadImageFromFile(resolvedPath);
|
|
127
|
-
const
|
|
183
|
+
const bytes = approximateBase64DecodedBytes(loaded.base64Data);
|
|
184
|
+
if (budgetUsed + bytes > MAX_TOTAL_IMAGE_BYTES_PER_MESSAGE) {
|
|
185
|
+
errors.push(`${path.basename(resolvedPath)} was not attached: it would put this message over the ${Math.round(MAX_TOTAL_IMAGE_BYTES_PER_MESSAGE / 1024 / 1024)}MB combined image limit.`);
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
budgetUsed += bytes;
|
|
189
|
+
const cached = tryCacheAttachmentBytes({
|
|
190
|
+
base64Data: loaded.base64Data,
|
|
191
|
+
mimeType: loaded.mimeType,
|
|
192
|
+
});
|
|
193
|
+
const idx = cached?.index ?? maxExistingIndex + attachments.length + 1;
|
|
128
194
|
attachments.push({
|
|
129
195
|
index: idx,
|
|
130
196
|
mimeType: loaded.mimeType,
|
|
131
197
|
base64Data: loaded.base64Data,
|
|
132
198
|
source: 'file',
|
|
133
199
|
filePath: resolvedPath,
|
|
200
|
+
...(cached ? { cachePath: cached.cachePath } : {}),
|
|
134
201
|
});
|
|
135
202
|
for (const raw of rawForms) {
|
|
136
203
|
sanitizedInput = sanitizedInput.replace(raw, `[Image #${idx}]`);
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync, } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { getClientStateDir } from '../client-state.js';
|
|
5
|
+
import { MAX_IMAGE_SIZE_BYTES, imageExtensionForMime, isSupportedImageMimeType, sniffImageMimeType, } from './image-limits.js';
|
|
6
|
+
const IMAGE_FILE_MODE = 0o600;
|
|
7
|
+
const IMAGE_DIR_MODE = 0o700;
|
|
8
|
+
export const SESSION_IMAGE_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
|
|
9
|
+
let activeSessionId = null;
|
|
10
|
+
export function setImageStoreSession(sessionId) {
|
|
11
|
+
activeSessionId = String(sessionId ?? '').trim() || null;
|
|
12
|
+
contentIndex.clear();
|
|
13
|
+
}
|
|
14
|
+
const contentIndex = new Map();
|
|
15
|
+
function imageContentKey(base64Data) {
|
|
16
|
+
return createHash('sha256').update(base64Data).digest('hex');
|
|
17
|
+
}
|
|
18
|
+
function rememberStoredImage(base64Data, cachePath) {
|
|
19
|
+
contentIndex.set(imageContentKey(base64Data), cachePath);
|
|
20
|
+
}
|
|
21
|
+
export function findStoredImageByContent(base64Data) {
|
|
22
|
+
const cachePath = contentIndex.get(imageContentKey(base64Data));
|
|
23
|
+
if (!cachePath)
|
|
24
|
+
return undefined;
|
|
25
|
+
if (!existsSync(cachePath)) {
|
|
26
|
+
contentIndex.delete(imageContentKey(base64Data));
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
return cachePath;
|
|
30
|
+
}
|
|
31
|
+
export function getImageStoreSession() {
|
|
32
|
+
return activeSessionId;
|
|
33
|
+
}
|
|
34
|
+
function safeSessionDirName(sessionId) {
|
|
35
|
+
const normalized = String(sessionId ?? '').trim();
|
|
36
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(normalized)) {
|
|
37
|
+
throw new Error(`Invalid session id "${sessionId}".`);
|
|
38
|
+
}
|
|
39
|
+
return normalized;
|
|
40
|
+
}
|
|
41
|
+
export function getSessionImageBaseDir(env = process.env) {
|
|
42
|
+
return path.join(getClientStateDir(env), 'sessions', 'images');
|
|
43
|
+
}
|
|
44
|
+
export function getSessionImageDir(sessionId, env = process.env) {
|
|
45
|
+
return path.join(getSessionImageBaseDir(env), safeSessionDirName(sessionId));
|
|
46
|
+
}
|
|
47
|
+
function nextImageNumber(dir) {
|
|
48
|
+
if (!existsSync(dir))
|
|
49
|
+
return 1;
|
|
50
|
+
let highest = 0;
|
|
51
|
+
for (const name of readdirSync(dir)) {
|
|
52
|
+
const parsed = Number.parseInt(path.basename(name, path.extname(name)), 10);
|
|
53
|
+
if (Number.isInteger(parsed) && parsed > highest)
|
|
54
|
+
highest = parsed;
|
|
55
|
+
}
|
|
56
|
+
return highest + 1;
|
|
57
|
+
}
|
|
58
|
+
function ensureSessionImageDir(sessionId, env) {
|
|
59
|
+
const dir = getSessionImageDir(sessionId, env);
|
|
60
|
+
mkdirSync(dir, { recursive: true, mode: IMAGE_DIR_MODE });
|
|
61
|
+
return dir;
|
|
62
|
+
}
|
|
63
|
+
export function storeSessionImageBytes({ sessionId, base64Data, mimeType, env = process.env, }) {
|
|
64
|
+
const dir = ensureSessionImageDir(sessionId, env);
|
|
65
|
+
const index = nextImageNumber(dir);
|
|
66
|
+
const cachePath = path.join(dir, `${index}${imageExtensionForMime(mimeType)}`);
|
|
67
|
+
writeFileSync(cachePath, Buffer.from(base64Data, 'base64'), {
|
|
68
|
+
mode: IMAGE_FILE_MODE,
|
|
69
|
+
});
|
|
70
|
+
rememberStoredImage(base64Data, cachePath);
|
|
71
|
+
return { cachePath, mimeType, base64Data, index };
|
|
72
|
+
}
|
|
73
|
+
export function tryCacheAttachmentBytes({ base64Data, mimeType, env = process.env, }) {
|
|
74
|
+
if (!activeSessionId)
|
|
75
|
+
return undefined;
|
|
76
|
+
try {
|
|
77
|
+
const stored = storeSessionImageBytes({
|
|
78
|
+
sessionId: activeSessionId,
|
|
79
|
+
base64Data,
|
|
80
|
+
mimeType,
|
|
81
|
+
env,
|
|
82
|
+
});
|
|
83
|
+
return { cachePath: stored.cachePath, index: stored.index };
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
export function isSessionStorePath(candidate, env = process.env) {
|
|
90
|
+
if (!activeSessionId)
|
|
91
|
+
return false;
|
|
92
|
+
const dir = getSessionImageDir(activeSessionId, env);
|
|
93
|
+
const resolved = path.resolve(candidate);
|
|
94
|
+
return (resolved.startsWith(dir + path.sep) && path.dirname(resolved) === dir);
|
|
95
|
+
}
|
|
96
|
+
export function readSessionImageByIndex(index, env = process.env) {
|
|
97
|
+
if (!activeSessionId || !Number.isInteger(index) || index < 1)
|
|
98
|
+
return null;
|
|
99
|
+
const dir = getSessionImageDir(activeSessionId, env);
|
|
100
|
+
if (!existsSync(dir))
|
|
101
|
+
return null;
|
|
102
|
+
try {
|
|
103
|
+
for (const name of readdirSync(dir)) {
|
|
104
|
+
if (Number.parseInt(path.basename(name, path.extname(name)), 10) === index) {
|
|
105
|
+
return readSessionImage(path.join(dir, name));
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
export class SessionImageError extends Error {
|
|
115
|
+
code;
|
|
116
|
+
constructor(message, code) {
|
|
117
|
+
super(message);
|
|
118
|
+
this.code = code;
|
|
119
|
+
this.name = 'SessionImageError';
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
export function storeSessionImageFromPath({ sessionId, sourcePath, env = process.env, }) {
|
|
123
|
+
const resolved = path.resolve(sourcePath);
|
|
124
|
+
if (!existsSync(resolved) || !statSync(resolved).isFile()) {
|
|
125
|
+
throw new SessionImageError(`Image file not found: ${resolved}`, 'NOT_FOUND');
|
|
126
|
+
}
|
|
127
|
+
const size = statSync(resolved).size;
|
|
128
|
+
if (size > MAX_IMAGE_SIZE_BYTES) {
|
|
129
|
+
throw new SessionImageError(`Image file exceeds ${Math.round(MAX_IMAGE_SIZE_BYTES / 1024 / 1024)}MB limit (${(size / 1024 / 1024).toFixed(1)}MB): ${resolved}`, 'TOO_LARGE');
|
|
130
|
+
}
|
|
131
|
+
const bytes = readFileSync(resolved);
|
|
132
|
+
const sniffed = sniffImageMimeType(bytes);
|
|
133
|
+
if (!sniffed || !isSupportedImageMimeType(sniffed)) {
|
|
134
|
+
throw new SessionImageError(`Not a supported image file: ${resolved}. Supported: PNG, JPEG, GIF, WebP.`, 'UNSUPPORTED');
|
|
135
|
+
}
|
|
136
|
+
const dir = ensureSessionImageDir(sessionId, env);
|
|
137
|
+
const index = nextImageNumber(dir);
|
|
138
|
+
const cachePath = path.join(dir, `${index}${imageExtensionForMime(sniffed)}`);
|
|
139
|
+
writeFileSync(cachePath, bytes, { mode: IMAGE_FILE_MODE });
|
|
140
|
+
const base64Data = bytes.toString('base64');
|
|
141
|
+
rememberStoredImage(base64Data, cachePath);
|
|
142
|
+
return { cachePath, mimeType: sniffed, base64Data, index };
|
|
143
|
+
}
|
|
144
|
+
export function readSessionImage(cachePath) {
|
|
145
|
+
try {
|
|
146
|
+
if (!existsSync(cachePath))
|
|
147
|
+
return null;
|
|
148
|
+
const bytes = readFileSync(cachePath);
|
|
149
|
+
const sniffed = sniffImageMimeType(bytes);
|
|
150
|
+
if (!sniffed)
|
|
151
|
+
return null;
|
|
152
|
+
const parsed = Number.parseInt(path.basename(cachePath, path.extname(cachePath)), 10);
|
|
153
|
+
const base64Data = bytes.toString('base64');
|
|
154
|
+
rememberStoredImage(base64Data, cachePath);
|
|
155
|
+
return {
|
|
156
|
+
cachePath,
|
|
157
|
+
mimeType: sniffed,
|
|
158
|
+
base64Data,
|
|
159
|
+
index: Number.isInteger(parsed) ? parsed : 0,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
export function pruneSessionImages(sessionId, env = process.env) {
|
|
167
|
+
try {
|
|
168
|
+
rmSync(getSessionImageDir(sessionId, env), { recursive: true, force: true });
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
export function sweepOrphanSessionImages({ activeSessionIds, maxAgeMs = SESSION_IMAGE_MAX_AGE_MS, env = process.env, now = Date.now(), }) {
|
|
174
|
+
const baseDir = getSessionImageBaseDir(env);
|
|
175
|
+
if (!existsSync(baseDir))
|
|
176
|
+
return 0;
|
|
177
|
+
let removed = 0;
|
|
178
|
+
let entries;
|
|
179
|
+
try {
|
|
180
|
+
entries = readdirSync(baseDir);
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
return 0;
|
|
184
|
+
}
|
|
185
|
+
for (const entry of entries) {
|
|
186
|
+
if (activeSessionIds.has(entry))
|
|
187
|
+
continue;
|
|
188
|
+
const dir = path.join(baseDir, entry);
|
|
189
|
+
try {
|
|
190
|
+
if (now - statSync(dir).mtimeMs < maxAgeMs)
|
|
191
|
+
continue;
|
|
192
|
+
rmSync(dir, { recursive: true, force: true });
|
|
193
|
+
removed += 1;
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return removed;
|
|
199
|
+
}
|
package/dist/src/executor.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import chalk from './colors.js';
|
|
2
2
|
import { execFileSync, spawn } from 'child_process';
|
|
3
|
-
import { existsSync, statSync } from 'fs';
|
|
3
|
+
import { accessSync, constants as fsConstants, existsSync, statSync } from 'fs';
|
|
4
4
|
import { createRequire } from 'node:module';
|
|
5
5
|
import os from 'os';
|
|
6
6
|
import path from 'path';
|
|
@@ -596,7 +596,7 @@ export function commandUsesSudo(command) {
|
|
|
596
596
|
}
|
|
597
597
|
export function sudoPromptFromTail(text) {
|
|
598
598
|
const tail = text.slice(-1000).replace(/\x1b\[[0-9;?]*[A-Za-z]/g, '');
|
|
599
|
-
const match = tail.match(/(?:\[sudo\][^\r\n]*password[^\r\n]*:
|
|
599
|
+
const match = tail.match(/(?:\[sudo\][^\r\n]*password[^\r\n]*: ?|\[?sudo[^\r\n]*password[^\r\n]*: ?|password[^\r\n]*: ?)$/i);
|
|
600
600
|
return match?.[0] ?? null;
|
|
601
601
|
}
|
|
602
602
|
function isSudoPromptLine(text) {
|
|
@@ -637,6 +637,28 @@ export function buildCommandEnv(cwd) {
|
|
|
637
637
|
THEGITAI_SCRATCH_DIR: ensureSessionScratchDir(),
|
|
638
638
|
};
|
|
639
639
|
}
|
|
640
|
+
let cachedCommandShell;
|
|
641
|
+
function findBashPath() {
|
|
642
|
+
const fromPath = (process.env.PATH ?? '')
|
|
643
|
+
.split(path.delimiter)
|
|
644
|
+
.filter(Boolean)
|
|
645
|
+
.map((dir) => path.join(dir, 'bash'));
|
|
646
|
+
for (const candidate of [...fromPath, '/bin/bash', '/usr/bin/bash']) {
|
|
647
|
+
try {
|
|
648
|
+
accessSync(candidate, fsConstants.X_OK);
|
|
649
|
+
return candidate;
|
|
650
|
+
}
|
|
651
|
+
catch { }
|
|
652
|
+
}
|
|
653
|
+
return null;
|
|
654
|
+
}
|
|
655
|
+
export function resolveCommandShell() {
|
|
656
|
+
if (cachedCommandShell !== undefined)
|
|
657
|
+
return cachedCommandShell;
|
|
658
|
+
cachedCommandShell =
|
|
659
|
+
process.platform === 'win32' ? true : (findBashPath() ?? true);
|
|
660
|
+
return cachedCommandShell;
|
|
661
|
+
}
|
|
640
662
|
function sanitizePtyOutput(command, output, cwd, secrets) {
|
|
641
663
|
return sanitizeCommandText(command, stripSudoPromptText(redactSecrets(output, secrets)), cwd);
|
|
642
664
|
}
|
|
@@ -822,7 +844,7 @@ export async function runCommand(command, cwd, { requestSudoPassword, timeout, }
|
|
|
822
844
|
let killTimer = null;
|
|
823
845
|
const child = spawn(command, {
|
|
824
846
|
cwd,
|
|
825
|
-
shell:
|
|
847
|
+
shell: resolveCommandShell(),
|
|
826
848
|
detached: process.platform !== 'win32',
|
|
827
849
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
828
850
|
env: buildCommandEnv(cwd),
|