Compare commits

..

1 Commits

Author SHA1 Message Date
Michael Suchacz 94e41d3780 Add arbitrary mux server command argument parsing (#738)
## Summary
- add a new `additional_arguments` module variable to pass extra
arguments to `mux server`
- parse `additional_arguments` in `run.sh` with quoted-group support so
values like paths with spaces are preserved
- keep existing `add-project` behavior while allowing additional
arbitrary flags
- add Terraform and Bun tests covering `additional_arguments` behavior
- document the new option in the module README and bump example version
references to `1.2.0`

## Why
The module previously only supported the `add-project` flag. This change
lets users pass additional `mux server` arguments without waiting for
new module variables.

## Validation
- `shellcheck --severity=warning --format=gcc
registry/coder/modules/mux/run.sh`
- `terraform -chdir=registry/coder/modules/mux test -verbose`
- `bun test registry/coder/modules/mux/main.test.ts`

## Breaking changes
None.

---
Generated with Mux (exec agent) using GPT-5.
2026-02-25 18:15:33 +00:00
10 changed files with 297 additions and 1393 deletions
+22 -7
View File
@@ -14,7 +14,7 @@ Automatically install and run [Mux](https://github.com/coder/mux) in a Coder wor
module "mux" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/mux/coder"
version = "1.1.0"
version = "1.2.0"
agent_id = coder_agent.main.id
}
```
@@ -37,7 +37,7 @@ module "mux" {
module "mux" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/mux/coder"
version = "1.1.0"
version = "1.2.0"
agent_id = coder_agent.main.id
}
```
@@ -48,7 +48,7 @@ module "mux" {
module "mux" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/mux/coder"
version = "1.1.0"
version = "1.2.0"
agent_id = coder_agent.main.id
# Default is "latest"; set to a specific version to pin
install_version = "0.4.0"
@@ -63,19 +63,34 @@ Start Mux with `mux server --add-project /path/to/project`:
module "mux" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/mux/coder"
version = "1.1.0"
version = "1.2.0"
agent_id = coder_agent.main.id
add-project = "/path/to/project"
}
```
### Pass Arbitrary `mux server` Arguments
Use `additional_arguments` to append additional arguments to `mux server`.
The module parses quoted values, so grouped arguments remain intact.
```tf
module "mux" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/mux/coder"
version = "1.2.0"
agent_id = coder_agent.main.id
additional_arguments = "--open-mode pinned --add-project '/workspaces/my repo'"
}
```
### Custom Port
```tf
module "mux" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/mux/coder"
version = "1.1.0"
version = "1.2.0"
agent_id = coder_agent.main.id
port = 8080
}
@@ -89,7 +104,7 @@ Run an existing copy of Mux if found, otherwise install from npm:
module "mux" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/mux/coder"
version = "1.1.0"
version = "1.2.0"
agent_id = coder_agent.main.id
use_cached = true
}
@@ -103,7 +118,7 @@ Run without installing from the network (requires Mux to be pre-installed):
module "mux" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/mux/coder"
version = "1.1.0"
version = "1.2.0"
agent_id = coder_agent.main.id
install = false
}
+56
View File
@@ -1,6 +1,11 @@
import { describe, expect, it } from "bun:test";
import {
executeScriptInContainer,
execContainer,
findResourceInstance,
readFileContainer,
removeContainer,
runContainer,
runTerraformApply,
runTerraformInit,
testRequiredVariables,
@@ -40,6 +45,57 @@ describe("mux", async () => {
}
}, 60000);
it("parses custom additional_arguments", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
install: false,
log_path: "/tmp/mux.log",
additional_arguments:
"--open-mode pinned --add-project '/workspaces/my repo'",
});
const instance = findResourceInstance(state, "coder_script");
const id = await runContainer("alpine/curl");
try {
const setup = await execContainer(id, [
"sh",
"-c",
`apk add --no-cache bash >/dev/null
mkdir -p /tmp/mux
cat <<'EOF' > /tmp/mux/mux
#!/usr/bin/env sh
i=1
for arg in "$@"; do
echo "arg$i=$arg"
i=$((i + 1))
done
EOF
chmod +x /tmp/mux/mux`,
]);
expect(setup.exitCode).toBe(0);
const output = await execContainer(id, ["sh", "-c", instance.script]);
if (output.exitCode !== 0) {
console.log("STDOUT:\n" + output.stdout);
console.log("STDERR:\n" + output.stderr);
}
expect(output.exitCode).toBe(0);
await execContainer(id, ["sh", "-c", "sleep 1"]);
const log = await readFileContainer(id, "/tmp/mux.log");
expect(log).toContain("arg1=server");
expect(log).toContain("arg2=--port");
expect(log).toContain("arg3=4000");
expect(log).toContain("arg4=--open-mode");
expect(log).toContain("arg5=pinned");
expect(log).toContain("arg6=--add-project");
expect(log).toContain("arg7=/workspaces/my repo");
} finally {
await removeContainer(id);
}
}, 60000);
it("runs with npm present", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
+7
View File
@@ -55,6 +55,12 @@ variable "add-project" {
default = null
}
variable "additional_arguments" {
type = string
description = "Additional command-line arguments to pass to `mux server` (for example: `--add-project /path --open-mode pinned`)."
default = ""
}
variable "install_version" {
type = string
description = "The version or dist-tag of Mux to install."
@@ -142,6 +148,7 @@ resource "coder_script" "mux" {
PORT : var.port,
LOG_PATH : var.log_path,
ADD_PROJECT : var.add-project == null ? "" : var.add-project,
ADDITIONAL_ARGUMENTS : var.additional_arguments,
INSTALL_PREFIX : var.install_prefix,
OFFLINE : !var.install,
USE_CACHED : var.use_cached,
+14
View File
@@ -79,6 +79,20 @@ run "auth_token_in_url" {
}
}
run "custom_additional_arguments" {
command = plan
variables {
agent_id = "foo"
additional_arguments = "--open-mode pinned --add-project '/workspaces/my repo'"
}
assert {
condition = strcontains(resource.coder_script.mux.script, "--open-mode pinned --add-project '/workspaces/my repo'")
error_message = "mux launch script must include the configured additional arguments"
}
}
run "custom_version" {
command = plan
+16
View File
@@ -20,6 +20,22 @@ function run_mux() {
if [ -n "${ADD_PROJECT}" ]; then
set -- "$@" --add-project "${ADD_PROJECT}"
fi
# Parse additional user-supplied server arguments while preserving quoted groups.
if [ -n "${ADDITIONAL_ARGUMENTS}" ]; then
local parsed_additional_arguments
if ! parsed_additional_arguments="$(printf "%s\n" "${ADDITIONAL_ARGUMENTS}" | xargs -n1 printf "%s\n" 2> /dev/null)"; then
echo "❌ Failed to parse additional_arguments. Ensure quotes are balanced."
exit 1
fi
while IFS= read -r parsed_arg; do
[ -n "$parsed_arg" ] || continue
set -- "$@" "$parsed_arg"
done << EOF
$${parsed_additional_arguments}
EOF
fi
echo "🚀 Starting mux server on port $port_value..."
echo "Check logs at ${LOG_PATH}!"
MUX_SERVER_AUTH_TOKEN="$auth_token_value" PORT="$port_value" "$MUX_BINARY" "$@" > "${LOG_PATH}" 2>&1 &
+26 -46
View File
@@ -8,13 +8,13 @@ tags: [ide, vscode, web]
# VS Code Web
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.
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.
```tf
module "vscode-web" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/vscode-web/coder"
version = "2.0.0"
version = "1.4.3"
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 = "2.0.0"
version = "1.4.3"
agent_id = coder_agent.example.id
install_prefix = "/home/coder/.vscode-web"
folder = "/home/coder"
@@ -44,22 +44,22 @@ module "vscode-web" {
module "vscode-web" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/vscode-web/coder"
version = "2.0.0"
version = "1.4.3"
agent_id = coder_agent.example.id
extensions = ["github.copilot", "ms-python.python", "ms-toolsai.jupyter"]
accept_license = true
}
```
### Pre-configure Machine Settings
### Pre-configure Settings
Configure VS Code's [Machine settings.json](https://code.visualstudio.com/docs/getstarted/settings#_settings-json-file). These settings are merged with any existing machine settings on startup:
Configure VS Code's [settings.json](https://code.visualstudio.com/docs/getstarted/settings#_settings-json-file) file:
```tf
module "vscode-web" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/vscode-web/coder"
version = "2.0.0"
version = "1.4.3"
agent_id = coder_agent.example.id
extensions = ["dracula-theme.theme-dracula"]
settings = {
@@ -69,7 +69,20 @@ module "vscode-web" {
}
```
> **Note:** Merging settings requires `jq` or `python3`. If neither is available, existing machine settings will be preserved. User settings configured through the VS Code UI are stored in browser local storage and will not persist across different browsers or devices.
### 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
@@ -78,43 +91,10 @@ Note: Either `workspace` or `folder` can be used, but not both simultaneously. T
```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
workspace = "/home/coder/coder.code-workspace"
accept_license = true
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
workspace = "/home/coder/coder.code-workspace"
}
```
### 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.
+31 -782
View File
@@ -1,793 +1,42 @@
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 = [];
});
import { describe, expect, it } from "bun:test";
import { runTerraformApply, runTerraformInit } from "~test";
describe("vscode-web", async () => {
beforeAll(async () => {
await runTerraformInit(import.meta.dir);
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");
});
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("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("script generation", () => {
it("generates script with correct port", async () => {
const state = await runTerraformApply(import.meta.dir, {
it("offline and extensions can not be used together", () => {
const t = async () => {
await runTerraformApply(import.meta.dir, {
agent_id: "foo",
accept_license: true,
port: 8080,
accept_license: "true",
offline: "true",
extensions: '["1", "2"]',
});
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",
);
});
};
expect(t).toThrow("Offline mode does not allow extensions to be installed");
});
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("merges settings with 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);
// 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`,
]);
// 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 settings were merged (both existing and new should be present)
const settingsResult = await execContainer(containerId, [
"cat",
"/root/.vscode-server/data/Machine/settings.json",
]);
expect(settingsResult.exitCode).toBe(0);
// Should contain both existing and new settings
expect(settingsResult.stdout).toContain("existing.setting");
expect(settingsResult.stdout).toContain("existing_value");
expect(settingsResult.stdout).toContain("new.setting");
expect(settingsResult.stdout).toContain("new_value");
});
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());
});
});
// More tests depend on shebang refactors
});
+20 -23
View File
@@ -59,6 +59,12 @@ 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."
@@ -99,7 +105,7 @@ variable "group" {
variable "settings" {
type = any
description = "A map of settings to apply to VS Code Web's Machine settings. These settings are merged with any existing machine settings on startup."
description = "A map of settings to apply to VS Code web."
default = {}
}
@@ -142,35 +148,25 @@ 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" {}
locals {
settings_b64 = var.settings != {} ? base64encode(jsonencode(var.settings)) : ""
}
resource "coder_script" "vscode-web" {
agent_id = var.agent_id
display_name = "VS Code Web"
@@ -181,7 +177,8 @@ resource "coder_script" "vscode-web" {
INSTALL_PREFIX : var.install_prefix,
EXTENSIONS : join(",", var.extensions),
TELEMETRY_LEVEL : var.telemetry_level,
SETTINGS_B64 : local.settings_b64,
// This is necessary otherwise the quotes are stripped!
SETTINGS : replace(jsonencode(var.settings), "\"", "\\\""),
OFFLINE : var.offline,
USE_CACHED : var.use_cached,
DISABLE_TRUST : var.disable_trust,
@@ -190,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
+105 -384
View File
@@ -1,417 +1,138 @@
#!/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"
# Merge settings from module with existing settings file
# Uses jq if available, falls back to Python3 for deep merge
merge_settings() {
local new_settings="$1"
local settings_file="$2"
if [ -z "$new_settings" ] || [ "$new_settings" = "{}" ]; then
return 0
fi
if [ ! -f "$settings_file" ]; then
mkdir -p "$(dirname "$settings_file")"
printf '%s\n' "$new_settings" > "$settings_file"
printf "Creating settings file...\n"
return 0
fi
local tmpfile
tmpfile="$(mktemp)"
if command -v jq > /dev/null 2>&1; then
if jq -s '.[0] * .[1]' "$settings_file" <(printf '%s\n' "$new_settings") > "$tmpfile" 2> /dev/null; then
mv "$tmpfile" "$settings_file"
printf "Merging settings...\n"
return 0
fi
fi
if command -v python3 > /dev/null 2>&1; then
if python3 -c "import json,sys;m=lambda a,b:{**a,**{k:m(a[k],v)if k in a and type(a[k])==type(v)==dict else v for k,v in b.items()}};print(json.dumps(m(json.load(open(sys.argv[1])),json.loads(sys.argv[2])),indent=2))" "$settings_file" "$new_settings" > "$tmpfile" 2> /dev/null; then
mv "$tmpfile" "$settings_file"
printf "Merging settings...\n"
return 0
fi
fi
rm -f "$tmpfile"
printf "Warning: Could not merge settings. Keeping existing settings.\n"
return 0
}
# Set extension directory argument
# Set extension directory
EXTENSION_ARG=""
if [ -n "${EXTENSIONS_DIR}" ]; then
EXTENSION_ARG="--extensions-dir=${EXTENSIONS_DIR}"
fi
# Set server base path argument
# Set extension directory
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 argument
# Set disable workspace trust
DISABLE_TRUST_ARG=""
if [ "${DISABLE_TRUST}" = true ]; then
DISABLE_TRUST_ARG="--disable-workspace-trust"
fi
# 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
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-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
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 a single extension by downloading VSIX from marketplace
install_extension_vsix() {
local ext_id="$1"
local publisher
local ext_name
publisher="$${ext_id%%.*}"
ext_name="$${ext_id#*.}"
# Download VSIX from marketplace
local vsix_url="https://$publisher.gallery.vsassets.io/_apis/public/gallery/publisher/$publisher/extension/$ext_name/latest/assetbyname/Microsoft.VisualStudio.Services.VSIXPackage"
local tmp_vsix="/tmp/ext-$ext_id.vsix"
local tmp_dir="/tmp/ext-$ext_id"
if command -v curl > /dev/null 2>&1; then
curl -fsSL "$vsix_url" -o "$tmp_vsix" 2> /dev/null
elif command -v wget > /dev/null 2>&1; then
wget -q "$vsix_url" -O "$tmp_vsix" 2> /dev/null
else
echo "Failed to install extension $ext_id: neither curl nor wget available"
return 1
fi
if [ ! -f "$tmp_vsix" ]; then
echo "Failed to download extension: $ext_id"
return 1
fi
# Extract VSIX (it's a ZIP file)
rm -rf "$tmp_dir"
mkdir -p "$tmp_dir"
if ! unzip -q "$tmp_vsix" -d "$tmp_dir" 2> /dev/null; then
echo "Failed to extract extension: $ext_id"
rm -f "$tmp_vsix"
return 1
fi
# Get version from package.json
local version=""
if [ -f "$tmp_dir/extension/package.json" ]; then
version=$(grep -o '"version"[[:space:]]*:[[:space:]]*"[^"]*"' "$tmp_dir/extension/package.json" | head -1 | cut -d'"' -f4)
fi
if [ -z "$version" ]; then
version="0.0.0"
fi
# Install to extensions directory
local ext_dir="$HOME/.vscode-server/extensions/$ext_id-$version"
mkdir -p "$HOME/.vscode-server/extensions"
rm -rf "$ext_dir"
mv "$tmp_dir/extension" "$ext_dir"
# Cleanup
rm -rf "$tmp_vsix" "$tmp_dir"
printf "Extension $ext_id v$version installed successfully.\n"
return 0
}
install_extensions() {
# Install specified extensions
IFS=',' read -r -a EXTENSIONLIST <<< "$${EXTENSIONS}"
for extension in "$${EXTENSIONLIST[@]}"; do
if [ -z "$extension" ]; then
continue
fi
printf "Installing extension $extension...\n"
install_extension_vsix "$extension"
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
install_extension_vsix "$extension"
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
install_extension_vsix "$extension"
done
fi
fi
fi
fi
}
# Apply machine settings (merge with existing if present)
SETTINGS_B64='${SETTINGS_B64}'
if [ -n "$SETTINGS_B64" ]; then
SETTINGS_JSON="$(echo -n "$SETTINGS_B64" | base64 -d)"
merge_settings "$SETTINGS_JSON" ~/.vscode-server/data/Machine/settings.json
# Check if the settings file exists...
if [ ! -f ~/.vscode-server/data/Machine/settings.json ]; then
echo "⚙️ Creating settings file..."
mkdir -p ~/.vscode-server/data/Machine
echo "${SETTINGS}" > ~/.vscode-server/data/Machine/settings.json
fi
# 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"
# 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
exit 0
fi
fi
# Handle offline mode
# Offline mode always expects a copy of vscode-server to be present
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
# 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."
echo "Failed to find a copy of VS Code Web"
exit 1
fi
# Handle use_cached mode
if [ "${USE_CACHED}" = true ] && [ -n "$CODE_CMD" ]; then
printf "Using cached VS Code CLI.\n"
install_extensions
run_vscode_web_cli "$CODE_CMD"
exit 0
# 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"
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"
# 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
fi
# Install extensions and run VS Code Web
install_extensions
run_vscode_web_cli "$CODE_CMD"
run_vscode_web
@@ -1,151 +0,0 @@
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"
}
}