refactor(vscode-web): migrate to VS Code CLI with code serve-web

- Replace code-server with official VS Code CLI
- Download CLI from code.visualstudio.com using cli-alpine-* URLs
- Add release_channel variable (stable/insiders)
- Add commit_id variable to pin specific VS Code versions
- Support offline mode with fallback to code-server or cached vscode-server
- Add comprehensive bun tests for settings, extensions, and CLI arguments
- Add Terraform tests for variable validation
This commit is contained in:
Muhammad Atif Ali 2025-12-31 16:28:08 +05:00
parent 678c3e631e
commit b52c0f9f63
No known key found for this signature in database
5 changed files with 1269 additions and 171 deletions

View File

@ -8,13 +8,13 @@ tags: [ide, vscode, web]
# VS Code Web
Automatically install [Visual Studio Code Server](https://code.visualstudio.com/docs/remote/vscode-server) in a workspace and create an app to access it via the dashboard.
Automatically install the [VS Code CLI](https://code.visualstudio.com/docs/editor/command-line) and run `code serve-web` in a workspace to access VS Code via the browser.
```tf
module "vscode-web" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/vscode-web/coder"
version = "1.4.3"
version = "2.0.0"
agent_id = coder_agent.example.id
accept_license = true
}
@ -30,7 +30,7 @@ module "vscode-web" {
module "vscode-web" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/vscode-web/coder"
version = "1.4.3"
version = "2.0.0"
agent_id = coder_agent.example.id
install_prefix = "/home/coder/.vscode-web"
folder = "/home/coder"
@ -44,7 +44,7 @@ module "vscode-web" {
module "vscode-web" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/vscode-web/coder"
version = "1.4.3"
version = "2.0.0"
agent_id = coder_agent.example.id
extensions = ["github.copilot", "ms-python.python", "ms-toolsai.jupyter"]
accept_license = true
@ -59,7 +59,7 @@ Configure VS Code's [settings.json](https://code.visualstudio.com/docs/getstarte
module "vscode-web" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/vscode-web/coder"
version = "1.4.3"
version = "2.0.0"
agent_id = coder_agent.example.id
extensions = ["dracula-theme.theme-dracula"]
settings = {
@ -69,21 +69,6 @@ module "vscode-web" {
}
```
### Pin a specific VS Code Web version
By default, this module installs the latest. To pin a specific version, retrieve the commit ID from the [VS Code Update API](https://update.code.visualstudio.com/api/commits/stable/server-linux-x64-web) and verify its corresponding release on the [VS Code GitHub Releases](https://github.com/microsoft/vscode/releases).
```tf
module "vscode-web" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/vscode-web/coder"
version = "1.4.3"
agent_id = coder_agent.example.id
commit_id = "e54c774e0add60467559eb0d1e229c6452cf8447"
accept_license = true
}
```
### Open an existing workspace on startup
To open an existing workspace on startup the `workspace` parameter can be used to represent a path on disk to a `code-workspace` file.
@ -93,8 +78,41 @@ Note: Either `workspace` or `folder` can be used, but not both simultaneously. T
module "vscode-web" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/vscode-web/coder"
version = "1.4.3"
version = "2.0.0"
agent_id = coder_agent.example.id
workspace = "/home/coder/coder.code-workspace"
accept_license = true
}
```
### Use VS Code Insiders
Use the VS Code Insiders release channel to get the latest features and bug fixes:
```tf
module "vscode-web" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/vscode-web/coder"
version = "2.0.0"
agent_id = coder_agent.example.id
release_channel = "insiders"
accept_license = true
}
```
### Pin a specific VS Code version
Use the `commit_id` variable to pin a specific VS Code Server version by its commit SHA:
```tf
module "vscode-web" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/vscode-web/coder"
version = "2.0.0"
agent_id = coder_agent.example.id
commit_id = "e54c774e0add60467559eb0d1e229c6452cf8447"
accept_license = true
}
```
You can find the commit SHA for a specific VS Code version on the [VS Code releases page](https://code.visualstudio.com/updates) or by checking the "About" dialog in VS Code.

View File

@ -1,42 +1,784 @@
import { describe, expect, it } from "bun:test";
import { runTerraformApply, runTerraformInit } from "~test";
import {
describe,
expect,
it,
beforeAll,
afterEach,
setDefaultTimeout,
} from "bun:test";
import {
runTerraformApply,
runTerraformInit,
runContainer,
execContainer,
removeContainer,
findResourceInstance,
} from "~test";
// Set timeout to 5 minutes for tests that download VS Code CLI
setDefaultTimeout(5 * 60 * 1000);
let cleanupContainers: string[] = [];
afterEach(async () => {
for (const id of cleanupContainers) {
try {
await removeContainer(id);
} catch {
// Ignore cleanup errors
}
}
cleanupContainers = [];
});
describe("vscode-web", async () => {
await runTerraformInit(import.meta.dir);
it("accept_license should be set to true", () => {
const t = async () => {
await runTerraformApply(import.meta.dir, {
agent_id: "foo",
accept_license: "false",
});
};
expect(t).toThrow("Invalid value for variable");
beforeAll(async () => {
await runTerraformInit(import.meta.dir);
});
it("use_cached and offline can not be used together", () => {
const t = async () => {
await runTerraformApply(import.meta.dir, {
agent_id: "foo",
accept_license: "true",
use_cached: "true",
offline: "true",
});
};
expect(t).toThrow("Offline and Use Cached can not be used together");
describe("terraform validation", () => {
it("accept_license should be set to true", async () => {
try {
await runTerraformApply(import.meta.dir, {
agent_id: "foo",
accept_license: false,
});
throw new Error("Expected terraform apply to fail");
} catch (ex) {
expect((ex as Error).message).toContain("Invalid value for variable");
}
});
it("use_cached and offline can not be used together", async () => {
try {
await runTerraformApply(import.meta.dir, {
agent_id: "foo",
accept_license: true,
use_cached: true,
offline: true,
});
throw new Error("Expected terraform apply to fail");
} catch (ex) {
expect((ex as Error).message).toContain(
"Offline and Use Cached can not be used together",
);
}
});
it("offline and extensions can not be used together", async () => {
try {
await runTerraformApply(import.meta.dir, {
agent_id: "foo",
accept_license: true,
offline: true,
extensions: '["ms-python.python"]',
});
throw new Error("Expected terraform apply to fail");
} catch (ex) {
expect((ex as Error).message).toContain(
"Offline mode does not allow extensions to be installed",
);
}
});
it("workspace and folder can not be used together", async () => {
try {
await runTerraformApply(import.meta.dir, {
agent_id: "foo",
accept_license: true,
folder: "/home/coder",
workspace: "/home/coder/test.code-workspace",
});
throw new Error("Expected terraform apply to fail");
} catch (ex) {
expect((ex as Error).message).toContain(
"Set only one of `workspace` or `folder`",
);
}
});
});
it("offline and extensions can not be used together", () => {
const t = async () => {
await runTerraformApply(import.meta.dir, {
describe("script generation", () => {
it("generates script with correct port", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
accept_license: "true",
offline: "true",
extensions: '["1", "2"]',
accept_license: true,
port: 8080,
});
};
expect(t).toThrow("Offline mode does not allow extensions to be installed");
const script = findResourceInstance(state, "coder_script");
expect(script.script).toContain("--port 8080");
});
it("generates script with extensions directory", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
accept_license: true,
extensions_dir: "/custom/extensions",
});
const script = findResourceInstance(state, "coder_script");
expect(script.script).toContain("--extensions-dir=/custom/extensions");
});
it("generates script with telemetry level", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
accept_license: true,
telemetry_level: "off",
});
const script = findResourceInstance(state, "coder_script");
expect(script.script).toContain("--telemetry-level off");
});
it("generates script with disable trust", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
accept_license: true,
disable_trust: true,
});
const script = findResourceInstance(state, "coder_script");
expect(script.script).toContain("--disable-workspace-trust");
});
it("generates script with serve-web command", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
accept_license: true,
});
const script = findResourceInstance(state, "coder_script");
expect(script.script).toContain("serve-web");
expect(script.script).toContain("--accept-server-license-terms");
expect(script.script).toContain("--without-connection-token");
});
it("generates script with stable release channel by default", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
accept_license: true,
});
const script = findResourceInstance(state, "coder_script");
expect(script.script).toContain("build=stable");
});
it("generates script with insiders release channel", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
accept_license: true,
release_channel: "insiders",
});
const script = findResourceInstance(state, "coder_script");
expect(script.script).toContain("build=insiders");
});
it("generates script without commit-id value when not specified", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
accept_license: true,
});
const script = findResourceInstance(state, "coder_script");
// The if condition should have an empty string, so no commit-id value is passed
expect(script.script).toContain('if [ -n "" ]; then');
// Should not contain any actual commit hash
expect(script.script).not.toMatch(/--commit-id [a-f0-9]{40}/);
});
it("generates script with commit-id when specified", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
accept_license: true,
commit_id: "e54c774e0add60467559eb0d1e229c6452cf8447",
});
const script = findResourceInstance(state, "coder_script");
expect(script.script).toContain(
"--commit-id e54c774e0add60467559eb0d1e229c6452cf8447",
);
});
});
// More tests depend on shebang refactors
describe("container integration tests", () => {
it("uses existing code CLI in PATH", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
accept_license: true,
});
const containerId = await runContainer("ubuntu:22.04");
cleanupContainers.push(containerId);
// Create a mock code CLI that logs when serve-web is called
await execContainer(containerId, [
"bash",
"-c",
`cat > /usr/local/bin/code << 'MOCKEOF'
#!/bin/bash
if [ "\$1" = "serve-web" ]; then
echo "MOCK_SERVER_STARTED with args: \$@"
exit 0
fi
echo "code mock called: \$@"
exit 0
MOCKEOF
chmod +x /usr/local/bin/code`,
]);
const script = findResourceInstance(state, "coder_script");
// Run the script - the mock will capture the serve-web call
const result = await execContainer(containerId, [
"bash",
"-c",
script.script,
]);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain("Found VS Code CLI");
});
it("offline mode fails when CLI not present", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
accept_license: true,
offline: true,
});
const containerId = await runContainer("ubuntu:22.04");
cleanupContainers.push(containerId);
const script = findResourceInstance(state, "coder_script");
const result = await execContainer(containerId, [
"bash",
"-c",
script.script,
]);
expect(result.exitCode).toBe(1);
expect(result.stdout).toContain(
"Offline mode enabled but no VS Code CLI, code-server, or cached VS Code Server found",
);
});
it("offline mode uses code-server as fallback", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
accept_license: true,
offline: true,
});
const containerId = await runContainer("ubuntu:22.04");
cleanupContainers.push(containerId);
// Install mock code-server in PATH
await execContainer(containerId, [
"bash",
"-c",
`mkdir -p /usr/local/bin && cat > /usr/local/bin/code-server << 'MOCKEOF'
#!/bin/bash
echo "MOCK_CODE_SERVER_STARTED with args: $@"
exit 0
MOCKEOF
chmod +x /usr/local/bin/code-server`,
]);
const script = findResourceInstance(state, "coder_script");
const result = await execContainer(containerId, [
"bash",
"-c",
script.script,
]);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain("offline fallback");
expect(result.stdout).toContain("Starting code-server");
});
it("offline mode works with pre-installed CLI", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
accept_license: true,
offline: true,
install_prefix: "/tmp/vscode-web",
});
const containerId = await runContainer("ubuntu:22.04");
cleanupContainers.push(containerId);
// Pre-install mock code CLI at expected location
await execContainer(containerId, [
"bash",
"-c",
`mkdir -p /tmp/vscode-web/bin && cat > /tmp/vscode-web/bin/code << 'MOCKEOF'
#!/bin/bash
if [ "\$1" = "serve-web" ]; then
echo "MOCK_OFFLINE_SERVER_STARTED"
exit 0
fi
exit 0
MOCKEOF
chmod +x /tmp/vscode-web/bin/code`,
]);
const script = findResourceInstance(state, "coder_script");
const result = await execContainer(containerId, [
"bash",
"-c",
script.script,
]);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain("Using cached VS Code CLI");
expect(result.stdout).toContain("Starting VS Code Web");
});
it("use_cached mode works with pre-installed CLI", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
accept_license: true,
use_cached: true,
install_prefix: "/tmp/vscode-web",
});
const containerId = await runContainer("ubuntu:22.04");
cleanupContainers.push(containerId);
// Pre-install mock code CLI
await execContainer(containerId, [
"bash",
"-c",
`mkdir -p /tmp/vscode-web/bin && cat > /tmp/vscode-web/bin/code << 'MOCKEOF'
#!/bin/bash
if [ "\$1" = "serve-web" ]; then
echo "MOCK_CACHED_SERVER_STARTED"
exit 0
fi
exit 0
MOCKEOF
chmod +x /tmp/vscode-web/bin/code`,
]);
const script = findResourceInstance(state, "coder_script");
const result = await execContainer(containerId, [
"bash",
"-c",
script.script,
]);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain("Using cached VS Code CLI");
});
it("creates settings file with correct content", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
accept_license: true,
settings: '{"editor.fontSize": 14}',
});
const containerId = await runContainer("ubuntu:22.04");
cleanupContainers.push(containerId);
// Create a mock code CLI
await execContainer(containerId, [
"bash",
"-c",
`mkdir -p /usr/local/bin && cat > /usr/local/bin/code << 'MOCKEOF'
#!/bin/bash
exit 0
MOCKEOF
chmod +x /usr/local/bin/code`,
]);
const script = findResourceInstance(state, "coder_script");
await execContainer(containerId, ["bash", "-c", script.script]);
// Check that settings file was created
const settingsResult = await execContainer(containerId, [
"cat",
"/root/.vscode-server/data/Machine/settings.json",
]);
expect(settingsResult.exitCode).toBe(0);
expect(settingsResult.stdout).toContain("editor.fontSize");
expect(settingsResult.stdout).toContain("14");
});
it("creates settings file with multiple settings", async () => {
const settings = {
"editor.fontSize": 16,
"editor.tabSize": 2,
"workbench.colorTheme": "Dracula",
"editor.formatOnSave": true,
};
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
accept_license: true,
settings: JSON.stringify(settings),
});
const containerId = await runContainer("ubuntu:22.04");
cleanupContainers.push(containerId);
// Create a mock code CLI
await execContainer(containerId, [
"bash",
"-c",
`mkdir -p /usr/local/bin && cat > /usr/local/bin/code << 'MOCKEOF'
#!/bin/bash
exit 0
MOCKEOF
chmod +x /usr/local/bin/code`,
]);
const script = findResourceInstance(state, "coder_script");
await execContainer(containerId, ["bash", "-c", script.script]);
// Check that settings file was created with all settings
const settingsResult = await execContainer(containerId, [
"cat",
"/root/.vscode-server/data/Machine/settings.json",
]);
expect(settingsResult.exitCode).toBe(0);
expect(settingsResult.stdout).toContain("editor.fontSize");
expect(settingsResult.stdout).toContain("16");
expect(settingsResult.stdout).toContain("editor.tabSize");
expect(settingsResult.stdout).toContain("2");
expect(settingsResult.stdout).toContain("workbench.colorTheme");
expect(settingsResult.stdout).toContain("Dracula");
expect(settingsResult.stdout).toContain("editor.formatOnSave");
expect(settingsResult.stdout).toContain("true");
});
it("creates settings file in correct directory structure", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
accept_license: true,
settings: '{"test.setting": "value"}',
});
const containerId = await runContainer("ubuntu:22.04");
cleanupContainers.push(containerId);
// Create a mock code CLI
await execContainer(containerId, [
"bash",
"-c",
`mkdir -p /usr/local/bin && cat > /usr/local/bin/code << 'MOCKEOF'
#!/bin/bash
exit 0
MOCKEOF
chmod +x /usr/local/bin/code`,
]);
const script = findResourceInstance(state, "coder_script");
await execContainer(containerId, ["bash", "-c", script.script]);
// Verify directory structure was created
const dirResult = await execContainer(containerId, [
"ls",
"-la",
"/root/.vscode-server/data/Machine/",
]);
expect(dirResult.exitCode).toBe(0);
expect(dirResult.stdout).toContain("settings.json");
// Verify parent directories exist
const parentDirResult = await execContainer(containerId, [
"ls",
"-la",
"/root/.vscode-server/data/",
]);
expect(parentDirResult.exitCode).toBe(0);
expect(parentDirResult.stdout).toContain("Machine");
});
it("does not overwrite existing settings file", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
accept_license: true,
settings: '{"new.setting": "new_value"}',
});
const containerId = await runContainer("ubuntu:22.04");
cleanupContainers.push(containerId);
// Create a mock code CLI
await execContainer(containerId, [
"bash",
"-c",
`mkdir -p /usr/local/bin && cat > /usr/local/bin/code << 'MOCKEOF'
#!/bin/bash
exit 0
MOCKEOF
chmod +x /usr/local/bin/code`,
]);
// Pre-create an existing settings file
await execContainer(containerId, [
"bash",
"-c",
`mkdir -p /root/.vscode-server/data/Machine && echo '{"existing.setting": "existing_value"}' > /root/.vscode-server/data/Machine/settings.json`,
]);
const script = findResourceInstance(state, "coder_script");
await execContainer(containerId, ["bash", "-c", script.script]);
// Check that existing settings file was NOT overwritten
const settingsResult = await execContainer(containerId, [
"cat",
"/root/.vscode-server/data/Machine/settings.json",
]);
expect(settingsResult.exitCode).toBe(0);
// Should contain existing setting, not the new one
expect(settingsResult.stdout).toContain("existing.setting");
expect(settingsResult.stdout).toContain("existing_value");
expect(settingsResult.stdout).not.toContain("new.setting");
});
it("creates valid JSON settings file", async () => {
const settings = {
"editor.fontSize": 14,
"editor.wordWrap": "on",
"files.autoSave": "afterDelay",
"files.autoSaveDelay": 1000,
};
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
accept_license: true,
settings: JSON.stringify(settings),
});
const containerId = await runContainer("ubuntu:22.04");
cleanupContainers.push(containerId);
// Install jq and create mock code CLI
await execContainer(containerId, ["apt-get", "update", "-qq"]);
await execContainer(containerId, [
"apt-get",
"install",
"-y",
"-qq",
"jq",
]);
await execContainer(containerId, [
"bash",
"-c",
`mkdir -p /usr/local/bin && cat > /usr/local/bin/code << 'MOCKEOF'
#!/bin/bash
exit 0
MOCKEOF
chmod +x /usr/local/bin/code`,
]);
const script = findResourceInstance(state, "coder_script");
await execContainer(containerId, ["bash", "-c", script.script]);
// Validate JSON using jq
const jsonValidResult = await execContainer(containerId, [
"bash",
"-c",
"jq '.' /root/.vscode-server/data/Machine/settings.json",
]);
expect(jsonValidResult.exitCode).toBe(0);
// Extract specific values using jq
const fontSizeResult = await execContainer(containerId, [
"bash",
"-c",
"jq '.\"editor.fontSize\"' /root/.vscode-server/data/Machine/settings.json",
]);
expect(fontSizeResult.stdout.trim()).toBe("14");
const wordWrapResult = await execContainer(containerId, [
"bash",
"-c",
"jq '.\"editor.wordWrap\"' /root/.vscode-server/data/Machine/settings.json",
]);
expect(wordWrapResult.stdout.trim()).toBe('"on"');
const autoSaveDelayResult = await execContainer(containerId, [
"bash",
"-c",
"jq '.\"files.autoSaveDelay\"' /root/.vscode-server/data/Machine/settings.json",
]);
expect(autoSaveDelayResult.stdout.trim()).toBe("1000");
});
it("installs extensions", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
accept_license: true,
extensions: '["ms-python.python", "golang.go"]',
});
const containerId = await runContainer("ubuntu:22.04");
cleanupContainers.push(containerId);
// Create a mock code CLI that logs extension installs
await execContainer(containerId, [
"bash",
"-c",
`mkdir -p /usr/local/bin && cat > /usr/local/bin/code << 'MOCKEOF'
#!/bin/bash
if [ "\$1" = "--install-extension" ]; then
echo "MOCK_EXTENSION_INSTALL: \$2"
fi
exit 0
MOCKEOF
chmod +x /usr/local/bin/code`,
]);
const script = findResourceInstance(state, "coder_script");
const result = await execContainer(containerId, [
"bash",
"-c",
script.script,
]);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain("Installing extension");
expect(result.stdout).toContain("ms-python.python");
expect(result.stdout).toContain("golang.go");
});
it("runs with correct server arguments", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
accept_license: true,
port: 9999,
telemetry_level: "off",
});
const containerId = await runContainer("ubuntu:22.04");
cleanupContainers.push(containerId);
// Create a mock code CLI that captures all arguments
await execContainer(containerId, [
"bash",
"-c",
`mkdir -p /usr/local/bin && cat > /usr/local/bin/code << 'MOCKEOF'
#!/bin/bash
echo "MOCK_CODE_ARGS: \$@"
exit 0
MOCKEOF
chmod +x /usr/local/bin/code`,
]);
const script = findResourceInstance(state, "coder_script");
const result = await execContainer(containerId, [
"bash",
"-c",
script.script,
]);
expect(result.exitCode).toBe(0);
// Check the output contains expected port message
expect(result.stdout).toContain("Starting VS Code Web on port 9999");
});
it("passes commit-id to code CLI when specified", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
accept_license: true,
commit_id: "abc123def456",
});
const containerId = await runContainer("ubuntu:22.04");
cleanupContainers.push(containerId);
// Create a mock code CLI that logs arguments to the log file (where output is redirected)
await execContainer(containerId, [
"bash",
"-c",
`mkdir -p /usr/local/bin && cat > /usr/local/bin/code << 'MOCKEOF'
#!/bin/bash
echo "MOCK_CODE_ARGS: $@"
exit 0
MOCKEOF
chmod +x /usr/local/bin/code`,
]);
const script = findResourceInstance(state, "coder_script");
await execContainer(containerId, ["bash", "-c", script.script]);
// Wait briefly for background process to write to log
await new Promise((resolve) => setTimeout(resolve, 500));
// Check the log file for the arguments (code CLI output goes there)
const logResult = await execContainer(containerId, [
"cat",
"/tmp/vscode-web.log",
]);
expect(logResult.exitCode).toBe(0);
expect(logResult.stdout).toContain("--commit-id abc123def456");
});
// This test downloads and starts the real VS Code server
it("starts real VS Code CLI and responds to healthcheck (requires network)", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
accept_license: true,
port: 13338,
install_prefix: "/tmp/vscode-web",
});
const containerId = await runContainer("ubuntu:22.04");
cleanupContainers.push(containerId);
// Install curl for downloading CLI and healthcheck
await execContainer(containerId, ["apt-get", "update", "-qq"]);
await execContainer(containerId, [
"apt-get",
"install",
"-y",
"-qq",
"curl",
]);
const script = findResourceInstance(state, "coder_script");
// Run the script - it will start the server in background
const startResult = await execContainer(containerId, [
"bash",
"-c",
script.script,
]);
expect(startResult.exitCode).toBe(0);
expect(startResult.stdout).toContain("Starting VS Code Web");
// Wait for server to start and check healthcheck
await new Promise((resolve) => setTimeout(resolve, 10000));
const healthResult = await execContainer(containerId, [
"curl",
"-s",
"-o",
"/dev/null",
"-w",
"%{http_code}",
"http://127.0.0.1:13338/healthz",
]);
// Server should respond (200, 202, or 404 is acceptable - means server is running)
expect(["200", "202", "404"]).toContain(healthResult.stdout.trim());
});
});
});

View File

@ -59,12 +59,6 @@ variable "install_prefix" {
default = "/tmp/vscode-web"
}
variable "commit_id" {
type = string
description = "Specify the commit ID of the VS Code Web binary to pin to a specific version. If left empty, the latest stable version is used."
default = ""
}
variable "extensions" {
type = list(string)
description = "A list of extensions to install."
@ -148,22 +142,28 @@ variable "subdomain" {
default = true
}
variable "platform" {
type = string
description = "The platform to use for the VS Code Web."
default = ""
validation {
condition = var.platform == "" || var.platform == "linux" || var.platform == "darwin" || var.platform == "alpine" || var.platform == "win32"
error_message = "Incorrect value. Please set either 'linux', 'darwin', or 'alpine' or 'win32'."
}
}
variable "workspace" {
type = string
description = "Path to a .code-workspace file to open in vscode-web."
default = ""
}
variable "release_channel" {
type = string
description = "The release channel for VS Code CLI (stable or insiders)."
default = "stable"
validation {
condition = var.release_channel == "stable" || var.release_channel == "insiders"
error_message = "Incorrect value. Please set either 'stable' or 'insiders'."
}
}
variable "commit_id" {
type = string
description = "The commit SHA to use for the VS Code Server. Leave empty to use the latest version."
default = ""
}
data "coder_workspace_owner" "me" {}
data "coder_workspace" "me" {}
@ -187,8 +187,8 @@ resource "coder_script" "vscode-web" {
WORKSPACE : var.workspace,
AUTO_INSTALL_EXTENSIONS : var.auto_install_extensions,
SERVER_BASE_PATH : local.server_base_path,
RELEASE_CHANNEL : var.release_channel,
COMMIT_ID : var.commit_id,
PLATFORM : var.platform,
})
run_on_start = true

View File

@ -1,138 +1,325 @@
#!/usr/bin/env bash
BOLD='\033[0;1m'
RESET='\033[0m'
CODE='\033[36;40;1m'
EXTENSIONS=("${EXTENSIONS}")
VSCODE_WEB="${INSTALL_PREFIX}/bin/code-server"
# Set extension directory
# Set extension directory argument
EXTENSION_ARG=""
if [ -n "${EXTENSIONS_DIR}" ]; then
EXTENSION_ARG="--extensions-dir=${EXTENSIONS_DIR}"
fi
# Set extension directory
# Set server base path argument
SERVER_BASE_PATH_ARG=""
if [ -n "${SERVER_BASE_PATH}" ]; then
SERVER_BASE_PATH_ARG="--server-base-path=${SERVER_BASE_PATH}"
fi
# Set disable workspace trust
# Set disable workspace trust argument
DISABLE_TRUST_ARG=""
if [ "${DISABLE_TRUST}" = true ]; then
DISABLE_TRUST_ARG="--disable-workspace-trust"
fi
run_vscode_web() {
echo "👷 Running $VSCODE_WEB serve-local $EXTENSION_ARG $SERVER_BASE_PATH_ARG $DISABLE_TRUST_ARG --port ${PORT} --host 127.0.0.1 --accept-server-license-terms --without-connection-token --telemetry-level ${TELEMETRY_LEVEL} in the background..."
echo "Check logs at ${LOG_PATH}!"
"$VSCODE_WEB" serve-local "$EXTENSION_ARG" "$SERVER_BASE_PATH_ARG" "$DISABLE_TRUST_ARG" --port "${PORT}" --host 127.0.0.1 --accept-server-license-terms --without-connection-token --telemetry-level "${TELEMETRY_LEVEL}" > "${LOG_PATH}" 2>&1 &
# Check if code CLI is installed
check_code_cli() {
if command -v code > /dev/null 2>&1; then
echo "code"
return 0
fi
if [ -f "${INSTALL_PREFIX}/bin/code" ]; then
echo "${INSTALL_PREFIX}/bin/code"
return 0
fi
return 1
}
# Check if the settings file exists...
# Check if code-server is installed (fallback option)
check_code_server() {
if command -v code-server > /dev/null 2>&1; then
echo "code-server"
return 0
fi
if [ -f "${INSTALL_PREFIX}/bin/code-server" ]; then
echo "${INSTALL_PREFIX}/bin/code-server"
return 0
fi
return 1
}
# Find existing vscode-server binary (used by code serve-web internally)
find_vscode_server() {
# Check common locations for pre-downloaded vscode-server
local server_dirs=(
"$HOME/.vscode-server/bin"
"$HOME/.vscode/cli/serve-web"
)
for dir in "$${server_dirs[@]}"; do
if [ -d "$dir" ]; then
# Find the most recent server version
local latest=$(ls -t "$dir" 2>/dev/null | head -1)
if [ -n "$latest" ] && [ -f "$dir/$latest/bin/code-server" ]; then
echo "$dir/$latest/bin/code-server"
return 0
fi
if [ -n "$latest" ] && [ -f "$dir/$latest/code-server" ]; then
echo "$dir/$latest/code-server"
return 0
fi
fi
done
return 1
}
# Install VS Code CLI if not present
install_code_cli() {
printf "$${BOLD}Installing VS Code CLI...$${RESET}\n"
# Detect architecture
ARCH=$(uname -m)
case "$ARCH" in
x86_64) ARCH="x64" ;;
aarch64 | arm64) ARCH="arm64" ;;
armv7l) ARCH="armhf" ;;
*)
echo "Unsupported architecture: $ARCH"
exit 1
;;
esac
# Detect platform
# Note: VS Code CLI uses 'alpine' for all Linux distributions
PLATFORM=$(uname -s)
case "$PLATFORM" in
Linux)
PLATFORM="alpine"
;;
Darwin)
PLATFORM="darwin"
;;
*)
echo "Unsupported platform: $PLATFORM"
exit 1
;;
esac
# Create install directory
mkdir -p "${INSTALL_PREFIX}/bin"
# Download VS Code CLI
CLI_URL="https://code.visualstudio.com/sha/download?build=${RELEASE_CHANNEL}&os=cli-$PLATFORM-$ARCH"
printf "Downloading VS Code CLI from %s\n" "$CLI_URL"
if command -v curl > /dev/null 2>&1; then
curl -fsSL "$CLI_URL" -o "/tmp/vscode-cli.tar.gz"
elif command -v wget > /dev/null 2>&1; then
wget -q "$CLI_URL" -O "/tmp/vscode-cli.tar.gz"
else
echo "Neither curl nor wget is available. Please install one of them."
exit 1
fi
# Extract CLI
tar -xzf /tmp/vscode-cli.tar.gz -C "${INSTALL_PREFIX}/bin"
rm -f /tmp/vscode-cli.tar.gz
# The CLI binary is named 'code'
if [ -f "${INSTALL_PREFIX}/bin/code" ]; then
chmod +x "${INSTALL_PREFIX}/bin/code"
export PATH="${INSTALL_PREFIX}/bin:$PATH"
printf "$${BOLD}VS Code CLI installed successfully.$${RESET}\n"
else
echo "Failed to install VS Code CLI"
exit 1
fi
}
# Run VS Code Web using the code CLI (serve-web command)
run_vscode_web_cli() {
local CODE_CMD="$1"
# Build the command arguments
ARGS="serve-web --port ${PORT} --host 127.0.0.1 --accept-server-license-terms --without-connection-token --telemetry-level ${TELEMETRY_LEVEL}"
if [ -n "$EXTENSION_ARG" ]; then
ARGS="$ARGS $EXTENSION_ARG"
fi
if [ -n "$SERVER_BASE_PATH_ARG" ]; then
ARGS="$ARGS $SERVER_BASE_PATH_ARG"
fi
if [ -n "$DISABLE_TRUST_ARG" ]; then
ARGS="$ARGS $DISABLE_TRUST_ARG"
fi
if [ -n "${COMMIT_ID}" ]; then
ARGS="$ARGS --commit-id ${COMMIT_ID}"
fi
printf "Starting VS Code Web on port ${PORT}...\n"
printf "Check logs at ${LOG_PATH}\n"
# shellcheck disable=SC2086
"$CODE_CMD" $ARGS > "${LOG_PATH}" 2>&1 &
}
# Run VS Code Web using code-server (fallback for offline mode)
run_code_server() {
local SERVER_CMD="$1"
printf "Starting code-server on port ${PORT}...\n"
printf "Check logs at ${LOG_PATH}\n"
# Build arguments for code-server
ARGS="--port ${PORT} --host 127.0.0.1 --auth none"
if [ -n "$EXTENSION_ARG" ]; then
ARGS="$ARGS $EXTENSION_ARG"
fi
# shellcheck disable=SC2086
"$SERVER_CMD" $ARGS > "${LOG_PATH}" 2>&1 &
}
# Run VS Code Web using vscode-server binary directly
run_vscode_server() {
local SERVER_CMD="$1"
printf "Starting VS Code Server on port ${PORT}...\n"
printf "Check logs at ${LOG_PATH}\n"
# Build arguments for vscode-server
ARGS="--port ${PORT} --host 127.0.0.1 --without-connection-token --accept-server-license-terms --telemetry-level ${TELEMETRY_LEVEL}"
if [ -n "$EXTENSION_ARG" ]; then
ARGS="$ARGS $EXTENSION_ARG"
fi
if [ -n "$SERVER_BASE_PATH_ARG" ]; then
ARGS="$ARGS $SERVER_BASE_PATH_ARG"
fi
# shellcheck disable=SC2086
"$SERVER_CMD" serve-local $ARGS > "${LOG_PATH}" 2>&1 &
}
install_extensions() {
local CODE_CMD="$1"
# Install specified extensions
IFS=',' read -r -a EXTENSIONLIST <<< "$${EXTENSIONS}"
for extension in "$${EXTENSIONLIST[@]}"; do
if [ -z "$extension" ]; then
continue
fi
printf "Installing extension $${CODE}$extension$${RESET}...\n"
output=$("$CODE_CMD" $EXTENSION_ARG --install-extension "$extension" --force 2>&1)
if [ $? -ne 0 ]; then
echo "Failed to install extension: $extension: $output"
fi
done
# Auto-install extensions from workspace or folder
if [ "${AUTO_INSTALL_EXTENSIONS}" = true ]; then
if ! command -v jq > /dev/null; then
echo "jq is required to install extensions from a workspace file."
else
if [ -n "${WORKSPACE}" ] && [ -f "${WORKSPACE}" ]; then
printf "Installing extensions from %s...\n" "${WORKSPACE}"
extensions=$(sed 's|//.*||g' "${WORKSPACE}" | jq -r '(.extensions.recommendations // [])[]')
for extension in $extensions; do
"$CODE_CMD" $EXTENSION_ARG --install-extension "$extension" --force
done
else
WORKSPACE_DIR="$HOME"
if [ -n "${FOLDER}" ]; then
WORKSPACE_DIR="${FOLDER}"
fi
if [ -f "$WORKSPACE_DIR/.vscode/extensions.json" ]; then
printf "Installing extensions from %s/.vscode/extensions.json...\n" "$WORKSPACE_DIR"
extensions=$(sed 's|//.*||g' "$WORKSPACE_DIR/.vscode/extensions.json" | jq -r '.recommendations[]')
for extension in $extensions; do
"$CODE_CMD" $EXTENSION_ARG --install-extension "$extension" --force
done
fi
fi
fi
fi
}
# Create settings file if it doesn't exist
if [ ! -f ~/.vscode-server/data/Machine/settings.json ]; then
echo "⚙️ Creating settings file..."
printf "Creating settings file...\n"
mkdir -p ~/.vscode-server/data/Machine
echo "${SETTINGS}" > ~/.vscode-server/data/Machine/settings.json
fi
# Check if vscode-server is already installed for offline or cached mode
if [ -f "$VSCODE_WEB" ]; then
if [ "${OFFLINE}" = true ] || [ "${USE_CACHED}" = true ]; then
echo "🥳 Found a copy of VS Code Web"
run_vscode_web
# Determine which command to use
CODE_CMD=""
RUN_MODE=""
# Check for code CLI first (preferred)
if CODE_CMD=$(check_code_cli); then
printf "$${BOLD}Found VS Code CLI at $CODE_CMD$${RESET}\n"
RUN_MODE="cli"
fi
# Handle offline mode
if [ "${OFFLINE}" = true ]; then
if [ -n "$CODE_CMD" ]; then
# Check if vscode-server is already downloaded (code serve-web won't need to download)
if VSCODE_SERVER=$(find_vscode_server); then
printf "Found cached VS Code Server at $VSCODE_SERVER\n"
printf "Using cached VS Code CLI.\n"
run_vscode_web_cli "$CODE_CMD"
exit 0
fi
# Code CLI exists but vscode-server not cached - try using it anyway
# (it might work if server was pre-downloaded, or fail gracefully)
printf "Warning: VS Code Server may not be cached. Attempting to start...\n"
printf "Using cached VS Code CLI.\n"
run_vscode_web_cli "$CODE_CMD"
exit 0
fi
fi
# Offline mode always expects a copy of vscode-server to be present
if [ "${OFFLINE}" = true ]; then
echo "Failed to find a copy of VS Code Web"
# Try code-server as fallback for offline mode
if SERVER_CMD=$(check_code_server); then
printf "$${BOLD}Found code-server at $SERVER_CMD (offline fallback)$${RESET}\n"
run_code_server "$SERVER_CMD"
exit 0
fi
# Try vscode-server binary directly
if VSCODE_SERVER=$(find_vscode_server); then
printf "$${BOLD}Found VS Code Server at $VSCODE_SERVER (offline fallback)$${RESET}\n"
run_vscode_server "$VSCODE_SERVER"
exit 0
fi
echo "Offline mode enabled but no VS Code CLI, code-server, or cached VS Code Server found."
exit 1
fi
# Create install prefix
mkdir -p ${INSTALL_PREFIX}
printf "$${BOLD}Installing Microsoft Visual Studio Code Server!\n"
# Download and extract vscode-server
ARCH=$(uname -m)
case "$ARCH" in
x86_64) ARCH="x64" ;;
aarch64) ARCH="arm64" ;;
*)
echo "Unsupported architecture"
exit 1
;;
esac
# Detect the platform
if [ -n "${PLATFORM}" ]; then
DETECTED_PLATFORM="${PLATFORM}"
elif [ -f /etc/alpine-release ] || grep -qi 'ID=alpine' /etc/os-release 2> /dev/null || command -v apk > /dev/null 2>&1; then
DETECTED_PLATFORM="alpine"
elif [ "$(uname -s)" = "Darwin" ]; then
DETECTED_PLATFORM="darwin"
else
DETECTED_PLATFORM="linux"
# Handle use_cached mode
if [ "${USE_CACHED}" = true ] && [ -n "$CODE_CMD" ]; then
printf "Using cached VS Code CLI.\n"
install_extensions "$CODE_CMD"
run_vscode_web_cli "$CODE_CMD"
exit 0
fi
# Check if a specific VS Code Web commit ID was provided
if [ -n "${COMMIT_ID}" ]; then
HASH="${COMMIT_ID}"
else
HASH=$(curl -fsSL https://update.code.visualstudio.com/api/commits/stable/server-$DETECTED_PLATFORM-$ARCH-web | cut -d '"' -f 2)
fi
printf "$${BOLD}VS Code Web commit id version $HASH.\n"
output=$(curl -fsSL "https://vscode.download.prss.microsoft.com/dbazure/download/stable/$HASH/vscode-server-$DETECTED_PLATFORM-$ARCH-web.tar.gz" | tar -xz -C "${INSTALL_PREFIX}" --strip-components 1)
if [ $? -ne 0 ]; then
echo "Failed to install Microsoft Visual Studio Code Server: $output"
exit 1
fi
printf "$${BOLD}VS Code Web has been installed.\n"
# Install each extension...
IFS=',' read -r -a EXTENSIONLIST <<< "$${EXTENSIONS}"
# shellcheck disable=SC2066
for extension in "$${EXTENSIONLIST[@]}"; do
if [ -z "$extension" ]; then
continue
fi
printf "🧩 Installing extension $${CODE}$extension$${RESET}...\n"
output=$($VSCODE_WEB "$EXTENSION_ARG" --install-extension "$extension" --force)
if [ $? -ne 0 ]; then
echo "Failed to install extension: $extension: $output"
fi
done
if [ "${AUTO_INSTALL_EXTENSIONS}" = true ]; then
if ! command -v jq > /dev/null; then
echo "jq is required to install extensions from a workspace file."
else
# Prefer WORKSPACE if set and points to a file
if [ -n "${WORKSPACE}" ] && [ -f "${WORKSPACE}" ]; then
printf "🧩 Installing extensions from %s...\n" "${WORKSPACE}"
# Strip single-line comments then parse .extensions.recommendations[]
extensions=$(sed 's|//.*||g' "${WORKSPACE}" | jq -r '(.extensions.recommendations // [])[]')
for extension in $extensions; do
$VSCODE_WEB "$EXTENSION_ARG" --install-extension "$extension" --force
done
else
# Fallback to folder-based .vscode/extensions.json (existing behavior)
WORKSPACE_DIR="$HOME"
if [ -n "${FOLDER}" ]; then
WORKSPACE_DIR="${FOLDER}"
fi
if [ -f "$WORKSPACE_DIR/.vscode/extensions.json" ]; then
printf "🧩 Installing extensions from %s/.vscode/extensions.json...\n" "$WORKSPACE_DIR"
extensions=$(sed 's|//.*||g' "$WORKSPACE_DIR/.vscode/extensions.json" | jq -r '.recommendations[]')
for extension in $extensions; do
$VSCODE_WEB "$EXTENSION_ARG" --install-extension "$extension" --force
done
fi
fi
fi
# Install VS Code CLI if not present
if [ -z "$CODE_CMD" ]; then
install_code_cli
CODE_CMD="${INSTALL_PREFIX}/bin/code"
RUN_MODE="cli"
fi
run_vscode_web
# Install extensions
install_extensions "$CODE_CMD"
# Run VS Code Web
run_vscode_web_cli "$CODE_CMD"

View File

@ -0,0 +1,151 @@
run "required_vars" {
command = plan
variables {
agent_id = "foo"
accept_license = true
}
}
run "accept_license_required" {
command = plan
variables {
agent_id = "foo"
accept_license = false
}
expect_failures = [
var.accept_license
]
}
run "offline_and_use_cached_conflict" {
command = plan
variables {
agent_id = "foo"
accept_license = true
use_cached = true
offline = true
}
expect_failures = [
resource.coder_script.vscode-web
]
}
run "offline_disallows_extensions" {
command = plan
variables {
agent_id = "foo"
accept_license = true
offline = true
extensions = ["ms-python.python", "golang.go"]
}
expect_failures = [
resource.coder_script.vscode-web
]
}
run "workspace_and_folder_conflict" {
command = plan
variables {
agent_id = "foo"
accept_license = true
folder = "/home/coder/project"
workspace = "/home/coder/project.code-workspace"
}
expect_failures = [
resource.coder_script.vscode-web
]
}
run "url_with_folder_query" {
command = plan
variables {
agent_id = "foo"
accept_license = true
folder = "/home/coder/project"
port = 13338
}
assert {
condition = resource.coder_app.vscode-web.url == "http://localhost:13338?folder=%2Fhome%2Fcoder%2Fproject"
error_message = "coder_app URL must include encoded folder query param"
}
}
run "url_with_workspace_query" {
command = plan
variables {
agent_id = "foo"
accept_license = true
workspace = "/home/coder/project.code-workspace"
port = 13338
}
assert {
condition = resource.coder_app.vscode-web.url == "http://localhost:13338?workspace=%2Fhome%2Fcoder%2Fproject.code-workspace"
error_message = "coder_app URL must include encoded workspace query param"
}
}
run "release_channel_stable" {
command = plan
variables {
agent_id = "foo"
accept_license = true
release_channel = "stable"
}
}
run "release_channel_insiders" {
command = plan
variables {
agent_id = "foo"
accept_license = true
release_channel = "insiders"
}
}
run "release_channel_invalid" {
command = plan
variables {
agent_id = "foo"
accept_license = true
release_channel = "invalid"
}
expect_failures = [
var.release_channel
]
}
run "commit_id_empty_by_default" {
command = plan
variables {
agent_id = "foo"
accept_license = true
}
}
run "commit_id_with_value" {
command = plan
variables {
agent_id = "foo"
accept_license = true
commit_id = "e54c774e0add60467559eb0d1e229c6452cf8447"
}
}