Compare commits

..

24 Commits

Author SHA1 Message Date
Michael Smith 02817d9ec1 Merge branch 'main' into mes/mod-temp-valid 2025-04-18 19:02:54 +00:00
Michael Smith d2ebc2b1d9 fix: update icon URLs 2025-04-16 15:22:13 +00:00
Michael Smith e6efd71fca wip: more progress 2025-04-16 04:02:45 +00:00
Michael Smith f10f5a4403 chore: add sample data 2025-04-16 02:44:34 +00:00
Michael Smith a00a9ce589 wip: commit progress 2025-04-16 02:17:15 +00:00
Michael Smith 6eef059e21 wip: commit progress on git manipulation 2025-04-16 01:58:36 +00:00
Michael Smith 135d5c8111 fix: remove potential race condition 2025-04-16 01:44:21 +00:00
Michael Smith f23bbca2e7 refactor: start making main() leaner 2025-04-16 00:03:01 +00:00
Michael Smith 19226af067 wip: commit more progress 2025-04-15 23:56:48 +00:00
Michael Smith e888506063 wip: get concurrency stubs set up 2025-04-15 18:10:32 +00:00
Michael Smith aa0b8710d3 wip: commit progress on validation 2025-04-15 17:29:51 +00:00
Michael Smith 17c9667db6 wip: commit progress for module validation 2025-04-15 16:35:33 +00:00
Michael Smith 18680d0a15 chore: add directory validation in separate file 2025-04-15 16:02:33 +00:00
Michael Smith 94ca584b9e refactor: move where validation phase is defined 2025-04-15 15:12:03 +00:00
Michael Smith 6e5d960871 refactor: split README logic into separate file 2025-04-15 15:06:16 +00:00
Michael Smith 3fa316dc37 wip: get basic GH API call stuff working 2025-04-15 14:54:30 +00:00
Michael Smith 9f035798d1 wip: commit progress 2025-04-15 14:32:38 +00:00
Michael Smith 25d301c654 wip: commit progress 2025-04-14 20:40:25 +00:00
Michael Smith d2c5f8d3bd fix: actually add the test calls 2025-04-14 18:27:10 +00:00
Michael Smith 0a597c23f4 test: try logging refs 2025-04-14 18:25:24 +00:00
Michael Smith ec1b4a72cb test: see how CI output works 2025-04-14 18:13:22 +00:00
Michael Smith 860a633e11 wip: add support for reading env from CI 2025-04-14 18:09:43 +00:00
Michael Smith a2abeaee2f fix: update script references for CI 2025-04-14 17:58:59 +00:00
Michael Smith 73f3ea23c0 refactor: move to cmd dir 2025-04-14 17:53:54 +00:00
221 changed files with 1850 additions and 12997 deletions
+17 -21
View File
@@ -1,23 +1,19 @@
# ---
# These are used for the site health script, run from GitHub Actions. They can
# be set manually if you need to run the script locally.
# This should be the value of the GitHub Actions actor who triggered a run. The
# CI script will inject this value from the GitHub Actions context to verify
# whether changing certain README fields is allowed. In local development, you
# can set this to your GitHub username.
CI_ACTOR=
# Coder admins for Instatus can get this value from https://dashboard.instatus.com/developer
export INSTATUS_API_KEY=
# This is the configurable base URL for accessing the GitHub REST API. This
# value will be injected by the CI script's Actions context, but if the value is
# not defined (either in CI or when running locally), "https://api.github.com/"
# will be used as a fallback.
GITHUB_API_URL=
# Can be obtained from calling the Instatus API with a valid token. This value
# might not actually need to be private, but better safe than sorry
# https://instatus.com/help/api/status-pages
export INSTATUS_PAGE_ID=
# Can be obtained from calling the Instatus API with a valid token. This value
# might not actually need to be private, but better safe than sorry
# https://instatus.com/help/api/components
export INSTATUS_COMPONENT_ID=
# Can be grabbed from https://vercel.com/codercom/registry/stores/integration/upstash/store_1YDPuBF4Jd0aNpuV/guides
# Please make sure that the token you use is KV_REST_API_TOKEN; the script needs
# to be able to queries and mutations
export VERCEL_API_KEY=
# ---
# This is the API token for the user that will be used to authenticate calls to
# the GitHub API. In CI, the value will be loaded with a token belonging to a
# Coder Registry admin to verify whether modifying certain README fields is
# allowed. In local development, you can set a token with the read:org
# permission. If the loaded token does not belong to a Coder employee, certain
# README verification steps will be skipped.
GITHUB_API_TOKEN=
-39
View File
@@ -1,39 +0,0 @@
## Description
<!-- Briefly describe what this PR does and why -->
---
## Type of Change
- [ ] New module
- [ ] 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
---
## Testing & Validation
- [ ] Tests pass (`bun test`)
- [ ] Code formatted (`bun run fmt`)
- [ ] Changes tested locally
---
## Related Issues
<!-- Link related issues or write "None" if not applicable -->
Closes #
+128 -128
View File
@@ -4,23 +4,23 @@ set -u
VERBOSE="${VERBOSE:-0}"
if [[ "${VERBOSE}" -ne "0" ]]; then
set -x
set -x
fi
# List of required environment variables
required_vars=(
"INSTATUS_API_KEY"
"INSTATUS_PAGE_ID"
"INSTATUS_COMPONENT_ID"
"VERCEL_API_KEY"
"INSTATUS_API_KEY"
"INSTATUS_PAGE_ID"
"INSTATUS_COMPONENT_ID"
"VERCEL_API_KEY"
)
# Check if each required variable is set
for var in "${required_vars[@]}"; do
if [[ -z "${!var:-}" ]]; then
echo "Error: Environment variable '$var' is not set."
exit 1
fi
if [[ -z "${!var:-}" ]]; then
echo "Error: Environment variable '$var' is not set."
exit 1
fi
done
REGISTRY_BASE_URL="${REGISTRY_BASE_URL:-https://registry.coder.com}"
@@ -31,38 +31,38 @@ declare -a failures=()
# Collect all module directories containing a main.tf file
for path in $(find . -maxdepth 2 -not -path '*/.*' -type f -name main.tf | cut -d '/' -f 2 | sort -u); do
modules+=("${path}")
modules+=("${path}")
done
echo "Checking modules: ${modules[*]}"
# Function to update the component status on Instatus
update_component_status() {
local component_status=$1
# see https://instatus.com/help/api/components
(curl -X PUT "https://api.instatus.com/v1/$INSTATUS_PAGE_ID/components/$INSTATUS_COMPONENT_ID" \
-H "Authorization: Bearer $INSTATUS_API_KEY" \
-H "Content-Type: application/json" \
-d "{\"status\": \"$component_status\"}")
local component_status=$1
# see https://instatus.com/help/api/components
(curl -X PUT "https://api.instatus.com/v1/$INSTATUS_PAGE_ID/components/$INSTATUS_COMPONENT_ID" \
-H "Authorization: Bearer $INSTATUS_API_KEY" \
-H "Content-Type: application/json" \
-d "{\"status\": \"$component_status\"}")
}
# Function to create an incident
create_incident() {
local incident_name="Degraded Service"
local message="The following modules are experiencing issues:\n"
for i in "${!failures[@]}"; do
message+="$((i + 1)). ${failures[$i]}\n"
done
local incident_name="Degraded Service"
local message="The following modules are experiencing issues:\n"
for i in "${!failures[@]}"; do
message+="$((i + 1)). ${failures[$i]}\n"
done
component_status="PARTIALOUTAGE"
if ((${#failures[@]} == ${#modules[@]})); then
component_status="MAJOROUTAGE"
fi
# see https://instatus.com/help/api/incidents
incident_id=$(curl -s -X POST "https://api.instatus.com/v1/$INSTATUS_PAGE_ID/incidents" \
-H "Authorization: Bearer $INSTATUS_API_KEY" \
-H "Content-Type: application/json" \
-d "{
component_status="PARTIALOUTAGE"
if ((${#failures[@]} == ${#modules[@]})); then
component_status="MAJOROUTAGE"
fi
# see https://instatus.com/help/api/incidents
incident_id=$(curl -s -X POST "https://api.instatus.com/v1/$INSTATUS_PAGE_ID/incidents" \
-H "Authorization: Bearer $INSTATUS_API_KEY" \
-H "Content-Type: application/json" \
-d "{
\"name\": \"$incident_name\",
\"message\": \"$message\",
\"components\": [\"$INSTATUS_COMPONENT_ID\"],
@@ -76,129 +76,129 @@ create_incident() {
]
}" | jq -r '.id')
echo "Created incident with ID: $incident_id"
echo "Created incident with ID: $incident_id"
}
# Function to check for existing unresolved incidents
check_existing_incident() {
# Fetch the latest incidents with status not equal to "RESOLVED"
local unresolved_incidents=$(curl -s -X GET "https://api.instatus.com/v1/$INSTATUS_PAGE_ID/incidents" \
-H "Authorization: Bearer $INSTATUS_API_KEY" \
-H "Content-Type: application/json" | jq -r '.incidents[] | select(.status != "RESOLVED") | .id')
# Fetch the latest incidents with status not equal to "RESOLVED"
local unresolved_incidents=$(curl -s -X GET "https://api.instatus.com/v1/$INSTATUS_PAGE_ID/incidents" \
-H "Authorization: Bearer $INSTATUS_API_KEY" \
-H "Content-Type: application/json" | jq -r '.incidents[] | select(.status != "RESOLVED") | .id')
if [[ -n "$unresolved_incidents" ]]; then
echo "Unresolved incidents found: $unresolved_incidents"
return 0 # Indicate that there are unresolved incidents
else
echo "No unresolved incidents found."
return 1 # Indicate that no unresolved incidents exist
fi
if [[ -n "$unresolved_incidents" ]]; then
echo "Unresolved incidents found: $unresolved_incidents"
return 0 # Indicate that there are unresolved incidents
else
echo "No unresolved incidents found."
return 1 # Indicate that no unresolved incidents exist
fi
}
force_redeploy_registry() {
# These are not secret values; safe to just expose directly in script
local VERCEL_TEAM_SLUG="codercom"
local VERCEL_TEAM_ID="team_tGkWfhEGGelkkqUUm9nXq17r"
local VERCEL_APP="registry"
# These are not secret values; safe to just expose directly in script
local VERCEL_TEAM_SLUG="codercom"
local VERCEL_TEAM_ID="team_tGkWfhEGGelkkqUUm9nXq17r"
local VERCEL_APP="registry"
local latest_res
latest_res=$(
curl "https://api.vercel.com/v6/deployments?app=$VERCEL_APP&limit=1&slug=$VERCEL_TEAM_SLUG&teamId=$VERCEL_TEAM_ID&target=production&state=BUILDING,INITIALIZING,QUEUED,READY" \
--fail \
--silent \
--header "Authorization: Bearer $VERCEL_API_KEY" \
--header "Content-Type: application/json"
)
local latest_res
latest_res=$(
curl "https://api.vercel.com/v6/deployments?app=$VERCEL_APP&limit=1&slug=$VERCEL_TEAM_SLUG&teamId=$VERCEL_TEAM_ID&target=production&state=BUILDING,INITIALIZING,QUEUED,READY" \
--fail \
--silent \
--header "Authorization: Bearer $VERCEL_API_KEY" \
--header "Content-Type: application/json"
)
# If we have zero deployments, something is VERY wrong. Make the whole
# script exit with a non-zero status code
local latest_id
latest_id=$(echo "${latest_res}" | jq -r '.deployments[0].uid')
if [[ "${latest_id}" = "null" ]]; then
echo "Unable to pull any previous deployments for redeployment"
echo "Please redeploy the latest deployment manually in Vercel."
echo "https://vercel.com/codercom/registry/deployments"
exit 1
fi
# If we have zero deployments, something is VERY wrong. Make the whole
# script exit with a non-zero status code
local latest_id
latest_id=$(echo "${latest_res}" | jq -r '.deployments[0].uid')
if [[ "${latest_id}" = "null" ]]; then
echo "Unable to pull any previous deployments for redeployment"
echo "Please redeploy the latest deployment manually in Vercel."
echo "https://vercel.com/codercom/registry/deployments"
exit 1
fi
local latest_date_ts_seconds
latest_date_ts_seconds=$(echo "${latest_res}" | jq -r '.deployments[0].createdAt/1000|floor')
local current_date_ts_seconds
current_date_ts_seconds="$(date +%s)"
local max_redeploy_interval_seconds=7200 # 2 hours
if ((current_date_ts_seconds - latest_date_ts_seconds < max_redeploy_interval_seconds)); then
echo "The registry was deployed less than 2 hours ago."
echo "Not automatically re-deploying the regitstry."
echo "A human reading this message should decide if a redeployment is necessary."
echo "Please check the Vercel dashboard for more information."
echo "https://vercel.com/codercom/registry/deployments"
exit 1
fi
local latest_date_ts_seconds
latest_date_ts_seconds=$(echo "${latest_res}" | jq -r '.deployments[0].createdAt/1000|floor')
local current_date_ts_seconds
current_date_ts_seconds="$(date +%s)"
local max_redeploy_interval_seconds=7200 # 2 hours
if ((current_date_ts_seconds - latest_date_ts_seconds < max_redeploy_interval_seconds)); then
echo "The registry was deployed less than 2 hours ago."
echo "Not automatically re-deploying the regitstry."
echo "A human reading this message should decide if a redeployment is necessary."
echo "Please check the Vercel dashboard for more information."
echo "https://vercel.com/codercom/registry/deployments"
exit 1
fi
local latest_deployment_state
latest_deployment_state="$(echo "${latest_res}" | jq -r '.deployments[0].state')"
if [[ "${latest_deployment_state}" != "READY" ]]; then
echo "Last deployment was not in READY state. Skipping redeployment."
echo "A human reading this message should decide if a redeployment is necessary."
echo "Please check the Vercel dashboard for more information."
echo "https://vercel.com/codercom/registry/deployments"
exit 1
fi
local latest_deployment_state
latest_deployment_state="$(echo "${latest_res}" | jq -r '.deployments[0].state')"
if [[ "${latest_deployment_state}" != "READY" ]]; then
echo "Last deployment was not in READY state. Skipping redeployment."
echo "A human reading this message should decide if a redeployment is necessary."
echo "Please check the Vercel dashboard for more information."
echo "https://vercel.com/codercom/registry/deployments"
exit 1
fi
echo "============================================================="
echo "!!! Redeploying registry with deployment ID: ${latest_id} !!!"
echo "============================================================="
echo "============================================================="
echo "!!! Redeploying registry with deployment ID: ${latest_id} !!!"
echo "============================================================="
if ! curl -X POST "https://api.vercel.com/v13/deployments?forceNew=1&skipAutoDetectionConfirmation=1&slug=$VERCEL_TEAM_SLUG&teamId=$VERCEL_TEAM_ID" \
--fail \
--header "Authorization: Bearer $VERCEL_API_KEY" \
--header "Content-Type: application/json" \
--data-raw "{ \"deploymentId\": \"${latest_id}\", \"name\": \"${VERCEL_APP}\", \"target\": \"production\" }"; then
echo "DEPLOYMENT FAILED! Please check the Vercel dashboard for more information."
echo "https://vercel.com/codercom/registry/deployments"
exit 1
fi
if ! curl -X POST "https://api.vercel.com/v13/deployments?forceNew=1&skipAutoDetectionConfirmation=1&slug=$VERCEL_TEAM_SLUG&teamId=$VERCEL_TEAM_ID" \
--fail \
--header "Authorization: Bearer $VERCEL_API_KEY" \
--header "Content-Type: application/json" \
--data-raw "{ \"deploymentId\": \"${latest_id}\", \"name\": \"${VERCEL_APP}\", \"target\": \"production\" }"; then
echo "DEPLOYMENT FAILED! Please check the Vercel dashboard for more information."
echo "https://vercel.com/codercom/registry/deployments"
exit 1
fi
}
# Check each module's accessibility
for module in "${modules[@]}"; do
# Trim leading/trailing whitespace from module name
module=$(echo "${module}" | xargs)
url="${REGISTRY_BASE_URL}/modules/${module}"
printf "=== Checking module %s at %s\n" "${module}" "${url}"
status_code=$(curl --output /dev/null --head --silent --fail --location "${url}" --retry 3 --write-out "%{http_code}")
if ((status_code != 200)); then
printf "==> FAIL(%s)\n" "${status_code}"
status=1
failures+=("${module}")
else
printf "==> OK(%s)\n" "${status_code}"
fi
# Trim leading/trailing whitespace from module name
module=$(echo "${module}" | xargs)
url="${REGISTRY_BASE_URL}/modules/${module}"
printf "=== Checking module %s at %s\n" "${module}" "${url}"
status_code=$(curl --output /dev/null --head --silent --fail --location "${url}" --retry 3 --write-out "%{http_code}")
if ((status_code != 200)); then
printf "==> FAIL(%s)\n" "${status_code}"
status=1
failures+=("${module}")
else
printf "==> OK(%s)\n" "${status_code}"
fi
done
# Determine overall status and update Instatus component
if ((status == 0)); then
echo "All modules are operational."
# set to
update_component_status "OPERATIONAL"
echo "All modules are operational."
# set to
update_component_status "OPERATIONAL"
else
echo "The following modules have issues: ${failures[*]}"
# check if all modules are down
if ((${#failures[@]} == ${#modules[@]})); then
update_component_status "MAJOROUTAGE"
else
update_component_status "PARTIALOUTAGE"
fi
echo "The following modules have issues: ${failures[*]}"
# check if all modules are down
if ((${#failures[@]} == ${#modules[@]})); then
update_component_status "MAJOROUTAGE"
else
update_component_status "PARTIALOUTAGE"
fi
# Check if there is an existing incident before creating a new one
if ! check_existing_incident; then
create_incident
fi
# Check if there is an existing incident before creating a new one
if ! check_existing_incident; then
create_incident
fi
# If a module is down, force a reployment to try getting things back online
# ASAP
# EDIT: registry.coder.com is no longer hosted on vercel
#force_redeploy_registry
# If a module is down, force a reployment to try getting things back online
# ASAP
# EDIT: registry.coder.com is no longer hosted on vercel
#force_redeploy_registry
fi
exit "${status}"
-238
View File
@@ -1,238 +0,0 @@
#!/bin/bash
# Version Bump Script
# Usage: ./version-bump.sh <bump_type> [base_ref]
# bump_type: patch, minor, or major
# base_ref: base reference for diff (default: origin/main)
set -euo pipefail
usage() {
echo "Usage: $0 <bump_type> [base_ref]"
echo " bump_type: patch, minor, or major"
echo " base_ref: base reference for diff (default: origin/main)"
echo ""
echo "Examples:"
echo " $0 patch # Update versions with patch bump"
echo " $0 minor # Update versions with minor bump"
echo " $0 major # Update versions with major bump"
exit 1
}
validate_version() {
local version="$1"
if ! [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "❌ Invalid version format: '$version'. Expected X.Y.Z format." >&2
return 1
fi
return 0
}
bump_version() {
local current_version="$1"
local bump_type="$2"
IFS='.' read -r major minor patch <<< "$current_version"
if ! [[ "$major" =~ ^[0-9]+$ ]] || ! [[ "$minor" =~ ^[0-9]+$ ]] || ! [[ "$patch" =~ ^[0-9]+$ ]]; then
echo "❌ Version components must be numeric: major='$major' minor='$minor' patch='$patch'" >&2
return 1
fi
case "$bump_type" in
"patch")
echo "$major.$minor.$((patch + 1))"
;;
"minor")
echo "$major.$((minor + 1)).0"
;;
"major")
echo "$((major + 1)).0.0"
;;
*)
echo "❌ Invalid bump type: '$bump_type'. Expected patch, minor, or major." >&2
return 1
;;
esac
}
update_readme_version() {
local readme_path="$1"
local namespace="$2"
local module_name="$3"
local new_version="$4"
if [ ! -f "$readme_path" ]; then
return 1
fi
local module_source="registry.coder.com/${namespace}/${module_name}/coder"
if grep -q "source.*${module_source}" "$readme_path"; then
echo "Updating version references for $namespace/$module_name in $readme_path"
awk -v module_source="$module_source" -v new_version="$new_version" '
/source.*=.*/ {
if ($0 ~ module_source) {
in_target_module = 1
} else {
in_target_module = 0
}
}
/version.*=.*"/ {
if (in_target_module) {
gsub(/version[[:space:]]*=[[:space:]]*"[^"]*"/, "version = \"" new_version "\"")
in_target_module = 0
}
}
{ print }
' "$readme_path" > "${readme_path}.tmp" && mv "${readme_path}.tmp" "$readme_path"
return 0
elif grep -q 'version\s*=\s*"' "$readme_path"; then
echo "⚠️ Found version references but no module source match for $namespace/$module_name"
return 1
fi
return 1
}
main() {
if [ $# -lt 1 ] || [ $# -gt 2 ]; then
usage
fi
local bump_type="$1"
local base_ref="${2:-origin/main}"
case "$bump_type" in
"patch" | "minor" | "major") ;;
*)
echo "❌ Invalid bump type: '$bump_type'. Expected patch, minor, or major." >&2
exit 1
;;
esac
echo "🔍 Detecting modified modules..."
local changed_files
changed_files=$(git diff --name-only "${base_ref}"...HEAD)
local modules
modules=$(echo "$changed_files" | grep -E '^registry/[^/]+/modules/[^/]+/' | cut -d'/' -f1-4 | sort -u)
if [ -z "$modules" ]; then
echo "❌ No modules detected in changes"
exit 1
fi
echo "Found modules:"
echo "$modules"
echo ""
local bumped_modules=""
local updated_readmes=""
local untagged_modules=""
local has_changes=false
while IFS= read -r module_path; do
if [ -z "$module_path" ]; then continue; fi
local namespace
namespace=$(echo "$module_path" | cut -d'/' -f2)
local module_name
module_name=$(echo "$module_path" | cut -d'/' -f4)
echo "📦 Processing: $namespace/$module_name"
local latest_tag
latest_tag=$(git tag -l "release/${namespace}/${module_name}/v*" | sort -V | tail -1)
local readme_path="$module_path/README.md"
local current_version
if [ -z "$latest_tag" ]; then
if [ -f "$readme_path" ] && grep -q 'version\s*=\s*"' "$readme_path"; then
local readme_version
readme_version=$(grep 'version\s*=\s*"' "$readme_path" | head -1 | sed 's/.*version\s*=\s*"\([^"]*\)".*/\1/')
echo "No git tag found, but README shows version: $readme_version"
if ! validate_version "$readme_version"; then
echo "Starting from v1.0.0 instead"
current_version="1.0.0"
else
current_version="$readme_version"
untagged_modules="$untagged_modules\n- $namespace/$module_name (README: v$readme_version)"
fi
else
echo "No existing tags or version references found for $namespace/$module_name, starting from v1.0.0"
current_version="1.0.0"
fi
else
current_version=$(echo "$latest_tag" | sed 's/.*\/v//')
echo "Found git tag: $latest_tag (v$current_version)"
fi
echo "Current version: $current_version"
if ! validate_version "$current_version"; then
exit 1
fi
local new_version
new_version=$(bump_version "$current_version" "$bump_type")
echo "New version: $new_version"
if update_readme_version "$readme_path" "$namespace" "$module_name" "$new_version"; then
updated_readmes="$updated_readmes\n- $namespace/$module_name"
has_changes=true
fi
bumped_modules="$bumped_modules\n- $namespace/$module_name: v$current_version → v$new_version"
echo ""
done <<< "$modules"
# Always run formatter to ensure consistent formatting
echo "🔧 Running formatter to ensure consistent formatting..."
if command -v bun >/dev/null 2>&1; then
bun fmt >/dev/null 2>&1 || echo "⚠️ Warning: bun fmt failed, but continuing..."
else
echo "⚠️ Warning: bun not found, skipping formatting"
fi
echo ""
echo "📋 Summary:"
echo "Bump Type: $bump_type"
echo ""
echo "Modules Updated:"
echo -e "$bumped_modules"
echo ""
if [ -n "$updated_readmes" ]; then
echo "READMEs Updated:"
echo -e "$updated_readmes"
echo ""
fi
if [ -n "$untagged_modules" ]; then
echo "⚠️ Modules Without Git Tags:"
echo -e "$untagged_modules"
echo "These modules were versioned based on README content. Consider creating proper release tags after merging."
echo ""
fi
if [ "$has_changes" = true ]; then
echo "✅ Version bump completed successfully!"
echo "📝 README files have been updated with new versions."
echo ""
echo "Next steps:"
echo "1. Review the changes: git diff"
echo "2. Commit the changes: git add . && git commit -m 'chore: bump module versions ($bump_type)'"
echo "3. Push the changes: git push"
exit 0
else
echo "️ No README files were updated (no version references found matching module sources)."
echo "Version calculations completed, but no files were modified."
exit 0
fi
}
main "$@"
-7
View File
@@ -1,7 +0,0 @@
[default.extend-words]
muc = "muc" # For Munich location code
Hashi = "Hashi"
HashiCorp = "HashiCorp"
[files]
extend-exclude = ["registry/coder/templates/aws-devcontainer/architecture.svg"] #False positive
@@ -1,23 +0,0 @@
# Check modules health on registry.coder.com
name: check-registry-site-health
on:
schedule:
- cron: "0,15,30,45 * * * *" # Runs every 15 minutes
workflow_dispatch: # Allows manual triggering of the workflow if needed
jobs:
run-script:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Run check.sh
run: |
./.github/scripts/check_registry_site_health.sh
env:
INSTATUS_API_KEY: ${{ secrets.INSTATUS_API_KEY }}
INSTATUS_PAGE_ID: ${{ secrets.INSTATUS_PAGE_ID }}
INSTATUS_COMPONENT_ID: ${{ secrets.INSTATUS_COMPONENT_ID }}
VERCEL_API_KEY: ${{ secrets.VERCEL_API_KEY }}
+20 -42
View File
@@ -7,8 +7,26 @@ concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs:
validate-readme-files:
runs-on: ubuntu-latest
env:
ACTOR: ${{ github.actor }}
BASE_REF: ${{ github.base_ref }}
HEAD_REF: ${{ github.head_ref }}
GITHUB_API_URL: ${{ github.api_url }}
GITHUB_API_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- name: Check out code
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: "1.23.2"
- name: Validate contributors
run: go build ./cmd/readmevalidation && ./readmevalidation
- name: Remove build file artifact
run: rm ./readmevalidation
test-terraform:
name: Validate Terraform output
runs-on: ubuntu-latest
steps:
- name: Check out code
@@ -26,45 +44,5 @@ jobs:
bun-version: latest
- name: Install dependencies
run: bun install
- name: Run TypeScript tests
- name: Run tests
run: bun test
- name: Run Terraform Validate
run: bun terraform-validate
validate-style:
name: Check for typos and unformatted code
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@v4
- name: Install Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
# Need Terraform for its formatter
- name: Install Terraform
uses: coder/coder/.github/actions/setup-tf@main
- name: Install dependencies
run: bun install
- name: Validate formatting
run: bun fmt:ci
- name: Check for typos
uses: crate-ci/typos@v1.33.1
with:
config: .github/typos.toml
validate-readme-files:
name: Validate README files
runs-on: ubuntu-latest
# We want to do some basic README checks first before we try analyzing the
# contents
needs: validate-style
steps:
- name: Check out code
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: "1.23.2"
- name: Validate contributors
run: go build ./cmd/readmevalidation && ./readmevalidation
- name: Remove build file artifact
run: rm ./readmevalidation
-33
View File
@@ -1,33 +0,0 @@
name: deploy-registry
on:
push:
tags:
# Matches release/<namespace>/<resource_name>/<semantic_version>
# (e.g., "release/whizus/exoscale-zone/v1.0.13")
- "release/*/*/v*.*.*"
jobs:
deploy:
runs-on: ubuntu-latest
# Set id-token permission for gcloud
permissions:
contents: read
id-token: write
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Authenticate with Google Cloud
uses: google-github-actions/auth@ba79af03959ebeac9769e648f473a284504d9193
with:
workload_identity_provider: projects/309789351055/locations/global/workloadIdentityPools/github-actions/providers/github
service_account: registry-v2-github@coder-registry-1.iam.gserviceaccount.com
- name: Set up Google Cloud SDK
uses: google-github-actions/setup-gcloud@77e7a554d41e2ee56fc945c52dfd3f33d12def9a
- name: Deploy to dev.registry.coder.com
run: gcloud builds triggers run 29818181-126d-4f8a-a937-f228b27d3d34 --branch dev
- name: Deploy to registry.coder.com
run: |
gcloud builds triggers run 106610ff-41fb-4bd0-90a2-7643583fb9c0 --branch main
-24
View File
@@ -1,24 +0,0 @@
name: golangci-lint
on:
push:
branches:
- main
- master
pull_request:
permissions:
contents: read
jobs:
golangci:
name: lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: stable
- name: golangci-lint
uses: golangci/golangci-lint-action@v8
with:
version: v2.1
-120
View File
@@ -1,120 +0,0 @@
name: Version Bump
on:
pull_request:
types: [labeled]
paths:
- "registry/**/modules/**"
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
version-bump:
if: github.event.label.name == 'version:patch' || github.event.label.name == 'version:minor' || github.event.label.name == 'version:major'
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
issues: write
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Set up Terraform
uses: coder/coder/.github/actions/setup-tf@main
- name: Install dependencies
run: bun install
- name: Extract bump type from label
id: bump-type
run: |
case "${{ github.event.label.name }}" in
"version:patch")
echo "type=patch" >> $GITHUB_OUTPUT
;;
"version:minor")
echo "type=minor" >> $GITHUB_OUTPUT
;;
"version:major")
echo "type=major" >> $GITHUB_OUTPUT
;;
*)
echo "Invalid version label: ${{ github.event.label.name }}"
exit 1
;;
esac
- name: Check version bump requirements
id: version-check
run: |
output_file=$(mktemp)
if ./.github/scripts/version-bump.sh "${{ steps.bump-type.outputs.type }}" origin/main > "$output_file" 2>&1; then
echo "Script completed successfully"
else
echo "Script failed"
cat "$output_file"
exit 1
fi
{
echo "output<<EOF"
cat "$output_file"
echo "EOF"
} >> $GITHUB_OUTPUT
cat "$output_file"
if git diff --quiet; then
echo "versions_up_to_date=true" >> $GITHUB_OUTPUT
echo "✅ All module versions are already up to date"
else
echo "versions_up_to_date=false" >> $GITHUB_OUTPUT
echo "❌ Module versions need to be updated"
echo "Files that would be changed:"
git diff --name-only
echo ""
echo "Diff preview:"
git diff
git checkout .
git clean -fd
exit 1
fi
- name: Comment on PR - Failure
if: failure() && steps.version-check.outputs.versions_up_to_date == 'false'
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const output = `${{ steps.version-check.outputs.output }}`;
const bumpType = `${{ steps.bump-type.outputs.type }}`;
let comment = `## ❌ Version Bump Validation Failed\n\n`;
comment += `**Bump Type:** \`${bumpType}\`\n\n`;
comment += `Module versions need to be updated but haven't been bumped yet.\n\n`;
comment += `**Required Actions:**\n`;
comment += `1. Run the version bump script locally: \`./.github/scripts/version-bump.sh ${bumpType}\`\n`;
comment += `2. Commit the changes: \`git add . && git commit -m "chore: bump module versions (${bumpType})"\`\n`;
comment += `3. Push the changes: \`git push\`\n\n`;
comment += `### Script Output:\n\`\`\`\n${output}\n\`\`\`\n\n`;
comment += `> Please update the module versions and push the changes to continue.`;
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: comment
});
+1
View File
@@ -137,6 +137,7 @@ dist
# Things needed for CI
/readmevalidation
/readmevalidation-git
# Terraform files generated during testing
.terraform*
-213
View File
@@ -1,213 +0,0 @@
version: "2"
linters:
default: none
enable:
- asciicheck
- bidichk
- bodyclose
- dogsled
- dupl
- errcheck
- errname
- errorlint
- exhaustruct
- forcetypeassert
- gocognit
- gocritic
- godot
- gomodguard
- gosec
- govet
- importas
- ineffassign
- makezero
- misspell
- nestif
- nilnil
# - noctx
# - paralleltest
- revive
- staticcheck
# - tparallel
- unconvert
- unused
settings:
dupl:
threshold: 412
godot:
scope: all
capital: true
exhaustruct:
include:
- httpmw\.\w+
- github.com/coder/coder/v2/coderd/database\.[^G][^e][^t]\w+Params
gocognit:
min-complexity: 300
goconst:
min-len: 4
min-occurrences: 3
gocritic:
enabled-checks:
- badLock
- badRegexp
- boolExprSimplify
- builtinShadow
- builtinShadowDecl
- commentedOutImport
- deferUnlambda
- dupImport
- dynamicFmtString
- emptyDecl
- emptyFallthrough
- emptyStringTest
- evalOrder
- externalErrorReassign
- filepathJoin
- hexLiteral
- httpNoBody
- importShadow
- indexAlloc
- initClause
- methodExprCall
- nestingReduce
- nilValReturn
- preferFilepathJoin
- rangeAppendAll
- regexpPattern
- redundantSprint
- regexpSimplify
- ruleguard
- sliceClear
- sortSlice
- sprintfQuotedString
- sqlQuery
- stringConcatSimplify
- stringXbytes
- todoCommentWithoutDetail
- tooManyResultsChecker
- truncateCmp
- typeAssertChain
- typeDefFirst
- unlabelStmt
- weakCond
- whyNoLint
settings:
ruleguard:
failOn: all
rules: ${base-path}/scripts/rules.go
gosec:
excludes:
- G601
govet:
disable:
- loopclosure
importas:
no-unaliased: true
misspell:
locale: US
ignore-rules:
- trialer
nestif:
min-complexity: 20
revive:
severity: warning
rules:
- name: atomic
- name: bare-return
- name: blank-imports
- name: bool-literal-in-expr
- name: call-to-gc
- name: confusing-results
- name: constant-logical-expr
- name: context-as-argument
- name: context-keys-type
# - name: deep-exit
- name: defer
- name: dot-imports
- name: duplicated-imports
- name: early-return
- name: empty-block
- name: empty-lines
- name: error-naming
- name: error-return
- name: error-strings
- name: errorf
- name: exported
- name: flag-parameter
- name: get-return
- name: identical-branches
- name: if-return
- name: import-shadowing
- name: increment-decrement
- name: indent-error-flow
- name: modifies-value-receiver
- name: package-comments
- name: range
- name: receiver-naming
- name: redefines-builtin-id
- name: string-of-int
- name: struct-tag
- name: superfluous-else
- name: time-naming
- name: unconditional-recursion
- name: unexported-naming
- name: unexported-return
- name: unhandled-error
- name: unnecessary-stmt
- name: unreachable-code
- name: unused-parameter
- name: unused-receiver
- name: var-declaration
- name: var-naming
- name: waitgroup-by-value
staticcheck:
checks:
- all
- SA4006 # Detects redundant assignments
- SA4009 # Detects redundant variable declarations
- SA1019
exclusions:
generated: lax
presets:
- comments
- common-false-positives
- legacy
- std-error-handling
rules:
- linters:
- errcheck
- exhaustruct
- forcetypeassert
path: _test\.go
- linters:
- exhaustruct
path: scripts/*
- linters:
- ALL
path: scripts/rules.go
paths:
- scripts/rules.go
- coderd/database/dbmem
- node_modules
- .git
- third_party$
- builtin$
- examples$
issues:
max-issues-per-linter: 0
max-same-issues: 0
fix: true
formatters:
enable:
- goimports
- gofmt
exclusions:
generated: lax
paths:
- scripts/rules.go
- coderd/database/dbmem
- node_modules
- .git
- third_party$
- builtin$
- examples$
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

-3
View File
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 14 KiB

-17
View File
@@ -1,17 +0,0 @@
<svg viewBox="0 0 106.14 115.53">
<path
d="M60.78,7.84c-4.36-2.52-9.73-2.52-14.08,0L14.44,26.47c-4.36,2.52-7.04,7.17-7.04,12.2v37.26c0,5.03,2.68,9.68,7.04,12.2l32.26,18.63c4.36,2.52,9.73,2.52,14.08,0l32.26-18.63c4.36-2.52,7.04-7.17,7.04-12.2v-37.26c0-5.03-2.68-9.68-7.04-12.2L60.78,7.84Z"
style="fill:url(#gradient)" />
<linearGradient id="gradient" x1="-228.54" y1="480.48" x2="-246.11" y2="446.65"
gradientTransform="translate(569.95 1056) scale(2.16 -2.16)"
gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#2fabff" />
<stop offset=".31" stop-color="#5570ff" />
<stop offset=".62" stop-color="#7b36ff" />
<stop offset=".81" stop-color="#6a2cdc" />
<stop offset="1" stop-color="#5921b8" />
</linearGradient>
<path
d="M48.26,21.44l-22.88,13.21c-3.49,2.01-5.63,5.73-5.63,9.76v26.43c0,4.03,2.15,7.74,5.63,9.76l22.88,13.21c3.49,2.01,7.78,2.01,11.27,0l22.88-13.21c3.49-2.01,5.63-5.73,5.63-9.76v-26.43c0-4.03-2.15-7.74-5.63-9.76l-22.88-13.21c-3.49-2.01-7.78-2.01-11.27,0ZM51.78,27.7c1.74-1.01,3.89-1.01,5.63,0l21.81,12.59c1.74,1.01,2.82,2.86,2.82,4.88v25.19c0,2.01-1.07,3.87-2.82,4.88l-21.81,12.59c-1.74,1.01-3.89,1.01-5.63,0l-21.81-12.59c-1.74-1.01-2.82-2.86-2.82-4.88v-25.19c0-2.01,1.07-3.87,2.82-4.88l21.81-12.59ZM54.25,51.32c-.44-.25-.97-.25-1.41,0l-4.69,2.71c-.44.25-.7.72-.7,1.22v5.42c0,.5.27.97.7,1.22l4.69,2.71c.44.25.97.25,1.41,0l4.69-2.71c.44-.25.7-.72.7-1.22v-5.42c0-.5-.27-.97-.7-1.22l-4.69-2.71ZM57.77,56.55l-2.82,5.63,26.76,15.49,2.82-5.63-26.76-15.49Z"
style="fill:#fff; stroke:#fff; stroke-width:2.93px;" />
</svg>

Before

Width:  |  Height:  |  Size: 1.6 KiB

-2
View File
@@ -1,2 +0,0 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg"><title>file_type_devcontainer</title><circle cx="16" cy="16" r="14" style="fill:#193e63"/><polygon points="10.777 22.742 9.343 21.348 12.729 17.865 9.346 14.417 10.774 13.017 15.525 17.859 10.777 22.742" style="fill:#add1ea"/><polygon points="21.42 19.101 22.854 17.706 19.468 14.224 22.851 10.776 21.423 9.376 16.672 14.218 21.42 19.101" style="fill:#add1ea"/></svg>

Before

Width:  |  Height:  |  Size: 575 B

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

-21
View File
@@ -1,21 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg version="1.1" baseProfile="full" width="340" height="310" xmlns="http://www.w3.org/2000/svg">
<g stroke="white" stroke-width="1.5">
<g transform="rotate(30) skewX(30)">
<rect x="110" y="-72" width="175" height="75" fill="#333333" transform="skewX(-50)"/>
<rect x="110" y="3" width="87.5" height="75" fill="#CDCDCD" transform="skewX(-50)"/>
<rect x="16.5" y="78.9" width="87.5" height="25" fill="#CDCDCD" />
<rect x="16.5" y="104.5" width="175" height="25" fill="#888888" />
<rect x="16.5" y="130" width="175" height="50" fill="#DD4814" />
<rect x="104" y="166" width="89.5" height="25" fill="#CDCDCD" transform="skewY(-40)"/>
<rect x="228.3" y="29.5" width="87.5" height="75" fill="#888888" transform="skewX(-50)"/>
<rect x="191.8" y="266" width="89.5" height="25" fill="#888888" transform="skewY(-40)"/>
<rect x="192" y="291" width="179.5" height="50" fill="#DD4814" transform="skewY(-40)"/>
<rect x="282.3" y="240" width="89.1" height="50" fill="#333333" transform="skewY(-40)"/>
<rect x="194" y="3.7" width="87.5" height="25" fill="#333333" />
</g>
<line x1="93" y1="57" x2="93" y2="88" />
<line x1="169" y1="131" x2="92" y2="88" />
<line x1="92" y1="88" x2="14" y2="128" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

-2
View File
@@ -1,2 +0,0 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="none"><path fill="#06D092" d="M8 0L1 4v8l7 4 7-4V4L8 0zm3.119 8.797L9.254 9.863 7.001 8.65v2.549l-2.118 1.33v-5.33l1.68-1.018 2.332 1.216V4.794l2.23-1.322-.006 5.325z"/></svg>

Before

Width:  |  Height:  |  Size: 390 B

-3
View File
@@ -1,3 +0,0 @@
<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M897.246 286.869H889.819C850.735 286.808 819.017 318.46 819.017 357.539V515.589C819.017 547.15 792.93 572.716 761.882 572.716C743.436 572.716 725.02 563.433 714.093 547.85L552.673 317.304C539.28 298.16 517.486 286.747 493.895 286.747C457.094 286.747 423.976 318.034 423.976 356.657V515.619C423.976 547.181 398.103 572.746 366.842 572.746C348.335 572.746 329.949 563.463 319.021 547.881L138.395 289.882C134.316 284.038 125.154 286.93 125.154 294.052V431.892C125.154 438.862 127.285 445.619 131.272 451.34L309.037 705.2C319.539 720.204 335.033 731.344 352.9 735.392C397.616 745.557 438.77 711.135 438.77 667.278V508.406C438.77 476.845 464.339 451.279 495.904 451.279H495.995C515.02 451.279 532.857 460.562 543.785 476.145L705.235 706.661C718.659 725.835 739.327 737.218 763.983 737.218C801.606 737.218 833.841 705.9 833.841 667.308V508.376C833.841 476.815 859.41 451.249 890.975 451.249H897.276C901.233 451.249 904.43 448.053 904.43 444.097V294.021C904.43 290.065 901.233 286.869 897.276 286.869H897.246Z" fill="white"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

-3
View File
@@ -1,3 +0,0 @@
# Code of Conduct
[Please see our code of conduct on the official Coder website](https://coder.com/docs/contributing/CODE_OF_CONDUCT)
-319
View File
@@ -1,319 +0,0 @@
# Contributing to the Coder Registry
Welcome! This guide covers how to contribute to the Coder Registry, whether you're creating a new module or improving an existing one.
## What is the Coder Registry?
The Coder Registry is a collection of Terraform modules that extend Coder workspaces with development tools like VS Code, Cursor, JetBrains IDEs, and more.
## Types of Contributions
- **[New Modules](#creating-a-new-module)** - Add support for a new tool or functionality
- **[Existing Modules](#contributing-to-existing-modules)** - Fix bugs, add features, or improve documentation
- **[Bug Reports](#reporting-issues)** - Report problems or request features
## Setup
### Prerequisites
- Basic Terraform knowledge (for module development)
- Terraform installed ([installation guide](https://developer.hashicorp.com/terraform/install))
- Docker (for running tests)
### Install Dependencies
Install Bun:
```bash
curl -fsSL https://bun.sh/install | bash
```
Install project dependencies:
```bash
bun install
```
### Understanding Namespaces
All modules are organized under `/registry/[namespace]/modules/`. Each contributor gets their own namespace (e.g., `/registry/your-username/modules/`). If a namespace is taken, choose a different unique namespace, but you can still use any display name on the Registry website.
### Images and Icons
- **Namespace avatars**: Must be named `avatar.png` or `avatar.svg` in `/registry/[namespace]/.images/`
- **Module screenshots/demos**: Use `/registry/[namespace]/.images/` for module-specific images
- **Module icons**: Use the shared `/.icons/` directory at the root for module icons
---
## Creating a New Module
### 1. Create Your Namespace (First Time Only)
If you're a new contributor, create your namespace:
```bash
mkdir -p registry/[your-username]
mkdir -p registry/[your-username]/.images
```
#### Add Your Avatar
Every namespace must have an avatar. We recommend using your GitHub avatar:
1. Download your GitHub avatar from `https://github.com/[your-username].png`
2. Save it as `avatar.png` in `registry/[your-username]/.images/`
3. This gives you a properly sized, square image that's already familiar to the community
The avatar must be:
- Named exactly `avatar.png` or `avatar.svg`
- Square image (recommended: 400x400px minimum)
- Supported formats: `.png` or `.svg` only
#### Create Your Namespace README
Create `registry/[your-username]/README.md`:
```markdown
---
display_name: "Your Name"
bio: "Brief description of who you are and what you do"
avatar_url: "./.images/avatar.png"
github: "your-username"
linkedin: "https://www.linkedin.com/in/your-username" # Optional
website: "https://yourwebsite.com" # Optional
support_email: "you@example.com" # Optional
status: "community"
---
# Your Name
Brief description of who you are and what you do.
```
> **Note**: The `avatar_url` must point to `./.images/avatar.png` or `./.images/avatar.svg`.
### 2. Generate Module Files
```bash
./scripts/new_module.sh [your-username]/[module-name]
cd registry/[your-username]/modules/[module-name]
```
This script generates:
- `main.tf` - Terraform configuration template
- `README.md` - Documentation template with frontmatter
- `run.sh` - Script for module execution (can be deleted if not required)
### 3. Build Your Module
1. **Edit `main.tf`** to implement your module's functionality
2. **Update `README.md`** with:
- Accurate description and usage examples
- Correct icon path (usually `../../../../.icons/your-icon.svg`)
- Proper tags that describe your module
3. **Create `main.test.ts`** to test your module
4. **Add any scripts** or additional files your module needs
### 4. Test and Submit
```bash
# Test your module
bun test -t 'module-name'
# Format code
bun fmt
# Commit and create PR
git add .
git commit -m "Add [module-name] module"
git push origin your-branch
```
> **Important**: It is your responsibility to implement tests for every new module. Test your module locally before opening a PR. The testing suite requires Docker containers with the `--network=host` flag, which typically requires running tests on Linux (this flag doesn't work with Docker Desktop on macOS/Windows). macOS users can use [Colima](https://github.com/abiosoft/colima) or [OrbStack](https://orbstack.dev/) instead of Docker Desktop.
---
## Contributing to Existing Modules
### 1. Find the Module
```bash
find registry -name "*[module-name]*" -type d
```
### 2. Make Your Changes
**For bug fixes:**
- Reproduce the issue
- Fix the code in `main.tf`
- Add/update tests
- Update documentation if needed
**For new features:**
- Add new variables with sensible defaults
- Implement the feature
- Add tests for new functionality
- Update README with new variables
**For documentation:**
- Fix typos and unclear explanations
- Add missing variable documentation
- Improve usage examples
### 3. Test Your Changes
```bash
# Test a specific module
bun test -t 'module-name'
# Test all modules
bun test
```
### 4. Maintain Backward Compatibility
- New variables should have default values
- Don't break existing functionality
- Test that minimal configurations still work
---
## Submitting Changes
1. **Fork and branch:**
```bash
git checkout -b fix/module-name-issue
```
2. **Commit with clear messages:**
```bash
git commit -m "Fix version parsing in module-name"
```
3. **Open PR with:**
- Clear title describing the change
- What you changed and why
- Any breaking changes
### Using PR Templates
We have different PR templates for different types of contributions. GitHub will show you options to choose from, or you can manually select:
- **New Module**: Use `?template=new_module.md`
- **Bug Fix**: Use `?template=bug_fix.md`
- **Feature**: Use `?template=feature.md`
- **Documentation**: Use `?template=documentation.md`
Example: `https://github.com/coder/registry/compare/main...your-branch?template=new_module.md`
---
## Requirements
### Every Module Must Have
- `main.tf` - Terraform code
- `main.test.ts` - Working tests
- `README.md` - Documentation with frontmatter
### README Frontmatter
Module README frontmatter must include:
```yaml
---
display_name: "Module Name" # Required - Name shown on Registry website
description: "What it does" # Required - Short description
icon: "../../../../.icons/tool.svg" # Required - Path to icon file
verified: false # Optional - Set by maintainers only
tags: ["tag1", "tag2"] # Required - Array of descriptive tags
---
```
### README Requirements
All README files must follow these rules:
- Must have frontmatter section with proper YAML
- Exactly one h1 header directly below frontmatter
- When increasing header levels, increment by one each time
- Use `tf` instead of `hcl` for code blocks
### Best Practices
- Use descriptive variable names and descriptions
- Include helpful comments
- Test all functionality
- Follow existing code patterns in the module
---
## Versioning Guidelines
When you modify a module, you need to update its version number in the README. Understanding version numbers helps you describe the impact of your changes:
- **Patch** (1.2.3 → 1.2.4): Bug fixes
- **Minor** (1.2.3 → 1.3.0): New features, adding inputs
- **Major** (1.2.3 → 2.0.0): Breaking changes (removing inputs, changing types)
### Updating Module Versions
If your changes require a version bump, use the version bump script:
```bash
# For bug fixes
./.github/scripts/version-bump.sh patch
# For new features
./.github/scripts/version-bump.sh minor
# For breaking changes
./.github/scripts/version-bump.sh major
```
The script will:
1. Detect which modules you've modified
2. Calculate the new version number
3. Update all version references in the module's README
4. Show you a summary of changes
**Important**: Only run the version bump script if your changes require a new release. Documentation-only changes don't need version updates.
---
## Reporting Issues
When reporting bugs, include:
- Module name and version
- Expected vs actual behavior
- Minimal reproduction case
- Error messages
- Environment details (OS, Terraform version)
---
## Getting Help
- **Examples**: Check `/registry/coder/modules/` for well-structured modules
- **Issues**: Open an issue for technical problems
- **Community**: Reach out to the Coder community for questions
## Common Pitfalls
1. **Missing frontmatter** in README
2. **No tests** or broken tests
3. **Hardcoded values** instead of variables
4. **Breaking changes** without defaults
5. **Not running** `bun fmt` before submitting
Happy contributing! 🚀
-103
View File
@@ -1,103 +0,0 @@
# Maintainer Guide
Quick reference for maintaining the Coder Registry repository.
## Setup
Install Go for README validation:
```bash
# macOS
brew install go
# Linux
sudo apt install golang-go
```
## Daily Tasks
### Review PRs
Check that PRs have:
- [ ] All required files (`main.tf`, `main.test.ts`, `README.md`)
- [ ] Proper frontmatter in README
- [ ] Working tests (`bun test`)
- [ ] Formatted code (`bun run fmt`)
- [ ] Avatar image for new namespaces (`avatar.png` or `avatar.svg` in `.images/`)
#### Version Guidelines
When reviewing PRs, ensure the version change follows semantic versioning:
- **Patch** (1.2.3 → 1.2.4): Bug fixes
- **Minor** (1.2.3 → 1.3.0): New features, adding inputs
- **Major** (1.2.3 → 2.0.0): Breaking changes (removing inputs, changing types)
PRs should clearly indicate the version change (e.g., `v1.2.3 → v1.2.4`).
### Validate READMEs
```bash
go build ./cmd/readmevalidation && ./readmevalidation
```
## Releases
### Create Release Tags
After merging a PR:
1. Get the new version from the PR (shown as `old → new`)
2. Checkout the merge commit and create the tag:
```bash
# Checkout the merge commit
git checkout MERGE_COMMIT_ID
# Create and push the release tag using the version from the PR
git tag -a "release/$namespace/$module/v$version" -m "Release $namespace/$module v$version"
git push origin release/$namespace/$module/v$version
```
Example: If PR shows `v1.2.3 → v1.2.4`, use `v1.2.4` in the tag.
### Publishing
Changes are automatically published to [registry.coder.com](https://registry.coder.com) after tags are pushed.
## README Requirements
### Module Frontmatter (Required)
```yaml
display_name: "Module Name"
description: "What it does"
icon: "../../../../.icons/tool.svg"
maintainer_github: "username"
partner_github: "partner-name" # Optional - For official partner modules
verified: false # Optional - Set by maintainers only
tags: ["tag1", "tag2"]
```
### Namespace Frontmatter (Required)
```yaml
display_name: "Your Name"
bio: "Brief description of who you are and what you do"
avatar_url: "./.images/avatar.png"
github: "username"
linkedin: "https://www.linkedin.com/in/username" # Optional
website: "https://yourwebsite.com" # Optional
support_email: "you@example.com" # Optional
status: "community" # or "partner", "official"
```
## Common Issues
- **README validation fails**: Check YAML syntax, ensure h1 header after frontmatter
- **Tests fail**: Ensure Docker with `--network=host`, check Terraform syntax
- **Wrong file structure**: Use `./scripts/new_module.sh` for new modules
- **Missing namespace avatar**: Must be `avatar.png` or `avatar.svg` in `.images/` directory
That's it. Keep it simple.
+2 -49
View File
@@ -1,50 +1,3 @@
# Coder Registry
# hub
[Registry Site](https://registry.coder.com) • [Coder OSS](https://github.com/coder/coder) • [Coder Docs](https://www.coder.com/docs) • [Official Discord](https://discord.gg/coder)
[![Health](https://github.com/coder/registry/actions/workflows/check_registry_site_health.yaml/badge.svg)](https://github.com/coder/registry/actions/workflows/check_registry_site_health.yaml)
Coder Registry is a community-driven platform for extending your Coder workspaces. Publish reusable Terraform as Coder Modules for users all over the world.
> [!NOTE]
> The Coder Registry repo will be updated to support Coder Templates in the coming weeks. You can currently find all official templates in the official coder/coder repo, [under the `examples/templates` directory](https://github.com/coder/coder/tree/main/examples/templates).
## Overview
Coder is built on HashiCorp's open-source Terraform language to provide developers an easy, declarative way to define the infrastructure for their remote development environments. Coder-flavored versions of Terraform allow you to mix in reusable Terraform snippets to add integrations with other popular development tools, such as JetBrains, Cursor, or Visual Studio Code.
Simply add the correct import snippet, along with any data dependencies, and your workspace can start using the new functionality immediately.
![Coder Agent Bar](./images/coder-agent-bar.png)
More information [about Coder Modules can be found here](https://coder.com/docs/admin/templates/extending-templates/modules), while more information [about Coder Templates can be found here](https://coder.com/docs/admin/templates/creating-templates).
## Getting started
The easiest way to discover new modules and templates is by visiting [the official Coder Registry website](https://registry.coder.com/). The website is a full mirror of the Coder Registry repo, and it is where .tar versions of the various resources can be downloaded from, for use within your Coder deployment.
Note that while Coder has a baseline set of requirements for allowing an external PR to be published, Coder cannot vouch for the validity or functionality of a resource until that resource has been flagged with the `verified` status. [All modules under the Coder namespace](https://github.com/coder/registry/tree/main/registry/coder) are automatically verified.
### Getting started with modules
To get started with a module, navigate to that module's page in either the registry site, or the main repo:
- [The Cursor repo directory](https://github.com/coder/registry/tree/main/registry/coder/modules/cursor)
- [The Cursor module page on the main website](https://registry.coder.com/modules/cursor)
In both cases, the main README contains a Terraform snippet for integrating the module into your workspace. The snippet for Cursor looks like this:
```tf
module "cursor" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/cursor/coder"
version = "1.0.19"
agent_id = coder_agent.example.id
}
```
Simply include that snippet inside your Coder template, defining any data dependencies referenced, and the next time you create a new workspace, the functionality will be ready for you to use.
## Contributing
We are always accepting new contributions. [Please see our contributing guide for more information.](./CONTRIBUTING.md)
Publish Coder modules and templates for other developers to use.
-3
View File
@@ -1,3 +0,0 @@
# Security
[Please see our security policy on the official Coder website](https://coder.com/security/policy)
+169
View File
@@ -0,0 +1,169 @@
// Package github provides utilities to make it easier to deal with various
// GitHub APIs
package github
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"time"
)
const defaultGithubAPIBaseRoute = "https://api.github.com/"
// Client is a reusable REST client for making requests to the GitHub API.
// It should be instantiated via NewGithubClient
type Client struct {
baseURL string
token string
httpClient http.Client
}
// ClientInit is used to instantiate a new client. If the value of BaseURL is
// not defined, a default value of "https://api.github.com/" is used instead
type ClientInit struct {
BaseURL string
APIToken string
}
// NewClient instantiates a GitHub client. If the baseURL is
func NewClient(init ClientInit) (*Client, error) {
// Considered letting the user continue on with no token and more aggressive
// rate-limiting, but from experimentation, the non-authenticated experience
// hit the rate limits really quickly, and had a lot of restrictions
apiToken := init.APIToken
if apiToken == "" {
return nil, errors.New("API token is missing")
}
baseURL := init.BaseURL
if baseURL == "" {
baseURL = defaultGithubAPIBaseRoute
}
return &Client{
baseURL: baseURL,
token: apiToken,
httpClient: http.Client{Timeout: 10 * time.Second},
}, nil
}
// User represents a truncated version of the API response from Github's /user
// endpoint.
type User struct {
Login string `json:"login"`
}
// GetUserFromToken returns the user associated with the loaded API token
func (gc *Client) GetUserFromToken() (User, error) {
req, err := http.NewRequest("GET", gc.baseURL+"user", nil)
if err != nil {
return User{}, err
}
if gc.token != "" {
req.Header.Add("Authorization", "Bearer "+gc.token)
}
res, err := gc.httpClient.Do(req)
if err != nil {
return User{}, err
}
defer res.Body.Close()
if res.StatusCode == http.StatusUnauthorized {
return User{}, errors.New("request is not authorized")
}
if res.StatusCode == http.StatusForbidden {
return User{}, errors.New("request is forbidden")
}
b, err := io.ReadAll(res.Body)
if err != nil {
return User{}, err
}
user := User{}
if err := json.Unmarshal(b, &user); err != nil {
return User{}, err
}
return user, nil
}
// OrgStatus indicates whether a GitHub user is a member of a given organization
type OrgStatus int
var _ fmt.Stringer = OrgStatus(0)
const (
// OrgStatusIndeterminate indicates when a user's organization status
// could not be determined. It is the zero value of the OrgStatus type, and
// any users with this value should be treated as completely untrusted
OrgStatusIndeterminate = iota
// OrgStatusNonMember indicates when a user is definitely NOT part of an
// organization
OrgStatusNonMember
// OrgStatusMember indicates when a user is a member of a Github
// organization
OrgStatusMember
)
func (s OrgStatus) String() string {
switch s {
case OrgStatusMember:
return "Member"
case OrgStatusNonMember:
return "Non-member"
default:
return "Indeterminate"
}
}
// GetUserOrgStatus takes a GitHub username, and checks the GitHub API to see
// whether that member is part of the provided organization
func (gc *Client) GetUserOrgStatus(orgName string, username string) (OrgStatus, error) {
// This API endpoint is really annoying, because it's able to produce false
// negatives. Any user can be:
// 1. A public member of an organization
// 2. A private member of an organization
// 3. Not a member of an organization
//
// So if the function returns status 200, you can always trust that. But if
// it returns any 400 code, that could indicate a few things:
// 1. The user associated with the token is a member of the organization,
// and the user being checked is not.
// 2. The user associated with the token is NOT a member of the
// organization, and the member being checked is a private member. The
// token user will have no way to view the private member's status.
// 3. Neither the user being checked nor the user associated with the token
// are members of the organization.
//
// The best option to avoid false positives is to make sure that the token
// being used belongs to a member of the organization being checked.
url := fmt.Sprintf("%sorgs/%s/members/%s", gc.baseURL, orgName, username)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return OrgStatusIndeterminate, err
}
if gc.token != "" {
req.Header.Add("Authorization", "Bearer "+gc.token)
}
res, err := gc.httpClient.Do(req)
if err != nil {
return OrgStatusIndeterminate, err
}
defer res.Body.Close()
switch res.StatusCode {
case http.StatusNoContent:
return OrgStatusMember, nil
case http.StatusNotFound:
return OrgStatusNonMember, nil
default:
return OrgStatusIndeterminate, nil
}
}
+313
View File
@@ -0,0 +1,313 @@
package main
import (
"errors"
"fmt"
"net/url"
"os"
"path"
"slices"
"strings"
"coder.com/coder-registry/cmd/github"
"gopkg.in/yaml.v3"
)
// dummyGitDirectory is the directory that a full version of the Registry will
// be cloned into during CI. The CI needs to use Git history to validate
// certain README files, and using the root branch itself (even though it's
// fully equivalent) has a risk of breaking other CI steps when switching
// branches. Better to make a full isolated copy and manipulate that.
const dummyGitDirectory = "./readmevalidation-git"
var supportedResourceTypes = []string{"modules", "templates"}
type coderResourceFrontmatter struct {
Description string `yaml:"description"`
IconURL string `yaml:"icon"`
DisplayName *string `yaml:"display_name"`
Verified *bool `yaml:"verified"`
Tags []string `yaml:"tags"`
}
// coderResource represents a generic concept for a Terraform resource used to
// help create Coder workspaces. As of 2025-04-15, this encapsulates both
// Coder Modules and Coder Templates. If the newReadmeBody and newFrontmatter
// fields are nil, that represents that the file has been deleted
type coderResource struct {
resourceType string
filePath string
newReadmeBody *string
oldFrontmatter *coderResourceFrontmatter
newFrontmatter *coderResourceFrontmatter
oldIsVerified bool
newIsVerified bool
}
func validateCoderResourceDisplayName(displayName *string) error {
if displayName == nil {
return nil
}
if *displayName == "" {
return errors.New("if defined, display_name must not be empty string")
}
return nil
}
func validateCoderResourceDescription(description string) error {
if description == "" {
return errors.New("frontmatter description cannot be empty")
}
return nil
}
func validateCoderResourceIconURL(iconURL string) []error {
problems := []error{}
if iconURL == "" {
problems = append(problems, errors.New("icon URL cannot be empty"))
return problems
}
isAbsoluteURL := !strings.HasPrefix(iconURL, ".") && !strings.HasPrefix(iconURL, "/")
if isAbsoluteURL {
if _, err := url.ParseRequestURI(iconURL); err != nil {
problems = append(problems, errors.New("absolute icon URL is not correctly formatted"))
}
if strings.Contains(iconURL, "?") {
problems = append(problems, errors.New("icon URLs cannot contain query parameters"))
}
return problems
}
// Would normally be skittish about having relative paths like this, but it
// should be safe because we have guarantees about the structure of the
// repo, and where this logic will run
isPermittedRelativeURL := strings.HasPrefix(iconURL, "./") ||
strings.HasPrefix(iconURL, "/") ||
strings.HasPrefix(iconURL, "../../../.icons")
if !isPermittedRelativeURL {
problems = append(problems, fmt.Errorf("relative icon URL %q must either be scoped to that module's directory, or the top-level /.icons directory (this can usually be done by starting the path with \"../../../.icons\")", iconURL))
}
return problems
}
func validateCoderResourceTags(tags []string) error {
if len(tags) == 0 {
return nil
}
// All of these tags are used for the module/template filter controls in the
// Registry site. Need to make sure they can all be placed in the browser
// URL without issue
invalidTags := []string{}
for _, t := range tags {
if t != url.QueryEscape(t) {
invalidTags = append(invalidTags, t)
}
}
if len(invalidTags) != 0 {
return fmt.Errorf("found invalid tags (tags that cannot be used for filter state in the Registry website): [%s]", strings.Join(invalidTags, ", "))
}
return nil
}
func validateCoderResourceVerifiedStatus(oldVerified bool, newVerified bool, actorOrgStatus github.OrgStatus) error {
// If the actor making the changes is an employee of Coder, any changes are
// assumed to be valid
if actorOrgStatus == github.OrgStatusMember {
return nil
}
// Right now, because we collapse the omitted/nil case and false together,
// the only field transition that's allowed is if the verified statuses are
// exactly the same (which includes the field going from omitted to
// explicitly false, or vice-versa).
isPermittedChangeForNonEmployee := oldVerified == newVerified
if isPermittedChangeForNonEmployee {
return nil
}
return fmt.Errorf("actor with status %q is not allowed to flip verified status from %t to %t", actorOrgStatus.String(), oldVerified, newVerified)
}
// Todo: once we decide on how we want the README frontmatter to be formatted
// for the Embedded Registry work, update this function to validate that the
// correct Terraform code snippets are included in the README and are actually
// valid Terraform. Might also want to validate that each header follows proper
// hierarchy (i.e., not jumping from h1 to h3 because you think it looks nicer)
func validateCoderResourceReadmeBody(body string) error {
trimmed := strings.TrimSpace(body)
if !strings.HasPrefix(trimmed, "# ") {
return errors.New("README body must start with ATX-style h1 header (i.e., \"# \")")
}
return nil
}
func validateCoderResourceChanges(resource coderResource, actorOrgStatus github.OrgStatus) []error {
var problems []error
if resource.newReadmeBody != nil {
if err := validateCoderResourceReadmeBody(*resource.newReadmeBody); err != nil {
problems = append(problems, addFilePathToError(resource.filePath, err))
}
}
if resource.newFrontmatter != nil {
if err := validateCoderResourceDisplayName(resource.newFrontmatter.DisplayName); err != nil {
problems = append(problems, addFilePathToError(resource.filePath, err))
}
if err := validateCoderResourceDescription(resource.newFrontmatter.Description); err != nil {
problems = append(problems, addFilePathToError(resource.filePath, err))
}
if err := validateCoderResourceTags(resource.newFrontmatter.Tags); err != nil {
problems = append(problems, addFilePathToError(resource.filePath, err))
}
if err := validateCoderResourceVerifiedStatus(resource.oldIsVerified, resource.newIsVerified, actorOrgStatus); err != nil {
problems = append(problems, addFilePathToError(resource.filePath, err))
}
for _, err := range validateCoderResourceIconURL(resource.newFrontmatter.IconURL) {
problems = append(problems, addFilePathToError(resource.filePath, err))
}
}
return problems
}
func parseCoderResourceFiles(resourceType string, oldReadmeFiles []readme, newReadmeFiles []readme, actorOrgStatus github.OrgStatus) (map[string]coderResource, error) {
if !slices.Contains(supportedResourceTypes, resourceType) {
return nil, fmt.Errorf("resource type %q is not in supported list [%s]", resourceType, strings.Join(supportedResourceTypes, ", "))
}
var errs []error
resourcesByFilePath := map[string]coderResource{}
zipped := zipReadmes(oldReadmeFiles, newReadmeFiles)
for filePath, z := range zipped {
resource := coderResource{
resourceType: resourceType,
filePath: filePath,
}
if z.new != nil {
fm, body, err := separateFrontmatter(z.new.rawText)
if err != nil {
errs = append(errs, fmt.Errorf("resource type %s - %q: %v", resourceType, filePath, err))
} else {
resource.newReadmeBody = &body
var newFm coderResourceFrontmatter
if err := yaml.Unmarshal([]byte(fm), &newFm); err != nil {
errs = append(errs, fmt.Errorf("resource type %s - %q: %v", resourceType, filePath, err))
} else {
resource.newFrontmatter = &newFm
if newFm.Verified != nil && *newFm.Verified {
resource.newIsVerified = true
}
}
}
}
if z.old != nil {
fm, _, err := separateFrontmatter(z.old.rawText)
if err != nil {
errs = append(errs, fmt.Errorf("resource type %s - %q: %v", resourceType, filePath, err))
} else {
var oldFm coderResourceFrontmatter
if err := yaml.Unmarshal([]byte(fm), &oldFm); err != nil {
errs = append(errs, fmt.Errorf("resource type %s - %q: %v", resourceType, filePath, err))
} else {
resource.oldFrontmatter = &oldFm
if oldFm.Verified != nil && *oldFm.Verified {
resource.oldIsVerified = true
}
}
}
}
if z.old != nil || z.new != nil {
resourcesByFilePath[filePath] = resource
}
}
for _, r := range resourcesByFilePath {
errs = append(errs, validateCoderResourceChanges(r, actorOrgStatus)...)
}
if len(errs) != 0 {
return nil, validationPhaseError{
phase: validationPhaseReadmeParsing,
errors: errs,
}
}
return resourcesByFilePath, nil
}
// Todo: because Coder Resource READMEs will have their full contents
// (frontmatter and body) rendered on the Registry site, we need to make sure
// that all image references in the body are valid, too
func validateCoderResourceRelativeUrls(map[string]coderResource) []error {
return nil
}
func aggregateCoderResourceReadmeFiles(resourceDirectoryName string) ([]readme, error) {
if !slices.Contains(supportedResourceTypes, resourceDirectoryName) {
return nil, fmt.Errorf("%q is not a supported resource type. Must be one of [%s]", resourceDirectoryName, strings.Join(supportedResourceTypes, ", "))
}
registryFiles, err := os.ReadDir(rootRegistryPath)
if err != nil {
return nil, err
}
var allReadmeFiles []readme
var problems []error
for _, f := range registryFiles {
if !f.IsDir() {
continue
}
resourceDirPath := path.Join(rootRegistryPath, f.Name(), resourceDirectoryName)
resourceFiles, err := os.ReadDir(resourceDirPath)
if err != nil {
if !errors.Is(err, os.ErrNotExist) {
problems = append(problems, err)
}
continue
}
for _, resFile := range resourceFiles {
// Not sure if we want to allow non-directories to live inside of
// main directories like /modules or /templates, but we can tighten
// things up later
if !resFile.IsDir() {
continue
}
readmePath := path.Join(resourceDirPath, resFile.Name(), "README.md")
rawRm, err := os.ReadFile(readmePath)
if err != nil {
problems = append(problems, err)
continue
}
allReadmeFiles = append(allReadmeFiles, readme{
filePath: readmePath,
rawText: string(rawRm),
})
}
}
if len(problems) != 0 {
return nil, validationPhaseError{
phase: validationPhaseFileLoad,
errors: problems,
}
}
return allReadmeFiles, nil
}
-351
View File
@@ -1,351 +0,0 @@
package main
import (
"bufio"
"context"
"errors"
"net/url"
"os"
"path"
"regexp"
"slices"
"strings"
"golang.org/x/xerrors"
"gopkg.in/yaml.v3"
)
var (
supportedResourceTypes = []string{"modules", "templates"}
// TODO: This is a holdover from the validation logic used by the Coder Modules repo. It gives us some assurance, but
// realistically, we probably want to parse any Terraform code snippets, and make some deeper guarantees about how it's
// structured. Just validating whether it *can* be parsed as Terraform would be a big improvement.
terraformVersionRe = regexp.MustCompile(`^\s*\bversion\s+=`)
)
type coderResourceFrontmatter struct {
Description string `yaml:"description"`
IconURL string `yaml:"icon"`
DisplayName *string `yaml:"display_name"`
Verified *bool `yaml:"verified"`
Tags []string `yaml:"tags"`
}
// coderResourceReadme represents a README describing a Terraform resource used
// to help create Coder workspaces. As of 2025-04-15, this encapsulates both
// Coder Modules and Coder Templates.
type coderResourceReadme struct {
resourceType string
filePath string
body string
frontmatter coderResourceFrontmatter
}
func validateCoderResourceDisplayName(displayName *string) error {
if displayName != nil && *displayName == "" {
return xerrors.New("if defined, display_name must not be empty string")
}
return nil
}
func validateCoderResourceDescription(description string) error {
if description == "" {
return xerrors.New("frontmatter description cannot be empty")
}
return nil
}
func isPermittedRelativeURL(checkURL string) bool {
// Would normally be skittish about having relative paths like this, but it should be safe because we have
// guarantees about the structure of the repo, and where this logic will run.
return strings.HasPrefix(checkURL, "./") || strings.HasPrefix(checkURL, "/") || strings.HasPrefix(checkURL, "../../../../.icons")
}
func validateCoderResourceIconURL(iconURL string) []error {
if iconURL == "" {
return []error{xerrors.New("icon URL cannot be empty")}
}
errs := []error{}
// If the URL does not have a relative path.
if !strings.HasPrefix(iconURL, ".") && !strings.HasPrefix(iconURL, "/") {
if _, err := url.ParseRequestURI(iconURL); err != nil {
errs = append(errs, xerrors.New("absolute icon URL is not correctly formatted"))
}
if strings.Contains(iconURL, "?") {
errs = append(errs, xerrors.New("icon URLs cannot contain query parameters"))
}
return errs
}
// If the URL has a relative path.
if !isPermittedRelativeURL(iconURL) {
errs = append(errs, xerrors.Errorf("relative icon URL %q must either be scoped to that module's directory, or the top-level /.icons directory (this can usually be done by starting the path with \"../../../.icons\")", iconURL))
}
return errs
}
func validateCoderResourceTags(tags []string) error {
if tags == nil {
return xerrors.New("provided tags array is nil")
}
if len(tags) == 0 {
return nil
}
// All of these tags are used for the module/template filter controls in the Registry site. Need to make sure they
// can all be placed in the browser URL without issue.
invalidTags := []string{}
for _, t := range tags {
if t != url.QueryEscape(t) {
invalidTags = append(invalidTags, t)
}
}
if len(invalidTags) != 0 {
return xerrors.Errorf("found invalid tags (tags that cannot be used for filter state in the Registry website): [%s]", strings.Join(invalidTags, ", "))
}
return nil
}
func validateCoderResourceReadmeBody(body string) []error {
var errs []error
trimmed := strings.TrimSpace(body)
// TODO: this may cause unexpected behavior since the errors slice may have a 0 length. Add a test.
errs = append(errs, validateReadmeBody(trimmed)...)
foundParagraph := false
terraformCodeBlockCount := 0
foundTerraformVersionRef := false
lineNum := 0
isInsideCodeBlock := false
isInsideTerraform := false
lineScanner := bufio.NewScanner(strings.NewReader(trimmed))
for lineScanner.Scan() {
lineNum++
nextLine := lineScanner.Text()
// Code assumes that invalid headers would've already been handled by the base validation function, so we don't
// need to check deeper if the first line isn't an h1.
if lineNum == 1 {
if !strings.HasPrefix(nextLine, "# ") {
break
}
continue
}
if strings.HasPrefix(nextLine, "```") {
isInsideCodeBlock = !isInsideCodeBlock
isInsideTerraform = isInsideCodeBlock && strings.HasPrefix(nextLine, "```tf")
if isInsideTerraform {
terraformCodeBlockCount++
}
if strings.HasPrefix(nextLine, "```hcl") {
errs = append(errs, xerrors.New("all .hcl language references must be converted to .tf"))
}
continue
}
if isInsideCodeBlock {
if isInsideTerraform {
foundTerraformVersionRef = foundTerraformVersionRef || terraformVersionRe.MatchString(nextLine)
}
continue
}
// Code assumes that we can treat this case as the end of the "h1 section" and don't need to process any further lines.
if lineNum > 1 && strings.HasPrefix(nextLine, "#") {
break
}
// Code assumes that if we've reached this point, the only other options are:
// (1) empty spaces, (2) paragraphs, (3) HTML, and (4) asset references made via [] syntax.
trimmedLine := strings.TrimSpace(nextLine)
isParagraph := trimmedLine != "" && !strings.HasPrefix(trimmedLine, "![") && !strings.HasPrefix(trimmedLine, "<")
foundParagraph = foundParagraph || isParagraph
}
if terraformCodeBlockCount == 0 {
errs = append(errs, xerrors.New("did not find Terraform code block within h1 section"))
} else {
if terraformCodeBlockCount > 1 {
errs = append(errs, xerrors.New("cannot have more than one Terraform code block in h1 section"))
}
if !foundTerraformVersionRef {
errs = append(errs, xerrors.New("did not find Terraform code block that specifies 'version' field"))
}
}
if !foundParagraph {
errs = append(errs, xerrors.New("did not find paragraph within h1 section"))
}
if isInsideCodeBlock {
errs = append(errs, xerrors.New("code blocks inside h1 section do not all terminate before end of file"))
}
return errs
}
func validateCoderResourceReadme(rm coderResourceReadme) []error {
var errs []error
for _, err := range validateCoderResourceReadmeBody(rm.body) {
errs = append(errs, addFilePathToError(rm.filePath, err))
}
if err := validateCoderResourceDisplayName(rm.frontmatter.DisplayName); err != nil {
errs = append(errs, addFilePathToError(rm.filePath, err))
}
if err := validateCoderResourceDescription(rm.frontmatter.Description); err != nil {
errs = append(errs, addFilePathToError(rm.filePath, err))
}
if err := validateCoderResourceTags(rm.frontmatter.Tags); err != nil {
errs = append(errs, addFilePathToError(rm.filePath, err))
}
for _, err := range validateCoderResourceIconURL(rm.frontmatter.IconURL) {
errs = append(errs, addFilePathToError(rm.filePath, err))
}
return errs
}
func parseCoderResourceReadme(resourceType string, rm readme) (coderResourceReadme, error) {
fm, body, err := separateFrontmatter(rm.rawText)
if err != nil {
return coderResourceReadme{}, xerrors.Errorf("%q: failed to parse frontmatter: %v", rm.filePath, err)
}
yml := coderResourceFrontmatter{}
if err := yaml.Unmarshal([]byte(fm), &yml); err != nil {
return coderResourceReadme{}, xerrors.Errorf("%q: failed to parse: %v", rm.filePath, err)
}
return coderResourceReadme{
resourceType: resourceType,
filePath: rm.filePath,
body: body,
frontmatter: yml,
}, nil
}
func parseCoderResourceReadmeFiles(resourceType string, rms []readme) (map[string]coderResourceReadme, error) {
resources := map[string]coderResourceReadme{}
var yamlParsingErrs []error
for _, rm := range rms {
p, err := parseCoderResourceReadme(resourceType, rm)
if err != nil {
yamlParsingErrs = append(yamlParsingErrs, err)
continue
}
resources[p.filePath] = p
}
if len(yamlParsingErrs) != 0 {
return nil, validationPhaseError{
phase: validationPhaseReadme,
errors: yamlParsingErrs,
}
}
yamlValidationErrors := []error{}
for _, readme := range resources {
errs := validateCoderResourceReadme(readme)
if len(errs) > 0 {
yamlValidationErrors = append(yamlValidationErrors, errs...)
}
}
if len(yamlValidationErrors) != 0 {
return nil, validationPhaseError{
phase: validationPhaseReadme,
errors: yamlValidationErrors,
}
}
return resources, nil
}
// Todo: Need to beef up this function by grabbing each image/video URL from
// the body's AST.
func validateCoderResourceRelativeURLs(_ map[string]coderResourceReadme) error {
return nil
}
func aggregateCoderResourceReadmeFiles(resourceType string) ([]readme, error) {
registryFiles, err := os.ReadDir(rootRegistryPath)
if err != nil {
return nil, err
}
var allReadmeFiles []readme
var errs []error
for _, rf := range registryFiles {
if !rf.IsDir() {
continue
}
resourceRootPath := path.Join(rootRegistryPath, rf.Name(), resourceType)
resourceDirs, err := os.ReadDir(resourceRootPath)
if err != nil {
if !errors.Is(err, os.ErrNotExist) {
errs = append(errs, err)
}
continue
}
for _, rd := range resourceDirs {
if !rd.IsDir() || rd.Name() == ".coder" {
continue
}
resourceReadmePath := path.Join(resourceRootPath, rd.Name(), "README.md")
rm, err := os.ReadFile(resourceReadmePath)
if err != nil {
errs = append(errs, err)
continue
}
allReadmeFiles = append(allReadmeFiles, readme{
filePath: resourceReadmePath,
rawText: string(rm),
})
}
}
if len(errs) != 0 {
return nil, validationPhaseError{
phase: validationPhaseFile,
errors: errs,
}
}
return allReadmeFiles, nil
}
func validateAllCoderResourceFilesOfType(resourceType string) error {
if !slices.Contains(supportedResourceTypes, resourceType) {
return xerrors.Errorf("resource type %q is not part of supported list [%s]", resourceType, strings.Join(supportedResourceTypes, ", "))
}
allReadmeFiles, err := aggregateCoderResourceReadmeFiles(resourceType)
if err != nil {
return err
}
logger.Info(context.Background(), "rocessing README files", "num_files", len(allReadmeFiles))
resources, err := parseCoderResourceReadmeFiles(resourceType, allReadmeFiles)
if err != nil {
return err
}
logger.Info(context.Background(), "rocessed README files as valid Coder resources", "num_files", len(resources), "type", resourceType)
if err := validateCoderResourceRelativeURLs(resources); err != nil {
return err
}
logger.Info(context.Background(), "all relative URLs for READMEs are valid", "type", resourceType)
return nil
}
@@ -1,22 +0,0 @@
package main
import (
_ "embed"
"testing"
)
//go:embed testSamples/sampleReadmeBody.md
var testBody string
func TestValidateCoderResourceReadmeBody(t *testing.T) {
t.Parallel()
t.Run("Parses a valid README body with zero issues", func(t *testing.T) {
t.Parallel()
errs := validateCoderResourceReadmeBody(testBody)
for _, e := range errs {
t.Error(e)
}
})
}
+171 -101
View File
@@ -1,38 +1,81 @@
package main
import (
"context"
"errors"
"fmt"
"log"
"net/url"
"os"
"path"
"slices"
"strings"
"golang.org/x/xerrors"
"gopkg.in/yaml.v3"
)
var validContributorStatuses = []string{"official", "partner", "community"}
type contributorProfileFrontmatter struct {
DisplayName string `yaml:"display_name"`
Bio string `yaml:"bio"`
ContributorStatus string `yaml:"status"`
AvatarURL *string `yaml:"avatar"`
LinkedinURL *string `yaml:"linkedin"`
WebsiteURL *string `yaml:"website"`
SupportEmail *string `yaml:"support_email"`
DisplayName string `yaml:"display_name"`
Bio string `yaml:"bio"`
GithubUsername string `yaml:"github"`
// Script assumes that if value is nil, the Registry site build step will
// backfill the value with the user's GitHub avatar URL
AvatarURL *string `yaml:"avatar"`
LinkedinURL *string `yaml:"linkedin"`
WebsiteURL *string `yaml:"website"`
SupportEmail *string `yaml:"support_email"`
EmployerGithubUsername *string `yaml:"employer_github"`
ContributorStatus *string `yaml:"status"`
}
type contributorProfileReadme struct {
type contributorProfile struct {
frontmatter contributorProfileFrontmatter
namespace string
filePath string
}
func validateContributorGithubUsername(githubUsername string) error {
if githubUsername == "" {
return errors.New("missing GitHub username")
}
lower := strings.ToLower(githubUsername)
if uriSafe := url.PathEscape(lower); uriSafe != lower {
return fmt.Errorf("gitHub username %q is not a valid URL path segment", githubUsername)
}
return nil
}
func validateContributorEmployerGithubUsername(
employerGithubUsername *string,
githubUsername string,
) []error {
if employerGithubUsername == nil {
return nil
}
problems := []error{}
if *employerGithubUsername == "" {
problems = append(problems, errors.New("company_github field is defined but has empty value"))
return problems
}
lower := strings.ToLower(*employerGithubUsername)
if uriSafe := url.PathEscape(lower); uriSafe != lower {
problems = append(problems, fmt.Errorf("gitHub company username %q is not a valid URL path segment", *employerGithubUsername))
}
if *employerGithubUsername == githubUsername {
problems = append(problems, fmt.Errorf("cannot list own GitHub name (%q) as employer", githubUsername))
}
return problems
}
func validateContributorDisplayName(displayName string) error {
if displayName == "" {
return xerrors.New("missing display_name")
return fmt.Errorf("missing display_name")
}
return nil
@@ -44,49 +87,50 @@ func validateContributorLinkedinURL(linkedinURL *string) error {
}
if _, err := url.ParseRequestURI(*linkedinURL); err != nil {
return xerrors.Errorf("linkedIn URL %q is not valid: %v", *linkedinURL, err)
return fmt.Errorf("linkedIn URL %q is not valid: %v", *linkedinURL, err)
}
return nil
}
// validateContributorSupportEmail does best effort validation of a contributors email address. We can't 100% validate
// that this is correct without actually sending an email, especially because some contributors are individual developers
// and we don't want to do that on every single run of the CI pipeline. The best we can do is verify the general structure.
func validateContributorSupportEmail(email *string) []error {
if email == nil {
return nil
}
errs := []error{}
problems := []error{}
// Can't 100% validate that this is correct without actually sending
// an email, and especially with some contributors being individual
// developers, we don't want to do that on every single run of the CI
// pipeline. Best we can do is verify the general structure
username, server, ok := strings.Cut(*email, "@")
if !ok {
errs = append(errs, xerrors.Errorf("email address %q is missing @ symbol", *email))
return errs
problems = append(problems, fmt.Errorf("email address %q is missing @ symbol", *email))
return problems
}
if username == "" {
errs = append(errs, xerrors.Errorf("email address %q is missing username", *email))
problems = append(problems, fmt.Errorf("email address %q is missing username", *email))
}
domain, tld, ok := strings.Cut(server, ".")
if !ok {
errs = append(errs, xerrors.Errorf("email address %q is missing period for server segment", *email))
return errs
problems = append(problems, fmt.Errorf("email address %q is missing period for server segment", *email))
return problems
}
if domain == "" {
errs = append(errs, xerrors.Errorf("email address %q is missing domain", *email))
problems = append(problems, fmt.Errorf("email address %q is missing domain", *email))
}
if tld == "" {
errs = append(errs, xerrors.Errorf("email address %q is missing top-level domain", *email))
problems = append(problems, fmt.Errorf("email address %q is missing top-level domain", *email))
}
if strings.Contains(*email, "?") {
errs = append(errs, xerrors.New("email is not allowed to contain query parameters"))
problems = append(problems, errors.New("email is not allowed to contain query parameters"))
}
return errs
return problems
}
func validateContributorWebsite(websiteURL *string) error {
@@ -95,40 +139,47 @@ func validateContributorWebsite(websiteURL *string) error {
}
if _, err := url.ParseRequestURI(*websiteURL); err != nil {
return xerrors.Errorf("linkedIn URL %q is not valid: %v", *websiteURL, err)
return fmt.Errorf("linkedIn URL %q is not valid: %v", *websiteURL, err)
}
return nil
}
func validateContributorStatus(status string) error {
if !slices.Contains(validContributorStatuses, status) {
return xerrors.Errorf("contributor status %q is not valid", status)
func validateContributorStatus(status *string) error {
if status == nil {
return nil
}
if !slices.Contains(validContributorStatuses, *status) {
return fmt.Errorf("contributor status %q is not valid", *status)
}
return nil
}
// Can't validate the image actually leads to a valid resource in a pure function, but can at least catch obvious problems.
// Can't validate the image actually leads to a valid resource in a pure
// function, but can at least catch obvious problems
func validateContributorAvatarURL(avatarURL *string) []error {
if avatarURL == nil {
return nil
}
problems := []error{}
if *avatarURL == "" {
return []error{xerrors.New("avatar URL must be omitted or non-empty string")}
problems = append(problems, errors.New("avatar URL must be omitted or non-empty string"))
return problems
}
errs := []error{}
// Have to use .Parse instead of .ParseRequestURI because this is the one field that's allowed to be a relative URL.
// Have to use .Parse instead of .ParseRequestURI because this is the
// one field that's allowed to be a relative URL
if _, err := url.Parse(*avatarURL); err != nil {
errs = append(errs, xerrors.Errorf("URL %q is not a valid relative or absolute URL", *avatarURL))
problems = append(problems, fmt.Errorf("URL %q is not a valid relative or absolute URL", *avatarURL))
}
if strings.Contains(*avatarURL, "?") {
errs = append(errs, xerrors.New("avatar URL is not allowed to contain search parameters"))
problems = append(problems, errors.New("avatar URL is not allowed to contain search parameters"))
}
var matched bool
matched := false
for _, ff := range supportedAvatarFileFormats {
matched = strings.HasSuffix(*avatarURL, ff)
if matched {
@@ -138,58 +189,63 @@ func validateContributorAvatarURL(avatarURL *string) []error {
if !matched {
segments := strings.Split(*avatarURL, ".")
fileExtension := segments[len(segments)-1]
errs = append(errs, xerrors.Errorf("avatar URL '.%s' does not end in a supported file format: [%s]", fileExtension, strings.Join(supportedAvatarFileFormats, ", ")))
problems = append(problems, fmt.Errorf("avatar URL '.%s' does not end in a supported file format: [%s]", fileExtension, strings.Join(supportedAvatarFileFormats, ", ")))
}
return errs
return problems
}
func validateContributorReadme(rm contributorProfileReadme) []error {
allErrs := []error{}
func validateContributorProfile(yml contributorProfile) []error {
allProblems := []error{}
if err := validateContributorDisplayName(rm.frontmatter.DisplayName); err != nil {
allErrs = append(allErrs, addFilePathToError(rm.filePath, err))
if err := validateContributorGithubUsername(yml.frontmatter.GithubUsername); err != nil {
allProblems = append(allProblems, addFilePathToError(yml.filePath, err))
}
if err := validateContributorLinkedinURL(rm.frontmatter.LinkedinURL); err != nil {
allErrs = append(allErrs, addFilePathToError(rm.filePath, err))
if err := validateContributorDisplayName(yml.frontmatter.DisplayName); err != nil {
allProblems = append(allProblems, addFilePathToError(yml.filePath, err))
}
if err := validateContributorWebsite(rm.frontmatter.WebsiteURL); err != nil {
allErrs = append(allErrs, addFilePathToError(rm.filePath, err))
if err := validateContributorLinkedinURL(yml.frontmatter.LinkedinURL); err != nil {
allProblems = append(allProblems, addFilePathToError(yml.filePath, err))
}
if err := validateContributorStatus(rm.frontmatter.ContributorStatus); err != nil {
allErrs = append(allErrs, addFilePathToError(rm.filePath, err))
if err := validateContributorWebsite(yml.frontmatter.WebsiteURL); err != nil {
allProblems = append(allProblems, addFilePathToError(yml.filePath, err))
}
if err := validateContributorStatus(yml.frontmatter.ContributorStatus); err != nil {
allProblems = append(allProblems, addFilePathToError(yml.filePath, err))
}
for _, err := range validateContributorSupportEmail(rm.frontmatter.SupportEmail) {
allErrs = append(allErrs, addFilePathToError(rm.filePath, err))
for _, err := range validateContributorEmployerGithubUsername(yml.frontmatter.EmployerGithubUsername, yml.frontmatter.GithubUsername) {
allProblems = append(allProblems, addFilePathToError(yml.filePath, err))
}
for _, err := range validateContributorAvatarURL(rm.frontmatter.AvatarURL) {
allErrs = append(allErrs, addFilePathToError(rm.filePath, err))
for _, err := range validateContributorSupportEmail(yml.frontmatter.SupportEmail) {
allProblems = append(allProblems, addFilePathToError(yml.filePath, err))
}
for _, err := range validateContributorAvatarURL(yml.frontmatter.AvatarURL) {
allProblems = append(allProblems, addFilePathToError(yml.filePath, err))
}
return allErrs
return allProblems
}
func parseContributorProfile(rm readme) (contributorProfileReadme, error) {
func parseContributorProfile(rm readme) (contributorProfile, error) {
fm, _, err := separateFrontmatter(rm.rawText)
if err != nil {
return contributorProfileReadme{}, xerrors.Errorf("%q: failed to parse frontmatter: %v", rm.filePath, err)
return contributorProfile{}, fmt.Errorf("%q: failed to parse frontmatter: %v", rm.filePath, err)
}
yml := contributorProfileFrontmatter{}
if err := yaml.Unmarshal([]byte(fm), &yml); err != nil {
return contributorProfileReadme{}, xerrors.Errorf("%q: failed to parse: %v", rm.filePath, err)
return contributorProfile{}, fmt.Errorf("%q: failed to parse: %v", rm.filePath, err)
}
return contributorProfileReadme{
return contributorProfile{
filePath: rm.filePath,
frontmatter: yml,
namespace: strings.TrimSuffix(strings.TrimPrefix(rm.filePath, "registry/"), "/README.md"),
}, nil
}
func parseContributorFiles(readmeEntries []readme) (map[string]contributorProfileReadme, error) {
profilesByNamespace := map[string]contributorProfileReadme{}
func parseContributorFiles(readmeEntries []readme) (map[string]contributorProfile, error) {
profilesByUsername := map[string]contributorProfile{}
yamlParsingErrors := []error{}
for _, rm := range readmeEntries {
p, err := parseContributorProfile(rm)
@@ -198,34 +254,49 @@ func parseContributorFiles(readmeEntries []readme) (map[string]contributorProfil
continue
}
if prev, alreadyExists := profilesByNamespace[p.namespace]; alreadyExists {
yamlParsingErrors = append(yamlParsingErrors, xerrors.Errorf("%q: namespace %q conflicts with namespace from %q", p.filePath, p.namespace, prev.filePath))
if prev, alreadyExists := profilesByUsername[p.frontmatter.GithubUsername]; alreadyExists {
yamlParsingErrors = append(yamlParsingErrors, fmt.Errorf("%q: GitHub name %s conflicts with field defined in %q", p.filePath, p.frontmatter.GithubUsername, prev.filePath))
continue
}
profilesByNamespace[p.namespace] = p
profilesByUsername[p.frontmatter.GithubUsername] = p
}
if len(yamlParsingErrors) != 0 {
return nil, validationPhaseError{
phase: validationPhaseReadme,
phase: validationPhaseReadmeParsing,
errors: yamlParsingErrors,
}
}
employeeGithubGroups := map[string][]string{}
yamlValidationErrors := []error{}
for _, p := range profilesByNamespace {
if errors := validateContributorReadme(p); len(errors) > 0 {
for _, p := range profilesByUsername {
errors := validateContributorProfile(p)
if len(errors) > 0 {
yamlValidationErrors = append(yamlValidationErrors, errors...)
continue
}
if p.frontmatter.EmployerGithubUsername != nil {
employeeGithubGroups[*p.frontmatter.EmployerGithubUsername] = append(
employeeGithubGroups[*p.frontmatter.EmployerGithubUsername],
p.frontmatter.GithubUsername,
)
}
}
for companyName, group := range employeeGithubGroups {
if _, found := profilesByUsername[companyName]; found {
continue
}
yamlValidationErrors = append(yamlValidationErrors, fmt.Errorf("company %q does not exist in %q directory but is referenced by these profiles: [%s]", companyName, rootRegistryPath, strings.Join(group, ", ")))
}
if len(yamlValidationErrors) != 0 {
return nil, validationPhaseError{
phase: validationPhaseReadme,
phase: validationPhaseReadmeValidation,
errors: yamlValidationErrors,
}
}
return profilesByNamespace, nil
return profilesByUsername, nil
}
func aggregateContributorReadmeFiles() ([]readme, error) {
@@ -235,18 +306,17 @@ func aggregateContributorReadmeFiles() ([]readme, error) {
}
allReadmeFiles := []readme{}
errs := []error{}
dirPath := ""
problems := []error{}
for _, e := range dirEntries {
dirPath := path.Join(rootRegistryPath, e.Name())
if !e.IsDir() {
continue
}
dirPath = path.Join(rootRegistryPath, e.Name())
readmePath := path.Join(dirPath, "README.md")
rmBytes, err := os.ReadFile(readmePath)
if err != nil {
errs = append(errs, err)
problems = append(problems, err)
continue
}
allReadmeFiles = append(allReadmeFiles, readme{
@@ -255,71 +325,71 @@ func aggregateContributorReadmeFiles() ([]readme, error) {
})
}
if len(errs) != 0 {
if len(problems) != 0 {
return nil, validationPhaseError{
phase: validationPhaseFile,
errors: errs,
phase: validationPhaseFileLoad,
errors: problems,
}
}
return allReadmeFiles, nil
}
func validateContributorRelativeURLs(contributors map[string]contributorProfileReadme) error {
// This function only validates relative avatar URLs for now, but it can be beefed up to validate more in the future.
var errs []error
func validateContributorRelativeUrls(contributors map[string]contributorProfile) error {
// This function only validates relative avatar URLs for now, but it can be
// beefed up to validate more in the future
problems := []error{}
for _, con := range contributors {
// If the avatar URL is missing, we'll just assume that the Registry site build step will take care of filling
// in the data properly.
// If the avatar URL is missing, we'll just assume that the Registry
// site build step will take care of filling in the data properly
if con.frontmatter.AvatarURL == nil {
continue
}
if !strings.HasPrefix(*con.frontmatter.AvatarURL, ".") || !strings.HasPrefix(*con.frontmatter.AvatarURL, "/") {
if isRelativeURL := strings.HasPrefix(*con.frontmatter.AvatarURL, ".") ||
strings.HasPrefix(*con.frontmatter.AvatarURL, "/"); !isRelativeURL {
continue
}
isAvatarInApprovedSpot := strings.HasPrefix(*con.frontmatter.AvatarURL, "./.images/") ||
strings.HasPrefix(*con.frontmatter.AvatarURL, ".images/")
if !isAvatarInApprovedSpot {
errs = append(errs, xerrors.Errorf("%q: relative avatar URLs cannot be placed outside a user's namespaced directory", con.filePath))
if strings.HasPrefix(*con.frontmatter.AvatarURL, "..") {
problems = append(problems, fmt.Errorf("%q: relative avatar URLs cannot be placed outside a user's namespaced directory", con.filePath))
continue
}
absolutePath := strings.TrimSuffix(con.filePath, "README.md") + *con.frontmatter.AvatarURL
if _, err := os.ReadFile(absolutePath); err != nil {
errs = append(errs, xerrors.Errorf("%q: relative avatar path %q does not point to image in file system", con.filePath, absolutePath))
absolutePath := strings.TrimSuffix(con.filePath, "README.md") +
*con.frontmatter.AvatarURL
_, err := os.ReadFile(absolutePath)
if err != nil {
problems = append(problems, fmt.Errorf("%q: relative avatar path %q does not point to image in file system", con.filePath, *con.frontmatter.AvatarURL))
}
}
if len(errs) == 0 {
if len(problems) == 0 {
return nil
}
return validationPhaseError{
phase: validationPhaseCrossReference,
errors: errs,
phase: validationPhaseAssetCrossReference,
errors: problems,
}
}
func validateAllContributorFiles() error {
func validateAllContributors() error {
allReadmeFiles, err := aggregateContributorReadmeFiles()
if err != nil {
return err
}
logger.Info(context.Background(), "processing README files", "num_files", len(allReadmeFiles))
log.Printf("Processing %d README files\n", len(allReadmeFiles))
contributors, err := parseContributorFiles(allReadmeFiles)
log.Printf("Processed %d README files as valid contributor profiles", len(contributors))
if err != nil {
return err
}
logger.Info(context.Background(), "processed README files as valid contributor profiles", "num_contributors", len(contributors))
if err := validateContributorRelativeURLs(contributors); err != nil {
err = validateContributorRelativeUrls(contributors)
if err != nil {
return err
}
logger.Info(context.Background(), "all relative URLs for READMEs are valid")
log.Println("All relative URLs for READMEs are valid")
log.Printf("Processed all READMEs in the %q directory\n", rootRegistryPath)
logger.Info(context.Background(), "processed all READMEs in directory", "dir", rootRegistryPath)
return nil
}
+8 -10
View File
@@ -1,22 +1,20 @@
package main
import (
"fmt"
import "fmt"
"golang.org/x/xerrors"
)
var _ error = validationPhaseError{}
// validationPhaseError represents an error that occurred during a specific phase of README validation. It should be
// used to collect ALL validation errors that happened during a specific phase, rather than the first one encountered.
// validationPhaseError represents an error that occurred during a specific
// phase of README validation. It should be used to collect ALL validation
// errors that happened during a specific phase, rather than the first one
// encountered.
type validationPhaseError struct {
phase validationPhase
errors []error
}
var _ error = validationPhaseError{}
func (vpe validationPhaseError) Error() string {
msg := fmt.Sprintf("Error during %q phase of README validation:", vpe.phase)
msg := fmt.Sprintf("Error during %q phase of README validation:", vpe.phase.String())
for _, e := range vpe.errors {
msg += fmt.Sprintf("\n- %v", e)
}
@@ -26,5 +24,5 @@ func (vpe validationPhaseError) Error() string {
}
func addFilePathToError(filePath string, err error) error {
return xerrors.Errorf("%q: %v", filePath, err)
return fmt.Errorf("%q: %v", filePath, err)
}
+33
View File
@@ -0,0 +1,33 @@
package main
import (
"fmt"
"os"
)
const actionsActorKey = "CI_ACTOR"
const (
githubAPIBaseURLKey = "GITHUB_API_URL"
githubAPITokenKey = "GITHUB_API_TOKEN"
)
// actionsActor returns the username of the GitHub user who triggered the
// current CI run as part of GitHub Actions. It is expected that this value be
// set using a local .env file in local development, and set via GitHub Actions
// context during CI.
func actionsActor() (string, error) {
username := os.Getenv(actionsActorKey)
if username == "" {
return "", fmt.Errorf("value for %q is not in env. If running from CI, please add value via ci.yaml file", actionsActorKey)
}
return username, nil
}
func githubAPIToken() (string, error) {
token := os.Getenv(githubAPITokenKey)
if token == "" {
return "", fmt.Errorf("value for %q is not in env. If running from CI, please add value via ci.yaml file", githubAPITokenKey)
}
return token, nil
}
+159 -26
View File
@@ -1,47 +1,180 @@
// This package is for validating all contributors within the main Registry
// directory. It validates that it has nothing but sub-directories, and that
// each sub-directory has a README.md file. Each of those files must then
// describe a specific contributor. The contents of these files will be parsed
// by the Registry site build step, to be displayed in the Registry site's UI.
// This package is for validating all the README files present in the Registry
// directory. The expectation is that each contributor, module, and template
// will have an associated README containing useful metadata. This metadata must
// be validated for correct structure during CI, because the files themselves
// are parsed and rendered as UI as part of the Registry site build step (the
// Registry site itself lives in a separate repo).
package main
import (
"context"
"fmt"
"log"
"os"
"sync"
"cdr.dev/slog"
"cdr.dev/slog/sloggers/sloghuman"
"coder.com/coder-registry/cmd/github"
"github.com/joho/godotenv"
)
var logger = slog.Make(sloghuman.Sink(os.Stdout))
func main() {
logger.Info(context.Background(), "starting README validation")
log.Println("Beginning README file validation")
// If there are fundamental problems with how the repo is structured, we can't make any guarantees that any further
// validations will be relevant or accurate.
err := validateRepoStructure()
// Do basic setup
err := godotenv.Load()
if err != nil {
logger.Error(context.Background(), "error when validating the repo structure", "error", err.Error())
os.Exit(1)
log.Panic(err)
}
actorUsername, err := actionsActor()
if err != nil {
log.Panic(err)
}
ghAPIToken, err := githubAPIToken()
if err != nil {
log.Panic(err)
}
var errs []error
err = validateAllContributorFiles()
// Retrieve data necessary from the GitHub API to help determine whether
// certain field changes are allowed
log.Printf("Using GitHub API to determine what fields can be set by user %q\n", actorUsername)
client, err := github.NewClient(github.ClientInit{
BaseURL: os.Getenv(githubAPIBaseURLKey),
APIToken: ghAPIToken,
})
if err != nil {
errs = append(errs, err)
log.Panic(err)
}
err = validateAllCoderResourceFilesOfType("modules")
tokenUser, err := client.GetUserFromToken()
if err != nil {
errs = append(errs, err)
log.Panic(err)
}
tokenUserStatus, err := client.GetUserOrgStatus("coder", tokenUser.Login)
if err != nil {
log.Panic(err)
}
var actorOrgStatus github.OrgStatus
if tokenUserStatus == github.OrgStatusMember {
actorOrgStatus, err = client.GetUserOrgStatus("coder", actorUsername)
if err != nil {
log.Panic(err)
}
} else {
log.Println("Provided API token does not belong to a Coder employee. Some README validation steps will be skipped compared to when they run in CI.")
}
fmt.Printf("Script GitHub actor %q has Coder organization status %q\n", actorUsername, actorOrgStatus.String())
// Start main validation
log.Println("Starting README validation")
// Validate file structure of main README directory. Have to do this
// synchronously and before everything else, or else there's no way to for
// the other main validation functions can't make any safe assumptions
// about where they should look in the repo
log.Println("Validating directory structure of the README directory")
err = validateRepoStructure()
if err != nil {
log.Panic(err)
}
if len(errs) == 0 {
logger.Info(context.Background(), "processed all READMEs in directory", "dir", rootRegistryPath)
os.Exit(0)
// Set up concurrency for validating each category of README file
var readmeValidationErrors []error
errChan := make(chan error, 1)
doneChan := make(chan struct{})
wg := sync.WaitGroup{}
go func() {
for err := range errChan {
readmeValidationErrors = append(readmeValidationErrors, err)
}
close(doneChan)
}()
// Validate contributor README files
wg.Add(1)
go func() {
defer wg.Done()
if err := validateAllContributors(); err != nil {
errChan <- fmt.Errorf("contributor validation: %v", err)
}
}()
// Validate modules
wg.Add(1)
go func() {
defer wg.Done()
moveToOuterScopeLater := func() error {
baseRefReadmeFiles, err := aggregateCoderResourceReadmeFiles("modules")
if err != nil {
return err
}
parsed, err := parseCoderResourceFiles("modules", baseRefReadmeFiles, baseRefReadmeFiles, actorOrgStatus)
if err != nil {
return err
}
fmt.Printf("------ got %d back\n", len(parsed))
// repo, err := git.PlainClone(dummyGitDirectory, true, &git.CloneOptions{
// URL: "https://github.com/coder/registry",
// Auth: &http.BasicAuth{},
// })
// if err != nil {
// return err
// }
// head, err := repo.Head()
// if err != nil {
// return err
// }
// activeBranchName := head.Name().Short()
// tree, err := repo.Worktree()
// if err != nil {
// return err
// }
// err = tree.Checkout(&git.CheckoutOptions{
// Branch: plumbing.NewBranchReferenceName(activeBranchName),
// Create: false,
// Force: false,
// Keep: true,
// })
// if err != nil {
// return err
// }
// files, _ := tree.Filesystem.ReadDir(".")
// for _, f := range files {
// if f.IsDir() {
// fmt.Println(f.Name())
// }
// }
return nil
}
if err := moveToOuterScopeLater(); err != nil {
errChan <- fmt.Errorf("module validation: %v", err)
}
}()
// Validate templates
wg.Add(1)
go func() {
defer wg.Done()
}()
// Clean up and then log errors
wg.Wait()
close(errChan)
<-doneChan
if len(readmeValidationErrors) == 0 {
log.Println("All validation was successful")
return
}
for _, err := range errs {
logger.Error(context.Background(), err.Error())
fmt.Println("---")
log.Println("Encountered the following problems")
for _, err := range readmeValidationErrors {
fmt.Println(err)
}
os.Exit(1)
}
-170
View File
@@ -1,170 +0,0 @@
package main
import (
"bufio"
"fmt"
"regexp"
"strings"
"golang.org/x/xerrors"
)
// validationPhase represents a specific phase during README validation. It is expected that each phase is discrete, and
// errors during one will prevent a future phase from starting.
type validationPhase string
const (
rootRegistryPath = "./registry"
// --- validationPhases ---
// validationPhaseStructure indicates when the entire Registry
// directory is being verified for having all files be placed in the file
// system as expected.
validationPhaseStructure validationPhase = "File structure validation"
// ValidationPhaseFile indicates when README files are being read from
// the file system.
validationPhaseFile validationPhase = "Filesystem reading"
// ValidationPhaseReadme indicates when a README's frontmatter is
// being parsed as YAML. This phase does not include YAML validation.
validationPhaseReadme validationPhase = "README parsing"
// ValidationPhaseCrossReference indicates when a README's frontmatter
// is having all its relative URLs be validated for whether they point to
// valid resources.
validationPhaseCrossReference validationPhase = "Cross-referencing relative asset URLs"
// --- end of validationPhases ---.
)
var (
supportedAvatarFileFormats = []string{".png", ".jpeg", ".jpg", ".gif", ".svg"}
// Matches markdown headers, must be at the beginning of a line, such as "# " or "### ".
readmeHeaderRe = regexp.MustCompile(`^(#+)(\s*)`)
)
// readme represents a single README file within the repo (usually within the top-level "/registry" directory).
type readme struct {
filePath string
rawText string
}
// separateFrontmatter attempts to separate a README file's frontmatter content from the main README body, returning
// both values in that order. It does not validate whether the structure of the frontmatter is valid (i.e., that it's
// structured as YAML).
func separateFrontmatter(readmeText string) (readmeFrontmatter string, readmeBody string, err error) {
if readmeText == "" {
return "", "", xerrors.New("README is empty")
}
const fence = "---"
var fm strings.Builder
var body strings.Builder
fenceCount := 0
lineScanner := bufio.NewScanner(strings.NewReader(strings.TrimSpace(readmeText)))
for lineScanner.Scan() {
nextLine := lineScanner.Text()
if fenceCount < 2 && nextLine == fence {
fenceCount++
continue
}
// Break early if the very first line wasn't a fence, because then we know for certain that the README has problems.
if fenceCount == 0 {
break
}
// It should be safe to trim each line of the frontmatter on a per-line basis, because there shouldn't be any
// extra meaning attached to the indentation. The same does NOT apply to the README; best we can do is gather
// all the lines and then trim around it.
if inReadmeBody := fenceCount >= 2; inReadmeBody {
fmt.Fprintf(&body, "%s\n", nextLine)
} else {
fmt.Fprintf(&fm, "%s\n", strings.TrimSpace(nextLine))
}
}
if fenceCount < 2 {
return "", "", xerrors.New("README does not have two sets of frontmatter fences")
}
if fm.Len() == 0 {
return "", "", xerrors.New("readme has frontmatter fences but no frontmatter content")
}
return fm.String(), strings.TrimSpace(body.String()), nil
}
// TODO: This seems to work okay for now, but the really proper way of doing this is by parsing this as an AST, and then
// checking the resulting nodes.
func validateReadmeBody(body string) []error {
trimmed := strings.TrimSpace(body)
if trimmed == "" {
return []error{xerrors.New("README body is empty")}
}
// If the very first line of the README doesn't start with an ATX-style H1 header, there's a risk that the rest of the
// validation logic will break, since we don't have many guarantees about how the README is actually structured.
if !strings.HasPrefix(trimmed, "# ") {
return []error{xerrors.New("README body must start with ATX-style h1 header (i.e., \"# \")")}
}
var errs []error
latestHeaderLevel := 0
foundFirstH1 := false
isInCodeBlock := false
lineScanner := bufio.NewScanner(strings.NewReader(trimmed))
for lineScanner.Scan() {
nextLine := lineScanner.Text()
// Have to check this because a lot of programming languages support # comments (including Terraform), and
// without any context, there's no way to tell the difference between a markdown header and code comment.
if strings.HasPrefix(nextLine, "```") {
isInCodeBlock = !isInCodeBlock
continue
}
if isInCodeBlock {
continue
}
headerGroups := readmeHeaderRe.FindStringSubmatch(nextLine)
if headerGroups == nil {
continue
}
// In the Markdown spec it is mandatory to have a space following the header # symbol(s).
if headerGroups[2] == "" {
errs = append(errs, xerrors.New("header does not have space between header characters and main header text"))
}
nextHeaderLevel := len(headerGroups[1])
if nextHeaderLevel == 1 && !foundFirstH1 {
foundFirstH1 = true
latestHeaderLevel = 1
continue
}
// If we have obviously invalid headers, it's not really safe to keep proceeding with the rest of the content.
if nextHeaderLevel == 1 {
errs = append(errs, xerrors.New("READMEs cannot contain more than h1 header"))
break
}
if nextHeaderLevel > 6 {
errs = append(errs, xerrors.Errorf("README/HTML files cannot have headers exceed level 6 (found level %d)", nextHeaderLevel))
break
}
// This is something we need to enforce for accessibility, not just for the Registry website, but also when
// users are viewing the README files in the GitHub web view.
if nextHeaderLevel > latestHeaderLevel && nextHeaderLevel != (latestHeaderLevel+1) {
errs = append(errs, xerrors.New("headers are not allowed to increase more than 1 level at a time"))
continue
}
// As long as the above condition passes, there's no problems with going up a header level or going down 1+ header levels.
latestHeaderLevel = nextHeaderLevel
}
return errs
}
+155
View File
@@ -0,0 +1,155 @@
package main
import (
"bufio"
"errors"
"strings"
)
const rootRegistryPath = "./registry"
var supportedAvatarFileFormats = []string{".png", ".jpeg", ".jpg", ".gif", ".svg"}
// Readme represents a single README file within the repo (usually within the
// "/registry" directory).
type readme struct {
filePath string
rawText string
}
// separateFrontmatter attempts to separate a README file's frontmatter content
// from the main README body, returning both values in that order. It does not
// validate whether the structure of the frontmatter is valid (i.e., that it's
// structured as YAML).
func separateFrontmatter(readmeText string) (string, string, error) {
if readmeText == "" {
return "", "", errors.New("README is empty")
}
const fence = "---"
fm := ""
body := ""
fenceCount := 0
lineScanner := bufio.NewScanner(
strings.NewReader(strings.TrimSpace(readmeText)),
)
for lineScanner.Scan() {
nextLine := lineScanner.Text()
if fenceCount < 2 && nextLine == fence {
fenceCount++
continue
}
// Break early if the very first line wasn't a fence, because then we
// know for certain that the README has problems
if fenceCount == 0 {
break
}
// It should be safe to trim each line of the frontmatter on a per-line
// basis, because there shouldn't be any extra meaning attached to the
// indentation. The same does NOT apply to the README; best we can do is
// gather all the lines, and then trim around it
if inReadmeBody := fenceCount >= 2; inReadmeBody {
body += nextLine + "\n"
} else {
fm += strings.TrimSpace(nextLine) + "\n"
}
}
if fenceCount < 2 {
return "", "", errors.New("README does not have two sets of frontmatter fences")
}
if fm == "" {
return "", "", errors.New("readme has frontmatter fences but no frontmatter content")
}
return fm, strings.TrimSpace(body), nil
}
// validationPhase represents a specific phase during README validation. It is
// expected that each phase is discrete, and errors during one will prevent a
// future phase from starting.
type validationPhase int
const (
// validationPhaseFileStructureValidation indicates when the entire Registry
// directory is being verified for having all files be placed in the file
// system as expected.
validationPhaseFileStructureValidation validationPhase = iota
// validationPhaseFileLoad indicates when README files are being read from
// the file system
validationPhaseFileLoad
// validationPhaseReadmeParsing indicates when a README's frontmatter is
// being parsed as YAML. This phase does not include YAML validation.
validationPhaseReadmeParsing
// validationPhaseReadmeValidation indicates when a README's frontmatter is
// being validated as proper YAML with expected keys.
validationPhaseReadmeValidation
// validationPhaseAssetCrossReference indicates when a README's frontmatter
// is having all its relative URLs be validated for whether they point to
// valid resources.
validationPhaseAssetCrossReference
)
func (p validationPhase) String() string {
switch p {
case validationPhaseFileLoad:
return "Filesystem reading"
case validationPhaseReadmeParsing:
return "README parsing"
case validationPhaseReadmeValidation:
return "README validation"
case validationPhaseAssetCrossReference:
return "Cross-referencing asset references"
default:
return "Unknown validation phase"
}
}
type zippedReadmes struct {
old *readme
new *readme
}
// zipReadmes takes two slices of README files, and combines them into a map,
// where each key is a file path, and each value is a struct containing the old
// value for the path, and the new value for the path. If the old value exists
// but the new one doesn't, that indicates that a file has been deleted. If the
// new value exists, but the old one doesn't, that indicates that the file was
// created.
func zipReadmes(prevReadmes []readme, newReadmes []readme) map[string]zippedReadmes {
oldMap := map[string]readme{}
for _, rm := range prevReadmes {
oldMap[rm.filePath] = rm
}
zipped := map[string]zippedReadmes{}
for _, rm := range newReadmes {
old, ok := oldMap[rm.filePath]
if ok {
zipped[rm.filePath] = zippedReadmes{
old: &old,
new: &rm,
}
} else {
zipped[rm.filePath] = zippedReadmes{
old: nil,
new: &rm,
}
}
}
for _, old := range oldMap {
_, ok := zipped[old.filePath]
if !ok {
zipped[old.filePath] = zippedReadmes{
old: &old,
new: nil,
}
}
}
return zipped
}
+114
View File
@@ -0,0 +1,114 @@
package main
import (
"errors"
"fmt"
"os"
"path"
)
func validateCoderResourceSubdirectory(dirPath string) []error {
errs := []error{}
dir, err := os.Stat(dirPath)
if err != nil {
// It's valid for a specific resource directory not to exist. It's just
// that if it does exist, it must follow specific rules
if !errors.Is(err, os.ErrNotExist) {
errs = append(errs, addFilePathToError(dirPath, err))
}
return errs
}
if !dir.IsDir() {
errs = append(errs, fmt.Errorf("%q: path is not a directory", dirPath))
return errs
}
files, err := os.ReadDir(dirPath)
if err != nil {
errs = append(errs, fmt.Errorf("%q: %v", dirPath, err))
return errs
}
for _, f := range files {
if !f.IsDir() {
continue
}
resourceReadmePath := path.Join(dirPath, f.Name(), "README.md")
_, err := os.Stat(resourceReadmePath)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
errs = append(errs, fmt.Errorf("%q: README file does not exist", resourceReadmePath))
} else {
errs = append(errs, addFilePathToError(resourceReadmePath, err))
}
}
mainTerraformPath := path.Join(dirPath, f.Name(), "main.tf")
_, err = os.Stat(mainTerraformPath)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
errs = append(errs, fmt.Errorf("%q: 'main.tf' file does not exist", mainTerraformPath))
} else {
errs = append(errs, addFilePathToError(mainTerraformPath, err))
}
}
}
return errs
}
func validateRegistryDirectory() []error {
dirEntries, err := os.ReadDir(rootRegistryPath)
if err != nil {
return []error{err}
}
problems := []error{}
for _, e := range dirEntries {
dirPath := path.Join(rootRegistryPath, e.Name())
if !e.IsDir() {
problems = append(problems, fmt.Errorf("detected non-directory file %q at base of main Registry directory", dirPath))
continue
}
readmePath := path.Join(dirPath, "README.md")
_, err := os.Stat(readmePath)
if err != nil {
problems = append(problems, err)
}
for _, rType := range supportedResourceTypes {
resourcePath := path.Join(dirPath, rType)
if errs := validateCoderResourceSubdirectory(resourcePath); len(errs) != 0 {
problems = append(problems, errs...)
}
}
}
return problems
}
func validateRepoStructure() error {
var problems []error
if errs := validateRegistryDirectory(); len(errs) != 0 {
problems = append(problems, errs...)
}
_, err := os.Stat("./.icons")
if err != nil {
problems = append(problems, err)
}
// Todo: figure out what other directories we want to make guarantees for
// and add them to this function
if len(problems) != 0 {
return validationPhaseError{
phase: validationPhaseFileStructureValidation,
errors: problems,
}
}
return nil
}
-129
View File
@@ -1,129 +0,0 @@
package main
import (
"errors"
"os"
"path"
"slices"
"strings"
"golang.org/x/xerrors"
)
var supportedUserNameSpaceDirectories = append(supportedResourceTypes, ".icons", ".images")
func validateCoderResourceSubdirectory(dirPath string) []error {
subDir, err := os.Stat(dirPath)
if err != nil {
// It's valid for a specific resource directory not to exist. It's just that if it does exist, it must follow specific rules.
if !errors.Is(err, os.ErrNotExist) {
return []error{addFilePathToError(dirPath, err)}
}
}
if !subDir.IsDir() {
return []error{xerrors.Errorf("%q: path is not a directory", dirPath)}
}
files, err := os.ReadDir(dirPath)
if err != nil {
return []error{addFilePathToError(dirPath, err)}
}
errs := []error{}
for _, f := range files {
// The .coder subdirectories are sometimes generated as part of Bun tests. These subdirectories will never be
// committed to the repo, but in the off chance that they don't get cleaned up properly, we want to skip over them.
if !f.IsDir() || f.Name() == ".coder" {
continue
}
resourceReadmePath := path.Join(dirPath, f.Name(), "README.md")
if _, err := os.Stat(resourceReadmePath); err != nil {
if errors.Is(err, os.ErrNotExist) {
errs = append(errs, xerrors.Errorf("%q: 'README.md' does not exist", resourceReadmePath))
} else {
errs = append(errs, addFilePathToError(resourceReadmePath, err))
}
}
mainTerraformPath := path.Join(dirPath, f.Name(), "main.tf")
if _, err := os.Stat(mainTerraformPath); err != nil {
if errors.Is(err, os.ErrNotExist) {
errs = append(errs, xerrors.Errorf("%q: 'main.tf' file does not exist", mainTerraformPath))
} else {
errs = append(errs, addFilePathToError(mainTerraformPath, err))
}
}
}
return errs
}
func validateRegistryDirectory() []error {
userDirs, err := os.ReadDir(rootRegistryPath)
if err != nil {
return []error{err}
}
allErrs := []error{}
for _, d := range userDirs {
dirPath := path.Join(rootRegistryPath, d.Name())
if !d.IsDir() {
allErrs = append(allErrs, xerrors.Errorf("detected non-directory file %q at base of main Registry directory", dirPath))
continue
}
contributorReadmePath := path.Join(dirPath, "README.md")
if _, err := os.Stat(contributorReadmePath); err != nil {
allErrs = append(allErrs, err)
}
files, err := os.ReadDir(dirPath)
if err != nil {
allErrs = append(allErrs, err)
continue
}
for _, f := range files {
// TODO: Decide if there's anything more formal that we want to ensure about non-directories scoped to user namespaces.
if !f.IsDir() {
continue
}
segment := f.Name()
filePath := path.Join(dirPath, segment)
if !slices.Contains(supportedUserNameSpaceDirectories, segment) {
allErrs = append(allErrs, xerrors.Errorf("%q: only these sub-directories are allowed at top of user namespace: [%s]", filePath, strings.Join(supportedUserNameSpaceDirectories, ", ")))
continue
}
if slices.Contains(supportedResourceTypes, segment) {
if errs := validateCoderResourceSubdirectory(filePath); len(errs) != 0 {
allErrs = append(allErrs, errs...)
}
}
}
}
return allErrs
}
func validateRepoStructure() error {
var errs []error
if vrdErrs := validateRegistryDirectory(); len(vrdErrs) != 0 {
errs = append(errs, vrdErrs...)
}
if _, err := os.Stat("./.icons"); err != nil {
errs = append(errs, xerrors.New("missing top-level .icons directory (used for storing reusable Coder resource icons)"))
}
if len(errs) != 0 {
return validationPhaseError{
phase: validationPhaseStructure,
errors: errs,
}
}
return nil
}
@@ -1,121 +0,0 @@
# Goose
Run the [Goose](https://block.github.io/goose/) agent in your workspace to generate code and perform tasks.
```tf
module "goose" {
source = "registry.coder.com/coder/goose/coder"
version = "1.0.31"
agent_id = coder_agent.example.id
folder = "/home/coder"
install_goose = true
goose_version = "v1.0.16"
}
```
## Prerequisites
- `screen` must be installed in your workspace to run Goose in the background
- You must add the [Coder Login](https://registry.coder.com/modules/coder-login) module to your template
The `codercom/oss-dogfood:latest` container image can be used for testing on container-based workspaces.
## Examples
Your workspace must have `screen` installed to use this.
### Run in the background and report tasks (Experimental)
> This functionality is in early access as of Coder v2.21 and is still evolving.
> For now, we recommend testing it in a demo or staging environment,
> rather than deploying to production
>
> Learn more in [the Coder documentation](https://coder.com/docs/tutorials/ai-agents)
>
> Join our [Discord channel](https://discord.gg/coder) or
> [contact us](https://coder.com/contact) to get help or share feedback.
```tf
module "coder-login" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/coder-login/coder"
version = "1.0.15"
agent_id = coder_agent.example.id
}
variable "anthropic_api_key" {
type = string
description = "The Anthropic API key"
sensitive = true
}
data "coder_parameter" "ai_prompt" {
type = "string"
name = "AI Prompt"
default = ""
description = "Write a prompt for Goose"
mutable = true
}
# Set the prompt and system prompt for Goose via environment variables
resource "coder_agent" "main" {
# ...
env = {
GOOSE_SYSTEM_PROMPT = <<-EOT
You are a helpful assistant that can help write code.
Run all long running tasks (e.g. npm run dev) in the background and not in the foreground.
Periodically check in on background tasks.
Notify Coder of the status of the task before and after your steps.
EOT
GOOSE_TASK_PROMPT = data.coder_parameter.ai_prompt.value
# An API key is required for experiment_auto_configure
# See https://block.github.io/goose/docs/getting-started/providers
ANTHROPIC_API_KEY = var.anthropic_api_key # or use a coder_parameter
}
}
module "goose" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/goose/coder"
version = "1.0.31"
agent_id = coder_agent.example.id
folder = "/home/coder"
install_goose = true
goose_version = "v1.0.16"
# Enable experimental features
experiment_report_tasks = true
# Run Goose in the background
experiment_use_screen = true
# Avoid configuring Goose manually
experiment_auto_configure = true
# Required for experiment_auto_configure
experiment_goose_provider = "anthropic"
experiment_goose_model = "claude-3-5-sonnet-latest"
}
```
## Run standalone
Run Goose as a standalone app in your workspace. This will install Goose and run it directly without using screen or any task reporting to the Coder UI.
```tf
module "goose" {
source = "registry.coder.com/coder/goose/coder"
version = "1.0.31"
agent_id = coder_agent.example.id
folder = "/home/coder"
install_goose = true
goose_version = "v1.0.16"
# Icon is not available in Coder v2.20 and below, so we'll use a custom icon URL
icon = "https://raw.githubusercontent.com/block/goose/refs/heads/main/ui/desktop/src/images/icon.svg"
}
```
+8 -8
View File
@@ -14,8 +14,8 @@ tags: [helper]
```tf
module "MODULE_NAME" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/NAMESPACE/MODULE_NAME/coder"
version = "1.0.0"
source = "registry.coder.com/modules/MODULE_NAME/coder"
version = "1.0.2"
}
```
@@ -30,8 +30,8 @@ Install the Dracula theme from [OpenVSX](https://open-vsx.org/):
```tf
module "MODULE_NAME" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/NAMESPACE/MODULE_NAME/coder"
version = "1.0.0"
source = "registry.coder.com/modules/MODULE_NAME/coder"
version = "1.0.2"
agent_id = coder_agent.example.id
extensions = [
"dracula-theme.theme-dracula"
@@ -48,8 +48,8 @@ Configure VS Code's [settings.json](https://code.visualstudio.com/docs/getstarte
```tf
module "MODULE_NAME" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/NAMESPACE/MODULE_NAME/coder"
version = "1.0.0"
source = "registry.coder.com/modules/MODULE_NAME/coder"
version = "1.0.2"
agent_id = coder_agent.example.id
extensions = ["dracula-theme.theme-dracula"]
settings = {
@@ -64,8 +64,8 @@ Run code-server in the background, don't fetch it from GitHub:
```tf
module "MODULE_NAME" {
source = "registry.coder.com/NAMESPACE/MODULE_NAME/coder"
version = "1.0.0"
source = "registry.coder.com/modules/MODULE_NAME/coder"
version = "1.0.2"
agent_id = coder_agent.example.id
offline = true
}
+2 -1
View File
@@ -4,7 +4,7 @@ terraform {
required_providers {
coder = {
source = "coder/coder"
version = ">= 2.5"
version = ">= 0.17"
}
}
}
@@ -105,3 +105,4 @@ data "coder_parameter" "MODULE_NAME" {
}
}
}
+2 -2
View File
@@ -11,10 +11,10 @@ BOLD='\033[0;1m'
printf "$${BOLD}Installing MODULE_NAME ...\n\n"
# Add code here
# Use variables from the templatefile function in main.tf
# Use varibles from the templatefile function in main.tf
# e.g. LOG_PATH, PORT, etc.
printf "🥳 Installation complete!\n\n"
printf "🥳 Installation comlete!\n\n"
printf "👷 Starting MODULE_NAME in background...\n\n"
# Start the app in here
+22 -19
View File
@@ -2,25 +2,28 @@ module coder.com/coder-registry
go 1.23.2
require (
cdr.dev/slog v1.6.1
github.com/quasilyte/go-ruleguard/dsl v0.3.22
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da
gopkg.in/yaml.v3 v3.0.1
)
require gopkg.in/yaml.v3 v3.0.1
require (
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/lipgloss v0.7.1 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
github.com/mattn/go-runewidth v0.0.15 // indirect
github.com/muesli/reflow v0.3.0 // indirect
github.com/muesli/termenv v0.15.2 // indirect
github.com/rivo/uniseg v0.4.4 // indirect
go.opentelemetry.io/otel v1.16.0 // indirect
go.opentelemetry.io/otel/trace v1.16.0 // indirect
golang.org/x/crypto v0.11.0 // indirect
golang.org/x/sys v0.10.0 // indirect
golang.org/x/term v0.10.0 // indirect
dario.cat/mergo v1.0.0 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/ProtonMail/go-crypto v1.1.6 // indirect
github.com/cloudflare/circl v1.6.1 // indirect
github.com/cyphar/filepath-securejoin v0.4.1 // indirect
github.com/emirpasic/gods v1.18.1 // indirect
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
github.com/go-git/go-billy/v5 v5.6.2 // indirect
github.com/go-git/go-git/v5 v5.16.0 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
github.com/joho/godotenv v1.5.1 // indirect
github.com/kevinburke/ssh_config v1.2.0 // indirect
github.com/pjbgf/sha1cd v0.3.2 // indirect
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
github.com/skeema/knownhosts v1.3.1 // indirect
github.com/xanzy/ssh-agent v0.3.3 // indirect
golang.org/x/crypto v0.37.0 // indirect
golang.org/x/net v0.39.0 // indirect
golang.org/x/sys v0.32.0 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
)
+67 -74
View File
@@ -1,80 +1,73 @@
cdr.dev/slog v1.6.1 h1:IQjWZD0x6//sfv5n+qEhbu3wBkmtBQY5DILXNvMaIv4=
cdr.dev/slog v1.6.1/go.mod h1:eHEYQLaZvxnIAXC+XdTSNLb/kgA/X2RVSF72v5wsxEI=
cloud.google.com/go/compute v1.23.0 h1:tP41Zoavr8ptEqaW6j+LQOnyBBhO7OkOMAGrgLopTwY=
cloud.google.com/go/compute v1.23.0/go.mod h1:4tCnrn48xsqlwSAiLf1HXMQk8CONslYbdiEZc9FEIbM=
cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGBW5aJ7UnBMY=
cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA=
cloud.google.com/go/logging v1.7.0 h1:CJYxlNNNNAMkHp9em/YEXcfJg+rPDg7YfwoRpMU+t5I=
cloud.google.com/go/logging v1.7.0/go.mod h1:3xjP2CjkM3ZkO73aj4ASA5wRPGGCRrPIAeNqVNkzY8M=
cloud.google.com/go/longrunning v0.5.1 h1:Fr7TXftcqTudoyRJa113hyaqlGdiBQkp0Gq7tErFDWI=
cloud.google.com/go/longrunning v0.5.1/go.mod h1:spvimkwdz6SPWKEt/XBij79E9fiTkHSQl/fRUUQJYJc=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/charmbracelet/lipgloss v0.7.1 h1:17WMwi7N1b1rVWOjMT+rCh7sQkvDU75B2hbZpc5Kc1E=
github.com/charmbracelet/lipgloss v0.7.1/go.mod h1:yG0k3giv8Qj8edTCbbg6AlQ5e8KNWpFujkNawKNhE2c=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk=
dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw=
github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE=
github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0=
github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=
github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s=
github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-logr/logr v1.2.4 h1:g01GSCwiDw2xSZfjJ2/T9M+S6pFdcNtFYsp+Y43HYDQ=
github.com/go-logr/logr v1.2.4/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo=
github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM=
github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU=
github.com/go-git/go-git/v5 v5.16.0 h1:k3kuOEpkc0DeY7xlL6NaaNg39xdgQbtH5mwCafHO9AQ=
github.com/go-git/go-git/v5 v5.16.0/go.mod h1:4Ge4alE/5gPs30F2H1esi2gPd69R0C39lolkucHBOp8=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4=
github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4=
github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/quasilyte/go-ruleguard/dsl v0.3.22 h1:wd8zkOhSNr+I+8Qeciml08ivDt1pSXe60+5DqOpCjPE=
github.com/quasilyte/go-ruleguard/dsl v0.3.22/go.mod h1:KeCP03KrjuSO0H1kTuZQCWlQPulDV6YMIXmpQss17rU=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis=
github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY=
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
go.opentelemetry.io/otel v1.16.0 h1:Z7GVAX/UkAXPKsy94IU+i6thsQS4nb7LviLpnaNeW8s=
go.opentelemetry.io/otel v1.16.0/go.mod h1:vl0h9NUa1D5s1nv3A5vZOYWn8av4K8Ml6JDeHrT/bx4=
go.opentelemetry.io/otel/metric v1.16.0 h1:RbrpwVG1Hfv85LgnZ7+txXioPDoh6EdbZHo26Q3hqOo=
go.opentelemetry.io/otel/metric v1.16.0/go.mod h1:QE47cpOmkwipPiefDwo2wDzwJrlfxxNYodqc4xnGCo4=
go.opentelemetry.io/otel/sdk v1.16.0 h1:Z1Ok1YsijYL0CSJpHt4cS3wDDh7p572grzNrBMiMWgE=
go.opentelemetry.io/otel/sdk v1.16.0/go.mod h1:tMsIuKXuuIWPBAOrH+eHtvhTL+SntFtXF9QD68aP6p4=
go.opentelemetry.io/otel/trace v1.16.0 h1:8JRpaObFoW0pxuVPapkgH8UhHQj+bJW8jJsCZEu5MQs=
go.opentelemetry.io/otel/trace v1.16.0/go.mod h1:Yt9vYq1SdNz3xdjZZK7wcXv1qv2pwLkqr2QVwea0ef0=
golang.org/x/crypto v0.11.0 h1:6Ewdq3tDic1mg5xRO4milcWCfMVQhI4NkqWWvqejpuA=
golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio=
golang.org/x/net v0.12.0 h1:cfawfvKITfUsFCeJIHJrbSxpeu/E81khclypR0GVT50=
golang.org/x/net v0.12.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.10.0 h1:SqMFp9UcQJZa+pmYuAKjd9xq1f0j5rLcDIk0mj4qAsA=
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.10.0 h1:3R7pNqamzBraeqj/Tj8qt1aQ2HpmlC+Cx/qL/7hn4/c=
golang.org/x/term v0.10.0/go.mod h1:lpqdcUyK/oCiQxvxVrppt5ggO2KCZ5QblwqPnfZ6d5o=
golang.org/x/text v0.11.0 h1:LAntKIrcmeSKERyiOh0XMV39LXS8IE9UL2yP7+f5ij4=
golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY=
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
google.golang.org/genproto v0.0.0-20230726155614-23370e0ffb3e h1:xIXmWJ303kJCuogpj0bHq+dcjcZHU+XFyc1I0Yl9cRg=
google.golang.org/genproto v0.0.0-20230726155614-23370e0ffb3e/go.mod h1:0ggbjUrZYpy1q+ANUS30SEoGZ53cdfwtbuG7Ptgy108=
google.golang.org/genproto/googleapis/api v0.0.0-20230706204954-ccb25ca9f130 h1:XVeBY8d/FaK4848myy41HBqnDwvxeV3zMZhwN1TvAMU=
google.golang.org/genproto/googleapis/api v0.0.0-20230706204954-ccb25ca9f130/go.mod h1:mPBs5jNgx2GuQGvFwUvVKqtn6HsUw9nP64BedgvqEsQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20230706204954-ccb25ca9f130 h1:2FZP5XuJY9zQyGM5N0rtovnoXjiMUEIUMvw0m9wlpLc=
google.golang.org/genproto/googleapis/rpc v0.0.0-20230706204954-ccb25ca9f130/go.mod h1:8mL13HKkDa+IuJ8yruA3ci0q+0vsUz4m//+ottjwS5o=
google.golang.org/grpc v1.57.0 h1:kfzNeI/klCGD2YPMUlaGNT3pxvYfga7smW3Vth8Zsiw=
google.golang.org/grpc v1.57.0/go.mod h1:Sd+9RMTACXwmub0zcNY2c4arhtrbBYD1AUHI/dt16Mo=
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8=
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8=
github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

+3 -4
View File
@@ -1,10 +1,9 @@
{
"name": "registry",
"name": "modules",
"scripts": {
"fmt": "bun x prettier --write **/*.sh **/*.ts **/*.md *.md && terraform fmt -recursive -diff",
"fmt:ci": "bun x prettier --check **/*.sh **/*.ts **/*.md *.md && terraform fmt -check -recursive -diff",
"terraform-validate": "./scripts/terraform_validate.sh",
"test": "bun test",
"fmt": "bun x prettier -w **/*.sh .sample/run.sh new.sh **/*.ts **/*.md *.md && terraform fmt **/*.tf .sample/main.tf",
"fmt:ci": "bun x prettier --check **/*.sh .sample/run.sh new.sh **/*.ts **/*.md *.md && terraform fmt -check **/*.tf .sample/main.tf",
"update-version": "./update-version.sh"
},
"devDependencies": {
Binary file not shown.

After

Width:  |  Height:  |  Size: 603 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 3.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 113 KiB

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 187 KiB

After

Width:  |  Height:  |  Size: 308 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 414 KiB

After

Width:  |  Height:  |  Size: 654 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 428 KiB

After

Width:  |  Height:  |  Size: 526 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 117 KiB

After

Width:  |  Height:  |  Size: 205 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 155 KiB

+1 -1
View File
@@ -2,9 +2,9 @@
display_name: Coder
bio: Coder provisions cloud development environments via Terraform, supporting Linux, macOS, Windows, X86, ARM, Kubernetes and more.
github: coder
avatar: ./.images/avatar.png
linkedin: https://www.linkedin.com/company/coderhq
website: https://www.coder.com
support_email: support@coder.com
status: official
---
-315
View File
@@ -1,315 +0,0 @@
---
display_name: Aider
description: Run Aider AI pair programming in your workspace
icon: ../../../../.icons/aider.svg
maintainer_github: coder
verified: true
tags: [agent, ai, aider]
---
# Aider
Run [Aider](https://aider.chat) AI pair programming in your workspace. This module installs Aider and provides a persistent session using screen or tmux.
```tf
module "aider" {
source = "registry.coder.com/coder/aider/coder"
version = "1.1.0"
agent_id = coder_agent.example.id
}
```
## Features
- **Interactive Parameter Selection**: Choose your AI provider, model, and configuration options when creating the workspace
- **Multiple AI Providers**: Supports Anthropic (Claude), OpenAI, DeepSeek, GROQ, and OpenRouter
- **Persistent Sessions**: Uses screen (default) or tmux to keep Aider running in the background
- **Optional Dependencies**: Install Playwright for web page scraping and PortAudio for voice coding
- **Project Integration**: Works with any project directory, including Git repositories
- **Browser UI**: Use Aider in your browser with a modern web interface instead of the terminal
- **Non-Interactive Mode**: Automatically processes tasks when provided via the `task_prompt` variable
## Module Parameters
| Parameter | Description | Type | Default |
| ---------------------------------- | -------------------------------------------------------------------------- | -------- | ------------------- |
| `agent_id` | The ID of a Coder agent (required) | `string` | - |
| `folder` | The folder to run Aider in | `string` | `/home/coder` |
| `install_aider` | Whether to install Aider | `bool` | `true` |
| `aider_version` | The version of Aider to install | `string` | `"latest"` |
| `use_screen` | Whether to use screen for running Aider in the background | `bool` | `true` |
| `use_tmux` | Whether to use tmux instead of screen for running Aider in the background | `bool` | `false` |
| `session_name` | Name for the persistent session (screen or tmux) | `string` | `"aider"` |
| `order` | Position of the app in the UI presentation | `number` | `null` |
| `icon` | The icon to use for the app | `string` | `"/icon/aider.svg"` |
| `experiment_report_tasks` | Whether to enable task reporting | `bool` | `true` |
| `system_prompt` | System prompt for instructing Aider on task reporting and behavior | `string` | See default in code |
| `task_prompt` | Task prompt to use with Aider | `string` | `""` |
| `ai_provider` | AI provider to use with Aider (openai, anthropic, azure, etc.) | `string` | `"anthropic"` |
| `ai_model` | AI model to use (can use Aider's built-in aliases like "sonnet", "4o") | `string` | `"sonnet"` |
| `ai_api_key` | API key for the selected AI provider | `string` | `""` |
| `custom_env_var_name` | Custom environment variable name when using custom provider | `string` | `""` |
| `experiment_pre_install_script` | Custom script to run before installing Aider | `string` | `null` |
| `experiment_post_install_script` | Custom script to run after installing Aider | `string` | `null` |
| `experiment_additional_extensions` | Additional extensions configuration in YAML format to append to the config | `string` | `null` |
> **Note**: `use_screen` and `use_tmux` cannot both be enabled at the same time. By default, `use_screen` is set to `true` and `use_tmux` is set to `false`.
## Usage Examples
### Basic setup with API key
```tf
variable "anthropic_api_key" {
type = string
description = "Anthropic API key"
sensitive = true
}
module "aider" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/aider/coder"
version = "1.1.0"
agent_id = coder_agent.example.id
ai_api_key = var.anthropic_api_key
}
```
This basic setup will:
- Install Aider in the workspace
- Create a persistent screen session named "aider"
- Configure Aider to use Anthropic Claude 3.7 Sonnet model
- Enable task reporting (configures Aider to report tasks to Coder MCP)
### Using OpenAI with tmux
```tf
variable "openai_api_key" {
type = string
description = "OpenAI API key"
sensitive = true
}
module "aider" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/aider/coder"
version = "1.1.0"
agent_id = coder_agent.example.id
use_tmux = true
ai_provider = "openai"
ai_model = "4o" # Uses Aider's built-in alias for gpt-4o
ai_api_key = var.openai_api_key
}
```
### Using a custom provider
```tf
variable "custom_api_key" {
type = string
description = "Custom provider API key"
sensitive = true
}
module "aider" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/aider/coder"
version = "1.1.0"
agent_id = coder_agent.example.id
ai_provider = "custom"
custom_env_var_name = "MY_CUSTOM_API_KEY"
ai_model = "custom-model"
ai_api_key = var.custom_api_key
}
```
### Adding Custom Extensions (Experimental)
You can extend Aider's capabilities by adding custom extensions:
```tf
module "aider" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/aider/coder"
version = "1.1.0"
agent_id = coder_agent.example.id
ai_api_key = var.anthropic_api_key
experiment_pre_install_script = <<-EOT
pip install some-custom-dependency
EOT
experiment_additional_extensions = <<-EOT
custom-extension:
args: []
cmd: custom-extension-command
description: A custom extension for Aider
enabled: true
envs: {}
name: custom-extension
timeout: 300
type: stdio
EOT
}
```
Note: The indentation in the heredoc is preserved, so you can write the YAML naturally.
## Task Reporting (Experimental)
> This functionality is in early access as of Coder v2.21 and is still evolving.
> For now, we recommend testing it in a demo or staging environment,
> rather than deploying to production
>
> Learn more in [the Coder documentation](https://coder.com/docs/tutorials/ai-agents)
>
> Join our [Discord channel](https://discord.gg/coder) or
> [contact us](https://coder.com/contact) to get help or share feedback.
Your workspace must have either `screen` or `tmux` installed to use this.
Task reporting is **enabled by default** in this module, allowing you to:
- Send an initial prompt to Aider during workspace creation
- Monitor task progress in the Coder UI
- Use the `coder_parameter` resource to collect prompts from users
### Setting up Task Reporting
To use task reporting effectively:
1. Add the Coder Login module to your template
2. Configure the necessary variables to pass the task prompt
3. Optionally add a coder_parameter to collect prompts from users
Here's a complete example:
```tf
module "coder-login" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/coder-login/coder"
version = "1.0.15"
agent_id = coder_agent.example.id
}
variable "anthropic_api_key" {
type = string
description = "Anthropic API key"
sensitive = true
}
data "coder_parameter" "ai_prompt" {
type = "string"
name = "AI Prompt"
default = ""
description = "Write a prompt for Aider"
mutable = true
ephemeral = true
}
module "aider" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/aider/coder"
version = "1.1.0"
agent_id = coder_agent.example.id
ai_api_key = var.anthropic_api_key
task_prompt = data.coder_parameter.ai_prompt.value
# Optionally customize the system prompt
system_prompt = <<-EOT
You are a helpful Coding assistant. Aim to autonomously investigate
and solve issues the user gives you and test your work, whenever possible.
Avoid shortcuts like mocking tests. When you get stuck, you can ask the user
but opt for autonomy.
YOU MUST REPORT ALL TASKS TO CODER.
When reporting tasks, you MUST follow these EXACT instructions:
- IMMEDIATELY report status after receiving ANY user message.
- Be granular. If you are investigating with multiple steps, report each step to coder.
Task state MUST be one of the following:
- Use "state": "working" when actively processing WITHOUT needing additional user input.
- Use "state": "complete" only when finished with a task.
- Use "state": "failure" when you need ANY user input, lack sufficient details, or encounter blockers.
Task summaries MUST:
- Include specifics about what you're doing.
- Include clear and actionable steps for the user.
- Be less than 160 characters in length.
EOT
}
```
When a task prompt is provided via the `task_prompt` variable, the module automatically:
1. Combines the system prompt with the task prompt into a single message in the format:
```
SYSTEM PROMPT:
[system_prompt content]
This is your current task: [task_prompt]
```
2. Executes the task during workspace creation using the `--message` and `--yes-always` flags
3. Logs task output to `$HOME/.aider.log` for reference
If you want to disable task reporting, set `experiment_report_tasks = false` in your module configuration.
## Using Aider in Your Workspace
After the workspace starts, Aider will be installed and configured according to your parameters. A persistent session will automatically be started during workspace creation.
### Session Options
You can run Aider in three different ways:
1. **Direct Mode**: Aider starts directly in the specified folder when you click the app button
- Simple setup without persistent context
- Suitable for quick coding sessions
2. **Screen Mode** (Default): Run Aider in a screen session that persists across connections
- Session name: "aider" (or configured via `session_name`)
3. **Tmux Mode**: Run Aider in a tmux session instead of screen
- Set `use_tmux = true` to enable
- Session name: "aider" (or configured via `session_name`)
- Configures tmux with mouse support for shared sessions
Persistent sessions (screen/tmux) allow you to:
- Disconnect and reconnect without losing context
- Run Aider in the background while doing other work
- Switch between terminal and browser interfaces
### Available AI Providers and Models
Aider supports various providers and models, and this module integrates directly with Aider's built-in model aliases:
| Provider | Example Models/Aliases | Default Model |
| ------------- | --------------------------------------------- | ---------------------- |
| **anthropic** | "sonnet" (Claude 3.7 Sonnet), "opus", "haiku" | "sonnet" |
| **openai** | "4o" (GPT-4o), "4" (GPT-4), "3.5-turbo" | "4o" |
| **azure** | Azure OpenAI models | "gpt-4" |
| **google** | "gemini" (Gemini Pro), "gemini-2.5-pro" | "gemini-2.5-pro" |
| **cohere** | "command-r-plus", etc. | "command-r-plus" |
| **mistral** | "mistral-large-latest" | "mistral-large-latest" |
| **ollama** | "llama3", etc. | "llama3" |
| **custom** | Any model name with custom ENV variable | - |
For a complete and up-to-date list of supported aliases and models, please refer to the [Aider LLM documentation](https://aider.chat/docs/llms.html) and the [Aider LLM Leaderboards](https://aider.chat/docs/leaderboards.html) which show performance comparisons across different models.
## Troubleshooting
If you encounter issues:
1. **Screen/Tmux issues**: If you can't reconnect to your session, check if the session exists with `screen -list` or `tmux list-sessions`
2. **API key issues**: Ensure you've entered the correct API key for your selected provider
3. **Browser mode issues**: If the browser interface doesn't open, check that you're accessing it from a machine that can reach your Coder workspace
For more information on using Aider, see the [Aider documentation](https://aider.chat/docs/).
```
```
-107
View File
@@ -1,107 +0,0 @@
import { describe, expect, it } from "bun:test";
import {
findResourceInstance,
runTerraformApply,
runTerraformInit,
testRequiredVariables,
} from "~test";
describe("aider", async () => {
await runTerraformInit(import.meta.dir);
testRequiredVariables(import.meta.dir, {
agent_id: "foo",
});
it("configures task prompt correctly", async () => {
const testPrompt = "Add a hello world function";
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
task_prompt: testPrompt,
});
const instance = findResourceInstance(state, "coder_script");
expect(instance.script).toContain(
`This is your current task: ${testPrompt}`,
);
expect(instance.script).toContain("aider --architect --yes-always");
});
it("handles custom system prompt", async () => {
const customPrompt = "Report all tasks with state: working";
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
system_prompt: customPrompt,
});
const instance = findResourceInstance(state, "coder_script");
expect(instance.script).toContain(customPrompt);
});
it("handles pre and post install scripts", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
experiment_pre_install_script: "echo 'Pre-install script executed'",
experiment_post_install_script: "echo 'Post-install script executed'",
});
const instance = findResourceInstance(state, "coder_script");
expect(instance.script).toContain("Running pre-install script");
expect(instance.script).toContain("Running post-install script");
expect(instance.script).toContain("base64 -d > /tmp/pre_install.sh");
expect(instance.script).toContain("base64 -d > /tmp/post_install.sh");
});
it("validates that use_screen and use_tmux cannot both be true", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
use_screen: true,
use_tmux: true,
});
const instance = findResourceInstance(state, "coder_script");
expect(instance.script).toContain(
"Error: Both use_screen and use_tmux cannot be enabled at the same time",
);
expect(instance.script).toContain("exit 1");
});
it("configures Aider with known provider and model", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
ai_provider: "anthropic",
ai_model: "sonnet",
ai_api_key: "test-anthropic-key",
});
const instance = findResourceInstance(state, "coder_script");
expect(instance.script).toContain(
'export ANTHROPIC_API_KEY=\\"test-anthropic-key\\"',
);
expect(instance.script).toContain("--model sonnet");
expect(instance.script).toContain(
"Starting Aider using anthropic provider and model: sonnet",
);
});
it("handles custom provider with custom env var and API key", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
ai_provider: "custom",
custom_env_var_name: "MY_CUSTOM_API_KEY",
ai_model: "custom-model",
ai_api_key: "test-custom-key",
});
const instance = findResourceInstance(state, "coder_script");
expect(instance.script).toContain(
'export MY_CUSTOM_API_KEY=\\"test-custom-key\\"',
);
expect(instance.script).toContain("--model custom-model");
expect(instance.script).toContain(
"Starting Aider using custom provider and model: custom-model",
);
});
});
-513
View File
@@ -1,513 +0,0 @@
terraform {
required_version = ">= 1.0"
required_providers {
coder = {
source = "coder/coder"
version = ">= 2.5"
}
}
}
variable "agent_id" {
type = string
description = "The ID of a Coder agent."
}
data "coder_workspace" "me" {}
data "coder_workspace_owner" "me" {}
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)."
default = null
}
variable "group" {
type = string
description = "The name of a group that this app belongs to."
default = null
}
variable "icon" {
type = string
description = "The icon to use for the app."
default = "/icon/aider.svg"
}
variable "folder" {
type = string
description = "The folder to run Aider in."
default = "/home/coder"
}
variable "install_aider" {
type = bool
description = "Whether to install Aider."
default = true
}
variable "aider_version" {
type = string
description = "The version of Aider to install."
default = "latest"
}
variable "use_screen" {
type = bool
description = "Whether to use screen for running Aider in the background"
default = true
}
variable "use_tmux" {
type = bool
description = "Whether to use tmux instead of screen for running Aider in the background"
default = false
}
variable "session_name" {
type = string
description = "Name for the persistent session (screen or tmux)"
default = "aider"
}
variable "experiment_report_tasks" {
type = bool
description = "Whether to enable task reporting."
default = true
}
variable "system_prompt" {
type = string
description = "System prompt for instructing Aider on task reporting and behavior"
default = <<-EOT
You are a helpful Coding assistant. Aim to autonomously investigate
and solve issues the user gives you and test your work, whenever possible.
Avoid shortcuts like mocking tests. When you get stuck, you can ask the user
but opt for autonomy.
YOU MUST REPORT ALL TASKS TO CODER.
When reporting tasks, you MUST follow these EXACT instructions:
- IMMEDIATELY report status after receiving ANY user message.
- Be granular. If you are investigating with multiple steps, report each step to coder.
Task state MUST be one of the following:
- Use "state": "working" when actively processing WITHOUT needing additional user input.
- Use "state": "complete" only when finished with a task.
- Use "state": "failure" when you need ANY user input, lack sufficient details, or encounter blockers.
Task summaries MUST:
- Include specifics about what you're doing.
- Include clear and actionable steps for the user.
- Be less than 160 characters in length.
EOT
}
variable "task_prompt" {
type = string
description = "Task prompt to use with Aider"
default = ""
}
variable "experiment_pre_install_script" {
type = string
description = "Custom script to run before installing Aider."
default = null
}
variable "experiment_post_install_script" {
type = string
description = "Custom script to run after installing Aider."
default = null
}
variable "experiment_additional_extensions" {
type = string
description = "Additional extensions configuration in YAML format to append to the config."
default = null
}
variable "ai_provider" {
type = string
description = "AI provider to use with Aider (openai, anthropic, azure, google, etc.)"
default = "anthropic"
validation {
condition = contains(["openai", "anthropic", "azure", "google", "cohere", "mistral", "ollama", "custom"], var.ai_provider)
error_message = "ai_provider must be one of: openai, anthropic, azure, google, cohere, mistral, ollama, custom"
}
}
variable "ai_model" {
type = string
description = "AI model to use with Aider. Can use Aider's built-in aliases like '4o' (gpt-4o), 'sonnet' (claude-3-7-sonnet), 'opus' (claude-3-opus), etc."
default = "sonnet"
}
variable "ai_api_key" {
type = string
description = "API key for the selected AI provider. This will be set as the appropriate environment variable based on the provider."
default = ""
sensitive = true
}
variable "custom_env_var_name" {
type = string
description = "Custom environment variable name when using custom provider"
default = ""
}
locals {
base_extensions = <<-EOT
coder:
args:
- exp
- mcp
- server
cmd: coder
description: Report ALL tasks and statuses (in progress, done, failed) you are working on.
enabled: true
envs:
CODER_MCP_APP_STATUS_SLUG: aider
name: Coder
timeout: 3000
type: stdio
developer:
display_name: Developer
enabled: true
name: developer
timeout: 300
type: builtin
EOT
formatted_base = " ${replace(trimspace(local.base_extensions), "\n", "\n ")}"
additional_extensions = var.experiment_additional_extensions != null ? "\n ${replace(trimspace(var.experiment_additional_extensions), "\n", "\n ")}" : ""
combined_extensions = <<-EOT
extensions:
${local.formatted_base}${local.additional_extensions}
EOT
encoded_pre_install_script = var.experiment_pre_install_script != null ? base64encode(var.experiment_pre_install_script) : ""
encoded_post_install_script = var.experiment_post_install_script != null ? base64encode(var.experiment_post_install_script) : ""
# Combine system prompt and task prompt for aider
combined_prompt = trimspace(<<-EOT
SYSTEM PROMPT:
${var.system_prompt}
This is your current task: ${var.task_prompt}
EOT
)
# Map providers to their environment variable names
provider_env_vars = {
openai = "OPENAI_API_KEY"
anthropic = "ANTHROPIC_API_KEY"
azure = "AZURE_OPENAI_API_KEY"
google = "GOOGLE_API_KEY"
cohere = "COHERE_API_KEY"
mistral = "MISTRAL_API_KEY"
ollama = "OLLAMA_HOST"
custom = var.custom_env_var_name
}
# Get the environment variable name for selected provider
env_var_name = local.provider_env_vars[var.ai_provider]
# Model flag for aider command
model_flag = var.ai_provider == "ollama" ? "--ollama-model" : "--model"
}
# Set environment variable for AI provider API key
resource "coder_env" "ai_api_key" {
agent_id = var.agent_id
name = local.env_var_name
value = var.ai_api_key
}
# Install and Initialize Aider
resource "coder_script" "aider" {
agent_id = var.agent_id
display_name = "Aider"
icon = var.icon
script = <<-EOT
#!/bin/bash
set -e
command_exists() {
command -v "$1" >/dev/null 2>&1
}
echo "Setting up Aider AI pair programming..."
if [ "${var.use_screen}" = "true" ] && [ "${var.use_tmux}" = "true" ]; then
echo "Error: Both use_screen and use_tmux cannot be enabled at the same time."
exit 1
fi
mkdir -p "${var.folder}"
if [ "$(uname)" = "Linux" ]; then
echo "Checking dependencies for Linux..."
if [ "${var.use_tmux}" = "true" ]; then
if ! command_exists tmux; then
echo "Installing tmux for persistent sessions..."
if command -v apt-get >/dev/null 2>&1; then
if command -v sudo >/dev/null 2>&1; then
sudo apt-get update -qq
sudo apt-get install -y -qq tmux
else
apt-get update -qq || echo "Warning: Cannot update package lists without sudo privileges"
apt-get install -y -qq tmux || echo "Warning: Cannot install tmux without sudo privileges"
fi
elif command -v dnf >/dev/null 2>&1; then
if command -v sudo >/dev/null 2>&1; then
sudo dnf install -y -q tmux
else
dnf install -y -q tmux || echo "Warning: Cannot install tmux without sudo privileges"
fi
else
echo "Warning: Unable to install tmux on this system. Neither apt-get nor dnf found."
fi
else
echo "tmux is already installed, skipping installation."
fi
elif [ "${var.use_screen}" = "true" ]; then
if ! command_exists screen; then
echo "Installing screen for persistent sessions..."
if command -v apt-get >/dev/null 2>&1; then
if command -v sudo >/dev/null 2>&1; then
sudo apt-get update -qq
sudo apt-get install -y -qq screen
else
apt-get update -qq || echo "Warning: Cannot update package lists without sudo privileges"
apt-get install -y -qq screen || echo "Warning: Cannot install screen without sudo privileges"
fi
elif command -v dnf >/dev/null 2>&1; then
if command -v sudo >/dev/null 2>&1; then
sudo dnf install -y -q screen
else
dnf install -y -q screen || echo "Warning: Cannot install screen without sudo privileges"
fi
else
echo "Warning: Unable to install screen on this system. Neither apt-get nor dnf found."
fi
else
echo "screen is already installed, skipping installation."
fi
fi
else
echo "This module currently only supports Linux workspaces."
exit 1
fi
if [ -n "${local.encoded_pre_install_script}" ]; then
echo "Running pre-install script..."
echo "${local.encoded_pre_install_script}" | base64 -d > /tmp/pre_install.sh
chmod +x /tmp/pre_install.sh
/tmp/pre_install.sh
fi
if [ "${var.install_aider}" = "true" ]; then
echo "Installing Aider..."
if ! command_exists python3 || ! command_exists pip3; then
echo "Installing Python dependencies required for Aider..."
if command -v apt-get >/dev/null 2>&1; then
if command -v sudo >/dev/null 2>&1; then
sudo apt-get update -qq
sudo apt-get install -y -qq python3-pip python3-venv
else
apt-get update -qq || echo "Warning: Cannot update package lists without sudo privileges"
apt-get install -y -qq python3-pip python3-venv || echo "Warning: Cannot install Python packages without sudo privileges"
fi
elif command -v dnf >/dev/null 2>&1; then
if command -v sudo >/dev/null 2>&1; then
sudo dnf install -y -q python3-pip python3-virtualenv
else
dnf install -y -q python3-pip python3-virtualenv || echo "Warning: Cannot install Python packages without sudo privileges"
fi
else
echo "Warning: Unable to install Python on this system. Neither apt-get nor dnf found."
fi
else
echo "Python is already installed, skipping installation."
fi
if ! command_exists aider; then
curl -LsSf https://aider.chat/install.sh | sh
fi
if [ -f "$HOME/.bashrc" ]; then
if ! grep -q 'export PATH="$HOME/bin:$PATH"' "$HOME/.bashrc"; then
echo 'export PATH="$HOME/bin:$PATH"' >> "$HOME/.bashrc"
fi
fi
if [ -f "$HOME/.zshrc" ]; then
if ! grep -q 'export PATH="$HOME/bin:$PATH"' "$HOME/.zshrc"; then
echo 'export PATH="$HOME/bin:$PATH"' >> "$HOME/.zshrc"
fi
fi
fi
if [ -n "${local.encoded_post_install_script}" ]; then
echo "Running post-install script..."
echo "${local.encoded_post_install_script}" | base64 -d > /tmp/post_install.sh
chmod +x /tmp/post_install.sh
/tmp/post_install.sh
fi
if [ "${var.experiment_report_tasks}" = "true" ]; then
echo "Configuring Aider to report tasks via Coder MCP..."
mkdir -p "$HOME/.config/aider"
cat > "$HOME/.config/aider/config.yml" << EOL
${trimspace(local.combined_extensions)}
EOL
echo "Added Coder MCP extension to Aider config.yml"
fi
echo "Starting persistent Aider session..."
touch "$HOME/.aider.log"
export LANG=en_US.UTF-8
export LC_ALL=en_US.UTF-8
export PATH="$HOME/bin:$PATH"
if [ "${var.use_tmux}" = "true" ]; then
if [ -n "${var.task_prompt}" ]; then
echo "Running Aider with message in tmux session..."
# Configure tmux for shared sessions
if [ ! -f "$HOME/.tmux.conf" ]; then
echo "Creating ~/.tmux.conf with shared session settings..."
echo "set -g mouse on" > "$HOME/.tmux.conf"
fi
if ! grep -q "^set -g mouse on$" "$HOME/.tmux.conf"; then
echo "Adding 'set -g mouse on' to ~/.tmux.conf..."
echo "set -g mouse on" >> "$HOME/.tmux.conf"
fi
echo "Starting Aider using ${var.ai_provider} provider and model: ${var.ai_model}"
tmux new-session -d -s ${var.session_name} -c ${var.folder} "aider --architect --yes-always ${local.model_flag} ${var.ai_model} --message \"${local.combined_prompt}\""
echo "Aider task started in tmux session '${var.session_name}'. Check the UI for progress."
else
# Configure tmux for shared sessions
if [ ! -f "$HOME/.tmux.conf" ]; then
echo "Creating ~/.tmux.conf with shared session settings..."
echo "set -g mouse on" > "$HOME/.tmux.conf"
fi
if ! grep -q "^set -g mouse on$" "$HOME/.tmux.conf"; then
echo "Adding 'set -g mouse on' to ~/.tmux.conf..."
echo "set -g mouse on" >> "$HOME/.tmux.conf"
fi
echo "Starting Aider using ${var.ai_provider} provider and model: ${var.ai_model}"
tmux new-session -d -s ${var.session_name} -c ${var.folder} "aider --architect --yes-always ${local.model_flag} ${var.ai_model} --message \"${var.system_prompt}\""
echo "Tmux session '${var.session_name}' started. Access it by clicking the Aider button."
fi
else
if [ -n "${var.task_prompt}" ]; then
echo "Running Aider with message in screen session..."
if [ ! -f "$HOME/.screenrc" ]; then
echo "Creating ~/.screenrc and adding multiuser settings..."
echo -e "multiuser on\nacladd $(whoami)" > "$HOME/.screenrc"
fi
if ! grep -q "^multiuser on$" "$HOME/.screenrc"; then
echo "Adding 'multiuser on' to ~/.screenrc..."
echo "multiuser on" >> "$HOME/.screenrc"
fi
if ! grep -q "^acladd $(whoami)$" "$HOME/.screenrc"; then
echo "Adding 'acladd $(whoami)' to ~/.screenrc..."
echo "acladd $(whoami)" >> "$HOME/.screenrc"
fi
echo "Starting Aider using ${var.ai_provider} provider and model: ${var.ai_model}"
screen -U -dmS ${var.session_name} bash -c "
cd ${var.folder}
export PATH=\"$HOME/bin:$HOME/.local/bin:$PATH\"
aider --architect --yes-always ${local.model_flag} ${var.ai_model} --message \"${local.combined_prompt}\"
/bin/bash
"
echo "Aider task started in screen session '${var.session_name}'. Check the UI for progress."
else
if [ ! -f "$HOME/.screenrc" ]; then
echo "Creating ~/.screenrc and adding multiuser settings..."
echo -e "multiuser on\nacladd $(whoami)" > "$HOME/.screenrc"
fi
if ! grep -q "^multiuser on$" "$HOME/.screenrc"; then
echo "Adding 'multiuser on' to ~/.screenrc..."
echo "multiuser on" >> "$HOME/.screenrc"
fi
if ! grep -q "^acladd $(whoami)$" "$HOME/.screenrc"; then
echo "Adding 'acladd $(whoami)' to ~/.screenrc..."
echo "acladd $(whoami)" >> "$HOME/.screenrc"
fi
echo "Starting Aider using ${var.ai_provider} provider and model: ${var.ai_model}"
screen -U -dmS ${var.session_name} bash -c "
cd ${var.folder}
export PATH=\"$HOME/bin:$HOME/.local/bin:$PATH\"
aider --architect --yes-always ${local.model_flag} ${var.ai_model} --message \"${local.combined_prompt}\"
/bin/bash
"
echo "Screen session '${var.session_name}' started. Access it by clicking the Aider button."
fi
fi
echo "Aider setup complete!"
EOT
run_on_start = true
}
# Aider CLI app
resource "coder_app" "aider_cli" {
agent_id = var.agent_id
slug = "aider"
display_name = "Aider"
icon = var.icon
command = <<-EOT
#!/bin/bash
set -e
export PATH="$HOME/bin:$HOME/.local/bin:$PATH"
export LANG=en_US.UTF-8
export LC_ALL=en_US.UTF-8
if [ "${var.use_tmux}" = "true" ]; then
if tmux has-session -t ${var.session_name} 2>/dev/null; then
echo "Attaching to existing Aider tmux session..."
tmux attach-session -t ${var.session_name}
else
echo "Starting new Aider tmux session..."
tmux new-session -s ${var.session_name} -c ${var.folder} "aider ${local.model_flag} ${var.ai_model} --message \"${local.combined_prompt}\"; exec bash"
fi
elif [ "${var.use_screen}" = "true" ]; then
if ! screen -list | grep -q "${var.session_name}"; then
echo "Error: No existing Aider session found. Please wait for the script to start it."
exit 1
fi
screen -xRR ${var.session_name}
else
cd "${var.folder}"
echo "Starting Aider directly..."
aider ${local.model_flag} ${var.ai_model} --message "${local.combined_prompt}"
fi
EOT
order = var.order
group = var.group
}
@@ -18,8 +18,8 @@ Enable DCV Server and Web Client on Windows workspaces.
```tf
module "dcv" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/amazon-dcv-windows/coder"
version = "1.1.0"
source = "registry.coder.com/modules/amazon-dcv-windows/coder"
version = "1.0.24"
agent_id = resource.coder_agent.main.id
}
@@ -4,23 +4,11 @@ terraform {
required_providers {
coder = {
source = "coder/coder"
version = ">= 2.5"
version = ">= 0.17"
}
}
}
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)."
default = null
}
variable "group" {
type = string
description = "The name of a group that this app belongs to."
default = null
}
variable "agent_id" {
type = string
description = "The ID of a Coder agent."
@@ -57,8 +45,6 @@ resource "coder_app" "web-dcv" {
url = "https://localhost:${var.port}${local.web_url_path}?username=${local.admin_username}&password=${var.admin_password}"
icon = "/icon/dcv.svg"
subdomain = var.subdomain
order = var.order
group = var.group
}
resource "coder_script" "install-dcv" {
-121
View File
@@ -1,121 +0,0 @@
---
display_name: Amazon Q
description: Run Amazon Q in your workspace to access Amazon's AI coding assistant.
icon: ../../../../.icons/amazon-q.svg
maintainer_github: coder
verified: true
tags: [agent, ai, aws, amazon-q]
---
# Amazon Q
Run [Amazon Q](https://aws.amazon.com/q/) in your workspace to access Amazon's AI coding assistant. This module installs and launches Amazon Q, with support for background operation, task reporting, and custom pre/post install scripts.
```tf
module "amazon-q" {
source = "registry.coder.com/coder/amazon-q/coder"
version = "1.1.0"
agent_id = coder_agent.example.id
# Required: see below for how to generate
experiment_auth_tarball = var.amazon_q_auth_tarball
}
```
![Amazon-Q in action](../../.images/amazon-q.png)
## Prerequisites
- You must generate an authenticated Amazon Q tarball on another machine:
```sh
cd ~/.local/share/amazon-q && tar -c . | zstd | base64 -w 0
```
Paste the result into the `experiment_auth_tarball` variable.
- To run in the background, your workspace must have `screen` or `tmux` installed.
<details>
<summary><strong>How to generate the Amazon Q auth tarball (step-by-step)</strong></summary>
**1. Install and authenticate Amazon Q on your local machine:**
- Download and install Amazon Q from the [official site](https://aws.amazon.com/q/developer/).
- Run `q login` and complete the authentication process in your terminal.
**2. Locate your Amazon Q config directory:**
- The config is typically stored at `~/.local/share/amazon-q`.
**3. Generate the tarball:**
- Run the following command in your terminal:
```sh
cd ~/.local/share/amazon-q
tar -c . | zstd | base64 -w 0
```
**4. Copy the output:**
- The command will output a long string. Copy this entire string.
**5. Paste into your Terraform variable:**
- Assign the string to the `experiment_auth_tarball` variable in your Terraform configuration, for example:
```tf
variable "amazon_q_auth_tarball" {
type = string
default = "PASTE_LONG_STRING_HERE"
}
```
**Note:**
- You must re-generate the tarball if you log out or re-authenticate Amazon Q on your local machine.
- This process is required for each user who wants to use Amazon Q in their workspace.
[Reference: Amazon Q documentation](https://docs.aws.amazon.com/amazonq/latest/qdeveloper-ug/generate-docs.html)
</details>
## Examples
### Run Amazon Q in the background with tmux
```tf
module "amazon-q" {
source = "registry.coder.com/coder/amazon-q/coder"
version = "1.1.0"
agent_id = coder_agent.example.id
experiment_auth_tarball = var.amazon_q_auth_tarball
experiment_use_tmux = true
}
```
### Enable task reporting (experimental)
```tf
module "amazon-q" {
source = "registry.coder.com/coder/amazon-q/coder"
version = "1.1.0"
agent_id = coder_agent.example.id
experiment_auth_tarball = var.amazon_q_auth_tarball
experiment_report_tasks = true
}
```
### Run custom scripts before/after install
```tf
module "amazon-q" {
source = "registry.coder.com/coder/amazon-q/coder"
version = "1.1.0"
agent_id = coder_agent.example.id
experiment_auth_tarball = var.amazon_q_auth_tarball
experiment_pre_install_script = "echo Pre-install!"
experiment_post_install_script = "echo Post-install!"
}
```
## Notes
- Only one of `experiment_use_screen` or `experiment_use_tmux` can be true at a time.
- If neither is set, Amazon Q runs in the foreground.
- For more details, see the [main.tf](./main.tf) source.
@@ -1,41 +0,0 @@
import { describe, it, expect } from "bun:test";
import {
runTerraformApply,
runTerraformInit,
testRequiredVariables,
findResourceInstance,
} from "~test";
import path from "path";
const moduleDir = path.resolve(__dirname);
const requiredVars = {
agent_id: "dummy-agent-id",
};
describe("amazon-q module", async () => {
await runTerraformInit(moduleDir);
// 1. Required variables
testRequiredVariables(moduleDir, requiredVars);
// 2. coder_script resource is created
it("creates coder_script resource", async () => {
const state = await runTerraformApply(moduleDir, requiredVars);
const scriptResource = findResourceInstance(state, "coder_script");
expect(scriptResource).toBeDefined();
expect(scriptResource.agent_id).toBe(requiredVars.agent_id);
// Optionally, check that the script contains expected lines
expect(scriptResource.script).toContain("Installing Amazon Q");
});
// 3. coder_app resource is created
it("creates coder_app resource", async () => {
const state = await runTerraformApply(moduleDir, requiredVars);
const appResource = findResourceInstance(state, "coder_app", "amazon_q");
expect(appResource).toBeDefined();
expect(appResource.agent_id).toBe(requiredVars.agent_id);
});
// Add more state-based tests as needed
});
-337
View File
@@ -1,337 +0,0 @@
terraform {
required_version = ">= 1.0"
required_providers {
coder = {
source = "coder/coder"
version = ">= 2.5"
}
}
}
variable "agent_id" {
type = string
description = "The ID of a Coder agent."
}
data "coder_workspace" "me" {}
data "coder_workspace_owner" "me" {}
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)."
default = null
}
variable "group" {
type = string
description = "The name of a group that this app belongs to."
default = null
}
variable "icon" {
type = string
description = "The icon to use for the app."
default = "/icon/amazon-q.svg"
}
variable "folder" {
type = string
description = "The folder to run Amazon Q in."
default = "/home/coder"
}
variable "install_amazon_q" {
type = bool
description = "Whether to install Amazon Q."
default = true
}
variable "amazon_q_version" {
type = string
description = "The version of Amazon Q to install."
default = "latest"
}
variable "experiment_use_screen" {
type = bool
description = "Whether to use screen for running Amazon Q in the background."
default = false
}
variable "experiment_use_tmux" {
type = bool
description = "Whether to use tmux instead of screen for running Amazon Q in the background."
default = false
}
variable "experiment_report_tasks" {
type = bool
description = "Whether to enable task reporting."
default = false
}
variable "experiment_pre_install_script" {
type = string
description = "Custom script to run before installing Amazon Q."
default = null
}
variable "experiment_post_install_script" {
type = string
description = "Custom script to run after installing Amazon Q."
default = null
}
variable "experiment_auth_tarball" {
type = string
description = "Base64 encoded, zstd compressed tarball of a pre-authenticated ~/.local/share/amazon-q directory. After running `q login` on another machine, you may generate it with: `cd ~/.local/share/amazon-q && tar -c . | zstd | base64 -w 0`"
default = "tarball"
}
variable "system_prompt" {
type = string
description = "The system prompt to use for Amazon Q. This should instruct the agent how to do task reporting."
default = <<-EOT
You are a helpful Coding assistant. Aim to autonomously investigate
and solve issues the user gives you and test your work, whenever possible.
Avoid shortcuts like mocking tests. When you get stuck, you can ask the user
but opt for autonomy.
YOU MUST REPORT ALL TASKS TO CODER.
When reporting tasks, you MUST follow these EXACT instructions:
- IMMEDIATELY report status after receiving ANY user message.
- Be granular. If you are investigating with multiple steps, report each step to coder.
Task state MUST be one of the following:
- Use "state": "working" when actively processing WITHOUT needing additional user input.
- Use "state": "complete" only when finished with a task.
- Use "state": "failure" when you need ANY user input, lack sufficient details, or encounter blockers.
Task summaries MUST:
- Include specifics about what you're doing.
- Include clear and actionable steps for the user.
- Be less than 160 characters in length.
EOT
}
variable "ai_prompt" {
type = string
description = "The initial task prompt to send to Amazon Q."
default = "Please help me with my coding tasks. I'll provide specific instructions as needed."
}
locals {
encoded_pre_install_script = var.experiment_pre_install_script != null ? base64encode(var.experiment_pre_install_script) : ""
encoded_post_install_script = var.experiment_post_install_script != null ? base64encode(var.experiment_post_install_script) : ""
# We need to use allowed tools to limit the context Amazon Q receives.
# Amazon Q can't handle big contexts, and the `create_template_version` tool
# has a description that's too long.
mcp_json = <<EOT
{
"mcpServers": {
"coder": {
"command": "coder",
"args": ["exp", "mcp", "server", "--allowed-tools", "coder_report_task"],
"env": {
"CODER_MCP_APP_STATUS_SLUG": "amazon-q"
}
}
}
}
EOT
encoded_mcp_json = base64encode(local.mcp_json)
full_prompt = <<-EOT
${var.system_prompt}
Your first task is:
${var.ai_prompt}
EOT
}
resource "coder_script" "amazon_q" {
agent_id = var.agent_id
display_name = "Amazon Q"
icon = var.icon
script = <<-EOT
#!/bin/bash
set -o errexit
set -o pipefail
command_exists() {
command -v "$1" >/dev/null 2>&1
}
if [ -n "${local.encoded_pre_install_script}" ]; then
echo "Running pre-install script..."
echo "${local.encoded_pre_install_script}" | base64 -d > /tmp/pre_install.sh
chmod +x /tmp/pre_install.sh
/tmp/pre_install.sh
fi
if [ "${var.install_amazon_q}" = "true" ]; then
echo "Installing Amazon Q..."
PREV_DIR="$PWD"
TMP_DIR="$(mktemp -d)"
cd "$TMP_DIR"
ARCH="$(uname -m)"
case "$ARCH" in
"x86_64")
Q_URL="https://desktop-release.q.us-east-1.amazonaws.com/${var.amazon_q_version}/q-x86_64-linux.zip"
;;
"aarch64"|"arm64")
Q_URL="https://desktop-release.codewhisperer.us-east-1.amazonaws.com/${var.amazon_q_version}/q-aarch64-linux.zip"
;;
*)
echo "Error: Unsupported architecture: $ARCH. Amazon Q only supports x86_64 and arm64."
exit 1
;;
esac
echo "Downloading Amazon Q for $ARCH..."
curl --proto '=https' --tlsv1.2 -sSf "$Q_URL" -o "q.zip"
unzip q.zip
./q/install.sh --no-confirm
cd "$PREV_DIR"
export PATH="$PATH:$HOME/.local/bin"
echo "Installed Amazon Q version: $(q --version)"
fi
echo "Extracting auth tarball..."
PREV_DIR="$PWD"
echo "${var.experiment_auth_tarball}" | base64 -d > /tmp/auth.tar.zst
rm -rf ~/.local/share/amazon-q
mkdir -p ~/.local/share/amazon-q
cd ~/.local/share/amazon-q
tar -I zstd -xf /tmp/auth.tar.zst
rm /tmp/auth.tar.zst
cd "$PREV_DIR"
echo "Extracted auth tarball"
if [ -n "${local.encoded_post_install_script}" ]; then
echo "Running post-install script..."
echo "${local.encoded_post_install_script}" | base64 -d > /tmp/post_install.sh
chmod +x /tmp/post_install.sh
/tmp/post_install.sh
fi
if [ "${var.experiment_report_tasks}" = "true" ]; then
echo "Configuring Amazon Q to report tasks via Coder MCP..."
mkdir -p ~/.aws/amazonq
echo "${local.encoded_mcp_json}" | base64 -d > ~/.aws/amazonq/mcp.json
echo "Created the ~/.aws/amazonq/mcp.json configuration file"
fi
if [ "${var.experiment_use_tmux}" = "true" ] && [ "${var.experiment_use_screen}" = "true" ]; then
echo "Error: Both experiment_use_tmux and experiment_use_screen cannot be true simultaneously."
echo "Please set only one of them to true."
exit 1
fi
if [ "${var.experiment_use_tmux}" = "true" ]; then
echo "Running Amazon Q in the background with tmux..."
if ! command_exists tmux; then
echo "Error: tmux is not installed. Please install tmux manually."
exit 1
fi
touch "$HOME/.amazon-q.log"
export LANG=en_US.UTF-8
export LC_ALL=en_US.UTF-8
tmux new-session -d -s amazon-q -c "${var.folder}" "q chat --trust-all-tools | tee -a "$HOME/.amazon-q.log" && exec bash"
tmux send-keys -t amazon-q "${local.full_prompt}"
sleep 5
tmux send-keys -t amazon-q Enter
fi
if [ "${var.experiment_use_screen}" = "true" ]; then
echo "Running Amazon Q in the background..."
if ! command_exists screen; then
echo "Error: screen is not installed. Please install screen manually."
exit 1
fi
touch "$HOME/.amazon-q.log"
if [ ! -f "$HOME/.screenrc" ]; then
echo "Creating ~/.screenrc and adding multiuser settings..." | tee -a "$HOME/.amazon-q.log"
echo -e "multiuser on\nacladd $(whoami)" > "$HOME/.screenrc"
fi
if ! grep -q "^multiuser on$" "$HOME/.screenrc"; then
echo "Adding 'multiuser on' to ~/.screenrc..." | tee -a "$HOME/.amazon-q.log"
echo "multiuser on" >> "$HOME/.screenrc"
fi
if ! grep -q "^acladd $(whoami)$" "$HOME/.screenrc"; then
echo "Adding 'acladd $(whoami)' to ~/.screenrc..." | tee -a "$HOME/.amazon-q.log"
echo "acladd $(whoami)" >> "$HOME/.screenrc"
fi
export LANG=en_US.UTF-8
export LC_ALL=en_US.UTF-8
screen -U -dmS amazon-q bash -c '
cd ${var.folder}
q chat --trust-all-tools | tee -a "$HOME/.amazon-q.log
exec bash
'
# Extremely hacky way to send the prompt to the screen session
# This will be fixed in the future, but `amazon-q` was not sending MCP
# tasks when an initial prompt is provided.
screen -S amazon-q -X stuff "${local.full_prompt}"
sleep 5
screen -S amazon-q -X stuff "^M"
else
if ! command_exists q; then
echo "Error: Amazon Q is not installed. Please enable install_amazon_q or install it manually."
exit 1
fi
fi
EOT
run_on_start = true
}
resource "coder_app" "amazon_q" {
slug = "amazon-q"
display_name = "Amazon Q"
agent_id = var.agent_id
command = <<-EOT
#!/bin/bash
set -e
export LANG=en_US.UTF-8
export LC_ALL=en_US.UTF-8
if [ "${var.experiment_use_tmux}" = "true" ]; then
if tmux has-session -t amazon-q 2>/dev/null; then
echo "Attaching to existing Amazon Q tmux session." | tee -a "$HOME/.amazon-q.log"
tmux attach-session -t amazon-q
else
echo "Starting a new Amazon Q tmux session." | tee -a "$HOME/.amazon-q.log"
tmux new-session -s amazon-q -c ${var.folder} "q chat --trust-all-tools | tee -a \"$HOME/.amazon-q.log\"; exec bash"
fi
elif [ "${var.experiment_use_screen}" = "true" ]; then
if screen -list | grep -q "amazon-q"; then
echo "Attaching to existing Amazon Q screen session." | tee -a "$HOME/.amazon-q.log"
screen -xRR amazon-q
else
echo "Starting a new Amazon Q screen session." | tee -a "$HOME/.amazon-q.log"
screen -S amazon-q bash -c 'q chat --trust-all-tools | tee -a "$HOME/.amazon-q.log"; exec bash'
fi
else
cd ${var.folder}
q chat --trust-all-tools
fi
EOT
icon = var.icon
order = var.order
group = var.group
}
+3 -3
View File
@@ -17,7 +17,7 @@ Customize the preselected parameter value:
```tf
module "aws-region" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/aws-region/coder"
source = "registry.coder.com/modules/aws-region/coder"
version = "1.0.12"
default = "us-east-1"
}
@@ -38,7 +38,7 @@ Change the display name and icon for a region using the corresponding maps:
```tf
module "aws-region" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/aws-region/coder"
source = "registry.coder.com/modules/aws-region/coder"
version = "1.0.12"
default = "ap-south-1"
@@ -65,7 +65,7 @@ Hide the Asia Pacific regions Seoul and Osaka:
```tf
module "aws-region" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/aws-region/coder"
source = "registry.coder.com/modules/aws-region/coder"
version = "1.0.12"
exclude = ["ap-northeast-2", "ap-northeast-3"]
}
@@ -14,7 +14,7 @@ This module adds a parameter with all Azure regions, allowing developers to sele
```tf
module "azure_region" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/azure-region/coder"
source = "registry.coder.com/modules/azure-region/coder"
version = "1.0.12"
default = "eastus"
}
@@ -35,7 +35,7 @@ Change the display name and icon for a region using the corresponding maps:
```tf
module "azure-region" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/azure-region/coder"
source = "registry.coder.com/modules/azure-region/coder"
version = "1.0.12"
custom_names = {
"australia" : "Go Australia!"
@@ -59,7 +59,7 @@ Hide all regions in Australia except australiacentral:
```tf
module "azure-region" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/azure-region/coder"
source = "registry.coder.com/modules/azure-region/coder"
version = "1.0.12"
exclude = [
"australia",
+24 -46
View File
@@ -4,7 +4,7 @@ description: Run Claude Code in your workspace
icon: ../../../../.icons/claude.svg
maintainer_github: coder
verified: true
tags: [agent, claude-code, ai]
tags: [agent, claude-code]
---
# Claude Code
@@ -13,8 +13,8 @@ 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 = "1.4.0"
source = "registry.coder.com/modules/claude-code/coder"
version = "1.0.31"
agent_id = coder_agent.example.id
folder = "/home/coder"
install_claude_code = true
@@ -22,15 +22,10 @@ module "claude-code" {
}
```
> **Security Notice**: This module uses the [`--dangerously-skip-permissions`](https://docs.anthropic.com/en/docs/claude-code/cli-usage#cli-flags) flag when running Claude Code. This flag
> bypasses standard permission checks and allows Claude Code broader access to your system than normally permitted. While
> this enables more functionality, it also means Claude Code can potentially execute commands with the same privileges as
> the user running it. Use this module _only_ in trusted environments and be aware of the security implications.
## Prerequisites
- Node.js and npm must be installed in your workspace to install Claude Code
- Either `screen` or `tmux` must be installed in your workspace to run Claude Code in the background
- `screen` must be installed in your workspace to run Claude Code in the background
- You must add the [Coder Login](https://registry.coder.com/modules/coder-login) module to your template
The `codercom/oss-dogfood:latest` container image can be used for testing on container-based workspaces.
@@ -48,7 +43,7 @@ The `codercom/oss-dogfood:latest` container image can be used for testing on con
> Join our [Discord channel](https://discord.gg/coder) or
> [contact us](https://coder.com/contact) to get help or share feedback.
Your workspace must have either `screen` or `tmux` installed to use this.
Your workspace must have `screen` installed to use this.
```tf
variable "anthropic_api_key" {
@@ -59,7 +54,7 @@ variable "anthropic_api_key" {
module "coder-login" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/coder-login/coder"
source = "registry.coder.com/modules/coder-login/coder"
version = "1.0.15"
agent_id = coder_agent.example.id
}
@@ -72,59 +67,42 @@ data "coder_parameter" "ai_prompt" {
mutable = true
}
# Set the prompt and system prompt for Claude Code via environment variables
resource "coder_agent" "main" {
# ...
env = {
CODER_MCP_CLAUDE_API_KEY = var.anthropic_api_key # or use a coder_parameter
CODER_MCP_CLAUDE_TASK_PROMPT = data.coder_parameter.ai_prompt.value
CODER_MCP_APP_STATUS_SLUG = "claude-code"
CODER_MCP_CLAUDE_SYSTEM_PROMPT = <<-EOT
You are a helpful assistant that can help with code.
EOT
}
}
module "claude-code" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/claude-code/coder"
version = "1.4.0"
source = "registry.coder.com/modules/claude-code/coder"
version = "1.0.31"
agent_id = coder_agent.example.id
folder = "/home/coder"
install_claude_code = true
claude_code_version = "0.2.57"
# Set API key and prompts directly in the module
claude_api_key = var.anthropic_api_key # or use a coder_parameter
task_prompt = data.coder_parameter.ai_prompt.value
system_prompt = <<-EOT
You are a helpful assistant that can help with code.
EOT
# Enable experimental features
experiment_use_screen = true # Or use experiment_use_tmux = true to use tmux instead
experiment_use_screen = true
experiment_report_tasks = true
}
```
## Session Persistence (Experimental)
Enable automatic session persistence to maintain Claude Code sessions across workspace restarts:
```tf
module "claude-code" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/claude-code/coder"
version = "1.4.0"
agent_id = coder_agent.example.id
folder = "/home/coder"
install_claude_code = true
# Enable tmux with session persistence
experiment_use_tmux = true
experiment_tmux_session_persistence = true
experiment_tmux_session_save_interval = "10" # Save every 10 minutes
experiment_report_tasks = true
}
```
Session persistence automatically saves and restores your Claude Code environment, including working directory and command history.
## Run standalone
Run Claude Code as a standalone app in your workspace. This will install Claude Code and run it directly without using screen or any task reporting to the Coder UI.
```tf
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "1.4.0"
source = "registry.coder.com/modules/claude-code/coder"
version = "1.0.31"
agent_id = coder_agent.example.id
folder = "/home/coder"
install_claude_code = true
+28 -265
View File
@@ -4,7 +4,7 @@ terraform {
required_providers {
coder = {
source = "coder/coder"
version = ">= 2.5"
version = ">= 0.17"
}
}
}
@@ -24,12 +24,6 @@ variable "order" {
default = null
}
variable "group" {
type = string
description = "The name of a group that this app belongs to."
default = null
}
variable "icon" {
type = string
description = "The icon to use for the app."
@@ -60,100 +54,12 @@ variable "experiment_use_screen" {
default = false
}
variable "experiment_use_tmux" {
type = bool
description = "Whether to use tmux instead of screen for running Claude Code in the background."
default = false
}
variable "experiment_report_tasks" {
type = bool
description = "Whether to enable task reporting."
default = false
}
variable "experiment_pre_install_script" {
type = string
description = "Custom script to run before installing Claude Code."
default = null
}
variable "experiment_post_install_script" {
type = string
description = "Custom script to run after installing Claude Code."
default = null
}
variable "experiment_tmux_session_persistence" {
type = bool
description = "Whether to enable tmux session persistence across workspace restarts."
default = false
}
variable "experiment_tmux_session_save_interval" {
type = string
description = "How often to save tmux sessions in minutes."
default = "15"
}
variable "claude_api_key" {
type = string
description = "Anthropic API key for Claude."
default = ""
sensitive = true
}
variable "task_prompt" {
type = string
description = "Task prompt for Claude Code."
default = ""
}
variable "system_prompt" {
type = string
description = "System prompt for Claude Code."
default = ""
}
variable "app_status_slug" {
type = string
description = "App status slug for Claude Code."
default = "claude-code"
}
locals {
encoded_pre_install_script = var.experiment_pre_install_script != null ? base64encode(var.experiment_pre_install_script) : ""
encoded_post_install_script = var.experiment_post_install_script != null ? base64encode(var.experiment_post_install_script) : ""
}
# Set environment variables for Claude Code
resource "coder_env" "claude_api_key" {
count = var.claude_api_key != "" ? 1 : 0
agent_id = var.agent_id
name = "CODER_MCP_CLAUDE_API_KEY"
value = var.claude_api_key
}
resource "coder_env" "claude_task_prompt" {
count = var.task_prompt != "" ? 1 : 0
agent_id = var.agent_id
name = "CODER_MCP_CLAUDE_TASK_PROMPT"
value = var.task_prompt
}
resource "coder_env" "claude_system_prompt" {
count = var.system_prompt != "" ? 1 : 0
agent_id = var.agent_id
name = "CODER_MCP_CLAUDE_SYSTEM_PROMPT"
value = var.system_prompt
}
resource "coder_env" "claude_app_status_slug" {
agent_id = var.agent_id
name = "CODER_MCP_APP_STATUS_SLUG"
value = var.app_status_slug
}
# Install and Initialize Claude Code
resource "coder_script" "claude_code" {
agent_id = var.agent_id
@@ -163,67 +69,16 @@ resource "coder_script" "claude_code" {
#!/bin/bash
set -e
# Function to check if a command exists
command_exists() {
command -v "$1" >/dev/null 2>&1
}
install_tmux() {
echo "Installing tmux..."
if command_exists apt-get; then
sudo apt-get update && sudo apt-get install -y tmux
elif command_exists yum; then
sudo yum install -y tmux
elif command_exists dnf; then
sudo dnf install -y tmux
elif command_exists pacman; then
sudo pacman -S --noconfirm tmux
elif command_exists apk; then
sudo apk add tmux
else
echo "Error: Unable to install tmux automatically. Package manager not recognized."
exit 1
fi
}
if [ ! -d "${var.folder}" ]; then
echo "Warning: The specified folder '${var.folder}' does not exist."
echo "Creating the folder..."
# The folder must exist before tmux is started or else claude will start
# in the home directory.
mkdir -p "${var.folder}"
echo "Folder created successfully."
fi
if [ -n "${local.encoded_pre_install_script}" ]; then
echo "Running pre-install script..."
echo "${local.encoded_pre_install_script}" | base64 -d > /tmp/pre_install.sh
chmod +x /tmp/pre_install.sh
/tmp/pre_install.sh
fi
# Install Claude Code if enabled
if [ "${var.install_claude_code}" = "true" ]; then
if ! command_exists npm; then
echo "npm not found, checking for Node.js installation..."
if ! command_exists node; then
echo "Node.js not found, installing Node.js via NVM..."
export NVM_DIR="$HOME/.nvm"
if [ ! -d "$NVM_DIR" ]; then
mkdir -p "$NVM_DIR"
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
else
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
fi
nvm install --lts
nvm use --lts
nvm alias default node
echo "Node.js installed: $(node --version)"
echo "npm installed: $(npm --version)"
else
echo "Node.js is installed but npm is not available. Please install npm manually."
exit 1
fi
echo "Error: npm is not installed. Please install Node.js and npm first."
exit 1
fi
echo "Installing Claude Code..."
npm install -g @anthropic-ai/claude-code@${var.claude_code_version}
@@ -234,109 +89,24 @@ resource "coder_script" "claude_code" {
coder exp mcp configure claude-code ${var.folder}
fi
if [ -n "${local.encoded_post_install_script}" ]; then
echo "Running post-install script..."
echo "${local.encoded_post_install_script}" | base64 -d > /tmp/post_install.sh
chmod +x /tmp/post_install.sh
/tmp/post_install.sh
fi
if [ "${var.experiment_use_tmux}" = "true" ] && [ "${var.experiment_use_screen}" = "true" ]; then
echo "Error: Both experiment_use_tmux and experiment_use_screen cannot be true simultaneously."
echo "Please set only one of them to true."
exit 1
fi
if [ "${var.experiment_tmux_session_persistence}" = "true" ] && [ "${var.experiment_use_tmux}" != "true" ]; then
echo "Error: Session persistence requires tmux to be enabled."
echo "Please set experiment_use_tmux = true when using session persistence."
exit 1
fi
if [ "${var.experiment_use_tmux}" = "true" ]; then
if ! command_exists tmux; then
install_tmux
fi
if [ "${var.experiment_tmux_session_persistence}" = "true" ]; then
echo "Setting up tmux session persistence..."
if ! command_exists git; then
echo "Git not found, installing git..."
if command_exists apt-get; then
sudo apt-get update && sudo apt-get install -y git
elif command_exists yum; then
sudo yum install -y git
elif command_exists dnf; then
sudo dnf install -y git
elif command_exists pacman; then
sudo pacman -S --noconfirm git
elif command_exists apk; then
sudo apk add git
else
echo "Error: Unable to install git automatically. Package manager not recognized."
echo "Please install git manually to enable session persistence."
exit 1
fi
fi
mkdir -p ~/.tmux/plugins
if [ ! -d ~/.tmux/plugins/tpm ]; then
git clone https://github.com/tmux-plugins/tpm ~/.tmux/plugins/tpm
fi
cat > ~/.tmux.conf << EOF
# Claude Code tmux persistence configuration
set -g @plugin 'tmux-plugins/tmux-resurrect'
set -g @plugin 'tmux-plugins/tmux-continuum'
# Configure session persistence
set -g @resurrect-processes ':all:'
set -g @resurrect-capture-pane-contents 'on'
set -g @resurrect-save-bash-history 'on'
set -g @continuum-restore 'on'
set -g @continuum-save-interval '${var.experiment_tmux_session_save_interval}'
set -g @continuum-boot 'on'
set -g @continuum-save-on 'on'
# Initialize plugin manager
run '~/.tmux/plugins/tpm/tpm'
EOF
~/.tmux/plugins/tpm/scripts/install_plugins.sh
fi
echo "Running Claude Code in the background with tmux..."
touch "$HOME/.claude-code.log"
export LANG=en_US.UTF-8
export LC_ALL=en_US.UTF-8
if [ "${var.experiment_tmux_session_persistence}" = "true" ]; then
sleep 3
if ! tmux has-session -t claude-code 2>/dev/null; then
# Only create a new session if one doesn't exist
tmux new-session -d -s claude-code -c ${var.folder} "claude --dangerously-skip-permissions \"$CODER_MCP_CLAUDE_TASK_PROMPT\""
fi
else
if ! tmux has-session -t claude-code 2>/dev/null; then
tmux new-session -d -s claude-code -c ${var.folder} "claude --dangerously-skip-permissions \"$CODER_MCP_CLAUDE_TASK_PROMPT\""
fi
fi
fi
# Run with screen if enabled
if [ "${var.experiment_use_screen}" = "true" ]; then
echo "Running Claude Code in the background..."
# Check if screen is installed
if ! command_exists screen; then
echo "Error: screen is not installed. Please install screen manually."
exit 1
fi
touch "$HOME/.claude-code.log"
# Ensure the screenrc exists
if [ ! -f "$HOME/.screenrc" ]; then
echo "Creating ~/.screenrc and adding multiuser settings..." | tee -a "$HOME/.claude-code.log"
echo -e "multiuser on\nacladd $(whoami)" > "$HOME/.screenrc"
fi
if ! grep -q "^multiuser on$" "$HOME/.screenrc"; then
echo "Adding 'multiuser on' to ~/.screenrc..." | tee -a "$HOME/.claude-code.log"
echo "multiuser on" >> "$HOME/.screenrc"
@@ -346,16 +116,22 @@ EOF
echo "Adding 'acladd $(whoami)' to ~/.screenrc..." | tee -a "$HOME/.claude-code.log"
echo "acladd $(whoami)" >> "$HOME/.screenrc"
fi
export LANG=en_US.UTF-8
export LC_ALL=en_US.UTF-8
screen -U -dmS claude-code bash -c '
cd ${var.folder}
claude --dangerously-skip-permissions "$CODER_MCP_CLAUDE_TASK_PROMPT" | tee -a "$HOME/.claude-code.log"
claude --dangerously-skip-permissions | tee -a "$HOME/.claude-code.log"
exec bash
'
# Extremely hacky way to send the prompt to the screen session
# This will be fixed in the future, but `claude` was not sending MCP
# tasks when an initial prompt is provided.
screen -S claude-code -X stuff "$CODER_MCP_CLAUDE_TASK_PROMPT"
sleep 5
screen -S claude-code -X stuff "^M"
else
# Check if claude is installed before running
if ! command_exists claude; then
echo "Error: Claude Code is not installed. Please enable install_claude_code or install it manually."
exit 1
@@ -373,35 +149,22 @@ resource "coder_app" "claude_code" {
#!/bin/bash
set -e
export LANG=en_US.UTF-8
export LC_ALL=en_US.UTF-8
if [ "${var.experiment_use_tmux}" = "true" ]; then
if tmux has-session -t claude-code 2>/dev/null; then
echo "Attaching to existing Claude Code tmux session." | tee -a "$HOME/.claude-code.log"
# If Claude isn't running in the session, start it without the prompt
if ! tmux list-panes -t claude-code -F '#{pane_current_command}' | grep -q "claude"; then
tmux send-keys -t claude-code "cd ${var.folder} && claude -c --dangerously-skip-permissions" C-m
fi
tmux attach-session -t claude-code
else
echo "Starting a new Claude Code tmux session." | tee -a "$HOME/.claude-code.log"
tmux new-session -s claude-code -c ${var.folder} "claude --dangerously-skip-permissions | tee -a \"$HOME/.claude-code.log\"; exec bash"
fi
elif [ "${var.experiment_use_screen}" = "true" ]; then
if [ "${var.experiment_use_screen}" = "true" ]; then
if screen -list | grep -q "claude-code"; then
echo "Attaching to existing Claude Code screen session." | tee -a "$HOME/.claude-code.log"
export LANG=en_US.UTF-8
export LC_ALL=en_US.UTF-8
echo "Attaching to existing Claude Code session." | tee -a "$HOME/.claude-code.log"
screen -xRR claude-code
else
echo "Starting a new Claude Code screen session." | tee -a "$HOME/.claude-code.log"
screen -S claude-code bash -c 'claude --dangerously-skip-permissions | tee -a "$HOME/.claude-code.log"; exec bash'
echo "Starting a new Claude Code session." | tee -a "$HOME/.claude-code.log"
screen -S claude-code bash -c 'export LANG=en_US.UTF-8; export LC_ALL=en_US.UTF-8; claude --dangerously-skip-permissions | tee -a "$HOME/.claude-code.log"; exec bash'
fi
else
cd ${var.folder}
export LANG=en_US.UTF-8
export LC_ALL=en_US.UTF-8
claude
fi
EOT
icon = var.icon
order = var.order
group = var.group
}
+15 -15
View File
@@ -4,7 +4,7 @@ description: VS Code in the browser
icon: ../../../../.icons/code.svg
maintainer_github: coder
verified: true
tags: [ide, web, code-server]
tags: [helper, ide, web]
---
# code-server
@@ -14,8 +14,8 @@ Automatically install [code-server](https://github.com/coder/code-server) in a w
```tf
module "code-server" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/code-server/coder"
version = "1.3.0"
source = "registry.coder.com/modules/code-server/coder"
version = "1.0.31"
agent_id = coder_agent.example.id
}
```
@@ -29,8 +29,8 @@ module "code-server" {
```tf
module "code-server" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/code-server/coder"
version = "1.3.0"
source = "registry.coder.com/modules/code-server/coder"
version = "1.0.31"
agent_id = coder_agent.example.id
install_version = "4.8.3"
}
@@ -43,8 +43,8 @@ Install the Dracula theme from [OpenVSX](https://open-vsx.org/):
```tf
module "code-server" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/code-server/coder"
version = "1.3.0"
source = "registry.coder.com/modules/code-server/coder"
version = "1.0.31"
agent_id = coder_agent.example.id
extensions = [
"dracula-theme.theme-dracula"
@@ -61,8 +61,8 @@ Configure VS Code's [settings.json](https://code.visualstudio.com/docs/getstarte
```tf
module "code-server" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/code-server/coder"
version = "1.3.0"
source = "registry.coder.com/modules/code-server/coder"
version = "1.0.31"
agent_id = coder_agent.example.id
extensions = ["dracula-theme.theme-dracula"]
settings = {
@@ -78,8 +78,8 @@ Just run code-server in the background, don't fetch it from GitHub:
```tf
module "code-server" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/code-server/coder"
version = "1.3.0"
source = "registry.coder.com/modules/code-server/coder"
version = "1.0.31"
agent_id = coder_agent.example.id
extensions = ["dracula-theme.theme-dracula", "ms-azuretools.vscode-docker"]
}
@@ -94,8 +94,8 @@ Run an existing copy of code-server if found, otherwise download from GitHub:
```tf
module "code-server" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/code-server/coder"
version = "1.3.0"
source = "registry.coder.com/modules/code-server/coder"
version = "1.0.31"
agent_id = coder_agent.example.id
use_cached = true
extensions = ["dracula-theme.theme-dracula", "ms-azuretools.vscode-docker"]
@@ -107,8 +107,8 @@ Just run code-server in the background, don't fetch it from GitHub:
```tf
module "code-server" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/code-server/coder"
version = "1.3.0"
source = "registry.coder.com/modules/code-server/coder"
version = "1.0.31"
agent_id = coder_agent.example.id
offline = true
}
+1 -30
View File
@@ -4,7 +4,7 @@ terraform {
required_providers {
coder = {
source = "coder/coder"
version = ">= 2.5"
version = ">= 0.17"
}
}
}
@@ -44,12 +44,6 @@ variable "settings" {
default = {}
}
variable "machine-settings" {
type = any
description = "A map of template level machine settings to apply to code-server. This will be overwritten at each container start."
default = {}
}
variable "folder" {
type = string
description = "The folder to open in code-server."
@@ -89,12 +83,6 @@ variable "order" {
default = null
}
variable "group" {
type = string
description = "The name of a group that this app belongs to."
default = null
}
variable "offline" {
type = bool
description = "Just run code-server in the background, don't fetch it from GitHub"
@@ -134,20 +122,6 @@ variable "subdomain" {
default = false
}
variable "open_in" {
type = string
description = <<-EOT
Determines where the app will be opened. Valid values are `"tab"` and `"slim-window" (default)`.
`"tab"` opens in a new tab in the same browser window.
`"slim-window"` opens a new browser window without navigation controls.
EOT
default = "slim-window"
validation {
condition = contains(["tab", "slim-window"], var.open_in)
error_message = "The 'open_in' variable must be one of: 'tab', 'slim-window'."
}
}
resource "coder_script" "code-server" {
agent_id = var.agent_id
display_name = "code-server"
@@ -161,7 +135,6 @@ resource "coder_script" "code-server" {
INSTALL_PREFIX : var.install_prefix,
// This is necessary otherwise the quotes are stripped!
SETTINGS : replace(jsonencode(var.settings), "\"", "\\\""),
MACHINE_SETTINGS : replace(jsonencode(var.machine-settings), "\"", "\\\""),
OFFLINE : var.offline,
USE_CACHED : var.use_cached,
USE_CACHED_EXTENSIONS : var.use_cached_extensions,
@@ -193,8 +166,6 @@ resource "coder_app" "code-server" {
subdomain = var.subdomain
share = var.share
order = var.order
group = var.group
open_in = var.open_in
healthcheck {
url = "http://localhost:${var.port}/healthz"
+1 -14
View File
@@ -23,20 +23,7 @@ function run_code_server() {
if [ ! -f ~/.local/share/code-server/User/settings.json ]; then
echo "⚙️ Creating settings file..."
mkdir -p ~/.local/share/code-server/User
if command -v jq &> /dev/null; then
echo "${SETTINGS}" | jq '.' > ~/.local/share/code-server/User/settings.json
else
echo "${SETTINGS}" > ~/.local/share/code-server/User/settings.json
fi
fi
# Apply/overwrite template based settings
echo "⚙️ Creating machine settings file..."
mkdir -p ~/.local/share/code-server/Machine
if command -v jq &> /dev/null; then
echo "${MACHINE_SETTINGS}" | jq '.' > ~/.local/share/code-server/Machine/settings.json
else
echo "${MACHINE_SETTINGS}" > ~/.local/share/code-server/Machine/settings.json
echo "${SETTINGS}" > ~/.local/share/code-server/User/settings.json
fi
# Check if code-server is already installed for offline
+1 -1
View File
@@ -14,7 +14,7 @@ Automatically logs the user into Coder when creating their workspace.
```tf
module "coder-login" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/coder-login/coder"
source = "registry.coder.com/modules/coder-login/coder"
version = "1.0.15"
agent_id = coder_agent.example.id
}
+5 -5
View File
@@ -4,7 +4,7 @@ description: Add a one-click button to launch Cursor IDE
icon: ../../../../.icons/cursor.svg
maintainer_github: coder
verified: true
tags: [ide, cursor, ai]
tags: [ide, cursor, helper]
---
# Cursor IDE
@@ -16,8 +16,8 @@ Uses the [Coder Remote VS Code Extension](https://github.com/coder/vscode-coder)
```tf
module "cursor" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/cursor/coder"
version = "1.2.0"
source = "registry.coder.com/modules/cursor/coder"
version = "1.0.19"
agent_id = coder_agent.example.id
}
```
@@ -29,8 +29,8 @@ module "cursor" {
```tf
module "cursor" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/cursor/coder"
version = "1.2.0"
source = "registry.coder.com/modules/cursor/coder"
version = "1.0.19"
agent_id = coder_agent.example.id
folder = "/home/coder/project"
}
+3 -22
View File
@@ -4,7 +4,7 @@ terraform {
required_providers {
coder = {
source = "coder/coder"
version = ">= 2.5"
version = ">= 0.23"
}
}
}
@@ -32,24 +32,6 @@ variable "order" {
default = null
}
variable "group" {
type = string
description = "The name of a group that this app belongs to."
default = null
}
variable "slug" {
type = string
description = "The slug of the app."
default = "cursor"
}
variable "display_name" {
type = string
description = "The display name of the app."
default = "Cursor Desktop"
}
data "coder_workspace" "me" {}
data "coder_workspace_owner" "me" {}
@@ -57,10 +39,9 @@ resource "coder_app" "cursor" {
agent_id = var.agent_id
external = true
icon = "/icon/cursor.svg"
slug = var.slug
display_name = var.display_name
slug = "cursor"
display_name = "Cursor Desktop"
order = var.order
group = var.group
url = join("", [
"cursor://coder.coder-remote/open",
"?owner=",
@@ -1,22 +0,0 @@
---
display_name: devcontainers-cli
description: devcontainers-cli module provides an easy way to install @devcontainers/cli into a workspace
icon: ../../../../.icons/devcontainers.svg
verified: true
maintainer_github: coder
tags: [devcontainers]
---
# devcontainers-cli
The devcontainers-cli module provides an easy way to install [`@devcontainers/cli`](https://github.com/devcontainers/cli) into a workspace. It can be used within any workspace as it runs only if
@devcontainers/cli is not installed yet.
`npm` is required and should be pre-installed in order for the module to work.
```tf
module "devcontainers-cli" {
source = "registry.coder.com/coder/devcontainers-cli/coder"
version = "1.0.3"
agent_id = coder_agent.example.id
}
```
@@ -1,144 +0,0 @@
import { describe, expect, it } from "bun:test";
import {
execContainer,
executeScriptInContainer,
findResourceInstance,
runContainer,
runTerraformApply,
runTerraformInit,
testRequiredVariables,
type TerraformState,
} from "~test";
const executeScriptInContainerWithPackageManager = async (
state: TerraformState,
image: string,
packageManager: string,
shell = "sh",
): Promise<{
exitCode: number;
stdout: string[];
stderr: string[];
}> => {
const instance = findResourceInstance(state, "coder_script");
const id = await runContainer(image);
// Install the specified package manager
if (packageManager === "npm") {
await execContainer(id, [shell, "-c", "apk add nodejs npm"]);
} else if (packageManager === "pnpm") {
await execContainer(id, [
shell,
"-c",
`wget -qO- https://get.pnpm.io/install.sh | ENV="$HOME/.shrc" SHELL="$(which sh)" sh -`,
]);
} else if (packageManager === "yarn") {
await execContainer(id, [
shell,
"-c",
"apk add nodejs npm && npm install -g yarn",
]);
}
const pathResp = await execContainer(id, [shell, "-c", "echo $PATH"]);
const path = pathResp.stdout.trim();
console.log(path);
const resp = await execContainer(
id,
[shell, "-c", instance.script],
[
"--env",
"CODER_SCRIPT_BIN_DIR=/tmp/coder-script-data/bin",
"--env",
`PATH=${path}:/tmp/coder-script-data/bin`,
],
);
const stdout = resp.stdout.trim().split("\n");
const stderr = resp.stderr.trim().split("\n");
return {
exitCode: resp.exitCode,
stdout,
stderr,
};
};
describe("devcontainers-cli", async () => {
await runTerraformInit(import.meta.dir);
testRequiredVariables(import.meta.dir, {
agent_id: "some-agent-id",
});
it("misses all package managers", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "some-agent-id",
});
const output = await executeScriptInContainer(state, "docker:dind");
expect(output.exitCode).toBe(1);
expect(output.stderr).toEqual([
"ERROR: No supported package manager (npm, pnpm, yarn) is installed. Please install one first.",
]);
}, 15000);
it("installs devcontainers-cli with npm", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "some-agent-id",
});
const output = await executeScriptInContainerWithPackageManager(
state,
"docker:dind",
"npm",
);
expect(output.exitCode).toBe(0);
expect(output.stdout[0]).toEqual(
"Installing @devcontainers/cli using npm...",
);
expect(output.stdout[output.stdout.length - 1]).toEqual(
"🥳 @devcontainers/cli has been installed into /usr/local/bin/devcontainer!",
);
}, 15000);
it("installs devcontainers-cli with yarn", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "some-agent-id",
});
const output = await executeScriptInContainerWithPackageManager(
state,
"docker:dind",
"yarn",
);
expect(output.exitCode).toBe(0);
expect(output.stdout[0]).toEqual(
"Installing @devcontainers/cli using yarn...",
);
expect(output.stdout[output.stdout.length - 1]).toEqual(
"🥳 @devcontainers/cli has been installed into /tmp/coder-script-data/bin/devcontainer!",
);
}, 15000);
it("displays warning if docker is not installed", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "some-agent-id",
});
const output = await executeScriptInContainerWithPackageManager(
state,
"alpine",
"npm",
);
expect(output.exitCode).toBe(0);
expect(output.stdout[0]).toEqual(
"WARNING: Docker was not found but is required to use @devcontainers/cli, please make sure it is available.",
);
expect(output.stdout[output.stdout.length - 1]).toEqual(
"🥳 @devcontainers/cli has been installed into /usr/local/bin/devcontainer!",
);
}, 15000);
});
@@ -1,23 +0,0 @@
terraform {
required_version = ">= 1.0"
required_providers {
coder = {
source = "coder/coder"
version = ">= 0.17"
}
}
}
variable "agent_id" {
type = string
description = "The ID of a Coder agent."
}
resource "coder_script" "devcontainers-cli" {
agent_id = var.agent_id
display_name = "devcontainers-cli"
icon = "/icon/devcontainers.svg"
script = templatefile("${path.module}/run.sh", {})
run_on_start = true
}
@@ -1,56 +0,0 @@
#!/usr/bin/env sh
# If @devcontainers/cli is already installed, we can skip
if command -v devcontainer >/dev/null 2>&1; then
echo "🥳 @devcontainers/cli is already installed into $(which devcontainer)!"
exit 0
fi
# Check if docker is installed
if ! command -v docker >/dev/null 2>&1; then
echo "WARNING: Docker was not found but is required to use @devcontainers/cli, please make sure it is available."
fi
# Determine the package manager to use: npm, pnpm, or yarn
if command -v yarn >/dev/null 2>&1; then
PACKAGE_MANAGER="yarn"
elif command -v npm >/dev/null 2>&1; then
PACKAGE_MANAGER="npm"
elif command -v pnpm >/dev/null 2>&1; then
PACKAGE_MANAGER="pnpm"
else
echo "ERROR: No supported package manager (npm, pnpm, yarn) is installed. Please install one first." 1>&2
exit 1
fi
install() {
echo "Installing @devcontainers/cli using $PACKAGE_MANAGER..."
if [ "$PACKAGE_MANAGER" = "npm" ]; then
npm install -g @devcontainers/cli
elif [ "$PACKAGE_MANAGER" = "pnpm" ]; then
# Check if PNPM_HOME is set, if not, set it to the script's bin directory
# pnpm needs this to be set to install binaries
# coder agent ensures this part is part of the PATH
# so that the devcontainer command is available
if [ -z "$PNPM_HOME" ]; then
PNPM_HOME="$CODER_SCRIPT_BIN_DIR"
export M_HOME
fi
pnpm add -g @devcontainers/cli
elif [ "$PACKAGE_MANAGER" = "yarn" ]; then
yarn global add @devcontainers/cli --prefix "$(dirname "$CODER_SCRIPT_BIN_DIR")"
fi
}
if ! install; then
echo "Failed to install @devcontainers/cli" >&2
exit 1
fi
if ! command -v devcontainer >/dev/null 2>&1; then
echo "Installation completed but 'devcontainer' command not found in PATH" >&2
exit 1
fi
echo "🥳 @devcontainers/cli has been installed into $(which devcontainer)!"
exit 0
+13 -13
View File
@@ -4,7 +4,7 @@ description: Allow developers to optionally bring their own dotfiles repository
icon: ../../../../.icons/dotfiles.svg
maintainer_github: coder
verified: true
tags: [helper, dotfiles]
tags: [helper]
---
# Dotfiles
@@ -18,8 +18,8 @@ Under the hood, this module uses the [coder dotfiles](https://coder.com/docs/v2/
```tf
module "dotfiles" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/dotfiles/coder"
version = "1.1.0"
source = "registry.coder.com/modules/dotfiles/coder"
version = "1.0.29"
agent_id = coder_agent.example.id
}
```
@@ -31,8 +31,8 @@ module "dotfiles" {
```tf
module "dotfiles" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/dotfiles/coder"
version = "1.1.0"
source = "registry.coder.com/modules/dotfiles/coder"
version = "1.0.29"
agent_id = coder_agent.example.id
}
```
@@ -42,8 +42,8 @@ module "dotfiles" {
```tf
module "dotfiles" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/dotfiles/coder"
version = "1.1.0"
source = "registry.coder.com/modules/dotfiles/coder"
version = "1.0.29"
agent_id = coder_agent.example.id
user = "root"
}
@@ -54,15 +54,15 @@ module "dotfiles" {
```tf
module "dotfiles" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/dotfiles/coder"
version = "1.1.0"
source = "registry.coder.com/modules/dotfiles/coder"
version = "1.0.29"
agent_id = coder_agent.example.id
}
module "dotfiles-root" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/dotfiles/coder"
version = "1.1.0"
source = "registry.coder.com/modules/dotfiles/coder"
version = "1.0.29"
agent_id = coder_agent.example.id
user = "root"
dotfiles_uri = module.dotfiles.dotfiles_uri
@@ -76,8 +76,8 @@ You can set a default dotfiles repository for all users by setting the `default_
```tf
module "dotfiles" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/dotfiles/coder"
version = "1.1.0"
source = "registry.coder.com/modules/dotfiles/coder"
version = "1.0.29"
agent_id = coder_agent.example.id
default_dotfiles_uri = "https://github.com/coder/dotfiles"
}
+1 -15
View File
@@ -4,23 +4,11 @@ terraform {
required_providers {
coder = {
source = "coder/coder"
version = ">= 2.5"
version = ">= 0.12"
}
}
}
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)."
default = null
}
variable "group" {
type = string
description = "The name of a group that this app belongs to."
default = null
}
variable "agent_id" {
type = string
description = "The ID of a Coder agent."
@@ -91,8 +79,6 @@ resource "coder_app" "dotfiles" {
display_name = "Refresh Dotfiles"
slug = "dotfiles"
icon = "/icon/dotfiles.svg"
order = var.order
group = var.group
command = templatefile("${path.module}/run.sh", {
DOTFILES_URI : local.dotfiles_uri,
DOTFILES_USER : local.user
+9 -9
View File
@@ -4,7 +4,7 @@ description: A file browser for your workspace
icon: ../../../../.icons/filebrowser.svg
maintainer_github: coder
verified: true
tags: [filebrowser, web]
tags: [helper, filebrowser]
---
# File Browser
@@ -14,8 +14,8 @@ A file browser for your workspace.
```tf
module "filebrowser" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/filebrowser/coder"
version = "1.1.0"
source = "registry.coder.com/modules/filebrowser/coder"
version = "1.0.31"
agent_id = coder_agent.example.id
}
```
@@ -29,8 +29,8 @@ module "filebrowser" {
```tf
module "filebrowser" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/filebrowser/coder"
version = "1.1.0"
source = "registry.coder.com/modules/filebrowser/coder"
version = "1.0.31"
agent_id = coder_agent.example.id
folder = "/home/coder/project"
}
@@ -41,8 +41,8 @@ module "filebrowser" {
```tf
module "filebrowser" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/filebrowser/coder"
version = "1.1.0"
source = "registry.coder.com/modules/filebrowser/coder"
version = "1.0.31"
agent_id = coder_agent.example.id
database_path = ".config/filebrowser.db"
}
@@ -53,8 +53,8 @@ module "filebrowser" {
```tf
module "filebrowser" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/filebrowser/coder"
version = "1.1.0"
source = "registry.coder.com/modules/filebrowser/coder"
version = "1.0.31"
agent_id = coder_agent.example.id
agent_name = "main"
subdomain = false
@@ -1,105 +0,0 @@
import { describe, expect, it } from "bun:test";
import {
executeScriptInContainer,
runTerraformApply,
runTerraformInit,
type scriptOutput,
testRequiredVariables,
} from "~test";
function testBaseLine(output: scriptOutput) {
expect(output.exitCode).toBe(0);
const expectedLines = [
"\u001b[[0;1mInstalling filebrowser ",
"🥳 Installation complete! ",
"👷 Starting filebrowser in background... ",
"📂 Serving /root at http://localhost:13339 ",
"📝 Logs at /tmp/filebrowser.log",
];
// we could use expect(output.stdout).toEqual(expect.arrayContaining(expectedLines)), but when it errors, it doesn't say which line is wrong
for (const line of expectedLines) {
expect(output.stdout).toContain(line);
}
}
describe("filebrowser", async () => {
await runTerraformInit(import.meta.dir);
testRequiredVariables(import.meta.dir, {
agent_id: "foo",
});
it("fails with wrong database_path", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
database_path: "nofb",
}).catch((e) => {
if (!e.message.startsWith("\nError: Invalid value for variable")) {
throw e;
}
});
});
it("runs with default", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
});
const output = await executeScriptInContainer(
state,
"alpine/curl",
"sh",
"apk add bash",
);
testBaseLine(output);
});
it("runs with database_path var", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
database_path: ".config/filebrowser.db",
});
const output = await await executeScriptInContainer(
state,
"alpine/curl",
"sh",
"apk add bash",
);
testBaseLine(output);
});
it("runs with folder var", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
folder: "/home/coder/project",
});
const output = await await executeScriptInContainer(
state,
"alpine/curl",
"sh",
"apk add bash",
);
});
it("runs with subdomain=false", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
agent_name: "main",
subdomain: false,
});
const output = await await executeScriptInContainer(
state,
"alpine/curl",
"sh",
"apk add bash",
);
testBaseLine(output);
});
});
+2 -9
View File
@@ -4,7 +4,7 @@ terraform {
required_providers {
coder = {
source = "coder/coder"
version = ">= 2.5"
version = ">= 0.17"
}
}
}
@@ -68,12 +68,6 @@ variable "order" {
default = null
}
variable "group" {
type = string
description = "The name of a group that this app belongs to."
default = null
}
variable "slug" {
type = string
description = "The slug of the coder_app resource."
@@ -114,7 +108,6 @@ resource "coder_app" "filebrowser" {
subdomain = var.subdomain
share = var.share
order = var.order
group = var.group
healthcheck {
url = local.healthcheck_url
@@ -127,4 +120,4 @@ locals {
server_base_path = var.subdomain ? "" : format("/@%s/%s%s/apps/%s", data.coder_workspace_owner.me.name, data.coder_workspace.me.name, var.agent_name != null ? ".${var.agent_name}" : "", var.slug)
url = "http://localhost:${var.port}${local.server_base_path}"
healthcheck_url = "http://localhost:${var.port}${local.server_base_path}/health"
}
}

Some files were not shown because too many files have changed in this diff Show More