Skip to content

Commit 9f6d32d

Browse files
committed
refactor: extract CLI command execution into core/cliExec module
1 parent d998ac2 commit 9f6d32d

File tree

16 files changed

+571
-442
lines changed

16 files changed

+571
-442
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,7 +2,6 @@ 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";
@@ -14,8 +13,8 @@ import {
1413
workspaceStatusLabel,
1514
} from "./api/api-helper";
1615
import { type CoderApi } from "./api/coderApi";
16+
import * as cliExec from "./core/cliExec";
1717
import { type CliManager } from "./core/cliManager";
18-
import * as cliUtils from "./core/cliUtils";
1918
import { type ServiceContainer } from "./core/container";
2019
import { type MementoManager } from "./core/mementoManager";
2120
import { type PathResolver } from "./core/pathResolver";
@@ -32,12 +31,8 @@ import {
3231
RECOMMENDED_SSH_SETTINGS,
3332
applySettingOverrides,
3433
} from "./remote/sshOverrides";
35-
import {
36-
getGlobalFlags,
37-
getGlobalShellFlags,
38-
resolveCliAuth,
39-
} from "./settings/cli";
40-
import { escapeCommandArg, toRemoteAuthority, toSafeHost } from "./util";
34+
import { resolveCliAuth } from "./settings/cli";
35+
import { toRemoteAuthority, toSafeHost } from "./util";
4136
import { vscodeProposed } from "./vscodeProposed";
4237
import {
4338
AgentTreeItem,
@@ -172,17 +167,17 @@ export class Commands {
172167
}
173168

174169
/**
175-
* Run a speed test against the currently connected workspace and display the
176-
* results in a new editor document.
170+
* Run a speed test against a workspace and display the results in a new
171+
* editor document. Can be triggered from the sidebar or command palette.
177172
*/
178-
public async speedTest(): Promise<void> {
179-
const workspace = this.workspace;
180-
const client = this.remoteWorkspaceClient;
181-
if (!workspace || !client) {
182-
vscode.window.showInformationMessage("No workspace connected.");
173+
public async speedTest(item?: OpenableTreeItem): Promise<void> {
174+
const resolved = await this.resolveClientAndWorkspace(item);
175+
if (!resolved) {
183176
return;
184177
}
185178

179+
const { client, workspaceId } = resolved;
180+
186181
const duration = await vscode.window.showInputBox({
187182
title: "Speed Test Duration",
188183
prompt: "Duration for the speed test (e.g., 5s, 10s, 1m)",
@@ -194,24 +189,8 @@ export class Commands {
194189

195190
const result = await withCancellableProgress(
196191
async ({ signal }) => {
197-
const baseUrl = client.getAxiosInstance().defaults.baseURL;
198-
if (!baseUrl) {
199-
throw new Error("No deployment URL for the connected workspace");
200-
}
201-
const safeHost = toSafeHost(baseUrl);
202-
const binary = await this.cliManager.fetchBinary(client);
203-
const version = semver.parse(await cliUtils.version(binary));
204-
const featureSet = featureSetForVersion(version);
205-
const configDir = this.pathResolver.getGlobalConfigDir(safeHost);
206-
const configs = vscode.workspace.getConfiguration();
207-
const auth = resolveCliAuth(configs, featureSet, baseUrl, configDir);
208-
const globalFlags = getGlobalFlags(configs, auth);
209-
const workspaceName = createWorkspaceIdentifier(workspace);
210-
211-
return cliUtils.speedtest(binary, globalFlags, workspaceName, {
212-
signal,
213-
duration: duration.trim(),
214-
});
192+
const env = await this.resolveCliEnv(client);
193+
return cliExec.speedtest(env, workspaceId, duration.trim(), signal);
215194
},
216195
{
217196
location: vscode.ProgressLocation.Notification,
@@ -568,17 +547,8 @@ export class Commands {
568547
title: `Connecting to AI Agent...`,
569548
},
570549
async () => {
571-
const { binary, globalFlags } = await this.resolveCliEnv(
572-
this.extensionClient,
573-
);
574-
575-
const terminal = vscode.window.createTerminal(app.name);
576-
terminal.sendText(
577-
`${escapeCommandArg(binary)} ${globalFlags.join(" ")} ssh ${app.workspace_name}`,
578-
);
579-
await new Promise((resolve) => setTimeout(resolve, 5000));
580-
terminal.sendText(app.command ?? "");
581-
terminal.show(false);
550+
const env = await this.resolveCliEnv(this.extensionClient);
551+
await cliExec.openAppStatusTerminal(env, app);
582552
},
583553
);
584554
}
@@ -729,175 +699,72 @@ export class Commands {
729699
}
730700

731701
public async pingWorkspace(item?: OpenableTreeItem): Promise<void> {
732-
let client: CoderApi;
733-
let workspaceId: string;
734-
735-
if (item) {
736-
client = this.extensionClient;
737-
workspaceId = createWorkspaceIdentifier(item.workspace);
738-
} else if (this.workspace && this.remoteWorkspaceClient) {
739-
client = this.remoteWorkspaceClient;
740-
workspaceId = createWorkspaceIdentifier(this.workspace);
741-
} else {
742-
client = this.extensionClient;
743-
const workspace = await this.pickWorkspace({
744-
title: "Ping a running workspace",
745-
initialValue: "owner:me status:running ",
746-
placeholder: "Search running workspaces...",
747-
filter: (w) => w.latest_build.status === "running",
748-
});
749-
if (!workspace) {
750-
return;
751-
}
752-
workspaceId = createWorkspaceIdentifier(workspace);
702+
const resolved = await this.resolveClientAndWorkspace(item);
703+
if (!resolved) {
704+
return;
753705
}
754706

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

885-
private async resolveCliEnv(
886-
client: CoderApi,
887-
): Promise<{ binary: string; globalFlags: string[] }> {
720+
/**
721+
* Resolve the API client and workspace identifier from a sidebar item,
722+
* the currently connected workspace, or by prompting the user to pick one.
723+
* Returns undefined if the user cancels the picker.
724+
*/
725+
private async resolveClientAndWorkspace(
726+
item?: OpenableTreeItem,
727+
): Promise<{ client: CoderApi; workspaceId: string } | undefined> {
728+
if (item) {
729+
return {
730+
client: this.extensionClient,
731+
workspaceId: createWorkspaceIdentifier(item.workspace),
732+
};
733+
}
734+
if (this.workspace && this.remoteWorkspaceClient) {
735+
return {
736+
client: this.remoteWorkspaceClient,
737+
workspaceId: createWorkspaceIdentifier(this.workspace),
738+
};
739+
}
740+
const workspace = await this.pickWorkspace({
741+
title: "Select a running workspace",
742+
initialValue: "owner:me status:running ",
743+
placeholder: "Search running workspaces...",
744+
filter: (w) => w.latest_build.status === "running",
745+
});
746+
if (!workspace) {
747+
return undefined;
748+
}
749+
return {
750+
client: this.extensionClient,
751+
workspaceId: createWorkspaceIdentifier(workspace),
752+
};
753+
}
754+
755+
private async resolveCliEnv(client: CoderApi): Promise<cliExec.CliEnv> {
888756
const baseUrl = client.getAxiosInstance().defaults.baseURL;
889757
if (!baseUrl) {
890758
throw new Error("You are not logged in");
891759
}
892760
const safeHost = toSafeHost(baseUrl);
893761
const binary = await this.cliManager.fetchBinary(client);
894-
const version = semver.parse(await cliUtils.version(binary));
762+
const version = semver.parse(await cliExec.version(binary));
895763
const featureSet = featureSetForVersion(version);
896764
const configDir = this.pathResolver.getGlobalConfigDir(safeHost);
897765
const configs = vscode.workspace.getConfiguration();
898766
const auth = resolveCliAuth(configs, featureSet, baseUrl, configDir);
899-
const globalFlags = getGlobalShellFlags(configs, auth);
900-
return { binary, globalFlags };
767+
return { binary, configs, auth };
901768
}
902769

903770
/**

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)