Compare commits

...

9 Commits

Author SHA1 Message Date
Danielle Maywood ff02249128 refactor(coder/claude-code): support terraform provider coder 2.12.0 (#488)
## Description

Updates the module to use the new version of the agentapi module for the
upcoming Coder 2.28 release

## Type of Change

- [ ] New module
- [ ] New template
- [ ] Bug fix
- [x] Feature/enhancement
- [ ] Documentation
- [ ] Other

## Module Information

**Path:** `registry/coder/modules/claude-code`  
**New version:** `v4.0.0`  
**Breaking change:** [x] Yes [ ] No

## Testing & Validation

- [x] Tests pass (`bun test`)
- [x] Code formatted (`bun fmt`)
- [ ] Changes tested locally

## Related Issues

- https://github.com/coder/internal/issues/1065

## Related PRs

- https://github.com/coder/registry/pull/485
- https://github.com/coder/registry/pull/497

Co-authored-by: Cian Johnston <cian@coder.com>
2025-11-03 16:26:08 +00:00
djarbz 4a11b06cba Fix/djarbz copyparty argcommas (#516)
## Description

I discovered that if we included a comma inside an argument that bash
would split it out as a separate argument.
I added a test to verify.
I also cleaned up some log formatting.

## Type of Change

- [ ] New module
- [ ] New template
- [x] Bug fix
- [ ] Feature/enhancement
- [ ] Documentation
- [ ] Other

## Module Information

<!-- Delete this section if not applicable -->

**Path:** `registry/djarbz/modules/copyparty`  
**New version:** `v1.0.1`  
**Breaking change:** [ ] Yes [x] No

## Testing & Validation

- [N/A] Tests pass (`bun test`)
- [x] Code formatted (`bun fmt`)
- [x] Changes tested locally

## Related Issues

None

---------

Co-authored-by: DevCats <christofer@coder.com>
2025-10-31 10:04:07 -05:00
DevCats 925c71e641 fix: improve version extraction logic to prevent false positives (#511)
## Description

Makes the version extract and replace logic more specific so it wont
replace any field that does is not specifically `field` under the
detected modules.
<!-- Briefly describe what this PR does and why -->

## Type of Change

- [ ] New module
- [ ] New template
- [X] Bug fix
- [ ] Feature/enhancement
- [ ] Documentation
- [ ] Other

## Testing & Validation

- [X] Tests pass (`bun test`)
- [X] Code formatted (`bun fmt`)
- [X] Changes tested locally

## Related Issues

#510 
<!-- Link related issues or write "None" if not applicable -->

---------

Co-authored-by: Atif Ali <atif@coder.com>
2025-10-31 07:45:52 -05:00
Atif Ali 5450113939 fix(coder/modules/claude-code): move set -euo pipefail after sourcing .bashrc (#520)
Co-authored-by: blink-so[bot] <211532188+blink-so[bot]@users.noreply.github.com>
2025-10-30 20:17:33 +05:00
uzair-coder07 0ce65b2b58 fix(coder-labs/modules/sourcegraph-amp): explicitly require external provider (#519)
Co-authored-by: Atif Ali <atif@coder.com>
2025-10-30 10:28:52 +05:00
Yevhenii Shcherbina 92ab526733 feat: change boundary rules according to new spec (#517) 2025-10-29 19:57:15 -04:00
Rhys Williams d6d0101f09 Fix Devolutions Auto-Complete (#508)
## Description

I’ve completed a set of modifications to improve the user experience and
session behaviour within Devolutions Gateway:

- Auto-Complete Fix: Resolved issues with auto-complete functionality.
- Container Visibility: Implemented logic to hide the app-net-scan
container, preventing it from displaying during the initial session
load.
- Default Settings: Enabled Unicode keyboard mode and dynamic window
resizing by default to enhance usability.
- Session Closure Behaviour: Modified the "Close Session" button to
fully close the session window, avoiding returns to the session manager.
- Dynamic Module Path Construction: Refactored the PowerShell module
path setup to be dynamically constructed.
- Input Variables: Added `slug` and `display_name` as input variables.

## Type of Change

- [ ] New module
- [ ] New template
- [x] Bug fix
- [x] Feature/enhancement
- [ ] Documentation
- [ ] Other

## Module Information

<!-- Delete this section if not applicable -->

**Path:** `registry/coder/modules/windows-rdp`  
**New version:** `v1.3.0`  
**Breaking change:** [ ] Yes [x] No

## Testing & Validation

- [x] Tests pass (`bun test`)
- [x] Code formatted (`bun fmt`)
- [x] Changes tested locally

## Related Issues

"None"

---------

Co-authored-by: DevCats <christofer@coder.com>
Co-authored-by: DevelopmentCats <chris@dualriver.com>
Co-authored-by: Eric Paulsen <ericpaulsen@coder.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-10-28 10:00:41 +00:00
Luis 1a15ad650a Update Vault CLI download link to use architecture (#514)
## Description

The download command was downloading only the amd64 version,

## Type of Change

- [ ] New module
- [ ] New template
- [x] Bug fix
- [ ] Feature/enhancement
- [ ] Documentation
- [ ] Other

## Module Information

<!-- Delete this section if not applicable -->

**Path:** `registry/[namespace]/modules/[module-name]`  
**New version:** `v1.0.0`  
**Breaking change:** [ ] Yes [ ] No

## Template Information

<!-- Delete this section if not applicable -->

**Path:** `registry/[namespace]/templates/[template-name]`

## Testing & Validation

- [ ] Tests pass (`bun test`)
- [ ] Code formatted (`bun fmt`)
- [ ] Changes tested locally

## Related Issues

<!-- Link related issues or write "None" if not applicable -->
2025-10-27 17:12:24 -05:00
Atif Ali d64851774b fix(jetbrains): update Terraform version requirement to 1.9+ (#513)
## Summary

- Updated `required_version` constraint from `>= 1.0` to `>= 1.9` in
jetbrains module
- Added inline comment explaining the cross-variable validation
requirement
- Bumped module version from `1.1.0` to `1.1.1` (patch version)

## Issue

The jetbrains module uses cross-variable validation at line 169-171
where `var.options` is referenced within the `var.ide_config` validation
block:

```tf
validation {
  condition = alltrue([
    for code in var.options : contains(keys(var.ide_config), code)
  ])
  error_message = "The ide_config must be a superset of var.options."
}
```

This pattern requires Terraform 1.9+ and fails on earlier versions with:
```
Error: Invalid reference in variable validation
The condition for variable "ide_config" can only refer to the variable itself, using var.ide_config.
```

## References

- Terrafomr release blog that talks abut this feature:
https://www.hashicorp.com/en/blog/terraform-1-9-enhances-input-variable-validations
- Terraform PR that added this feature:
https://github.com/hashicorp/terraform/pull/34955
- HashiCorp Support Article:
https://support.hashicorp.com/hc/en-us/articles/43291233547027

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: DevCats <christofer@coder.com>
2025-10-27 08:36:19 -05:00
20 changed files with 562 additions and 438 deletions
+8 -5
View File
@@ -77,16 +77,19 @@ update_readme_version() {
in_target_module = 0
}
}
/version.*=.*"/ {
/^[[:space:]]*version[[:space:]]*=/ {
if (in_target_module) {
gsub(/version[[:space:]]*=[[:space:]]*"[^"]*"/, "version = \"" new_version "\"")
match($0, /^[[:space]]*/
indent = substr($0, 1, RLENGTH)
print indent "version = \"" new_version "\""
in_target_module = 0
next
}
}
{ print }
' "$readme_path" > "${readme_path}.tmp" && mv "${readme_path}.tmp" "$readme_path"
return 0
elif grep -q 'version\s*=\s*"' "$readme_path"; then
elif grep -q '^[[:space:]]*version[[:space:]]*=' "$readme_path"; then
echo "⚠️ Found version references but no module source match for $namespace/$module_name"
return 1
fi
@@ -148,9 +151,9 @@ main() {
local current_version
if [ -z "$latest_tag" ]; then
if [ -f "$readme_path" ] && grep -q 'version\s*=\s*"' "$readme_path"; then
if [ -f "$readme_path" ] && grep -q '^[[:space:]]*version[[:space:]]*=' "$readme_path"; then
local readme_version
readme_version=$(grep 'version\s*=\s*"' "$readme_path" | head -1 | sed 's/.*version\s*=\s*"\([^"]*\)".*/\1/')
readme_version=$(awk '/^[[:space:]]*version[[:space:]]*=/ { match($0, /"[^"]*"/); print substr($0, RSTART+1, RLENGTH-2); exit }' "$readme_path")
echo "No git tag found, but README shows version: $readme_version"
if ! validate_version "$readme_version"; then
@@ -13,7 +13,7 @@ Run [Amp CLI](https://ampcode.com/) in your workspace to access Sourcegraph's AI
```tf
module "amp-cli" {
source = "registry.coder.com/coder-labs/sourcegraph-amp/coder"
version = "2.0.0"
version = "2.0.1"
agent_id = coder_agent.example.id
sourcegraph_amp_api_key = var.sourcegraph_amp_api_key
install_sourcegraph_amp = true
@@ -48,7 +48,7 @@ variable "amp_api_key" {
module "amp-cli" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder-labs/sourcegraph-amp/coder"
amp_version = "2.0.0"
amp_version = "2.0.1"
agent_id = coder_agent.example.id
amp_api_key = var.amp_api_key # recommended for tasks usage
workdir = "/home/coder/project"
@@ -6,7 +6,12 @@ terraform {
source = "coder/coder"
version = ">= 2.7"
}
external = {
source = "hashicorp/external"
version = "2.3.5"
}
}
}
variable "agent_id" {
+7 -7
View File
@@ -13,7 +13,7 @@ Run the [Claude Code](https://docs.anthropic.com/en/docs/agents-and-tools/claude
```tf
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "3.3.3"
version = "4.0.0"
agent_id = coder_agent.example.id
workdir = "/home/coder/project"
claude_api_key = "xxxx-xxxxx-xxxx"
@@ -51,7 +51,7 @@ module "claude-code" {
boundary_log_level = "WARN"
boundary_additional_allowed_urls = ["GET *google.com"]
boundary_proxy_port = "8087"
version = "3.3.3"
version = "3.4.3"
}
```
@@ -70,7 +70,7 @@ data "coder_parameter" "ai_prompt" {
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "3.3.3"
version = "4.0.0"
agent_id = coder_agent.example.id
workdir = "/home/coder/project"
@@ -106,7 +106,7 @@ Run and configure Claude Code as a standalone CLI in your workspace.
```tf
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "3.3.3"
version = "4.0.0"
agent_id = coder_agent.example.id
workdir = "/home/coder"
install_claude_code = true
@@ -129,7 +129,7 @@ variable "claude_code_oauth_token" {
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "3.3.3"
version = "4.0.0"
agent_id = coder_agent.example.id
workdir = "/home/coder/project"
claude_code_oauth_token = var.claude_code_oauth_token
@@ -202,7 +202,7 @@ resource "coder_env" "bedrock_api_key" {
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "3.3.3"
version = "4.0.0"
agent_id = coder_agent.example.id
workdir = "/home/coder/project"
model = "global.anthropic.claude-sonnet-4-5-20250929-v1:0"
@@ -259,7 +259,7 @@ resource "coder_env" "google_application_credentials" {
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "3.3.3"
version = "4.0.0"
agent_id = coder_agent.example.id
workdir = "/home/coder/project"
model = "claude-sonnet-4@20250514"
+8 -5
View File
@@ -4,7 +4,7 @@ terraform {
required_providers {
coder = {
source = "coder/coder"
version = ">= 2.7"
version = ">= 2.12"
}
}
}
@@ -270,7 +270,7 @@ resource "coder_env" "claude_api_key" {
locals {
# we have to trim the slash because otherwise coder exp mcp will
# set up an invalid claude config
# set up an invalid claude config
workdir = trimsuffix(var.workdir, "/")
app_slug = "ccw"
install_script = file("${path.module}/scripts/install.sh")
@@ -313,9 +313,8 @@ locals {
}
module "agentapi" {
source = "registry.coder.com/coder/agentapi/coder"
version = "1.2.0"
version = "2.0.0"
agent_id = var.agent_id
web_app_slug = local.app_slug
@@ -353,7 +352,7 @@ module "agentapi" {
ARG_BOUNDARY_VERSION='${var.boundary_version}' \
ARG_BOUNDARY_LOG_DIR='${var.boundary_log_dir}' \
ARG_BOUNDARY_LOG_LEVEL='${var.boundary_log_level}' \
ARG_BOUNDARY_ADDITIONAL_ALLOWED_URLS='${join(" ", var.boundary_additional_allowed_urls)}' \
ARG_BOUNDARY_ADDITIONAL_ALLOWED_URLS='${join("|", var.boundary_additional_allowed_urls)}' \
ARG_BOUNDARY_PROXY_PORT='${var.boundary_proxy_port}' \
ARG_ENABLE_BOUNDARY_PPROF='${var.enable_boundary_pprof}' \
ARG_BOUNDARY_PPROF_PORT='${var.boundary_pprof_port}' \
@@ -379,3 +378,7 @@ module "agentapi" {
/tmp/install.sh
EOT
}
output "task_app_id" {
value = module.agentapi.task_app_id
}
@@ -1,10 +1,12 @@
#!/bin/bash
set -euo pipefail
if [ -f "$HOME/.bashrc" ]; then
source "$HOME"/.bashrc
fi
# Set strict error handling AFTER sourcing bashrc to avoid unbound variable errors from user dotfiles
set -euo pipefail
BOLD='\033[0;1m'
command_exists() {
@@ -1,9 +1,12 @@
#!/bin/bash
set -euo pipefail
if [ -f "$HOME/.bashrc" ]; then
source "$HOME"/.bashrc
fi
# Set strict error handling AFTER sourcing bashrc to avoid unbound variable errors from user dotfiles
set -euo pipefail
export PATH="$HOME/.local/bin:$PATH"
command_exists() {
@@ -144,12 +147,13 @@ function start_agentapi() {
# Build boundary args with conditional --unprivileged flag
BOUNDARY_ARGS=(--log-dir "$ARG_BOUNDARY_LOG_DIR")
# Add default allowed URLs
BOUNDARY_ARGS+=(--allow "*anthropic.com" --allow "registry.npmjs.org" --allow "*sentry.io" --allow "claude.ai" --allow "$ARG_CODER_HOST")
BOUNDARY_ARGS+=(--allow "domain=anthropic.com" --allow "domain=registry.npmjs.org" --allow "domain=sentry.io" --allow "domain=claude.ai" --allow "domain=$ARG_CODER_HOST")
# Add any additional allowed URLs from the variable
if [ -n "$ARG_BOUNDARY_ADDITIONAL_ALLOWED_URLS" ]; then
IFS=' ' read -ra ADDITIONAL_URLS <<< "$ARG_BOUNDARY_ADDITIONAL_ALLOWED_URLS"
IFS='|' read -ra ADDITIONAL_URLS <<< "$ARG_BOUNDARY_ADDITIONAL_ALLOWED_URLS"
for url in "${ADDITIONAL_URLS[@]}"; do
# Quote the URL to preserve spaces within the allow rule
BOUNDARY_ARGS+=(--allow "$url")
done
fi
+7 -7
View File
@@ -14,7 +14,7 @@ This module adds JetBrains IDE buttons to launch IDEs directly from the dashboar
module "jetbrains" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/jetbrains/coder"
version = "1.1.0"
version = "1.1.1"
agent_id = coder_agent.example.id
folder = "/home/coder/project"
# tooltip = "You need to [Install Coder Desktop](https://coder.com/docs/user-guides/desktop#install-coder-desktop) to use this button." # Optional
@@ -40,7 +40,7 @@ When `default` contains IDE codes, those IDEs are created directly without user
module "jetbrains" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/jetbrains/coder"
version = "1.1.0"
version = "1.1.1"
agent_id = coder_agent.example.id
folder = "/home/coder/project"
default = ["PY", "IU"] # Pre-configure GoLand and IntelliJ IDEA
@@ -53,7 +53,7 @@ module "jetbrains" {
module "jetbrains" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/jetbrains/coder"
version = "1.1.0"
version = "1.1.1"
agent_id = coder_agent.example.id
folder = "/home/coder/project"
# Show parameter with limited options
@@ -67,7 +67,7 @@ module "jetbrains" {
module "jetbrains" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/jetbrains/coder"
version = "1.1.0"
version = "1.1.1"
agent_id = coder_agent.example.id
folder = "/home/coder/project"
default = ["IU", "PY"]
@@ -82,7 +82,7 @@ module "jetbrains" {
module "jetbrains" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/jetbrains/coder"
version = "1.1.0"
version = "1.1.1"
agent_id = coder_agent.example.id
folder = "/workspace/project"
@@ -108,7 +108,7 @@ module "jetbrains" {
module "jetbrains_pycharm" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/jetbrains/coder"
version = "1.1.0"
version = "1.1.1"
agent_id = coder_agent.example.id
folder = "/workspace/project"
@@ -128,7 +128,7 @@ Add helpful tooltip text that appears when users hover over the IDE app buttons:
module "jetbrains" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/jetbrains/coder"
version = "1.1.0"
version = "1.1.1"
agent_id = coder_agent.example.id
folder = "/home/coder/project"
default = ["IU", "PY"]
+3 -2
View File
@@ -1,5 +1,5 @@
terraform {
required_version = ">= 1.0"
required_version = ">= 1.9"
required_providers {
coder = {
@@ -163,7 +163,8 @@ variable "ide_config" {
condition = length(var.ide_config) > 0
error_message = "The ide_config must not be empty."
}
# ide_config must be a superset of var.. options
# ide_config must be a superset of var.options
# Requires Terraform 1.9+ for cross-variable validation references
validation {
condition = alltrue([
for code in var.options : contains(keys(var.ide_config), code)
+2 -2
View File
@@ -19,7 +19,7 @@ variable "vault_token" {
module "vault" {
source = "registry.coder.com/coder/vault-token/coder"
version = "1.2.1"
version = "1.2.2"
agent_id = coder_agent.example.id
vault_token = var.token # optional
vault_addr = "https://vault.example.com"
@@ -73,7 +73,7 @@ variable "vault_token" {
module "vault" {
source = "registry.coder.com/coder/vault-token/coder"
version = "1.2.1"
version = "1.2.2"
agent_id = coder_agent.example.id
vault_addr = "https://vault.example.com"
vault_token = var.token
+1 -1
View File
@@ -68,7 +68,7 @@ install() {
else
printf "Upgrading Vault CLI from version %s to %s ...\n\n" "$${CURRENT_VERSION}" "${INSTALL_VERSION}"
fi
fetch vault.zip "https://releases.hashicorp.com/vault/$${INSTALL_VERSION}/vault_$${INSTALL_VERSION}_linux_amd64.zip"
fetch vault.zip "https://releases.hashicorp.com/vault/$${INSTALL_VERSION}/vault_$${INSTALL_VERSION}_linux_$${ARCH}.zip"
if [ $? -ne 0 ]; then
printf "Failed to download Vault.\n"
return 1
+4 -4
View File
@@ -15,7 +15,7 @@ Enable Remote Desktop + a web based client on Windows workspaces, powered by [de
module "windows_rdp" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/windows-rdp/coder"
version = "1.2.3"
version = "1.3.0"
agent_id = resource.coder_agent.main.id
}
```
@@ -32,7 +32,7 @@ module "windows_rdp" {
module "windows_rdp" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/windows-rdp/coder"
version = "1.2.3"
version = "1.3.0"
agent_id = resource.coder_agent.main.id
}
```
@@ -43,7 +43,7 @@ module "windows_rdp" {
module "windows_rdp" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/windows-rdp/coder"
version = "1.2.3"
version = "1.3.0"
agent_id = resource.coder_agent.main.id
}
```
@@ -54,7 +54,7 @@ module "windows_rdp" {
module "windows_rdp" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/windows-rdp/coder"
version = "1.2.3"
version = "1.3.0"
agent_id = resource.coder_agent.main.id
devolutions_gateway_version = "2025.2.2" # Specify a specific version
}
@@ -25,401 +25,426 @@
* @typedef {Readonly<{ querySelector: string; value: string; }>} FormFieldEntry
* @typedef {Readonly<Record<string, FormFieldEntry>>} FormFieldEntries
*/
(function () {
/**
* The communication protocol to set Devolutions to.
*/
const PROTOCOL = "RDP";
/**
* The communication protocol to set Devolutions to.
*/
const PROTOCOL = "RDP";
/**
* The hostname to use with Devolutions.
*/
const HOSTNAME = "localhost";
/**
* The hostname to use with Devolutions.
*/
const HOSTNAME = "localhost";
/**
* How often to poll the screen for the main Devolutions form.
*/
const POLL_INTERVAL_MS = 500;
/**
* 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: {
/**
* 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 */
querySelector: "web-client-username-control input",
username: {
/** @readonly */
querySelector: "web-client-username-control input",
/** @readonly */
value: "${CODER_USERNAME}",
},
/** @readonly */
value: "${CODER_USERNAME}",
},
password: {
/** @readonly */
querySelector: "web-client-password-control input",
/** @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<void>}
*/
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<void>}
*/
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(
// biome-ignore lint/style/useTemplate: Have to skip interpolation for the main.tf interpolation
'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();
/** @readonly */
value: "${CODER_PASSWORD}",
},
};
const setHostname = () => {
/** @type {HTMLInputElement | null} */
const hostnameInput = myForm.querySelector("p-autocomplete#hostname input");
/**
* This ensures that the Devolutions login form (which by default, always shows
* up on screen when the app first launches) stays visually hidden from the user
* when they open Devolutions via the Coder module.
*
* The form will still be filled out automatically and submitted in the
* background via the rest of the logic in this file, so this function is mainly
* to help avoid screen flickering and make the overall experience feel a little
* more polished (even though it's just one giant hack).
*
* @returns {void}
*/
function hideFormForInitialSubmission() {
const styleId = "coder-patch--styles-initial-submission";
const cssOpacityVariableName = "--coder-opacity-multiplier";
if (hostnameInput === null) {
throw new Error("Unable to find field for adding hostname");
/** @type {HTMLStyleElement | null} */
// biome-ignore lint/style/useTemplate: Have to skip interpolation for the main.tf interpolation
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);
}
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(
// biome-ignore lint/style/useTemplate: Have to skip interpolation for the main.tf interpolation
'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) {
// 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;
}
window.clearInterval(pollingId);
// 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");
};
// Call the mutation callback manually, to ensure it runs at least once
onDynamicTabMutation();
// 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.
// 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,
});
};
// 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);
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";
// biome-ignore lint/style/useTemplate: Have to skip interpolation for the main.tf interpolation
const existingContainer = document.querySelector("#" + styleId);
if (existingContainer) {
return;
/** @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 },
);
}
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;
/**
* 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";
// biome-ignore lint/style/useTemplate: Have to skip interpolation for the main.tf interpolation
const existingContainer = document.querySelector("#" + styleId);
if (existingContainer) {
return;
}
`;
document.head.appendChild(styleContainer);
}
/**
* This ensures that the Devolutions login form (which by default, always shows
* up on screen when the app first launches) stays visually hidden from the user
* when they open Devolutions via the Coder module.
*
* The form will still be filled out automatically and submitted in the
* background via the rest of the logic in this file, so this function is mainly
* to help avoid screen flickering and make the overall experience feel a little
* more polished (even though it's just one giant hack).
*
* @returns {void}
*/
function hideFormForInitialSubmission() {
const styleId = "coder-patch--styles-initial-submission";
const cssOpacityVariableName = "--coder-opacity-multiplier";
/** @type {HTMLStyleElement | null} */
// biome-ignore lint/style/useTemplate: Have to skip interpolation for the main.tf interpolation
let styleContainer = document.querySelector("#" + styleId);
if (!styleContainer) {
styleContainer = document.createElement("style");
const 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;
/* app-menu corresponds to the sidebar of the default view. */
app-menu {
display: none !important;
}
/*
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;
/* app-net-scan corresponds to the auto-discovery feature. */
app-net-scan {
display: none !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;
/**
* 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<void>}
*/
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);
});
}
// 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");
};
/**
* 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} form
*/
async function fillForm(form) {
try {
log("Form detected. Starting auto-fill...");
// 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.
// By default, RDP is selected. Leaving this here if needed
// in the future.
const protocolTrigger = form.querySelector('p-dropdown[id="protocol"]');
if (protocolTrigger) {
protocolTrigger.click();
const protocolOption = document.querySelector(
`li[aria-label="$${PROTOCOL}"]`,
);
if (protocolOption) {
protocolOption.click();
log(`Protocol set to $${PROTOCOL}`);
} else {
log("Protocol option not found.");
}
} else {
log("Protocol dropdown trigger not found.");
}
// 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);
const hostnameInput = form.querySelector("p-autocomplete#hostname input");
if (hostnameInput) {
await setInputValue(hostnameInput, HOSTNAME);
log(`Hostname set to $${HOSTNAME}`);
} else {
log("Hostname input not found.");
}
/** @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 },
);
}
for (const [key, { querySelector, value }] of Object.entries(
formFieldEntries,
)) {
const input = document.querySelector(querySelector);
if (input) {
await setInputValue(input, value);
log(`Set $${key} to $${value}`);
} else {
log(`Input for $${key} not found with selector: $${querySelector}`);
}
}
// 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();
const submitButton = form.querySelector(
'p-button[class="p-element"] button',
);
if (submitButton && !submitButton.disabled) {
submitButton.click();
log("Form submitted.");
} else {
log("Submit button not found or disabled.");
}
} catch (err) {
console.error("[Devolutions Patch] Error during form fill:", err);
}
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", setupFormDetection);
} else {
setupFormDetection();
}
/**
* Attaches a click event listener to the "Close Session" button within the provided top bar element.
* When clicked, the listener triggers the window to close.
* Logs a message indicating whether the listener was successfully attached or if the button was not found.
*
* @param {HTMLElement} topBar - The container element that includes the "Close Session" button.
* @returns {void}
*/
function attachCloseListener(topBar) {
const buttons = topBar.querySelectorAll("button");
const closeButton = Array.from(buttons).find((button) => {
const labelSpan = button.querySelector(".p-button-label");
return labelSpan && labelSpan.textContent.trim() === "Close Session";
});
if (closeButton) {
closeButton.parentElement.addEventListener("click", () => {
window.close();
});
log("Close listener attached.");
} else {
log("Close button not found in top bar.");
}
}
/**
* Sets the checked state of a checkbox based on its label text.
* Searches all <p-checkbox> components in the document and identifies the one
* whose label matches the provided `filterText`. Once found, it sets the checkbox
* to the specified `checked` state (true or false) and dispatches a change event
* to ensure any bound listeners (e.g., Angular change detection) are triggered.
* Logs the outcome of the operation for debugging or audit purposes.
*
* @param {string} filterText - The exact label text of the checkbox to target.
* @param {boolean} checked - The desired checked state (true to check, false to uncheck).
* @returns {void}
*/
function setCheckbox(filterText, checked) {
const checkboxes = document.querySelectorAll("p-checkbox");
const targetCheckbox = Array.from(checkboxes).find((checkbox) => {
const label = checkbox.querySelector(".p-checkbox-label");
return label && label.textContent.trim() === filterText;
});
if (targetCheckbox) {
const input = targetCheckbox.querySelector('input[type="checkbox"]');
if (input) {
input.checked = checked;
input.dispatchEvent(new Event("change", { bubbles: true }));
}
log(`$${filterText} set to $${checked}.`);
} else {
log(`$${filterText} checkbox not found in top bar.`);
}
}
/**
* Continuously polls the DOM for a specific form element.
* - Searches for a <form> inside a <web-client-form> element.
* - If found, calls `fillForm(form)` to process it.
* - If not found, logs a retry message and schedules another check after a delay.
*
* @returns {void}
*/
function pollForForm() {
const form = document.querySelector("web-client-form form");
if (form) {
fillForm(form);
// Start polling for top bar after form is filled
pollForSessionToolBar();
} else {
log("Form not yet available. Retrying...");
setTimeout(pollForForm, POLL_INTERVAL_MS);
}
}
/**
* Continuously polls the DOM for a specific form element.
* - Searches for a <session-toolbar> element.
* - If found, adds another listener to session toolbar
* - If not found, logs a retry message and schedules another check after a delay.
*
* @returns {void}
*/
function pollForSessionToolBar() {
const sessionToolBar = document.querySelector("session-toolbar");
if (sessionToolBar) {
log("Top bar detected. Proceeding with next steps...");
attachCloseListener(sessionToolBar);
// Automatically set checkboxes to improve user experience
setCheckbox("Unicode Keyboard Mode", true);
setCheckbox("Dynamic Resize", true);
} else {
log("Top bar not yet available. Retrying...");
setTimeout(pollForSessionToolBar, POLL_INTERVAL_MS);
}
}
/**
* Logs a message to the console with a standardized prefix.
* Format: [Devolutions Patch] $<message>
*
* @param {string} msg - The message to log.
* @returns {void}
*/
function log(msg) {
console.log(`[Devolutions Patch] $${msg}`);
}
// 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();
log("Script loaded. Starting form detection...");
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", pollForForm);
} else {
pollForForm();
}
})();
@@ -59,9 +59,11 @@ describe("Web RDP", async () => {
expect(lines).toEqual(
expect.arrayContaining<string>([
'$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",
// Default is "latest" to automatically get the newest version
'$moduleVersion = "latest"',
"[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12",
"Set-PSRepository -Name PSGallery -InstallationPolicy Trusted",
"Install-Module -Name $moduleName -Force",
]),
);
});
@@ -86,7 +88,7 @@ describe("Web RDP", async () => {
* @see {@link https://regex101.com/r/UMgQpv/2}
*/
const formEntryValuesRe =
/^const formFieldEntries = \{$.*?^\s+username: \{$.*?^\s*?querySelector.*?,$.*?^\s*value: "(?<username>.+?)",$.*?password: \{$.*?^\s+querySelector: .*?,$.*?^\s*value: "(?<password>.+?)",$.*?^};$/ms;
/username:\s*\{[\s\S]*?value:\s*"(?<username>[^"]+)"[\s\S]*?password:\s*\{[\s\S]*?value:\s*"(?<password>[^"]+)"/;
// Test that things work with the default username/password
const defaultState = await runTerraformApply<TestVariables>(
+23 -5
View File
@@ -9,6 +9,24 @@ terraform {
}
}
variable "display_name" {
type = string
description = "The display name for the Web RDP application."
default = "Web RDP"
}
variable "slug" {
type = string
description = "The slug for the Web RDP application."
default = "web-rdp"
}
variable "icon" {
type = string
description = "The icon for the Web RDP application."
default = "/icon/desktop.svg"
}
variable "order" {
type = number
description = "The order determines the position of app in the UI presentation. The lowest order is shown first and apps with equal order are sorted by name (ascending order)."
@@ -48,8 +66,8 @@ variable "admin_password" {
variable "devolutions_gateway_version" {
type = string
default = "2025.2.2"
description = "Version of Devolutions Gateway to install. Defaults to the latest available version."
default = "latest"
description = "Version of Devolutions Gateway to install. Use 'latest' for the most recent version, or specify a version like '2025.3.2'."
}
resource "coder_script" "windows-rdp" {
@@ -77,10 +95,10 @@ resource "coder_script" "windows-rdp" {
resource "coder_app" "windows-rdp" {
agent_id = var.agent_id
share = var.share
slug = "web-rdp"
display_name = "Web RDP"
slug = var.slug
display_name = var.display_name
url = "http://localhost:7171"
icon = "/icon/desktop.svg"
icon = var.icon
subdomain = true
order = var.order
group = var.group
@@ -2,6 +2,9 @@ function Set-AdminPassword {
param (
[string]$adminPassword
)
# Explicitly import LocalAccounts module
Import-Module Microsoft.PowerShell.LocalAccounts -ErrorAction SilentlyContinue
# Set admin password
Get-LocalUser -Name "${admin_username}" | Set-LocalUser -Password (ConvertTo-SecureString -AsPlainText $adminPassword -Force)
# Enable admin user
@@ -28,23 +31,61 @@ function Install-DevolutionsGateway {
$moduleName = "DevolutionsGateway"
$moduleVersion = "${devolutions_gateway_version}"
# Ensure TLS 1.2 is enabled for PSGallery
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
# 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
# Set PSGallery as trusted after NuGet is installed
Set-PSRepository -Name PSGallery -InstallationPolicy Trusted
if ($moduleVersion -eq "latest" -or [string]::IsNullOrWhiteSpace($moduleVersion)) {
Install-Module -Name $moduleName -Force
} else {
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
if ($moduleVersion -eq "latest" -or [string]::IsNullOrWhiteSpace($moduleVersion)) {
Install-Module -Name $moduleName -Force
} else {
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"
$modulePath = $null # Declare outside the loop
if ($moduleVersion -eq "latest" -or [string]::IsNullOrWhiteSpace($moduleVersion)) {
$installedModule = Get-InstalledModule -Name $moduleName -ErrorAction SilentlyContinue
if ($installedModule) {
$installedVersion = $installedModule.Version.ToString()
}
} else {
$installedVersion = $moduleVersion
}
$paths = $env:PSModulePath -split ';'
foreach ($path in $paths) {
$candidatePath = Join-Path -Path $path -ChildPath $moduleName
if ($installedVersion) {
$candidatePath = Join-Path -Path $candidatePath -ChildPath $installedVersion
}
$psd1Path = Join-Path -Path $candidatePath -ChildPath "$moduleName.psd1"
if (Test-Path $psd1Path) {
$modulePath = $psd1Path
break
}
}
# Import the module using the full path
Import-Module $modulePath
+4 -4
View File
@@ -17,7 +17,7 @@ This module installs Copyparty, an alternative to Filebrowser.
module "copyparty" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/djarbz/copyparty/coder"
version = "1.0.0"
version = "1.0.1"
}
```
@@ -35,7 +35,7 @@ Some basic command line options:
module "copyparty" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/djarbz/copyparty/coder"
version = "1.0.0"
version = "1.0.1"
agent_id = coder_agent.example.id
arguments = [
"-v", "/home/coder/:/home:r", # Share home directory (read-only)
@@ -51,14 +51,14 @@ module "copyparty" {
module "copyparty" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/djarbz/copyparty/coder"
version = "1.0.0"
version = "1.0.1"
agent_id = coder_agent.example.id
subdomain = true
arguments = [
"-v", "/tmp:/tmp:r", # Share tmp directory (read-only)
"-v", "/home/coder/:/home:rw", # Share home directory (read-write)
"-v", "${local.root_dir}:/work:A:c,dotsrch", # Share work directory (All Perms)
"-e2dsa", # Enables general file indexing"
"-e2dsa", # Enables general file indexing
"--re-maxage", "900", # Rescan filesystem for changes every SEC
"--see-dots", # Show dotfiles by default if user has correct permissions on volume
"--xff-src=lan", # List of trusted reverse-proxy CIDRs (comma-separated) or `lan` for private IPs.
@@ -80,7 +80,7 @@ run "test_defaults" {
}
assert {
condition = strcontains(coder_script.copyparty.script, "IFS=',' read -r -a ARGUMENTS \u003c\u003c\u003c \"\"")
condition = strcontains(coder_script.copyparty.script, "ARGUMENTS=()")
error_message = "Script content does not reflect default empty arguments"
}
}
@@ -138,7 +138,7 @@ run "test_custom_values" {
}
assert {
condition = strcontains(coder_script.copyparty.script, "IFS=',' read -r -a ARGUMENTS \u003c\u003c\u003c \"--verbose,-v\"")
condition = strcontains(coder_script.copyparty.script, "ARGUMENTS=(\"--verbose\" \"-v\")")
error_message = "Script content does not reflect custom arguments"
}
@@ -179,3 +179,26 @@ run "test_invalid_share" {
var.share,
]
}
# --- Test Case 7: Comma in Arguments [Readme Example 2] ---
run "test_comma_args" {
# Arguments containing commas
variables {
agent_id = "example-agent-id"
arguments = [
"-v", "/tmp:/tmp:r", # Share tmp directory (read-only)
"-v", "/home/coder/:/home:rw", # Share home directory (read-write)
"-v", "/work:/work:A:c,dotsrch", # Share work directory (All Perms)
"-e2dsa", # Enables general file indexing
"--re-maxage", "900", # Rescan filesystem for changes every SEC
"--see-dots", # Show dotfiles by default if user has correct permissions on volume
"--xff-src=lan", # List of trusted reverse-proxy CIDRs (comma-separated) or `lan` for private IPs.
"--rproxy", "1", # Which ip to associate clients with, index of X-FWD IP.
]
}
assert {
condition = strcontains(coder_script.copyparty.script, "ARGUMENTS=(\"-v\" \"/tmp:/tmp:r\" \"-v\" \"/home/coder/:/home:rw\" \"-v\" \"/work:/work:A:c,dotsrch\" \"-e2dsa\" \"--re-maxage\" \"900\" \"--see-dots\" \"--xff-src=lan\" \"--rproxy\" \"1\")")
error_message = "Script content does not reflect Readme Example #2 arguments with commas"
}
}
+1 -1
View File
@@ -129,7 +129,7 @@ resource "coder_script" "copyparty" {
LOG_PATH : var.log_path,
PORT : var.port,
PINNED_VERSION : var.pinned_version,
ARGUMENTS : join(",", var.arguments),
ARGUMENTS : join(" ", formatlist("\"%s\"", var.arguments)),
})
run_on_start = true
run_on_stop = false
+17 -20
View File
@@ -9,19 +9,16 @@ LOG_PATH="${LOG_PATH}"
PORT="${PORT}"
# Pinned version (e.g., v1.19.16); overrides latest release discovery if set
PINNED_VERSION="${PINNED_VERSION}"
# Custom CLI Arguments# The variable from Terraform is a single, comma-separated string.
# We need to split it into a proper bash array using the comma (,) as the delimiter.
IFS=',' read -r -a ARGUMENTS <<< "${ARGUMENTS}"
# Custom CLI Arguments
# The variable from Terraform is a series of quoted and space separated strings.
# We need to parse it into a proper bash array.
ARGUMENTS=(${ARGUMENTS})
# VARIABLE appears unused. Verify use (or export if used externally).
# shellcheck disable=SC2034
MODULE_NAME="Copyparty"
# VARIABLE appears unused. Verify use (or export if used externally).
# shellcheck disable=SC2034
BOLD='\033[0;1m'
printf '%sInstalling %s ...\n\n' "$${BOLD}" "$${MODULE_NAME}"
printf '\e[1mInstalling %s ...\e[0m\n' "$${MODULE_NAME}"
# Add code here
# Use variables from the templatefile function in main.tf
@@ -32,7 +29,7 @@ if ! command -v python3 &> /dev/null; then
printf "❌ Python3 could not be found. Please install it to continue.\n"
exit 1
fi
printf "✅ Python3 is installed.\n\n"
printf "✅ Python3 is installed.\n"
RELEASE_TO_INSTALL=""
# Install provided version to pin, otherwise discover latest github release from `https://github.com/9001/copyparty`.
@@ -44,7 +41,7 @@ if [[ -n "$${PINNED_VERSION}" ]]; then
exit 1
fi
RELEASE_TO_INSTALL="$${PINNED_VERSION}"
printf "✅ Using pinned version %s.\n\n" "$${RELEASE_TO_INSTALL}"
printf "✅ Using pinned version %s.\n" "$${RELEASE_TO_INSTALL}"
else
printf "🔎 Discovering latest release from GitHub...\n"
# Use curl to get the latest release tag from the GitHub API and sed to parse it
@@ -54,11 +51,11 @@ else
exit 1
fi
RELEASE_TO_INSTALL="$${LATEST_RELEASE}"
printf "🏷️ Latest release is %s.\n\n" "$${RELEASE_TO_INSTALL}"
printf "🏷️ Latest release is %s.\n" "$${RELEASE_TO_INSTALL}"
fi
# Download appropriate release version assets: `copyparty-sfx.py` and `helptext.html`.
printf "🚀 Downloading copyparty v%s...\n" "$${RELEASE_TO_INSTALL}"
printf "🚀 Downloading copyparty %s...\n" "$${RELEASE_TO_INSTALL}"
DOWNLOAD_URL="https://github.com/9001/copyparty/releases/download/$${RELEASE_TO_INSTALL}"
printf "⏬ Downloading copyparty-sfx.py...\n"
@@ -74,9 +71,9 @@ if ! curl -fsSL -o /tmp/helptext.html "$${DOWNLOAD_URL}/helptext.html"; then
fi
chmod +x /tmp/copyparty-sfx.py
printf "✅ Download complete.\n\n"
printf "✅ Download complete.\n"
printf "🥳 Installation complete!\n\n"
printf "🥳 Installation complete!\n"
# Build a clean, quoted string of the command for logging purposes only.
log_command="python3 /tmp/copyparty-sfx.py -p '$${PORT}'"
@@ -85,16 +82,16 @@ for arg in "$${ARGUMENTS[@]}"; do
log_command+=" '$${arg}'"
done
# Clear the log file and write the header and command string using printf.
# Dump the executing command to a tmp file for diagnostic review.
{
printf "=== Starting copyparty at %s ===\n" "$(date)"
printf "EXECUTING: %s\n" "$${log_command}"
} > "$${LOG_PATH}"
} > "/tmp/copyparty.cmd"
printf "👷 Starting %s in background...\n\n" "$${MODULE_NAME}"
printf "👷 Starting %s in background...\n" "$${MODULE_NAME}"
# Execute the actual command using the robust array expansion.
# Then, append its output (stdout and stderr) to the log file.
python3 /tmp/copyparty-sfx.py -p "$${PORT}" "$${ARGUMENTS[@]}" >> "$${LOG_PATH}" 2>&1 &
# Then, capture its output (stdout and stderr) to the log file.
python3 /tmp/copyparty-sfx.py -p "$${PORT}" "$${ARGUMENTS[@]}" > "$${LOG_PATH}" 2>&1 &
printf "✅ Service started. Check logs at %s\n\n" "$${LOG_PATH}"
printf "✅ Service started. Check logs at %s\n" "$${LOG_PATH}"