-
Notifications
You must be signed in to change notification settings - Fork 42
Expand file tree
/
Copy pathutil.ts
More file actions
210 lines (191 loc) · 6.3 KB
/
util.ts
File metadata and controls
210 lines (191 loc) · 6.3 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
import * as os from "node:os";
import url from "node:url";
export interface AuthorityParts {
agent: string | undefined;
sshHost: string;
safeHostname: string;
username: string;
workspace: string;
}
// Prefix is a magic string that is prepended to SSH hosts to indicate that
// they should be handled by this extension.
export const AuthorityPrefix = "coder-vscode";
// Regex patterns to find the SSH port from Remote SSH extension logs.
// `ms-vscode-remote.remote-ssh`: `-> socksPort <port> ->` or `between local port <port>`
// `codeium.windsurf-remote-openssh`, `jeanp413.open-remote-ssh`, `google.antigravity-remote-openssh`: `=> <port>(socks) =>`
// `anysphere.remote-ssh`: `Socks port: <port>`
export const RemoteSSHLogPortRegex =
/(?:-> socksPort (\d+) ->|between local port (\d+)|=> (\d+)\(socks\) =>|Socks port: (\d+))/g;
/**
* Given the contents of a Remote - SSH log file, find the most recent port
* number used by the SSH process. This is typically the socks port, but the
* local port works too.
*
* Returns null if no port is found.
*/
export function findPort(text: string): number | null {
const allMatches = [...text.matchAll(RemoteSSHLogPortRegex)];
if (allMatches.length === 0) {
return null;
}
// Get the last match, which is the most recent port.
const lastMatch = allMatches.at(-1)!;
// Each capture group corresponds to a different Remote SSH extension log format:
// [0] full match, [1] and [2] ms-vscode-remote.remote-ssh,
// [3] windsurf/open-remote-ssh/antigravity, [4] anysphere.remote-ssh
const portStr = lastMatch[1] || lastMatch[2] || lastMatch[3] || lastMatch[4];
if (!portStr) {
return null;
}
return Number.parseInt(portStr);
}
/**
* Given an authority, parse into the expected parts.
*
* If this is not a Coder host, return null.
*
* Throw an error if the host is invalid.
*/
export function parseRemoteAuthority(authority: string): AuthorityParts | null {
// The authority looks like: vscode://ssh-remote+<ssh host name>
const authorityParts = authority.split("+");
// We create SSH host names in a format matching:
// coder-vscode(--|.)<username>--<workspace>(--|.)<agent?>
// The agent can be omitted; the user will be prompted for it instead.
// Anything else is unrelated to Coder and can be ignored.
const parts = authorityParts[1].split("--");
if (
parts.length <= 1 ||
(parts[0] !== AuthorityPrefix &&
!parts[0].startsWith(`${AuthorityPrefix}.`))
) {
return null;
}
// It has the proper prefix, so this is probably a Coder host name.
// Validate the SSH host name. Including the prefix, we expect at least
// three parts, or four if including the agent.
if ((parts.length !== 3 && parts.length !== 4) || parts.some((p) => !p)) {
throw new Error(
`Invalid Coder SSH authority. Must be: <username>--<workspace>(--|.)<agent?>`,
);
}
let workspace = parts[2];
let agent = "";
if (parts.length === 4) {
agent = parts[3];
} else if (parts.length === 3) {
const workspaceParts = parts[2].split(".");
if (workspaceParts.length === 2) {
workspace = workspaceParts[0];
agent = workspaceParts[1];
}
}
return {
agent: agent,
sshHost: authorityParts[1],
safeHostname: parts[0].replace(/^coder-vscode\.?/, ""),
username: parts[1],
workspace: workspace,
};
}
export function toRemoteAuthority(
baseUrl: string,
workspaceOwner: string,
workspaceName: string,
workspaceAgent: string | undefined,
): string {
let remoteAuthority = `ssh-remote+${AuthorityPrefix}.${toSafeHost(baseUrl)}--${workspaceOwner}--${workspaceName}`;
if (workspaceAgent) {
remoteAuthority += `.${workspaceAgent}`;
}
return remoteAuthority;
}
/**
* Given a URL, return the host in a format that is safe to write.
*/
export function toSafeHost(rawUrl: string): string {
const u = new URL(rawUrl);
// If the host is invalid, an empty string is returned. Although, `new URL`
// should already have thrown in that case.
return url.domainToASCII(u.hostname) || u.hostname;
}
/**
* Expand a path if it starts with tilde (~) or contains ${userHome}.
*/
export function expandPath(input: string): string {
const userHome = os.homedir();
if (input.startsWith("~")) {
input = userHome + input.substring("~".length);
}
return input.replaceAll("${userHome}", userHome);
}
/**
* Return the number of times a substring appears in a string.
*/
export function countSubstring(needle: string, haystack: string): number {
if (needle.length < 1 || haystack.length < 1) {
return 0;
}
let count = 0;
let pos = haystack.indexOf(needle);
while (pos !== -1) {
count++;
pos = haystack.indexOf(needle, pos + needle.length);
}
return count;
}
const transientRenameCodes: ReadonlySet<string> = new Set([
"EPERM",
"EACCES",
"EBUSY",
]);
/**
* Rename with retry for transient Windows filesystem errors (EPERM, EACCES,
* EBUSY). On Windows, antivirus, Search Indexer, cloud sync, or concurrent
* processes can briefly lock files causing renames to fail.
*
* On non-Windows platforms, calls renameFn directly with no retry.
*
* Matches the strategy used by VS Code (pfs.ts) and graceful-fs: 60s
* wall-clock timeout with linear backoff (10ms increments) capped at 100ms.
*/
export async function renameWithRetry(
renameFn: (src: string, dest: string) => Promise<void>,
source: string,
destination: string,
timeoutMs = 60_000,
delayCapMs = 100,
): Promise<void> {
if (process.platform !== "win32") {
return renameFn(source, destination);
}
const startTime = Date.now();
for (let attempt = 1; ; attempt++) {
try {
return await renameFn(source, destination);
} catch (err) {
const code = (err as NodeJS.ErrnoException).code;
if (
!code ||
!transientRenameCodes.has(code) ||
Date.now() - startTime >= timeoutMs
) {
throw err;
}
const delay = Math.min(delayCapMs, attempt * 10);
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
}
export function escapeCommandArg(arg: string): string {
const escapedString = arg.replaceAll('"', String.raw`\"`);
return `"${escapedString}"`;
}
/**
* Generate a temporary file path by appending a suffix with a random component.
* The suffix describes the purpose of the temp file (e.g. "temp", "old").
* Example: tempFilePath("/a/b", "temp") → "/a/b.temp-k7x3f9qw"
*/
export function tempFilePath(basePath: string, suffix: string): string {
return `${basePath}.${suffix}-${crypto.randomUUID().substring(0, 8)}`;
}