Skip to content

Commit 00a3baa

Browse files
committed
refactor: extract CLI command execution into core/cliExec module
Split core/cliUtils into two focused modules: - cliUtils: binary file management (stat, eTag, rmOld, platform naming) - cliExec: command execution (version, speedtest, ping, openAppStatusTerminal) Add resolveClientAndWorkspace to Commands for shared 3-way workspace resolution (sidebar item, connected workspace, or picker). Both pingWorkspace and speedTest now support sidebar invocation. Introduce CliEnv interface so callers provide {binary, auth} and each cliExec function resolves its own global flags internally based on execution mode (execFile vs shell spawn). Add speedTest to the sidebar diagnostics submenu alongside ping.
1 parent 081ee00 commit 00a3baa

File tree

12 files changed

+514
-428
lines changed

12 files changed

+514
-428
lines changed

package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -401,7 +401,7 @@
401401
},
402402
{
403403
"command": "coder.speedTest",
404-
"when": "coder.workspace.connected"
404+
"when": "coder.authenticated"
405405
},
406406
{
407407
"command": "coder.navigateToWorkspace",
@@ -531,6 +531,9 @@
531531
"coder.diagnostics": [
532532
{
533533
"command": "coder.pingWorkspace"
534+
},
535+
{
536+
"command": "coder.speedTest"
534537
}
535538
],
536539
"statusBar/remoteIndicator": [

src/commands.ts

Lines changed: 58 additions & 191 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,15 @@ import {
22
type Workspace,
33
type WorkspaceAgent,
44
} from "coder/site/src/api/typesGenerated";
5-
import { spawn } from "node:child_process";
65
import * as fs from "node:fs/promises";
76
import * as path from "node:path";
87
import * as semver from "semver";
98
import * as vscode from "vscode";
109

1110
import { createWorkspaceIdentifier, extractAgents } from "./api/api-helper";
1211
import { type CoderApi } from "./api/coderApi";
12+
import * as cliExec from "./core/cliExec";
1313
import { type CliManager } from "./core/cliManager";
14-
import * as cliUtils from "./core/cliUtils";
1514
import { type ServiceContainer } from "./core/container";
1615
import { type MementoManager } from "./core/mementoManager";
1716
import { type PathResolver } from "./core/pathResolver";
@@ -28,12 +27,8 @@ import {
2827
RECOMMENDED_SSH_SETTINGS,
2928
applySettingOverrides,
3029
} from "./remote/sshOverrides";
31-
import {
32-
getGlobalFlags,
33-
getGlobalShellFlags,
34-
resolveCliAuth,
35-
} from "./settings/cli";
36-
import { escapeCommandArg, toRemoteAuthority, toSafeHost } from "./util";
30+
import { resolveCliAuth } from "./settings/cli";
31+
import { toRemoteAuthority, toSafeHost } from "./util";
3732
import { vscodeProposed } from "./vscodeProposed";
3833
import {
3934
AgentTreeItem,
@@ -168,17 +163,17 @@ export class Commands {
168163
}
169164

170165
/**
171-
* Run a speed test against the currently connected workspace and display the
172-
* results in a new editor document.
166+
* Run a speed test against a workspace and display the results in a new
167+
* editor document. Can be triggered from the sidebar or command palette.
173168
*/
174-
public async speedTest(): Promise<void> {
175-
const workspace = this.workspace;
176-
const client = this.remoteWorkspaceClient;
177-
if (!workspace || !client) {
178-
vscode.window.showInformationMessage("No workspace connected.");
169+
public async speedTest(item?: OpenableTreeItem): Promise<void> {
170+
const resolved = await this.resolveClientAndWorkspace(item);
171+
if (!resolved) {
179172
return;
180173
}
181174

175+
const { client, workspaceId } = resolved;
176+
182177
const duration = await vscode.window.showInputBox({
183178
title: "Speed Test Duration",
184179
prompt: "Duration for the speed test (e.g., 5s, 10s, 1m)",
@@ -190,24 +185,8 @@ export class Commands {
190185

191186
const result = await withCancellableProgress(
192187
async ({ signal }) => {
193-
const baseUrl = client.getAxiosInstance().defaults.baseURL;
194-
if (!baseUrl) {
195-
throw new Error("No deployment URL for the connected workspace");
196-
}
197-
const safeHost = toSafeHost(baseUrl);
198-
const binary = await this.cliManager.fetchBinary(client);
199-
const version = semver.parse(await cliUtils.version(binary));
200-
const featureSet = featureSetForVersion(version);
201-
const configDir = this.pathResolver.getGlobalConfigDir(safeHost);
202-
const configs = vscode.workspace.getConfiguration();
203-
const auth = resolveCliAuth(configs, featureSet, baseUrl, configDir);
204-
const globalFlags = getGlobalFlags(configs, auth);
205-
const workspaceName = createWorkspaceIdentifier(workspace);
206-
207-
return cliUtils.speedtest(binary, globalFlags, workspaceName, {
208-
signal,
209-
duration: duration.trim(),
210-
});
188+
const env = await this.resolveCliEnv(client);
189+
return cliExec.speedtest(env, workspaceId, duration.trim(), signal);
211190
},
212191
{
213192
location: vscode.ProgressLocation.Notification,
@@ -564,17 +543,8 @@ export class Commands {
564543
title: `Connecting to AI Agent...`,
565544
},
566545
async () => {
567-
const { binary, globalFlags } = await this.resolveCliEnv(
568-
this.extensionClient,
569-
);
570-
571-
const terminal = vscode.window.createTerminal(app.name);
572-
terminal.sendText(
573-
`${escapeCommandArg(binary)} ${globalFlags.join(" ")} ssh ${app.workspace_name}`,
574-
);
575-
await new Promise((resolve) => setTimeout(resolve, 5000));
576-
terminal.sendText(app.command ?? "");
577-
terminal.show(false);
546+
const env = await this.resolveCliEnv(this.extensionClient);
547+
await cliExec.openAppStatusTerminal(env, app);
578548
},
579549
);
580550
}
@@ -725,175 +695,72 @@ export class Commands {
725695
}
726696

727697
public async pingWorkspace(item?: OpenableTreeItem): Promise<void> {
728-
let client: CoderApi;
729-
let workspaceId: string;
730-
731-
if (item) {
732-
client = this.extensionClient;
733-
workspaceId = createWorkspaceIdentifier(item.workspace);
734-
} else if (this.workspace && this.remoteWorkspaceClient) {
735-
client = this.remoteWorkspaceClient;
736-
workspaceId = createWorkspaceIdentifier(this.workspace);
737-
} else {
738-
client = this.extensionClient;
739-
const workspace = await this.pickWorkspace({
740-
title: "Ping a running workspace",
741-
initialValue: "owner:me status:running ",
742-
placeholder: "Search running workspaces...",
743-
filter: (w) => w.latest_build.status === "running",
744-
});
745-
if (!workspace) {
746-
return;
747-
}
748-
workspaceId = createWorkspaceIdentifier(workspace);
698+
const resolved = await this.resolveClientAndWorkspace(item);
699+
if (!resolved) {
700+
return;
749701
}
750702

751-
return this.spawnPing(client, workspaceId);
752-
}
753-
754-
private spawnPing(client: CoderApi, workspaceId: string): Thenable<void> {
703+
const { client, workspaceId } = resolved;
755704
return withProgress(
756705
{
757706
location: vscode.ProgressLocation.Notification,
758707
title: `Starting ping for ${workspaceId}...`,
759708
},
760709
async () => {
761-
const { binary, globalFlags } = await this.resolveCliEnv(client);
762-
763-
const writeEmitter = new vscode.EventEmitter<string>();
764-
const closeEmitter = new vscode.EventEmitter<number | void>();
765-
766-
const args = [...globalFlags, "ping", escapeCommandArg(workspaceId)];
767-
const cmd = `${escapeCommandArg(binary)} ${args.join(" ")}`;
768-
// On Unix, spawn in a new process group so we can signal the
769-
// entire group (shell + coder binary) on Ctrl+C. On Windows,
770-
// detached opens a visible console window and negative-PID kill
771-
// is unsupported, so we fall back to proc.kill().
772-
const useProcessGroup = process.platform !== "win32";
773-
const proc = spawn(cmd, {
774-
shell: true,
775-
detached: useProcessGroup,
776-
});
777-
778-
let closed = false;
779-
let exited = false;
780-
let forceKillTimer: ReturnType<typeof setTimeout> | undefined;
781-
782-
const sendSignal = (sig: "SIGINT" | "SIGKILL") => {
783-
try {
784-
if (useProcessGroup && proc.pid) {
785-
process.kill(-proc.pid, sig);
786-
} else {
787-
proc.kill(sig);
788-
}
789-
} catch {
790-
// Process already exited.
791-
}
792-
};
793-
794-
const gracefulKill = () => {
795-
sendSignal("SIGINT");
796-
// Escalate to SIGKILL if the process doesn't exit promptly.
797-
forceKillTimer = setTimeout(() => sendSignal("SIGKILL"), 5000);
798-
};
799-
800-
const terminal = vscode.window.createTerminal({
801-
name: `Coder Ping: ${workspaceId}`,
802-
pty: {
803-
onDidWrite: writeEmitter.event,
804-
onDidClose: closeEmitter.event,
805-
open: () => {
806-
writeEmitter.fire("Press Ctrl+C (^C) to stop.\r\n");
807-
writeEmitter.fire("─".repeat(40) + "\r\n");
808-
},
809-
close: () => {
810-
closed = true;
811-
clearTimeout(forceKillTimer);
812-
sendSignal("SIGKILL");
813-
writeEmitter.dispose();
814-
closeEmitter.dispose();
815-
},
816-
handleInput: (data: string) => {
817-
if (exited) {
818-
closeEmitter.fire();
819-
} else if (data === "\x03") {
820-
if (forceKillTimer) {
821-
// Second Ctrl+C: force kill immediately.
822-
clearTimeout(forceKillTimer);
823-
sendSignal("SIGKILL");
824-
} else {
825-
if (!closed) {
826-
writeEmitter.fire("\r\nStopping...\r\n");
827-
}
828-
gracefulKill();
829-
}
830-
}
831-
},
832-
},
833-
});
834-
835-
const fireLines = (data: Buffer) => {
836-
if (closed) {
837-
return;
838-
}
839-
const lines = data
840-
.toString()
841-
.split(/\r*\n/)
842-
.filter((line) => line !== "");
843-
for (const line of lines) {
844-
writeEmitter.fire(line + "\r\n");
845-
}
846-
};
847-
848-
proc.stdout?.on("data", fireLines);
849-
proc.stderr?.on("data", fireLines);
850-
proc.on("error", (err) => {
851-
exited = true;
852-
clearTimeout(forceKillTimer);
853-
if (closed) {
854-
return;
855-
}
856-
writeEmitter.fire(`\r\nFailed to start: ${err.message}\r\n`);
857-
writeEmitter.fire("Press any key to close.\r\n");
858-
});
859-
proc.on("close", (code, signal) => {
860-
exited = true;
861-
clearTimeout(forceKillTimer);
862-
if (closed) {
863-
return;
864-
}
865-
let reason: string;
866-
if (signal === "SIGKILL") {
867-
reason = "Ping force killed (SIGKILL)";
868-
} else if (signal) {
869-
reason = "Ping stopped";
870-
} else {
871-
reason = `Process exited with code ${code}`;
872-
}
873-
writeEmitter.fire(`\r\n${reason}. Press any key to close.\r\n`);
874-
});
875-
876-
terminal.show(false);
710+
const env = await this.resolveCliEnv(client);
711+
cliExec.ping(env, workspaceId);
877712
},
878713
);
879714
}
880715

881-
private async resolveCliEnv(
882-
client: CoderApi,
883-
): Promise<{ binary: string; globalFlags: string[] }> {
716+
/**
717+
* Resolve the API client and workspace identifier from a sidebar item,
718+
* the currently connected workspace, or by prompting the user to pick one.
719+
* Returns undefined if the user cancels the picker.
720+
*/
721+
private async resolveClientAndWorkspace(
722+
item?: OpenableTreeItem,
723+
): Promise<{ client: CoderApi; workspaceId: string } | undefined> {
724+
if (item) {
725+
return {
726+
client: this.extensionClient,
727+
workspaceId: createWorkspaceIdentifier(item.workspace),
728+
};
729+
}
730+
if (this.workspace && this.remoteWorkspaceClient) {
731+
return {
732+
client: this.remoteWorkspaceClient,
733+
workspaceId: createWorkspaceIdentifier(this.workspace),
734+
};
735+
}
736+
const workspace = await this.pickWorkspace({
737+
title: "Select a running workspace",
738+
initialValue: "owner:me status:running ",
739+
placeholder: "Search running workspaces...",
740+
filter: (w) => w.latest_build.status === "running",
741+
});
742+
if (!workspace) {
743+
return undefined;
744+
}
745+
return {
746+
client: this.extensionClient,
747+
workspaceId: createWorkspaceIdentifier(workspace),
748+
};
749+
}
750+
751+
private async resolveCliEnv(client: CoderApi): Promise<cliExec.CliEnv> {
884752
const baseUrl = client.getAxiosInstance().defaults.baseURL;
885753
if (!baseUrl) {
886754
throw new Error("You are not logged in");
887755
}
888756
const safeHost = toSafeHost(baseUrl);
889757
const binary = await this.cliManager.fetchBinary(client);
890-
const version = semver.parse(await cliUtils.version(binary));
758+
const version = semver.parse(await cliExec.version(binary));
891759
const featureSet = featureSetForVersion(version);
892760
const configDir = this.pathResolver.getGlobalConfigDir(safeHost);
893761
const configs = vscode.workspace.getConfiguration();
894762
const auth = resolveCliAuth(configs, featureSet, baseUrl, configDir);
895-
const globalFlags = getGlobalShellFlags(configs, auth);
896-
return { binary, globalFlags };
763+
return { binary, auth };
897764
}
898765

899766
/**

src/core/cliCredentialManager.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { isKeyringEnabled } from "../settings/cli";
1111
import { getHeaderArgs } from "../settings/headers";
1212
import { renameWithRetry, tempFilePath, toSafeHost } from "../util";
1313

14-
import * as cliUtils from "./cliUtils";
14+
import { version } from "./cliExec";
1515

1616
import type { WorkspaceConfiguration } from "vscode";
1717

@@ -172,8 +172,8 @@ export class CliCredentialManager {
172172
return undefined;
173173
}
174174
const binPath = await this.resolveBinary(url);
175-
const version = semver.parse(await cliUtils.version(binPath));
176-
return featureSetForVersion(version)[feature] ? binPath : undefined;
175+
const cliVersion = semver.parse(await version(binPath));
176+
return featureSetForVersion(cliVersion)[feature] ? binPath : undefined;
177177
}
178178

179179
/**

0 commit comments

Comments
 (0)