diff --git a/.icons/desktop.svg b/.icons/desktop.svg
new file mode 100644
index 0000000..77d231c
--- /dev/null
+++ b/.icons/desktop.svg
@@ -0,0 +1,5 @@
+
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 557171e..c0a9c0f 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -1,24 +1,47 @@
# Contributing
-To create a new module, clone this repository and run:
+## Getting started
+
+This repo uses the [Bun runtime](https://bun.sh/) to to run all code and tests. To install Bun, you can run this command on Linux/MacOS:
+
+```shell
+curl -fsSL https://bun.sh/install | bash
+```
+
+Or this command on Windows:
```shell
-./new.sh MODULE_NAME
+powershell -c "irm bun.sh/install.ps1 | iex"
+```
+
+Follow the instructions to ensure that Bun is available globally. Once Bun has been installed, clone this repository. From there, run this script to create a new module:
+
+```shell
+./new.sh NAME_OF_NEW_MODULE
```
## Testing a Module
+> **Note:** It is the responsibility of the module author to implement tests for their module. The author must test the module locally before submitting a PR.
+
A suite of test-helpers exists to run `terraform apply` on modules with variables, and test script output against containers.
-The testing suite must be able to run docker containers with the `--network=host` flag, which typically requires running the tests on Linux as this flag does not apply to Docker Desktop for MacOS and Windows. MacOS users can work around this by using something like [colima](https://github.com/abiosoft/colima) or [Orbstack](https://orbstack.dev/) instead of Docker Desktop.
+The testing suite must be able to run docker containers with the `--network=host` flag. This typically requires running the tests on Linux as this flag does not apply to Docker Desktop for MacOS and Windows. MacOS users can work around this by using something like [colima](https://github.com/abiosoft/colima) or [Orbstack](https://orbstack.dev/) instead of Docker Desktop.
+
+Reference the existing `*.test.ts` files to get an idea for how to set up tests.
-Reference existing `*.test.ts` files for implementation.
+You can run all tests in a specific file with this command:
```shell
-# Run tests for a specific module!
$ bun test -t ''
```
+Or run all tests by running this command:
+
+```shell
+$ bun test
+```
+
You can test a module locally by updating the source as follows
```tf
@@ -27,4 +50,25 @@ module "example" {
}
```
-> **Note:** This is the responsibility of the module author to implement tests for their module. and test the module locally before submitting a PR.
+## Releases
+
+> [!WARNING]
+> When creating a new release, make sure that your new version number is fully accurate. If a version number is incorrect or does not exist, we may end up serving incorrect/old data for our various tools and providers.
+
+Much of our release process is automated. To cut a new release:
+
+1. Navigate to [GitHub's Releases page](https://github.com/coder/modules/releases)
+2. Click "Draft a new release"
+3. Click the "Choose a tag" button and type a new release number in the format `v..` (e.g., `v1.18.0`). Then click "Create new tag".
+4. Click the "Generate release notes" button, and clean up the resulting README. Be sure to remove any notes that would not be relevant to end-users (e.g., bumping dependencies).
+5. Once everything looks good, click the "Publish release" button.
+
+Once the release has been cut, a script will run to check whether there are any modules that will require that the new release number be published to Terraform. If there are any, a new pull request will automatically be generated. Be sure to approve this PR and merge it into the `main` branch.
+
+Following that, our automated processes will handle publishing new data for [`registry.coder.com`](https://github.com/coder/registry.coder.com/):
+
+1. Publishing new versions to Coder's [Terraform Registry](https://registry.terraform.io/providers/coder/coder/latest)
+2. Publishing new data to the [Coder Registry](https://registry.coder.com)
+
+> [!NOTE]
+> Some data in `registry.coder.com` is fetched on demand from the Module repo's main branch. This data should be updated almost immediately after a new release, but other changes will take some time to propagate.
diff --git a/README.md b/README.md
index 4b67594..48a96a3 100644
--- a/README.md
+++ b/README.md
@@ -3,14 +3,14 @@
Modules
-[Registry](https://registry.coder.com) | [Coder Docs](https://coder.com/docs) | [Why Coder](https://coder.com/why) | [Coder Enterprise](https://coder.com/docs/v2/latest/enterprise)
+[Module Registry](https://registry.coder.com) | [Coder Docs](https://coder.com/docs) | [Why Coder](https://coder.com/why) | [Coder Enterprise](https://coder.com/docs/v2/latest/enterprise)
[](https://discord.gg/coder)
[](./LICENSE)
-Modules extend Templates to create reusable components for your development environment.
+Modules extend Coder Templates to create reusable components for your development environment.
e.g.
diff --git a/bun.lockb b/bun.lockb
index d3e2214..7576953 100755
Binary files a/bun.lockb and b/bun.lockb differ
diff --git a/code-server/README.md b/code-server/README.md
index e1ca7a2..3692d71 100644
--- a/code-server/README.md
+++ b/code-server/README.md
@@ -14,7 +14,7 @@ Automatically install [code-server](https://github.com/coder/code-server) in a w
```tf
module "code-server" {
source = "registry.coder.com/modules/code-server/coder"
- version = "1.0.15"
+ version = "1.0.17"
agent_id = coder_agent.example.id
}
```
@@ -28,7 +28,7 @@ module "code-server" {
```tf
module "code-server" {
source = "registry.coder.com/modules/code-server/coder"
- version = "1.0.15"
+ version = "1.0.17"
agent_id = coder_agent.example.id
install_version = "4.8.3"
}
@@ -41,7 +41,7 @@ Install the Dracula theme from [OpenVSX](https://open-vsx.org/):
```tf
module "code-server" {
source = "registry.coder.com/modules/code-server/coder"
- version = "1.0.15"
+ version = "1.0.17"
agent_id = coder_agent.example.id
extensions = [
"dracula-theme.theme-dracula"
@@ -58,7 +58,7 @@ Configure VS Code's [settings.json](https://code.visualstudio.com/docs/getstarte
```tf
module "code-server" {
source = "registry.coder.com/modules/code-server/coder"
- version = "1.0.15"
+ version = "1.0.17"
agent_id = coder_agent.example.id
extensions = ["dracula-theme.theme-dracula"]
settings = {
@@ -74,7 +74,7 @@ Just run code-server in the background, don't fetch it from GitHub:
```tf
module "code-server" {
source = "registry.coder.com/modules/code-server/coder"
- version = "1.0.15"
+ version = "1.0.17"
agent_id = coder_agent.example.id
extensions = ["dracula-theme.theme-dracula", "ms-azuretools.vscode-docker"]
}
@@ -89,7 +89,7 @@ Run an existing copy of code-server if found, otherwise download from GitHub:
```tf
module "code-server" {
source = "registry.coder.com/modules/code-server/coder"
- version = "1.0.15"
+ version = "1.0.17"
agent_id = coder_agent.example.id
use_cached = true
extensions = ["dracula-theme.theme-dracula", "ms-azuretools.vscode-docker"]
@@ -101,7 +101,7 @@ Just run code-server in the background, don't fetch it from GitHub:
```tf
module "code-server" {
source = "registry.coder.com/modules/code-server/coder"
- version = "1.0.15"
+ version = "1.0.17"
agent_id = coder_agent.example.id
offline = true
}
diff --git a/code-server/main.tf b/code-server/main.tf
index 30b705c..9961693 100644
--- a/code-server/main.tf
+++ b/code-server/main.tf
@@ -113,6 +113,15 @@ variable "auto_install_extensions" {
default = false
}
+variable "subdomain" {
+ type = bool
+ description = <<-EOT
+ Determines whether the app will be accessed via it's own subdomain or whether it will be accessed via a path on Coder.
+ If wildcards have not been setup by the administrator then apps with "subdomain" set to true will not be accessible.
+ EOT
+ default = false
+}
+
resource "coder_script" "code-server" {
agent_id = var.agent_id
display_name = "code-server"
@@ -154,7 +163,7 @@ resource "coder_app" "code-server" {
display_name = var.display_name
url = "http://localhost:${var.port}/${var.folder != "" ? "?folder=${urlencode(var.folder)}" : ""}"
icon = "/icon/code.svg"
- subdomain = false
+ subdomain = var.subdomain
share = var.share
order = var.order
diff --git a/code-server/run.sh b/code-server/run.sh
index 8e068b8..9af391e 100755
--- a/code-server/run.sh
+++ b/code-server/run.sh
@@ -10,6 +10,7 @@ CODE_SERVER="${INSTALL_PREFIX}/bin/code-server"
EXTENSION_ARG=""
if [ -n "${EXTENSIONS_DIR}" ]; then
EXTENSION_ARG="--extensions-dir=${EXTENSIONS_DIR}"
+ mkdir -p "${EXTENSIONS_DIR}"
fi
function run_code_server() {
diff --git a/dotfiles/main.tf b/dotfiles/main.tf
index bfb67e4..9bc3735 100644
--- a/dotfiles/main.tf
+++ b/dotfiles/main.tf
@@ -39,9 +39,14 @@ variable "coder_parameter_order" {
default = null
}
-data "coder_parameter" "dotfiles_uri" {
- count = var.dotfiles_uri == null ? 1 : 0
+variable "manual_update" {
+ type = bool
+ description = "If true, this adds a button to workspace page to refresh dotfiles on demand."
+ default = false
+}
+data "coder_parameter" "dotfiles_uri" {
+ count = var.dotfiles_uri == null ? 1 : 0
type = "string"
name = "dotfiles_uri"
display_name = "Dotfiles URL"
@@ -68,6 +73,18 @@ resource "coder_script" "dotfiles" {
run_on_start = true
}
+resource "coder_app" "dotfiles" {
+ count = var.manual_update ? 1 : 0
+ agent_id = var.agent_id
+ display_name = "Refresh Dotfiles"
+ slug = "dotfiles"
+ icon = "/icon/dotfiles.svg"
+ command = templatefile("${path.module}/run.sh", {
+ DOTFILES_URI : local.dotfiles_uri,
+ DOTFILES_USER : local.user
+ })
+}
+
output "dotfiles_uri" {
description = "Dotfiles URI"
value = local.dotfiles_uri
diff --git a/filebrowser/README.md b/filebrowser/README.md
index 2881376..50b503a 100644
--- a/filebrowser/README.md
+++ b/filebrowser/README.md
@@ -13,9 +13,10 @@ A file browser for your workspace.
```tf
module "filebrowser" {
- source = "registry.coder.com/modules/filebrowser/coder"
- version = "1.0.8"
- agent_id = coder_agent.example.id
+ source = "registry.coder.com/modules/filebrowser/coder"
+ version = "1.0.8"
+ agent_id = coder_agent.example.id
+ agent_name = "main"
}
```
@@ -27,10 +28,11 @@ module "filebrowser" {
```tf
module "filebrowser" {
- source = "registry.coder.com/modules/filebrowser/coder"
- version = "1.0.8"
- agent_id = coder_agent.example.id
- folder = "/home/coder/project"
+ source = "registry.coder.com/modules/filebrowser/coder"
+ version = "1.0.8"
+ agent_id = coder_agent.example.id
+ agent_name = "main"
+ folder = "/home/coder/project"
}
```
@@ -41,6 +43,18 @@ module "filebrowser" {
source = "registry.coder.com/modules/filebrowser/coder"
version = "1.0.8"
agent_id = coder_agent.example.id
+ agent_name = "main"
database_path = ".config/filebrowser.db"
}
```
+
+### Serve from the same domain (no subdomain)
+
+```tf
+module "filebrowser" {
+ source = "registry.coder.com/modules/filebrowser/coder"
+ agent_id = coder_agent.example.id
+ agent_name = "main"
+ subdomain = false
+}
+```
diff --git a/filebrowser/main.test.ts b/filebrowser/main.test.ts
index 79dd99d..ff6d045 100644
--- a/filebrowser/main.test.ts
+++ b/filebrowser/main.test.ts
@@ -11,11 +11,13 @@ describe("filebrowser", async () => {
testRequiredVariables(import.meta.dir, {
agent_id: "foo",
+ agent_name: "main",
});
it("fails with wrong database_path", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
+ agent_name: "main",
database_path: "nofb",
}).catch((e) => {
if (!e.message.startsWith("\nError: Invalid value for variable")) {
@@ -27,6 +29,7 @@ describe("filebrowser", async () => {
it("runs with default", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
+ agent_name: "main",
});
const output = await executeScriptInContainer(state, "alpine");
expect(output.exitCode).toBe(0);
@@ -48,6 +51,7 @@ describe("filebrowser", async () => {
it("runs with database_path var", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
+ agent_name: "main",
database_path: ".config/filebrowser.db",
});
const output = await executeScriptInContainer(state, "alpine");
@@ -70,6 +74,7 @@ describe("filebrowser", async () => {
it("runs with folder var", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
+ agent_name: "main",
folder: "/home/coder/project",
});
const output = await executeScriptInContainer(state, "alpine");
@@ -88,4 +93,27 @@ describe("filebrowser", async () => {
"📝 Logs at /tmp/filebrowser.log",
]);
});
+
+ it("runs with subdomain=false", async () => {
+ const state = await runTerraformApply(import.meta.dir, {
+ agent_id: "foo",
+ agent_name: "main",
+ subdomain: false,
+ });
+ const output = await executeScriptInContainer(state, "alpine");
+ expect(output.exitCode).toBe(0);
+ expect(output.stdout).toEqual([
+ "\u001B[0;1mInstalling filebrowser ",
+ "",
+ "🥳 Installation complete! ",
+ "",
+ "👷 Starting filebrowser in background... ",
+ "",
+ "📂 Serving /root at http://localhost:13339 ",
+ "",
+ "Running 'filebrowser --noauth --root /root --port 13339' ",
+ "",
+ "📝 Logs at /tmp/filebrowser.log",
+ ]);
+ });
});
diff --git a/filebrowser/main.tf b/filebrowser/main.tf
index a07072b..e6b88c6 100644
--- a/filebrowser/main.tf
+++ b/filebrowser/main.tf
@@ -14,6 +14,15 @@ variable "agent_id" {
description = "The ID of a Coder agent."
}
+data "coder_workspace" "me" {}
+
+data "coder_workspace_owner" "me" {}
+
+variable "agent_name" {
+ type = string
+ description = "The name of the main deployment. (Used to build the subpath for coder_app.)"
+}
+
variable "database_path" {
type = string
description = "The path to the filebrowser database."
@@ -58,6 +67,15 @@ variable "order" {
default = null
}
+variable "subdomain" {
+ type = bool
+ description = <<-EOT
+ Determines whether the app will be accessed via it's own subdomain or whether it will be accessed via a path on Coder.
+ If wildcards have not been setup by the administrator then apps with "subdomain" set to true will not be accessible.
+ EOT
+ default = true
+}
+
resource "coder_script" "filebrowser" {
agent_id = var.agent_id
display_name = "File Browser"
@@ -67,7 +85,9 @@ resource "coder_script" "filebrowser" {
PORT : var.port,
FOLDER : var.folder,
LOG_PATH : var.log_path,
- DB_PATH : var.database_path
+ DB_PATH : var.database_path,
+ SUBDOMAIN : var.subdomain,
+ SERVER_BASE_PATH : var.subdomain ? "" : format("/@%s/%s.%s/apps/filebrowser", data.coder_workspace_owner.me.name, data.coder_workspace.me.name, var.agent_name),
})
run_on_start = true
}
@@ -78,7 +98,7 @@ resource "coder_app" "filebrowser" {
display_name = "File Browser"
url = "http://localhost:${var.port}"
icon = "https://raw.githubusercontent.com/filebrowser/logo/master/icon_raw.svg"
- subdomain = true
+ subdomain = var.subdomain
share = var.share
order = var.order
}
diff --git a/filebrowser/run.sh b/filebrowser/run.sh
index 8744edb..22f13ed 100644
--- a/filebrowser/run.sh
+++ b/filebrowser/run.sh
@@ -17,6 +17,9 @@ if [ "${DB_PATH}" != "filebrowser.db" ]; then
DB_FLAG=" -d ${DB_PATH}"
fi
+# set baseurl to be able to run if sudomain=false; if subdomain=true the SERVER_BASE_PATH value will be ""
+filebrowser config set --baseurl "${SERVER_BASE_PATH}" > ${LOG_PATH} 2>&1
+
printf "📂 Serving $${ROOT_DIR} at http://localhost:${PORT} \n\n"
printf "Running 'filebrowser --noauth --root $ROOT_DIR --port ${PORT}$${DB_FLAG}' \n\n"
diff --git a/git-clone/README.md b/git-clone/README.md
index 255b3f1..5efc50e 100644
--- a/git-clone/README.md
+++ b/git-clone/README.md
@@ -153,3 +153,20 @@ module "git-clone" {
branch_name = "feat/example"
}
```
+
+## Git clone with different destination folder
+
+By default, the repository will be cloned into a folder matching the repository name. You can use the `folder_name` attribute to change the name of the destination folder to something else.
+
+For example, this will clone into the `~/projects/coder/coder-dev` folder:
+
+```tf
+module "git-clone" {
+ source = "registry.coder.com/modules/git-clone/coder"
+ version = "1.0.12"
+ agent_id = coder_agent.example.id
+ url = "https://github.com/coder/coder"
+ folder_name = "coder-dev"
+ base_dir = "~/projects/coder"
+}
+```
diff --git a/git-clone/main.test.ts b/git-clone/main.test.ts
index 87b0e4a..9fbd202 100644
--- a/git-clone/main.test.ts
+++ b/git-clone/main.test.ts
@@ -79,6 +79,22 @@ describe("git-clone", async () => {
expect(state.outputs.branch_name.value).toEqual("");
});
+ it("repo_dir should match base_dir/folder_name", async () => {
+ const url = "git@github.com:coder/coder.git";
+ const state = await runTerraformApply(import.meta.dir, {
+ agent_id: "foo",
+ base_dir: "/tmp",
+ folder_name: "foo",
+ url,
+ });
+ expect(state.outputs.repo_dir.value).toEqual("/tmp/foo");
+ expect(state.outputs.folder_name.value).toEqual("foo");
+ expect(state.outputs.clone_url.value).toEqual(url);
+ const https_url = "https://github.com/coder/coder.git";
+ expect(state.outputs.web_url.value).toEqual(https_url);
+ expect(state.outputs.branch_name.value).toEqual("");
+ });
+
it("branch_name should not include query string", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
diff --git a/git-clone/main.tf b/git-clone/main.tf
index 4af5000..0295444 100644
--- a/git-clone/main.tf
+++ b/git-clone/main.tf
@@ -50,6 +50,12 @@ variable "branch_name" {
default = ""
}
+variable "folder_name" {
+ description = "The destination folder to clone the repository into."
+ type = string
+ default = ""
+}
+
locals {
# Remove query parameters and fragments from the URL
url = replace(replace(var.url, "/\\?.*/", ""), "/#.*/", "")
@@ -64,7 +70,7 @@ locals {
# Extract the branch name from the URL
branch_name = var.branch_name == "" && local.tree_path != "" ? replace(replace(local.url, local.clone_url, ""), "/.*${local.tree_path}/", "") : var.branch_name
# Extract the folder name from the URL
- folder_name = replace(basename(local.clone_url), ".git", "")
+ folder_name = var.folder_name == "" ? replace(basename(local.clone_url), ".git", "") : var.folder_name
# Construct the path to clone the repository
clone_path = var.base_dir != "" ? join("/", [var.base_dir, local.folder_name]) : join("/", ["~", local.folder_name])
# Construct the web URL
diff --git a/lint.ts b/lint.ts
index db1ee9a..6652fcb 100644
--- a/lint.ts
+++ b/lint.ts
@@ -5,14 +5,15 @@ import grayMatter from "gray-matter";
const files = await readdir(".", { withFileTypes: true });
const dirs = files.filter(
- (f) => f.isDirectory() && !f.name.startsWith(".") && f.name !== "node_modules"
+ (f) =>
+ f.isDirectory() && !f.name.startsWith(".") && f.name !== "node_modules",
);
let badExit = false;
// error reports an error to the console and sets badExit to true
// so that the process will exit with a non-zero exit code.
-const error = (...data: any[]) => {
+const error = (...data: unknown[]) => {
console.error(...data);
badExit = true;
};
@@ -22,7 +23,7 @@ const verifyCodeBlocks = (
res = {
codeIsTF: false,
codeIsHCL: false,
- }
+ },
) => {
for (const token of tokens) {
// Check in-depth.
@@ -30,7 +31,12 @@ const verifyCodeBlocks = (
verifyCodeBlocks(token.items, res);
continue;
}
+
if (token.type === "list_item") {
+ if (token.tokens === undefined) {
+ throw new Error("Tokens are missing for type list_item");
+ }
+
verifyCodeBlocks(token.tokens, res);
continue;
}
@@ -80,8 +86,9 @@ for (const dir of dirs) {
if (!data.maintainer_github) {
error(dir.name, "missing maintainer_github");
}
+
try {
- await stat(path.join(".", dir.name, data.icon));
+ await stat(path.join(".", dir.name, data.icon ?? ""));
} catch (ex) {
error(dir.name, "icon does not exist", data.icon);
}
diff --git a/package-lock.json b/package-lock.json
deleted file mode 100644
index 8039c1c..0000000
--- a/package-lock.json
+++ /dev/null
@@ -1,263 +0,0 @@
-{
- "name": "modules",
- "lockfileVersion": 3,
- "requires": true,
- "packages": {
- "": {
- "name": "modules",
- "devDependencies": {
- "bun-types": "^1.0.18",
- "gray-matter": "^4.0.3",
- "marked": "^12.0.0",
- "prettier": "^3.2.5",
- "prettier-plugin-sh": "^0.13.1",
- "prettier-plugin-terraform-formatter": "^1.2.1"
- },
- "peerDependencies": {
- "typescript": "^5.3.3"
- }
- },
- "node_modules/@types/node": {
- "version": "20.11.30",
- "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.30.tgz",
- "integrity": "sha512-dHM6ZxwlmuZaRmUPfv1p+KrdD1Dci04FbdEm/9wEMouFqxYoFl5aMkt0VMAUtYRQDyYvD41WJLukhq/ha3YuTw==",
- "dev": true,
- "dependencies": {
- "undici-types": "~5.26.4"
- }
- },
- "node_modules/@types/ws": {
- "version": "8.5.10",
- "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.10.tgz",
- "integrity": "sha512-vmQSUcfalpIq0R9q7uTo2lXs6eGIpt9wtnLdMv9LVpIjCA/+ufZRozlVoVelIYixx1ugCBKDhn89vnsEGOCx9A==",
- "dev": true,
- "dependencies": {
- "@types/node": "*"
- }
- },
- "node_modules/argparse": {
- "version": "1.0.10",
- "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
- "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
- "dev": true,
- "dependencies": {
- "sprintf-js": "~1.0.2"
- }
- },
- "node_modules/bun-types": {
- "version": "1.1.4",
- "resolved": "https://registry.npmjs.org/bun-types/-/bun-types-1.1.4.tgz",
- "integrity": "sha512-E1kk0FNpxpkSSlCVXEa4HfyhSUEpKtCFrybPVyz1A4TEnBGy5bqqtSYkyjKTfKScdyZTBeFrTxJLiKGOIRWgwg==",
- "dev": true,
- "dependencies": {
- "@types/node": "~20.11.3",
- "@types/ws": "~8.5.10"
- }
- },
- "node_modules/esprima": {
- "version": "4.0.1",
- "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
- "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
- "dev": true,
- "bin": {
- "esparse": "bin/esparse.js",
- "esvalidate": "bin/esvalidate.js"
- },
- "engines": {
- "node": ">=4"
- }
- },
- "node_modules/extend-shallow": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
- "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==",
- "dev": true,
- "dependencies": {
- "is-extendable": "^0.1.0"
- },
- "engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/gray-matter": {
- "version": "4.0.3",
- "resolved": "https://registry.npmjs.org/gray-matter/-/gray-matter-4.0.3.tgz",
- "integrity": "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==",
- "dev": true,
- "dependencies": {
- "js-yaml": "^3.13.1",
- "kind-of": "^6.0.2",
- "section-matter": "^1.0.0",
- "strip-bom-string": "^1.0.0"
- },
- "engines": {
- "node": ">=6.0"
- }
- },
- "node_modules/is-extendable": {
- "version": "0.1.1",
- "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz",
- "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==",
- "dev": true,
- "engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/js-yaml": {
- "version": "3.14.1",
- "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz",
- "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==",
- "dev": true,
- "dependencies": {
- "argparse": "^1.0.7",
- "esprima": "^4.0.0"
- },
- "bin": {
- "js-yaml": "bin/js-yaml.js"
- }
- },
- "node_modules/kind-of": {
- "version": "6.0.3",
- "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz",
- "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==",
- "dev": true,
- "engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/marked": {
- "version": "12.0.2",
- "resolved": "https://registry.npmjs.org/marked/-/marked-12.0.2.tgz",
- "integrity": "sha512-qXUm7e/YKFoqFPYPa3Ukg9xlI5cyAtGmyEIzMfW//m6kXwCy2Ps9DYf5ioijFKQ8qyuscrHoY04iJGctu2Kg0Q==",
- "dev": true,
- "bin": {
- "marked": "bin/marked.js"
- },
- "engines": {
- "node": ">= 18"
- }
- },
- "node_modules/mvdan-sh": {
- "version": "0.10.1",
- "resolved": "https://registry.npmjs.org/mvdan-sh/-/mvdan-sh-0.10.1.tgz",
- "integrity": "sha512-kMbrH0EObaKmK3nVRKUIIya1dpASHIEusM13S4V1ViHFuxuNxCo+arxoa6j/dbV22YBGjl7UKJm9QQKJ2Crzhg==",
- "dev": true
- },
- "node_modules/prettier": {
- "version": "3.2.5",
- "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.2.5.tgz",
- "integrity": "sha512-3/GWa9aOC0YeD7LUfvOG2NiDyhOWRvt1k+rcKhOuYnMY24iiCphgneUfJDyFXd6rZCAnuLBv6UeAULtrhT/F4A==",
- "dev": true,
- "bin": {
- "prettier": "bin/prettier.cjs"
- },
- "engines": {
- "node": ">=14"
- },
- "funding": {
- "url": "https://github.com/prettier/prettier?sponsor=1"
- }
- },
- "node_modules/prettier-plugin-sh": {
- "version": "0.13.1",
- "resolved": "https://registry.npmjs.org/prettier-plugin-sh/-/prettier-plugin-sh-0.13.1.tgz",
- "integrity": "sha512-ytMcl1qK4s4BOFGvsc9b0+k9dYECal7U29bL/ke08FEUsF/JLN0j6Peo0wUkFDG4y2UHLMhvpyd6Sd3zDXe/eg==",
- "dev": true,
- "dependencies": {
- "mvdan-sh": "^0.10.1",
- "sh-syntax": "^0.4.1"
- },
- "engines": {
- "node": ">=16.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/unts"
- },
- "peerDependencies": {
- "prettier": "^3.0.0"
- }
- },
- "node_modules/prettier-plugin-terraform-formatter": {
- "version": "1.2.1",
- "resolved": "https://registry.npmjs.org/prettier-plugin-terraform-formatter/-/prettier-plugin-terraform-formatter-1.2.1.tgz",
- "integrity": "sha512-rdzV61Bs/Ecnn7uAS/vL5usTX8xUWM+nQejNLZxt3I1kJH5WSeLEmq7LYu1wCoEQF+y7Uv1xGvPRfl3lIe6+tA==",
- "dev": true,
- "peerDependencies": {
- "prettier": ">= 1.16.0"
- },
- "peerDependenciesMeta": {
- "prettier": {
- "optional": true
- }
- }
- },
- "node_modules/section-matter": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz",
- "integrity": "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==",
- "dev": true,
- "dependencies": {
- "extend-shallow": "^2.0.1",
- "kind-of": "^6.0.0"
- },
- "engines": {
- "node": ">=4"
- }
- },
- "node_modules/sh-syntax": {
- "version": "0.4.2",
- "resolved": "https://registry.npmjs.org/sh-syntax/-/sh-syntax-0.4.2.tgz",
- "integrity": "sha512-/l2UZ5fhGZLVZa16XQM9/Vq/hezGGbdHeVEA01uWjOL1+7Ek/gt6FquW0iKKws4a9AYPYvlz6RyVvjh3JxOteg==",
- "dev": true,
- "dependencies": {
- "tslib": "^2.6.2"
- },
- "engines": {
- "node": ">=16.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/unts"
- }
- },
- "node_modules/sprintf-js": {
- "version": "1.0.3",
- "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
- "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
- "dev": true
- },
- "node_modules/strip-bom-string": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/strip-bom-string/-/strip-bom-string-1.0.0.tgz",
- "integrity": "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==",
- "dev": true,
- "engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/tslib": {
- "version": "2.6.2",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz",
- "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==",
- "dev": true
- },
- "node_modules/typescript": {
- "version": "5.4.5",
- "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz",
- "integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==",
- "peer": true,
- "bin": {
- "tsc": "bin/tsc",
- "tsserver": "bin/tsserver"
- },
- "engines": {
- "node": ">=14.17"
- }
- },
- "node_modules/undici-types": {
- "version": "5.26.5",
- "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
- "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
- "dev": true
- }
- }
-}
diff --git a/package.json b/package.json
index f3136b1..eea421d 100644
--- a/package.json
+++ b/package.json
@@ -8,15 +8,15 @@
"update-version": "./update-version.sh"
},
"devDependencies": {
- "bun-types": "^1.0.18",
+ "bun-types": "^1.1.23",
"gray-matter": "^4.0.3",
- "marked": "^12.0.0",
- "prettier": "^3.2.5",
+ "marked": "^12.0.2",
+ "prettier": "^3.3.3",
"prettier-plugin-sh": "^0.13.1",
"prettier-plugin-terraform-formatter": "^1.2.1"
},
"peerDependencies": {
- "typescript": "^5.3.3"
+ "typescript": "^5.5.4"
},
"prettier": {
"plugins": [
diff --git a/slackme/main.test.ts b/slackme/main.test.ts
index 402a690..eca4f5d 100644
--- a/slackme/main.test.ts
+++ b/slackme/main.test.ts
@@ -126,7 +126,10 @@ const assertSlackMessage = async (opts: {
durationMS?: number;
output: string;
}) => {
- let url: URL;
+ // Have to use non-null assertion because TS can't tell when the fetch
+ // function will run
+ let url!: URL;
+
const fakeSlackHost = serve({
fetch: (req) => {
url = new URL(req.url);
@@ -138,15 +141,16 @@ const assertSlackMessage = async (opts: {
},
port: 0,
});
+
const { instance, id } = await setupContainer(
"alpine/curl",
- opts.format && {
- slack_message: opts.format,
- },
+ opts.format ? { slack_message: opts.format } : undefined,
);
+
await writeCoder(id, "echo 'token'");
let exec = await execContainer(id, ["sh", "-c", instance.script]);
expect(exec.exitCode).toBe(0);
+
exec = await execContainer(id, [
"sh",
"-c",
@@ -154,6 +158,7 @@ const assertSlackMessage = async (opts: {
fakeSlackHost.hostname
}:${fakeSlackHost.port}" slackme ${opts.command}`,
]);
+
expect(exec.stderr.trim()).toBe("");
expect(url.pathname).toEqual("/api/chat.postMessage");
expect(url.searchParams.get("channel")).toEqual("token");
diff --git a/test.ts b/test.ts
index c2eb65e..6bdf9d9 100644
--- a/test.ts
+++ b/test.ts
@@ -29,8 +29,10 @@ export const runContainer = async (
return containerID.trim();
};
-// executeScriptInContainer finds the only "coder_script"
-// resource in the given state and runs it in a container.
+/**
+ * Finds the only "coder_script" resource in the given state and runs it in a
+ * container.
+ */
export const executeScriptInContainer = async (
state: TerraformState,
image: string,
@@ -76,27 +78,34 @@ export const execContainer = async (
};
};
-export interface TerraformState {
- outputs: {
- [key: string]: {
- type: string;
- value: any;
- };
- }
- resources: [
+type JsonValue =
+ | string
+ | number
+ | boolean
+ | null
+ | JsonValue[]
+ | { [key: string]: JsonValue };
+
+type TerraformStateResource = {
+ type: string;
+ name: string;
+ provider: string;
+
+ instances: [
{
- type: string;
- name: string;
- provider: string;
- instances: [
- {
- attributes: {
- [key: string]: any;
- };
- },
- ];
+ attributes: Record;
},
];
+};
+
+type TerraformOutput = {
+ type: string;
+ value: JsonValue;
+};
+
+export interface TerraformState {
+ outputs: Record;
+ resources: [TerraformStateResource, ...TerraformStateResource[]];
}
export interface CoderScriptAttributes {
@@ -105,10 +114,11 @@ export interface CoderScriptAttributes {
url: string;
}
-// findResourceInstance finds the first instance of the given resource
-// type in the given state. If name is specified, it will only find
-// the instance with the given name.
-export const findResourceInstance = (
+/**
+ * finds the first instance of the given resource type in the given state. If
+ * name is specified, it will only find the instance with the given name.
+ */
+export const findResourceInstance = (
state: TerraformState,
type: T,
name?: string,
@@ -131,30 +141,37 @@ export const findResourceInstance = (
return resource.instances[0].attributes as any;
};
-// testRequiredVariables creates a test-case
-// for each variable provided and ensures that
-// the apply fails without it.
-export const testRequiredVariables = (
+/**
+ * Creates a test-case for each variable provided and ensures that the apply
+ * fails without it.
+ */
+export const testRequiredVariables = >(
dir: string,
- vars: Record,
+ vars: TVars,
) => {
// Ensures that all required variables are provided.
it("required variables", async () => {
await runTerraformApply(dir, vars);
});
+
const varNames = Object.keys(vars);
varNames.forEach((varName) => {
// Ensures that every variable provided is required!
it("missing variable " + varName, async () => {
- const localVars = {};
+ const localVars: Record = {};
varNames.forEach((otherVarName) => {
if (otherVarName !== varName) {
localVars[otherVarName] = vars[otherVarName];
}
});
+
try {
await runTerraformApply(dir, localVars);
} catch (ex) {
+ if (!(ex instanceof Error)) {
+ throw new Error("Unknown error generated");
+ }
+
expect(ex.message).toContain(
`input variable \"${varName}\" is not set`,
);
@@ -165,16 +182,25 @@ export const testRequiredVariables = (
});
};
-// runTerraformApply runs terraform apply in the given directory
-// with the given variables. It is fine to run in parallel with
-// other instances of this function, as it uses a random state file.
-export const runTerraformApply = async (
+/**
+ * Runs terraform apply in the given directory with the given variables. It is
+ * fine to run in parallel with other instances of this function, as it uses a
+ * random state file.
+ */
+export const runTerraformApply = async <
+ TVars extends Readonly>,
+>(
dir: string,
- vars: Record,
- env: Record = {},
+ vars: TVars,
+ env?: Record,
): Promise => {
const stateFile = `${dir}/${crypto.randomUUID()}.tfstate`;
- Object.keys(vars).forEach((key) => (env[`TF_VAR_${key}`] = vars[key]));
+
+ const combinedEnv = env === undefined ? {} : { ...env };
+ for (const [key, value] of Object.entries(vars)) {
+ combinedEnv[`TF_VAR_${key}`] = String(value);
+ }
+
const proc = spawn(
[
"terraform",
@@ -188,22 +214,26 @@ export const runTerraformApply = async (
],
{
cwd: dir,
- env,
+ env: combinedEnv,
stderr: "pipe",
stdout: "pipe",
},
);
+
const text = await readableStreamToText(proc.stderr);
const exitCode = await proc.exited;
if (exitCode !== 0) {
throw new Error(text);
}
+
const content = await readFile(stateFile, "utf8");
await unlink(stateFile);
return JSON.parse(content);
};
-// runTerraformInit runs terraform init in the given directory.
+/**
+ * Runs terraform init in the given directory.
+ */
export const runTerraformInit = async (dir: string) => {
const proc = spawn(["terraform", "init"], {
cwd: dir,
@@ -221,8 +251,8 @@ export const createJSONResponse = (obj: object, statusCode = 200): Response => {
"Content-Type": "application/json",
},
status: statusCode,
- })
-}
+ });
+};
export const writeCoder = async (id: string, script: string) => {
const exec = await execContainer(id, [
diff --git a/tsconfig.json b/tsconfig.json
index e7b89cd..dd38e58 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -2,6 +2,7 @@
"compilerOptions": {
"target": "esnext",
"module": "esnext",
+ "strict": true,
"allowSyntheticDefaultImports": true,
"moduleResolution": "nodenext",
"types": ["bun-types"]
diff --git a/vscode-desktop/main.test.ts b/vscode-desktop/main.test.ts
index e61e6ec..207b492 100644
--- a/vscode-desktop/main.test.ts
+++ b/vscode-desktop/main.test.ts
@@ -24,9 +24,10 @@ describe("vscode-desktop", async () => {
const coder_app = state.resources.find(
(res) => res.type == "coder_app" && res.name == "vscode",
);
+
expect(coder_app).not.toBeNull();
- expect(coder_app.instances.length).toBe(1);
- expect(coder_app.instances[0].attributes.order).toBeNull();
+ expect(coder_app?.instances.length).toBe(1);
+ expect(coder_app?.instances[0].attributes.order).toBeNull();
});
it("adds folder", async () => {
@@ -43,7 +44,7 @@ describe("vscode-desktop", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
folder: "/foo/bar",
- open_recent: true,
+ open_recent: "true",
});
expect(state.outputs.vscode_url.value).toBe(
"vscode://coder.coder-remote/open?owner=default&workspace=default&folder=/foo/bar&openRecent&url=https://mydeployment.coder.com&token=$SESSION_TOKEN",
@@ -54,7 +55,7 @@ describe("vscode-desktop", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
folder: "/foo/bar",
- openRecent: false,
+ openRecent: "false",
});
expect(state.outputs.vscode_url.value).toBe(
"vscode://coder.coder-remote/open?owner=default&workspace=default&folder=/foo/bar&url=https://mydeployment.coder.com&token=$SESSION_TOKEN",
@@ -64,7 +65,7 @@ describe("vscode-desktop", async () => {
it("adds open_recent", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
- open_recent: true,
+ open_recent: "true",
});
expect(state.outputs.vscode_url.value).toBe(
"vscode://coder.coder-remote/open?owner=default&workspace=default&openRecent&url=https://mydeployment.coder.com&token=$SESSION_TOKEN",
@@ -80,8 +81,9 @@ describe("vscode-desktop", async () => {
const coder_app = state.resources.find(
(res) => res.type == "coder_app" && res.name == "vscode",
);
+
expect(coder_app).not.toBeNull();
- expect(coder_app.instances.length).toBe(1);
- expect(coder_app.instances[0].attributes.order).toBe(22);
+ expect(coder_app?.instances.length).toBe(1);
+ expect(coder_app?.instances[0].attributes.order).toBe(22);
});
});
diff --git a/windows-rdp/README.md b/windows-rdp/README.md
new file mode 100644
index 0000000..e8c5a1c
--- /dev/null
+++ b/windows-rdp/README.md
@@ -0,0 +1,57 @@
+---
+display_name: Windows RDP
+description: RDP Server and Web Client, powered by Devolutions Gateway
+icon: ../.icons/desktop.svg
+maintainer_github: coder
+verified: true
+tags: [windows, rdp, web, desktop]
+---
+
+# Windows RDP
+
+Enable Remote Desktop + a web based client on Windows workspaces, powered by [devolutions-gateway](https://github.com/Devolutions/devolutions-gateway).
+
+```tf
+# AWS example. See below for examples of using this module with other providers
+module "windows_rdp" {
+ source = "registry.coder.com/modules/windows-rdp/coder"
+ version = "1.0.16"
+ count = data.coder_workspace.me.start_count
+ agent_id = resource.coder_agent.main.id
+ resource_id = resource.aws_instance.dev.id
+}
+```
+
+## Video
+
+[](https://github.com/coder/modules/assets/28937484/fb5f4a55-7b69-4550-ab62-301e13a4be02)
+
+## Examples
+
+### With AWS
+
+```tf
+module "windows_rdp" {
+ source = "registry.coder.com/modules/windows-rdp/coder"
+ version = "1.0.16"
+ count = data.coder_workspace.me.start_count
+ agent_id = resource.coder_agent.main.id
+ resource_id = resource.aws_instance.dev.id
+}
+```
+
+### With Google Cloud
+
+```tf
+module "windows_rdp" {
+ source = "registry.coder.com/modules/windows-rdp/coder"
+ version = "1.0.16"
+ count = data.coder_workspace.me.start_count
+ agent_id = resource.coder_agent.main.id
+ resource_id = resource.google_compute_instance.dev[0].id
+}
+```
+
+## Roadmap
+
+- [ ] Test on Microsoft Azure.
diff --git a/windows-rdp/devolutions-patch.js b/windows-rdp/devolutions-patch.js
new file mode 100644
index 0000000..020a40f
--- /dev/null
+++ b/windows-rdp/devolutions-patch.js
@@ -0,0 +1,409 @@
+// @ts-check
+/**
+ * @file Defines the custom logic for patching in UI changes/behavior into the
+ * base Devolutions Gateway Angular app.
+ *
+ * Defined as a JS file to remove the need to have a separate compilation step.
+ * It is highly recommended that you work on this file from within VS Code so
+ * that you can take advantage of the @ts-check directive and get some type-
+ * checking still.
+ *
+ * Other notes about the weird ways this file is set up:
+ * - A lot of the HTML selectors in this file will look nonstandard. This is
+ * because they are actually custom Angular components.
+ * - It is strongly advised that you avoid template literals that use the
+ * placeholder syntax via the dollar sign. The Terraform file is treating this
+ * as a template file, and because it also uses a similar syntax, there's a
+ * risk that some values will trigger false positives. If a template literal
+ * must be used, be sure to use a double dollar sign to escape things.
+ * - All the CSS should be written via custom style tags and the !important
+ * directive (as much as that is a bad idea most of the time). We do not
+ * control the Angular app, so we have to modify things from afar to ensure
+ * that as Angular's internal state changes, it doesn't modify its HTML nodes
+ * in a way that causes our custom styles to get wiped away.
+ *
+ * @typedef {Readonly<{ querySelector: string; value: string; }>} FormFieldEntry
+ * @typedef {Readonly>} FormFieldEntries
+ */
+
+/**
+ * The communication protocol to set Devolutions to.
+ */
+const PROTOCOL = "RDP";
+
+/**
+ * The hostname to use with Devolutions.
+ */
+const HOSTNAME = "localhost";
+
+/**
+ * How often to poll the screen for the main Devolutions form.
+ */
+const SCREEN_POLL_INTERVAL_MS = 500;
+
+/**
+ * The fields in the Devolutions sign-in form that should be populated with
+ * values from the Coder workspace.
+ *
+ * All properties should be defined as placeholder templates in the form
+ * VALUE_NAME. The Coder module, when spun up, should then run some logic to
+ * replace the template slots with actual values. These values should never
+ * change from within JavaScript itself.
+ *
+ * @satisfies {FormFieldEntries}
+ */
+const formFieldEntries = {
+ /** @readonly */
+ username: {
+ /** @readonly */
+ querySelector: "web-client-username-control input",
+
+ /** @readonly */
+ value: "${CODER_USERNAME}",
+ },
+
+ /** @readonly */
+ password: {
+ /** @readonly */
+ querySelector: "web-client-password-control input",
+
+ /** @readonly */
+ value: "${CODER_PASSWORD}",
+ },
+};
+
+/**
+ * Handles typing in the values for the input form. All values are written
+ * immediately, even though that would be physically impossible with a real
+ * keyboard.
+ *
+ * Note: this code will never break, but you might get warnings in the console
+ * from Angular about unexpected value changes. Angular patches over a lot of
+ * the built-in browser APIs to support its component change detection system.
+ * As part of that, it has validations for checking whether an input it
+ * previously had control over changed without it doing anything.
+ *
+ * But the only way to simulate a keyboard input is by setting the input's
+ * .value property, and then firing an input event. So basically, the inner
+ * value will change, which Angular won't be happy about, but then the input
+ * event will fire and sync everything back together.
+ *
+ * @param {HTMLInputElement} inputField
+ * @param {string} inputText
+ * @returns {Promise}
+ */
+function setInputValue(inputField, inputText) {
+ return new Promise((resolve, reject) => {
+ // Adding timeout for input event, even though we'll be dispatching it
+ // immediately, just in the off chance that something in the Angular app
+ // intercepts it or stops it from propagating properly
+ const timeoutId = window.setTimeout(() => {
+ reject(new Error("Input event did not get processed correctly in time."));
+ }, 3_000);
+
+ const handleSuccessfulDispatch = () => {
+ window.clearTimeout(timeoutId);
+ inputField.removeEventListener("input", handleSuccessfulDispatch);
+ resolve();
+ };
+
+ inputField.addEventListener("input", handleSuccessfulDispatch);
+
+ // Code assumes that Angular will have an event handler in place to handle
+ // the new event
+ const inputEvent = new Event("input", {
+ bubbles: true,
+ cancelable: true,
+ });
+
+ inputField.value = inputText;
+ inputField.dispatchEvent(inputEvent);
+ });
+}
+
+/**
+ * Takes a Devolutions remote session form, auto-fills it with data, and then
+ * submits it.
+ *
+ * The logic here is more convoluted than it should be for two main reasons:
+ * 1. Devolutions' HTML markup has errors. There are labels, but they aren't
+ * bound to the inputs they're supposed to describe. This means no easy hooks
+ * for selecting the elements, unfortunately.
+ * 2. Trying to modify the .value properties on some of the inputs doesn't
+ * work. Probably some combo of Angular data-binding and some inputs having
+ * the readonly attribute. Have to simulate user input to get around this.
+ *
+ * @param {HTMLFormElement} myForm
+ * @returns {Promise}
+ */
+async function autoSubmitForm(myForm) {
+ const setProtocolValue = () => {
+ /** @type {HTMLDivElement | null} */
+ const protocolDropdownTrigger = myForm.querySelector('div[role="button"]');
+ if (protocolDropdownTrigger === null) {
+ throw new Error("No clickable trigger for setting protocol value");
+ }
+
+ protocolDropdownTrigger.click();
+
+ // Can't use form as container for querying the list of dropdown options,
+ // because the elements don't actually exist inside the form. They're placed
+ // in the top level of the HTML doc, and repositioned to make it look like
+ // they're part of the form. Avoids CSS stacking context issues, maybe?
+ /** @type {HTMLLIElement | null} */
+ const protocolOption = document.querySelector(
+ 'p-dropdownitem[ng-reflect-label="' + PROTOCOL + '"] li',
+ );
+
+ if (protocolOption === null) {
+ throw new Error(
+ "Unable to find protocol option on screen that matches desired protocol",
+ );
+ }
+
+ protocolOption.click();
+ };
+
+ const setHostname = () => {
+ /** @type {HTMLInputElement | null} */
+ const hostnameInput = myForm.querySelector("p-autocomplete#hostname input");
+
+ if (hostnameInput === null) {
+ throw new Error("Unable to find field for adding hostname");
+ }
+
+ return setInputValue(hostnameInput, HOSTNAME);
+ };
+
+ const setCoderFormFieldValues = async () => {
+ // The RDP form will not appear on screen unless the dropdown is set to use
+ // the RDP protocol
+ const rdpSubsection = myForm.querySelector("rdp-form");
+ if (rdpSubsection === null) {
+ throw new Error(
+ "Unable to find RDP subsection. Is the value of the protocol set to RDP?",
+ );
+ }
+
+ for (const { value, querySelector } of Object.values(formFieldEntries)) {
+ /** @type {HTMLInputElement | null} */
+ const input = document.querySelector(querySelector);
+
+ if (input === null) {
+ throw new Error(
+ 'Unable to element that matches query "' + querySelector + '"',
+ );
+ }
+
+ await setInputValue(input, value);
+ }
+ };
+
+ const triggerSubmission = () => {
+ /** @type {HTMLButtonElement | null} */
+ const submitButton = myForm.querySelector(
+ 'p-button[ng-reflect-type="submit"] button',
+ );
+
+ if (submitButton === null) {
+ throw new Error("Unable to find submission button");
+ }
+
+ if (submitButton.disabled) {
+ throw new Error(
+ "Unable to submit form because submit button is disabled. Are all fields filled out correctly?",
+ );
+ }
+
+ submitButton.click();
+ };
+
+ setProtocolValue();
+ await setHostname();
+ await setCoderFormFieldValues();
+ triggerSubmission();
+}
+
+/**
+ * Sets up logic for auto-populating the form data when the form appears on
+ * screen.
+ *
+ * @returns {void}
+ */
+function setupFormDetection() {
+ /** @type {HTMLFormElement | null} */
+ let formValueFromLastMutation = null;
+
+ /** @returns {void} */
+ const onDynamicTabMutation = () => {
+ /** @type {HTMLFormElement | null} */
+ const latestForm = document.querySelector("web-client-form > form");
+
+ // Only try to auto-fill if we went from having no form on screen to
+ // having a form on screen. That way, we don't accidentally override the
+ // form if the user is trying to customize values, and this essentially
+ // makes the script values function as default values
+ const mounted = formValueFromLastMutation === null && latestForm !== null;
+ if (mounted) {
+ autoSubmitForm(latestForm);
+ }
+
+ formValueFromLastMutation = latestForm;
+ };
+
+ /** @type {number | undefined} */
+ let pollingId = undefined;
+
+ /** @returns {void} */
+ const checkScreenForDynamicTab = () => {
+ const dynamicTab = document.querySelector("web-client-dynamic-tab");
+
+ // Keep polling until the main content container is on screen
+ if (dynamicTab === null) {
+ return;
+ }
+
+ window.clearInterval(pollingId);
+
+ // Call the mutation callback manually, to ensure it runs at least once
+ onDynamicTabMutation();
+
+ // Having the mutation observer is kind of an extra safety net that isn't
+ // really expected to run that often. Most of the content in the dynamic
+ // tab is being rendered through Canvas, which won't trigger any mutations
+ // that the observer can detect
+ const dynamicTabObserver = new MutationObserver(onDynamicTabMutation);
+ dynamicTabObserver.observe(dynamicTab, {
+ subtree: true,
+ childList: true,
+ });
+ };
+
+ pollingId = window.setInterval(
+ checkScreenForDynamicTab,
+ SCREEN_POLL_INTERVAL_MS,
+ );
+}
+
+/**
+ * Sets up custom styles for hiding default Devolutions elements that Coder
+ * users shouldn't need to care about.
+ *
+ * @returns {void}
+ */
+function setupAlwaysOnStyles() {
+ const styleId = "coder-patch--styles-always-on";
+ const existingContainer = document.querySelector("#" + styleId);
+ if (existingContainer) {
+ return;
+ }
+
+ const styleContainer = document.createElement("style");
+ styleContainer.id = styleId;
+ styleContainer.innerHTML = `
+ /* app-menu corresponds to the sidebar of the default view. */
+ app-menu {
+ display: none !important;
+ }
+ `;
+
+ document.head.appendChild(styleContainer);
+}
+
+function hideFormForInitialSubmission() {
+ const styleId = "coder-patch--styles-initial-submission";
+ const cssOpacityVariableName = "--coder-opacity-multiplier";
+
+ /** @type {HTMLStyleElement | null} */
+ let styleContainer = document.querySelector("#" + styleId);
+ if (!styleContainer) {
+ styleContainer = document.createElement("style");
+ styleContainer.id = styleId;
+ styleContainer.innerHTML = `
+ /*
+ Have to use opacity instead of visibility, because the element still
+ needs to be interactive via the script so that it can be auto-filled.
+ */
+ :root {
+ /*
+ Can be 0 or 1. Start off invisible to avoid risks of UI flickering,
+ but the rest of the function should be in charge of making the form
+ container visible again if something goes wrong during setup.
+
+ Double dollar sign needed to avoid Terraform script false positives
+ */
+ $${cssOpacityVariableName}: 0;
+ }
+
+ /*
+ web-client-form is the container for the main session form, while
+ the div is for the dropdown that is used for selecting the protocol.
+ The dropdown is not inside of the form for CSS styling reasons, so we
+ need to select both.
+ */
+ web-client-form,
+ body > div.p-overlay {
+ /*
+ Double dollar sign needed to avoid Terraform script false positives
+ */
+ opacity: calc(100% * var($${cssOpacityVariableName})) !important;
+ }
+ `;
+
+ document.head.appendChild(styleContainer);
+ }
+
+ // The root node being undefined should be physically impossible (if it's
+ // undefined, the browser itself is busted), but we need to do a type check
+ // here so that the rest of the function doesn't need to do type checks over
+ // and over.
+ const rootNode = document.querySelector(":root");
+ if (!(rootNode instanceof HTMLHtmlElement)) {
+ // Remove the container entirely because if the browser is busted, who knows
+ // if the CSS variables can be applied correctly. Better to have something
+ // be a bit more ugly/painful to use, than have it be impossible to use
+ styleContainer.remove();
+ return;
+ }
+
+ // It's safe to make the form visible preemptively because Devolutions
+ // outputs the Windows view through an HTML canvas that it overlays on top
+ // of the rest of the app. Even if the form isn't hidden at the style level,
+ // it will still be covered up.
+ const restoreOpacity = () => {
+ rootNode.style.setProperty(cssOpacityVariableName, "1");
+ };
+
+ // If this file gets more complicated, it might make sense to set up the
+ // timeout and event listener so that if one triggers, it cancels the other,
+ // but having restoreOpacity run more than once is a no-op for right now.
+ // Not a big deal if these don't get cleaned up.
+
+ // Have the form automatically reappear no matter what, so that if something
+ // does break, the user isn't left out to dry
+ window.setTimeout(restoreOpacity, 5_000);
+
+ /** @type {HTMLFormElement | null} */
+ const form = document.querySelector("web-client-form > form");
+ form?.addEventListener(
+ "submit",
+ () => {
+ // Not restoring opacity right away just to give the HTML canvas a little
+ // bit of time to get spun up and cover up the main form
+ window.setTimeout(restoreOpacity, 1_000);
+ },
+ { once: true },
+ );
+}
+
+// Always safe to call these immediately because even if the Angular app isn't
+// loaded by the time the function gets called, the CSS will always be globally
+// available for when Angular is finally ready
+setupAlwaysOnStyles();
+hideFormForInitialSubmission();
+
+if (document.readyState === "loading") {
+ document.addEventListener("DOMContentLoaded", setupFormDetection);
+} else {
+ setupFormDetection();
+}
diff --git a/windows-rdp/main.test.ts b/windows-rdp/main.test.ts
new file mode 100644
index 0000000..61075d9
--- /dev/null
+++ b/windows-rdp/main.test.ts
@@ -0,0 +1,134 @@
+import { describe, expect, it } from "bun:test";
+import {
+ TerraformState,
+ runTerraformApply,
+ runTerraformInit,
+ testRequiredVariables,
+} from "../test";
+
+type TestVariables = Readonly<{
+ agent_id: string;
+ resource_id: string;
+ share?: string;
+ admin_username?: string;
+ admin_password?: string;
+}>;
+
+function findWindowsRdpScript(state: TerraformState): string | null {
+ for (const resource of state.resources) {
+ const isRdpScriptResource =
+ resource.type === "coder_script" && resource.name === "windows-rdp";
+
+ if (!isRdpScriptResource) {
+ continue;
+ }
+
+ for (const instance of resource.instances) {
+ if (
+ instance.attributes.display_name === "windows-rdp" &&
+ typeof instance.attributes.script === "string"
+ ) {
+ return instance.attributes.script;
+ }
+ }
+ }
+
+ return null;
+}
+
+/**
+ * @todo It would be nice if we had a way to verify that the Devolutions root
+ * HTML file is modified to include the import for the patched Coder script,
+ * but the current test setup doesn't really make that viable
+ */
+describe("Web RDP", async () => {
+ await runTerraformInit(import.meta.dir);
+ testRequiredVariables(import.meta.dir, {
+ agent_id: "foo",
+ resource_id: "bar",
+ });
+
+ it("Has the PowerShell script install Devolutions Gateway", async () => {
+ const state = await runTerraformApply(import.meta.dir, {
+ agent_id: "foo",
+ resource_id: "bar",
+ });
+
+ const lines = findWindowsRdpScript(state)
+ ?.split("\n")
+ .filter(Boolean)
+ .map((line) => line.trim());
+
+ expect(lines).toEqual(
+ expect.arrayContaining([
+ '$moduleName = "DevolutionsGateway"',
+ // Devolutions does versioning in the format year.minor.patch
+ expect.stringMatching(/^\$moduleVersion = "\d{4}\.\d+\.\d+"$/),
+ "Install-Module -Name $moduleName -RequiredVersion $moduleVersion -Force",
+ ]),
+ );
+ });
+
+ it("Injects Terraform's username and password into the JS patch file", async () => {
+ /**
+ * Using a regex as a quick-and-dirty way to get at the username and
+ * password values.
+ *
+ * Tried going through the trouble of extracting out the form entries
+ * variable from the main output, converting it from Prettier/JS-based JSON
+ * text to universal JSON text, and exposing it as a parsed JSON value. That
+ * got to be a bit too much, though.
+ *
+ * Regex is a little bit more verbose and pedantic than normal. Want to
+ * have some basic safety nets for validating the structure of the form
+ * entries variable after the JS file has had values injected. Even with all
+ * the wildcard classes set to lazy mode, we want to make sure that they
+ * don't overshoot and grab too much content.
+ *
+ * Written and tested via Regex101
+ * @see {@link https://regex101.com/r/UMgQpv/2}
+ */
+ const formEntryValuesRe =
+ /^const formFieldEntries = \{$.*?^\s+username: \{$.*?^\s*?querySelector.*?,$.*?^\s*value: "(?.+?)",$.*?password: \{$.*?^\s+querySelector: .*?,$.*?^\s*value: "(?.+?)",$.*?^};$/ms;
+
+ // Test that things work with the default username/password
+ const defaultState = await runTerraformApply(
+ import.meta.dir,
+ {
+ agent_id: "foo",
+ resource_id: "bar",
+ },
+ );
+
+ const defaultRdpScript = findWindowsRdpScript(defaultState);
+ expect(defaultRdpScript).toBeString();
+
+ const defaultResultsGroup =
+ formEntryValuesRe.exec(defaultRdpScript ?? "")?.groups ?? {};
+
+ expect(defaultResultsGroup.username).toBe("Administrator");
+ expect(defaultResultsGroup.password).toBe("coderRDP!");
+
+ // Test that custom usernames/passwords are also forwarded correctly
+ const customAdminUsername = "crouton";
+ const customAdminPassword = "VeryVeryVeryVeryVerySecurePassword97!";
+ const customizedState = await runTerraformApply(
+ import.meta.dir,
+ {
+ agent_id: "foo",
+ resource_id: "bar",
+ admin_username: customAdminUsername,
+ admin_password: customAdminPassword,
+ },
+ );
+
+ const customRdpScript = findWindowsRdpScript(customizedState);
+ expect(customRdpScript).toBeString();
+
+ const customResultsGroup =
+ formEntryValuesRe.exec(customRdpScript ?? "")?.groups ?? {};
+
+ expect(customResultsGroup.username).toBe(customAdminUsername);
+ expect(customResultsGroup.password).toBe(customAdminPassword);
+ });
+});
diff --git a/windows-rdp/main.tf b/windows-rdp/main.tf
new file mode 100644
index 0000000..10ece09
--- /dev/null
+++ b/windows-rdp/main.tf
@@ -0,0 +1,86 @@
+terraform {
+ required_version = ">= 1.0"
+
+ required_providers {
+ coder = {
+ source = "coder/coder"
+ version = ">= 0.17"
+ }
+ }
+}
+
+variable "share" {
+ type = string
+ default = "owner"
+ validation {
+ condition = var.share == "owner" || var.share == "authenticated" || var.share == "public"
+ error_message = "Incorrect value. Please set either 'owner', 'authenticated', or 'public'."
+ }
+}
+
+variable "agent_id" {
+ type = string
+ description = "The ID of a Coder agent."
+}
+
+variable "resource_id" {
+ type = string
+ description = "The ID of the primary Coder resource (e.g. VM)."
+}
+
+variable "admin_username" {
+ type = string
+ default = "Administrator"
+}
+
+variable "admin_password" {
+ type = string
+ default = "coderRDP!"
+ sensitive = true
+}
+
+resource "coder_script" "windows-rdp" {
+ agent_id = var.agent_id
+ display_name = "windows-rdp"
+ icon = "/icon/desktop.svg"
+
+ script = templatefile("${path.module}/powershell-installation-script.tftpl", {
+ admin_username = var.admin_username
+ admin_password = var.admin_password
+
+ # Wanted to have this be in the powershell template file, but Terraform
+ # doesn't allow recursive calls to the templatefile function. Have to feed
+ # results of the JS template replace into the powershell template
+ patch_file_contents = templatefile("${path.module}/devolutions-patch.js", {
+ CODER_USERNAME = var.admin_username
+ CODER_PASSWORD = var.admin_password
+ })
+ })
+
+ run_on_start = true
+}
+
+resource "coder_app" "windows-rdp" {
+ agent_id = var.agent_id
+ share = var.share
+ slug = "web-rdp"
+ display_name = "Web RDP"
+ url = "http://localhost:7171"
+ icon = "/icon/desktop.svg"
+ subdomain = true
+
+ healthcheck {
+ url = "http://localhost:7171"
+ interval = 5
+ threshold = 15
+ }
+}
+
+resource "coder_app" "rdp-docs" {
+ agent_id = var.agent_id
+ display_name = "Local RDP"
+ slug = "rdp-docs"
+ icon = "https://raw.githubusercontent.com/matifali/logos/main/windows.svg"
+ url = "https://coder.com/docs/ides/remote-desktops#rdp-desktop"
+ external = true
+}
diff --git a/windows-rdp/powershell-installation-script.tftpl b/windows-rdp/powershell-installation-script.tftpl
new file mode 100644
index 0000000..1b7ab48
--- /dev/null
+++ b/windows-rdp/powershell-installation-script.tftpl
@@ -0,0 +1,85 @@
+function Set-AdminPassword {
+ param (
+ [string]$adminPassword
+ )
+ # Set admin password
+ Get-LocalUser -Name "${admin_username}" | Set-LocalUser -Password (ConvertTo-SecureString -AsPlainText $adminPassword -Force)
+ # Enable admin user
+ Get-LocalUser -Name "${admin_username}" | Enable-LocalUser
+}
+
+function Configure-RDP {
+ # Enable RDP
+ New-ItemProperty -Path 'HKLM:\System\CurrentControlSet\Control\Terminal Server' -Name "fDenyTSConnections" -Value 0 -PropertyType DWORD -Force
+ # Disable NLA
+ New-ItemProperty -Path 'HKLM:\System\CurrentControlSet\Control\Terminal Server\WinStations\RDP-Tcp' -Name "UserAuthentication" -Value 0 -PropertyType DWORD -Force
+ New-ItemProperty -Path 'HKLM:\System\CurrentControlSet\Control\Terminal Server\WinStations\RDP-Tcp' -Name "SecurityLayer" -Value 1 -PropertyType DWORD -Force
+ # Enable RDP through Windows Firewall
+ Enable-NetFirewallRule -DisplayGroup "Remote Desktop"
+}
+
+function Install-DevolutionsGateway {
+# Define the module name and version
+$moduleName = "DevolutionsGateway"
+$moduleVersion = "2024.1.5"
+
+# Install the module with the specified version for all users
+# This requires administrator privileges
+try {
+ # Install-PackageProvider is required for AWS. Need to set command to
+ # terminate on failure so that try/catch actually triggers
+ Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force -ErrorAction Stop
+ Install-Module -Name $moduleName -RequiredVersion $moduleVersion -Force
+}
+catch {
+ # If the first command failed, assume that we're on GCP and run
+ # Install-Module only
+ Install-Module -Name $moduleName -RequiredVersion $moduleVersion -Force
+}
+
+# Construct the module path for system-wide installation
+$moduleBasePath = "C:\Windows\system32\config\systemprofile\Documents\PowerShell\Modules\$moduleName\$moduleVersion"
+$modulePath = Join-Path -Path $moduleBasePath -ChildPath "$moduleName.psd1"
+
+# Import the module using the full path
+Import-Module $modulePath
+Install-DGatewayPackage
+
+# Configure Devolutions Gateway
+$Hostname = "localhost"
+$HttpListener = New-DGatewayListener 'http://*:7171' 'http://*:7171'
+$WebApp = New-DGatewayWebAppConfig -Enabled $true -Authentication None
+$ConfigParams = @{
+ Hostname = $Hostname
+ Listeners = @($HttpListener)
+ WebApp = $WebApp
+}
+Set-DGatewayConfig @ConfigParams
+New-DGatewayProvisionerKeyPair -Force
+
+# Configure and start the Windows service
+Set-Service 'DevolutionsGateway' -StartupType 'Automatic'
+Start-Service 'DevolutionsGateway'
+}
+
+function Patch-Devolutions-HTML {
+$root = "C:\Program Files\Devolutions\Gateway\webapp\client"
+$devolutionsHtml = "$root\index.html"
+$patch = ''
+
+# Always copy the file in case we change it.
+@'
+${patch_file_contents}
+'@ | Set-Content "$root\coder.js"
+
+# Only inject the src if we have not before.
+$isPatched = Select-String -Path "$devolutionsHtml" -Pattern "$patch" -SimpleMatch
+if ($isPatched -eq $null) {
+ (Get-Content $devolutionsHtml).Replace('', "$patch") | Set-Content $devolutionsHtml
+}
+}
+
+Set-AdminPassword -adminPassword "${admin_password}"
+Configure-RDP
+Install-DevolutionsGateway
+Patch-Devolutions-HTML
diff --git a/windows-rdp/video-thumbnails/video-thumbnail.png b/windows-rdp/video-thumbnails/video-thumbnail.png
new file mode 100644
index 0000000..f37d65d
Binary files /dev/null and b/windows-rdp/video-thumbnails/video-thumbnail.png differ