-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathcommand-utils.ts
More file actions
211 lines (176 loc) · 7.31 KB
/
Copy pathcommand-utils.ts
File metadata and controls
211 lines (176 loc) · 7.31 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
import * as fs from 'node:fs';
type WorkspaceFolderLike<T> = { uri: T };
type WorkspaceLike<T> = {
workspaceFolders?: readonly WorkspaceFolderLike<T>[];
getWorkspaceFolder(uri: T): WorkspaceFolderLike<T> | undefined;
};
type ActiveEditorLike<T> = { document: { uri: T } };
/** Appends a chunk without allowing a long-running process to grow the retained output indefinitely. */
export function appendBoundedText(current: string, chunk: string, maxLength: number): string {
if (current.length >= maxLength) {
return current;
}
return current + chunk.slice(0, maxLength - current.length);
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function getExecutableBaseName(command: string): string {
const executable = extractExecutable(command);
const fileName = executable.split(/[\\/]/).pop() ?? executable;
return fileName.replace(/\.(?:exe|cmd|bat|ps1)$/i, '');
}
function buildCommandNotFoundPatterns(command: string): RegExp[] {
const executableName = getExecutableBaseName(command);
if (!executableName) {
return [];
}
const escapedName = escapeRegExp(executableName);
return [
new RegExp(`(?:^|\\s)${escapedName}:\\s+command not found`, 'i'),
new RegExp(`(?:^|\\s)${escapedName}:\\s+not found`, 'i'),
new RegExp(`command not found:\\s*${escapedName}`, 'i'),
new RegExp(`unknown command:?\\s*${escapedName}`, 'i'),
new RegExp(`['"]?${escapedName}['"]?.*is not recognized`, 'i'),
];
}
/** Returns the configured terminal base name without any numeric suffix. */
export function normalizeTerminalName(value: string | undefined, fallback: string): string {
return (value ?? fallback).trim() || fallback;
}
/** Returns the terminal label with the numeric suffix used by the extension. */
export function buildTerminalName(value: string | undefined, sequence: number, fallback: string): string {
const baseName = normalizeTerminalName(value, fallback);
const suffix = sequence <= 1 ? '' : ` ${sequence}`;
return `${baseName}${suffix}`;
}
/**
* Finds the agent a terminal name was built for, or undefined when it matches none.
*
* This is how sessions are recovered after a window reload: VS Code reconnects the terminal processes
* but the in-memory session registry starts empty, so the only thing left tying a surviving terminal
* to an agent is the name `buildTerminalName` gave it — either the agent's label, or the label plus a
* numeric suffix. Exact matches are preferred over suffix matches so that an agent whose label itself
* ends in a number can't be shadowed by a shorter one.
*/
export function findAgentByTerminalName<T extends { label: string }>(
terminalName: string,
agents: readonly T[],
): T | undefined {
const name = terminalName.trim();
if (!name) {
return undefined;
}
const exact = agents.find((agent) => agent.label.trim() === name);
if (exact) {
return exact;
}
return agents.find((agent) => {
const base = agent.label.trim();
if (!base || !name.startsWith(`${base} `)) {
return false;
}
return /^\d+$/.test(name.slice(base.length + 1));
});
}
/** Returns the settings search query for the current extension id. */
export function buildExtensionSettingsQuery(extensionId: string): string {
return `@ext:${extensionId}`;
}
/** Extracts the executable token while preserving quoted Windows paths with spaces. */
export function extractExecutable(command: string): string {
const normalized = command.trim();
if (!normalized) {
return '';
}
const firstCharacter = normalized[0];
if (firstCharacter === '"' || firstCharacter === "'") {
const closingQuoteIndex = normalized.indexOf(firstCharacter, 1);
if (closingQuoteIndex > 0) {
return normalized.slice(1, closingQuoteIndex);
}
}
const whitespaceIndex = normalized.search(/\s/);
return whitespaceIndex === -1 ? normalized : normalized.slice(0, whitespaceIndex);
}
/** Returns whether a terminal failure likely means the configured CLI is missing. */
export function shouldPromptToInstall(command: string, exitCode: number | undefined, output: string): boolean {
if (exitCode !== undefined && exitCode !== 1 && exitCode !== 127) {
return false;
}
return buildCommandNotFoundPatterns(command).some((pattern) => pattern.test(output));
}
/** Returns whether a path points to a file the current host can execute. */
export function isExecutableFile(filePath: string, platform: NodeJS.Platform = process.platform): boolean {
try {
if (!fs.statSync(filePath).isFile()) {
return false;
}
if (platform !== 'win32') {
fs.accessSync(filePath, fs.constants.X_OK);
}
return true;
} catch {
return false;
}
}
/** Resolves the terminal cwd from the active editor or the first workspace folder. */
export function resolveTerminalCwd<T>(
activeEditor: ActiveEditorLike<T> | undefined,
workspace: WorkspaceLike<T>,
): T | undefined {
const activeWorkspaceFolder = activeEditor ? workspace.getWorkspaceFolder(activeEditor.document.uri) : undefined;
return activeWorkspaceFolder?.uri ?? workspace.workspaceFolders?.[0]?.uri;
}
/**
* Returns true when `resolveTerminalCwd` would otherwise fall back to the first workspace folder
* arbitrarily — i.e. there is no active-editor-derived folder, and there is more than one candidate
* to choose from. Callers can use this to prompt instead of guessing.
*/
export function isTerminalCwdAmbiguous<T>(
activeEditor: ActiveEditorLike<T> | undefined,
workspace: WorkspaceLike<T>,
): boolean {
const activeWorkspaceFolder = activeEditor ? workspace.getWorkspaceFolder(activeEditor.document.uri) : undefined;
return !activeWorkspaceFolder && (workspace.workspaceFolders?.length ?? 0) > 1;
}
const DEFAULT_PATHEXT = '.COM;.EXE;.BAT;.CMD';
/** Returns the executable extensions to try on Windows (the bare name plus each PATHEXT entry). */
function resolveWindowsExtensions(pathExt: string | undefined): string[] {
const entries = (pathExt && pathExt.trim() ? pathExt : DEFAULT_PATHEXT)
.split(';')
.map((entry) => entry.trim())
.filter(Boolean);
return ['', ...entries];
}
/**
* Best-effort check that a command's executable resolves on PATH, without spawning a process.
* On Windows the bare name is also tried with each PATHEXT extension. The executable predicate is
* injected so path resolution stays pure and testable.
*/
export function executableExistsOnPath(
command: string,
env: Record<string, string | undefined>,
platform: string,
isExecutable: (filePath: string) => boolean,
): boolean {
const executable = extractExecutable(command);
if (!executable) {
return false;
}
const isWindows = platform === 'win32';
const extensions = isWindows ? resolveWindowsExtensions(env.PATHEXT) : [''];
const existsWithExt = (base: string): boolean => extensions.some((ext) => isExecutable(base + ext));
// A path-qualified command is checked directly, not searched on PATH.
if (/[\\/]/.test(executable)) {
return existsWithExt(executable);
}
const pathValue = env.PATH ?? env.Path ?? '';
const delimiter = isWindows ? ';' : ':';
const separator = isWindows ? '\\' : '/';
return pathValue
.split(delimiter)
.map((dir) => isWindows ? dir.trim().replace(/^"(.*)"$/, '$1') : dir)
.filter((dir) => dir.length > 0)
.some((dir) => existsWithExt(dir.replace(/[\\/]+$/, '') + separator + executable));
}