Skip to content

Commit 82d229d

Browse files
committed
fix: improve cliExec error handling and input validation
1 parent 9f6d32d commit 82d229d

File tree

4 files changed

+83
-22
lines changed

4 files changed

+83
-22
lines changed

package.json

Lines changed: 5 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -259,12 +259,6 @@
259259
"when": "!coder.authenticated && coder.loaded"
260260
}
261261
],
262-
"submenus": [
263-
{
264-
"id": "coder.diagnostics",
265-
"label": "Diagnostics"
266-
}
267-
],
268262
"commands": [
269263
{
270264
"command": "coder.login",
@@ -327,7 +321,7 @@
327321
},
328322
{
329323
"command": "coder.speedTest",
330-
"title": "Run Speed Test",
324+
"title": "Speed Test Workspace",
331325
"category": "Coder"
332326
},
333327
{
@@ -523,17 +517,14 @@
523517
"group": "inline"
524518
},
525519
{
526-
"submenu": "coder.diagnostics",
520+
"command": "coder.pingWorkspace",
527521
"when": "coder.authenticated && viewItem =~ /\\+running/",
528522
"group": "navigation"
529-
}
530-
],
531-
"coder.diagnostics": [
532-
{
533-
"command": "coder.pingWorkspace"
534523
},
535524
{
536-
"command": "coder.speedTest"
525+
"command": "coder.speedTest",
526+
"when": "coder.authenticated && viewItem =~ /\\+running/",
527+
"group": "navigation"
537528
}
538529
],
539530
"statusBar/remoteIndicator": [

src/commands.ts

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -180,21 +180,31 @@ export class Commands {
180180

181181
const duration = await vscode.window.showInputBox({
182182
title: "Speed Test Duration",
183-
prompt: "Duration for the speed test (e.g., 5s, 10s, 1m)",
183+
prompt: "Duration for the speed test",
184184
value: "5s",
185+
validateInput: (value) => {
186+
const v = value.trim();
187+
if (v && !cliExec.isGoDuration(v)) {
188+
return "Invalid Go duration (e.g., 5s, 10s, 1m, 1m30s)";
189+
}
190+
return undefined;
191+
},
185192
});
186193
if (duration === undefined) {
187194
return;
188195
}
196+
const trimmedDuration = duration.trim();
189197

190198
const result = await withCancellableProgress(
191199
async ({ signal }) => {
192200
const env = await this.resolveCliEnv(client);
193-
return cliExec.speedtest(env, workspaceId, duration.trim(), signal);
201+
return cliExec.speedtest(env, workspaceId, trimmedDuration, signal);
194202
},
195203
{
196204
location: vscode.ProgressLocation.Notification,
197-
title: `Running ${duration.trim()} speed test...`,
205+
title: trimmedDuration
206+
? `Running speed test (${trimmedDuration})...`
207+
: "Running speed test...",
198208
cancellable: true,
199209
},
200210
);
@@ -752,13 +762,19 @@ export class Commands {
752762
};
753763
}
754764

765+
/** Resolve a CliEnv, preferring a locally cached binary over a network fetch. */
755766
private async resolveCliEnv(client: CoderApi): Promise<cliExec.CliEnv> {
756767
const baseUrl = client.getAxiosInstance().defaults.baseURL;
757768
if (!baseUrl) {
758769
throw new Error("You are not logged in");
759770
}
760771
const safeHost = toSafeHost(baseUrl);
761-
const binary = await this.cliManager.fetchBinary(client);
772+
let binary: string;
773+
try {
774+
binary = await this.cliManager.locateBinary(baseUrl);
775+
} catch {
776+
binary = await this.cliManager.fetchBinary(client);
777+
}
762778
const version = semver.parse(await cliExec.version(binary));
763779
const featureSet = featureSetForVersion(version);
764780
const configDir = this.pathResolver.getGlobalConfigDir(safeHost);

src/core/cliExec.ts

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { type ExecFileException, execFile, spawn } from "node:child_process";
22
import { promisify } from "node:util";
33
import * as vscode from "vscode";
44

5+
import { toError } from "../error/errorUtils";
56
import {
67
type CliAuth,
78
getGlobalFlags,
@@ -22,6 +23,23 @@ const execFileAsync = promisify(execFile);
2223
// and we have to make our own.
2324
type ExecException = ExecFileException & { stdout?: string; stderr?: string };
2425

26+
/** Prefer stderr over the default message which includes the full command line. */
27+
function cliError(error: unknown): Error {
28+
const stderr = (error as ExecException)?.stderr?.trim();
29+
if (stderr) {
30+
return new Error(stderr, { cause: error });
31+
}
32+
return toError(error);
33+
}
34+
35+
/** Go duration regex: one or more {number}{unit} segments (e.g. 5s, 1h30m). */
36+
const GO_DURATION_RE = /^(\d+(\.\d+)?(ns|us|µs|ms|s|m|h))+$/;
37+
38+
/** Returns true if the string is a valid Go duration. */
39+
export function isGoDuration(value: string): boolean {
40+
return GO_DURATION_RE.test(value);
41+
}
42+
2543
/**
2644
* Return the version from the binary. Throw if unable to execute the binary or
2745
* find the version for any reason.
@@ -49,7 +67,7 @@ export async function version(binPath: string): Promise<string> {
4967
return v;
5068
}
5169
}
52-
throw error;
70+
throw cliError(error);
5371
}
5472

5573
const json = JSON.parse(stdout) as { version?: string };
@@ -73,8 +91,12 @@ export async function speedtest(
7391
if (duration) {
7492
args.push("-t", duration);
7593
}
76-
const result = await execFileAsync(env.binary, args, { signal });
77-
return result.stdout;
94+
try {
95+
const result = await execFileAsync(env.binary, args, { signal });
96+
return result.stdout;
97+
} catch (error) {
98+
throw cliError(error);
99+
}
78100
}
79101

80102
/**
@@ -232,7 +254,7 @@ export async function openAppStatusTerminal(
232254
const globalFlags = getGlobalShellFlags(env.configs, env.auth);
233255
const terminal = vscode.window.createTerminal(app.name);
234256
terminal.sendText(
235-
`${escapeCommandArg(env.binary)} ${globalFlags.join(" ")} ssh ${app.workspace_name}`,
257+
`${escapeCommandArg(env.binary)} ${globalFlags.join(" ")} ssh ${escapeCommandArg(app.workspace_name)}`,
236258
);
237259
await new Promise((resolve) => setTimeout(resolve, 5000));
238260
terminal.sendText(app.command ?? "");

test/unit/core/cliExec.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,5 +207,37 @@ describe("cliExec", () => {
207207
"ENOENT",
208208
);
209209
});
210+
211+
it("surfaces stderr instead of full command line on failure", async () => {
212+
const code = [
213+
`process.stderr.write("invalid argument for -t flag\\n");`,
214+
`process.exitCode = 1;`,
215+
].join("\n");
216+
const bin = await writeExecutable(tmp, "speedtest-err", code);
217+
const { env } = setup({ mode: "global-config", configDir: "/tmp" }, bin);
218+
await expect(
219+
cliExec.speedtest(env, "owner/workspace", "bad"),
220+
).rejects.toThrow("invalid argument for -t flag");
221+
});
222+
});
223+
224+
describe("isGoDuration", () => {
225+
it.each([
226+
"5s",
227+
"10m",
228+
"1h",
229+
"1h30m",
230+
"500ms",
231+
"1.5s",
232+
"2h45m10s",
233+
"100ns",
234+
"50us",
235+
"50µs",
236+
])("accepts %s", (v) => expect(cliExec.isGoDuration(v)).toBe(true));
237+
238+
it.each(["", "bjbmn", "5", "s", "5x", "1h 30m", "-5s", "5S"])(
239+
"rejects %s",
240+
(v) => expect(cliExec.isGoDuration(v)).toBe(false),
241+
);
210242
});
211243
});

0 commit comments

Comments
 (0)