Compare commits

..

2 Commits

Author SHA1 Message Date
DevCats 17705fac52 Merge branch 'main' into cat/version-label-workflow 2025-06-12 21:01:31 -05:00
DevelopmentCats 29743bf0da feat: add GitHub Actions workflow for automatic version bumping
This commit introduces a new workflow that triggers on pull request labeling to automatically detect version bumps (patch, minor, major) for Coder modules. It processes modified modules, updates their version references in README files, and commits the changes. Additionally, it comments on the pull request with a summary of the version bumps and any modules without existing git tags.
2025-06-05 01:08:00 +00:00
18 changed files with 547 additions and 873 deletions
-9
View File
@@ -190,15 +190,6 @@ main() {
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 ""
+179 -83
View File
@@ -3,8 +3,6 @@ name: Version Bump
on:
pull_request:
types: [labeled]
paths:
- "registry/**/modules/**"
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
@@ -15,9 +13,8 @@ jobs:
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
contents: write
pull-requests: write
issues: write
steps:
- name: Checkout code
uses: actions/checkout@v4
@@ -25,96 +22,195 @@ jobs:
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
- name: Setup Git
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 }}"
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
- name: Detect version bump type
id: version-type
run: |
if [[ "${{ github.event.label.name }}" == "version:patch" ]]; then
echo "bump_type=patch" >> $GITHUB_OUTPUT
elif [[ "${{ github.event.label.name }}" == "version:minor" ]]; then
echo "bump_type=minor" >> $GITHUB_OUTPUT
elif [[ "${{ github.event.label.name }}" == "version:major" ]]; then
echo "bump_type=major" >> $GITHUB_OUTPUT
else
echo "Invalid version label: ${{ github.event.label.name }}"
exit 1
fi
- name: Detect modified modules
id: detect-modules
run: |
CHANGED_FILES=$(git diff --name-only origin/main...HEAD)
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 "modules<<EOF" >> $GITHUB_OUTPUT
echo "$MODULES" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
- name: Process version bumps
id: process-bumps
run: |
BUMP_TYPE="${{ steps.version-type.outputs.bump_type }}"
BUMPED_MODULES=""
UPDATED_READMES=""
UNTAGGED_MODULES=""
while IFS= read -r module_path; do
if [ -z "$module_path" ]; then continue; fi
NAMESPACE=$(echo "$module_path" | cut -d'/' -f2)
MODULE_NAME=$(echo "$module_path" | cut -d'/' -f4)
echo "Processing: $NAMESPACE/$MODULE_NAME"
LATEST_TAG=$(git tag -l "release/${NAMESPACE}/${MODULE_NAME}/v*" | sort -V | tail -1)
README_PATH="$module_path/README.md"
if [ -z "$LATEST_TAG" ]; then
# No tag found, check if README has version references
if [ -f "$README_PATH" ] && grep -q 'version\s*=\s*"' "$README_PATH"; then
# Extract version from README
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"
# Validate extracted version format
if ! [[ "$README_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "❌ Invalid README version format: '$README_VERSION'. Expected X.Y.Z format."
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"
# Validate version format (semantic versioning: X.Y.Z)
if ! [[ "$CURRENT_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "❌ Invalid version format: '$CURRENT_VERSION'. Expected X.Y.Z format."
exit 1
;;
esac
- name: Check version bump requirements
id: version-check
fi
IFS='.' read -r major minor patch <<< "$CURRENT_VERSION"
# Validate that components are numeric
if ! [[ "$major" =~ ^[0-9]+$ ]] || ! [[ "$minor" =~ ^[0-9]+$ ]] || ! [[ "$patch" =~ ^[0-9]+$ ]]; then
echo "❌ Version components must be numeric: major='$major' minor='$minor' patch='$patch'"
exit 1
fi
case "$BUMP_TYPE" in
"patch")
NEW_VERSION="$major.$minor.$((patch + 1))"
;;
"minor")
NEW_VERSION="$major.$((minor + 1)).0"
;;
"major")
NEW_VERSION="$((major + 1)).0.0"
;;
esac
echo "New version: $NEW_VERSION"
if [ -f "$README_PATH" ]; then
# Check if README contains version references for this specific module
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"
# Use awk to only update versions that follow the specific module source
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"
UPDATED_READMES="$UPDATED_READMES\n- $NAMESPACE/$MODULE_NAME"
elif grep -q 'version\s*=\s*"' "$README_PATH"; then
echo "⚠️ Found version references but no module source match for $NAMESPACE/$MODULE_NAME"
fi
fi
BUMPED_MODULES="$BUMPED_MODULES\n- $NAMESPACE/$MODULE_NAME: v$CURRENT_VERSION → v$NEW_VERSION"
done <<< "${{ steps.detect-modules.outputs.modules }}"
echo "bumped_modules<<EOF" >> $GITHUB_OUTPUT
echo -e "$BUMPED_MODULES" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
echo "updated_readmes<<EOF" >> $GITHUB_OUTPUT
echo -e "$UPDATED_READMES" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
echo "untagged_modules<<EOF" >> $GITHUB_OUTPUT
echo -e "$UNTAGGED_MODULES" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
- name: Commit changes
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"
# Check if any README files were modified
if git diff --quiet 'registry/*/modules/*/README.md'; then
echo "No README changes to commit"
else
echo "Script failed"
cat "$output_file"
exit 1
echo "Committing README changes..."
git diff --name-only 'registry/*/modules/*/README.md'
git add registry/*/modules/*/README.md
git commit -m "chore: bump module versions (${{ steps.version-type.outputs.bump_type }})"
git push
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'
- name: Comment on PR
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`;
const bumpedModules = `${{ steps.process-bumps.outputs.bumped_modules }}`;
const updatedReadmes = `${{ steps.process-bumps.outputs.updated_readmes }}`;
const untaggedModules = `${{ steps.process-bumps.outputs.untagged_modules }}`;
const bumpType = `${{ steps.version-type.outputs.bump_type }}`;
let comment = `## 🚀 Version Bump Summary\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.`;
comment += `**Modules Updated:**\n${bumpedModules}\n\n`;
if (updatedReadmes.trim()) {
comment += `**READMEs Updated:**\n${updatedReadmes}\n\n`;
}
if (untaggedModules.trim()) {
comment += `⚠️ **Modules Without Git Tags:**\n${untaggedModules}\n\n`;
comment += `> These modules were versioned based on README content. Consider creating proper release tags after merging.\n\n`;
}
comment += `> Versions have been automatically updated in module READMEs where applicable.`;
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: comment
});
});
+268 -17
View File
@@ -1,17 +1,268 @@
<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>
<?xml version="1.0" encoding="UTF-8"?>
<svg id="katman_1" xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 841.9 595.3">
<!-- Generator: Adobe Illustrator 29.3.1, SVG Export Plug-In . SVG Version: 2.1.0 Build 151) -->
<defs>
<style>
.st0 {
fill: #2e3c4e;
}
.st1 {
opacity: 0;
}
.st1, .st2, .st3, .st4, .st5, .st6, .st7, .st8 {
display: none;
}
.st9 {
fill: #7300e5;
}
.st10, .st11 {
fill: #fff;
}
.st12 {
fill: url(#Adsız_degrade_4);
}
.st2 {
opacity: 0;
}
.st13 {
fill: url(#Adsız_degrade_41);
}
.st14 {
fill: #d7c8f9;
}
.st15 {
fill: none;
}
.st16 {
stroke: #d1d1d6;
stroke-width: .5px;
}
.st16, .st17, .st11 {
fill-opacity: 0;
}
.st3 {
opacity: 0;
}
.st17 {
stroke: #fff;
stroke-width: 4px;
}
.st4 {
opacity: 0;
}
.st5 {
opacity: 0;
}
.st7 {
opacity: 0;
}
.st18 {
fill: url(#Adsız_degrade_5);
}
.st8 {
opacity: 0;
}
.st19 {
fill: url(#Adsız_degrade_2);
}
</style>
<mask id="mask" x="69.2" y="33.9" width="704" height="528" maskUnits="userSpaceOnUse">
<g id="lottie-ymehjmywpqh__lottie_element_1058_2">
<g>
<rect class="st11" x="69.2" y="33.9" width="704" height="528"/>
<g class="st3">
<path class="st14" d="M324.5,185.8v33.5c0,4.4-3.6,8-8,8h-107.8c-4.4,0-8-3.6-8-8v-33.5c0-4.4,3.6-8,8-8h107.8c4.4,0,8,3.6,8,8Z"/>
</g>
</g>
</g>
</mask>
<linearGradient id="Adsız_degrade_2" data-name="Adsız degrade 2" x1="68.8" y1="563" x2="-1.8" y2="563.4" gradientTransform="translate(194.4 765.6) scale(1 -1)" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#fff"/>
<stop offset=".5" stop-color="#fff" stop-opacity=".7"/>
<stop offset="1" stop-color="#fff" stop-opacity=".4"/>
</linearGradient>
<mask id="mask-1" x="69.2" y="33.9" width="704" height="528" maskUnits="userSpaceOnUse">
<g id="lottie-ymehjmywpqh__lottie_element_1038_2">
<g>
<rect class="st11" x="69.2" y="33.9" width="704" height="528"/>
<g class="st3">
<path d="M329.4,427.5v34c0,4.4-3.6,8-8,8h-127.2c-4.4.1-8-3.5-8-7.9v-34c0-4.4,3.6-8,8-8h127.2c4.4-.1,8,3.5,8,7.9Z"/>
</g>
</g>
</g>
</mask>
<linearGradient id="Adsız_degrade_4" data-name="Adsız degrade 4" x1="69.7" y1="567.5" x2="-11.4" y2="566.9" gradientTransform="translate(178.6 1012.1) scale(1 -1)" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#fff"/>
<stop offset=".5" stop-color="#fff" stop-opacity=".6"/>
<stop offset="1" stop-color="#fff" stop-opacity=".2"/>
</linearGradient>
<mask id="mask-2" x="69.2" y="33.9" width="704" height="528" maskUnits="userSpaceOnUse">
<g id="lottie-ymehjmywpqh__lottie_element_1018_2">
<g>
<rect class="st11" x="69.2" y="33.9" width="704" height="528"/>
<g class="st3">
<path class="st9" d="M689.3,383.8v34.2c-.1,4.4-3.7,8-8.1,8h-150.3c-4.4,0-8-3.6-8-8v-34.2c.1-4.4,3.7-8,8.1-8h150.2c4.4,0,8,3.6,8,8Z"/>
</g>
</g>
</g>
</mask>
<linearGradient id="Adsız_degrade_41" data-name="Adsız degrade 4" x1="21.7" y1="568.3" x2="163.7" y2="568.3" gradientTransform="translate(551.4 969.2) scale(1 -1)" xlink:href="#Adsız_degrade_4"/>
<linearGradient id="Adsız_degrade_5" data-name="Adsız degrade 5" x1="56.8" y1="647.8" x2="-1.1" y2="536.3" gradientTransform="translate(349.3 2027.2) scale(3 -3)" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#2fabff"/>
<stop offset=".3" stop-color="#5570ff"/>
<stop offset=".6" stop-color="#7b36ff"/>
<stop offset=".8" stop-color="#6a2cdc"/>
<stop offset="1" stop-color="#5921b8"/>
</linearGradient>
</defs>
<rect class="st15" x="69.2" y="33.9" width="704" height="528"/>
<g class="st5">
<path class="st17" d="M424.4,138.3c-2.9-1.6-7.6-1.6-10.5,0l-130.4,69.8c-2.9,1.6-5.3,5.5-5.3,8.8v154.3c0,3.3,2.4,7.3,5.2,8.9l130.4,72.3c2.9,1.6,7.6,1.6,10.5,0l131.5-72.1c2.9-1.6,5.3-5.6,5.3-8.9v-151.8c0-3.3-2.4-7.3-5.3-8.9l-131.5-72.5Z"/>
</g>
<g class="st2">
<path class="st17" d="M424.4,138.3c-2.9-1.6-7.6-1.6-10.5,0l-130.4,69.8c-2.9,1.6-5.3,5.5-5.3,8.8v154.3c0,3.3,2.4,7.3,5.2,8.9l130.4,72.3c2.9,1.6,7.6,1.6,10.5,0l131.5-72.1c2.9-1.6,5.3-5.6,5.3-8.9v-151.8c0-3.3-2.4-7.3-5.3-8.9l-131.5-72.5Z"/>
</g>
<g class="st1">
<path class="st17" d="M424.4,138.3c-2.9-1.6-7.6-1.6-10.5,0l-130.4,69.8c-2.9,1.6-5.3,5.5-5.3,8.8v154.3c0,3.3,2.4,7.3,5.2,8.9l130.4,72.3c2.9,1.6,7.6,1.6,10.5,0l131.5-72.1c2.9-1.6,5.3-5.6,5.3-8.9v-151.8c0-3.3-2.4-7.3-5.3-8.9l-131.5-72.5Z"/>
</g>
<g class="st5">
<path class="st17" d="M424.4,138.3c-2.9-1.6-7.6-1.6-10.5,0l-130.4,69.8c-2.9,1.6-5.3,5.5-5.3,8.8v154.3c0,3.3,2.4,7.3,5.2,8.9l130.4,72.3c2.9,1.6,7.6,1.6,10.5,0l131.5-72.1c2.9-1.6,5.3-5.6,5.3-8.9v-151.8c0-3.3-2.4-7.3-5.3-8.9l-131.5-72.5Z"/>
</g>
<g class="st4">
<path class="st17" d="M424.4,139.4c-2.9-1.6-7.6-1.6-10.5,0l-129.6,69.3c-2.9,1.6-5.3,5.5-5.3,8.8v153.3c0,3.3,2.4,7.3,5.2,8.9l129.6,71.9c2.9,1.6,7.6,1.6,10.5,0l130.7-71.6c2.9-1.6,5.3-5.6,5.3-8.9v-150.9c0-3.3-2.4-7.3-5.3-8.9l-130.7-72Z"/>
</g>
<g class="st8">
<path class="st17" d="M424.4,157.4c-2.9-1.6-7.7-1.6-10.6,0l-115.9,61.1c-2.9,1.5-5.3,5.5-5.3,8.8v135.9c0,3.3,2.4,7.3,5.2,8.9l116,64.5c2.9,1.6,7.6,1.6,10.5,0l117.1-63.3c2.9-1.6,5.3-5.5,5.3-8.9v-135c0-3.3-2.4-7.3-5.3-8.8l-117.1-63.2Z"/>
</g>
<g class="st3">
<g>
<path class="st19" d="M324.5,185.8v33.5c0,4.4-3.6,8-8,8h-107.8c-4.4,0-8-3.6-8-8v-33.5c0-4.4,3.6-8,8-8h107.8c4.4,0,8,3.6,8,8Z"/>
<path class="st16" d="M324.5,185.8v33.5c0,4.4-3.6,8-8,8h-107.8c-4.4,0-8-3.6-8-8v-33.5c0-4.4,3.6-8,8-8h107.8c4.4,0,8,3.6,8,8Z"/>
</g>
</g>
<g class="st7">
<g class="st6">
<path class="st0" d="M236.1,209.6c.2,0,.3-.1.4-.2,0,0,0-.3,0-.5v-1.3c0-.3-.1-.5-.3-.5s-.2,0-.4,0c-.4,0-.8.2-1.3.2-.5,0-.9,0-1.2,0-1.4,0-2.4-.4-3.1-1.1s-1-1.8-1-3.3v-.5c0-1.5.3-2.6,1-3.4s1.7-1.1,3.1-1.1,1.4,0,2.2.3c.2,0,.4,0,.4,0,.2,0,.3-.1.3-.5v-1.3c0-.2,0-.4,0-.5s-.2-.2-.3-.2c-1-.3-1.9-.4-2.9-.4-2.2,0-3.9.6-5.1,1.9-1.2,1.3-1.8,3.1-1.8,5.4s.6,4.1,1.7,5.3c1.2,1.2,2.9,1.8,5.1,1.8s2.2-.1,3.2-.5Z"/>
</g>
<g class="st6">
<path class="st0" d="M240.4,204.7c0-2.1.7-3.2,2.1-3.2s2.1,1,2.1,3.2-.7,3.2-2.1,3.2-2.1-1.1-2.1-3.2ZM246.2,208.7c.9-.9,1.3-2.3,1.3-4s-.4-3-1.3-4c-.9-.9-2.1-1.4-3.7-1.4s-2.8.5-3.7,1.4c-.9.9-1.3,2.3-1.3,4s.5,3,1.3,4c.9.9,2.1,1.4,3.7,1.4s2.8-.5,3.7-1.4Z"/>
</g>
<g class="st6">
<path class="st0" d="M252.3,209.8c.2,0,.3,0,.4-.1,0,0,.1-.2.1-.4v-7.1c.7-.5,1.4-.7,2.2-.7s.9.1,1.1.4c.2.2.4.7.4,1.2v6.2c0,.2,0,.3.1.4,0,0,.2.1.4.1h1.9c.2,0,.3,0,.4-.1,0,0,.1-.2.1-.4v-7.1c0-1-.3-1.7-.8-2.2-.5-.5-1.2-.8-2.2-.8-1.4,0-2.6.5-3.8,1.4l-.2-.7c0-.3-.2-.4-.6-.4h-1.4c-.2,0-.3,0-.4.1,0,0-.1.2-.1.4v9.2c0,.2,0,.3.1.4,0,0,.2.1.4.1h1.9Z"/>
</g>
<g class="st6">
<path class="st0" d="M267.7,209.7c.2,0,.3-.1.3-.2,0,0,0-.2,0-.5v-1c0-.3-.1-.4-.3-.4s-.3,0-.5,0c-.2,0-.4,0-.6,0-.5,0-.9,0-1.1-.3-.2-.2-.3-.5-.3-.9v-4.8h2.3c.2,0,.3,0,.4-.1s.1-.2.1-.4v-1.2c0-.2,0-.3-.1-.4,0,0-.2-.1-.4-.1h-2.3v-2.3c0-.2,0-.3-.1-.4,0,0-.2-.1-.4-.1h-1.4c-.2,0-.3,0-.4.1,0,0-.1.2-.2.4l-.4,2.3-1.1.2c-.2,0-.3,0-.4.2,0,0-.1.2-.1.4v.8c0,.2,0,.3.1.4,0,0,.2.1.4.1h1v4.9c0,1.1.3,1.9.8,2.5.5.5,1.4.8,2.5.8s1.4,0,2-.3Z"/>
</g>
<g class="st6">
<path class="st0" d="M272.5,203.6c0-.8.3-1.3.7-1.7.4-.4.9-.6,1.6-.6,1.1,0,1.7.7,1.7,2v.3h-3.9ZM278.3,209.6c.2,0,.3-.1.4-.2,0,0,0-.2,0-.4v-.9c0-.3-.1-.5-.3-.5s0,0-.1,0h-.1c-1,.3-1.8.4-2.6.4s-1.8-.2-2.3-.6-.8-1-.8-1.9h5.8c.2,0,.3,0,.4,0s.1-.2.2-.4c0-.5,0-1,0-1.4,0-1.3-.4-2.4-1.1-3.1-.7-.7-1.7-1.1-3-1.1s-2.8.5-3.7,1.4c-.9,1-1.3,2.3-1.3,4s.4,3.1,1.3,4c.9.9,2.2,1.4,3.9,1.4s2.2-.2,3.2-.6Z"/>
</g>
<g class="st6">
<path class="st0" d="M283.6,209.8c.2,0,.3,0,.4-.1,0,0,.1-.2.1-.4v-7.1c.7-.5,1.4-.7,2.2-.7s.9.1,1.1.4c.2.2.4.7.4,1.2v6.2c0,.2,0,.3.1.4,0,0,.2.1.4.1h1.9c.2,0,.3,0,.4-.1,0,0,.1-.2.1-.4v-7.1c0-1-.3-1.7-.8-2.2-.5-.5-1.2-.8-2.2-.8-1.4,0-2.6.5-3.8,1.4l-.2-.7c0-.3-.2-.4-.6-.4h-1.4c-.2,0-.3,0-.4.1,0,0-.1.2-.1.4v9.2c0,.2,0,.3.1.4,0,0,.2.1.4.1h1.9Z"/>
</g>
<g class="st6">
<path class="st0" d="M299.1,209.7c.2,0,.3-.1.3-.2,0,0,0-.2,0-.5v-1c0-.3-.1-.4-.3-.4s-.3,0-.5,0c-.2,0-.4,0-.6,0-.5,0-.9,0-1.1-.3-.2-.2-.3-.5-.3-.9v-4.8h2.3c.2,0,.3,0,.4-.1s.1-.2.1-.4v-1.2c0-.2,0-.3-.1-.4,0,0-.2-.1-.4-.1h-2.3v-2.3c0-.2,0-.3-.1-.4,0,0-.2-.1-.4-.1h-1.4c-.2,0-.3,0-.4.1,0,0-.1.2-.2.4l-.4,2.3-1.1.2c-.2,0-.3,0-.4.2,0,0-.1.2-.1.4v.8c0,.2,0,.3.1.4,0,0,.2.1.4.1h1v4.9c0,1.1.3,1.9.8,2.5.5.5,1.4.8,2.5.8s1.4,0,2-.3Z"/>
</g>
</g>
<g class="st3">
<g>
<path class="st12" d="M329.4,427.5v34c0,4.4-3.6,8-8,8h-127.2c-4.4.1-8-3.5-8-7.9v-34c0-4.4,3.6-8,8-8h127.2c4.4-.1,8,3.5,8,7.9Z"/>
<path class="st16" d="M329.4,427.5v34c0,4.4-3.6,8-8,8h-127.2c-4.4.1-8-3.5-8-7.9v-34c0-4.4,3.6-8,8-8h127.2c4.4-.1,8,3.5,8,7.9Z"/>
</g>
</g>
<g class="st7">
<g class="st6">
<path class="st0" d="M213.9,439.7h1.7c2.4,0,3.6,1.5,3.6,4.4v.4c0,2.9-1.2,4.4-3.6,4.4h-1.7v-9.2ZM215.9,451.2c2,0,3.6-.6,4.7-1.8,1.1-1.2,1.7-2.9,1.7-5.1s-.6-3.9-1.7-5.1c-1.1-1.2-2.7-1.8-4.8-1.8h-4.5c-.2,0-.3,0-.4.1,0,0-.1.2-.1.4v12.9c0,.2,0,.3.1.4,0,0,.2.1.4.1h4.5Z"/>
</g>
<g class="st6">
<path class="st0" d="M227.4,449.3c-.2-.2-.3-.5-.3-1,0-.9.6-1.4,1.7-1.4s1,0,1.6.1v1.7c-.3.2-.6.5-1,.6s-.7.2-1,.2-.7-.1-1-.3ZM229.1,451.2c.5-.2,1-.5,1.4-.9v.5c.2.2.2.3.3.3,0,0,.2.1.4.1h1.4c.2,0,.3,0,.4-.1,0,0,.1-.2.1-.4v-6.6c0-1.2-.3-2.1-1-2.6-.6-.6-1.6-.8-3-.8s-1.3,0-2,.2c-.7.1-1.2.3-1.7.5-.2,0-.3.2-.4.2,0,0,0,.2,0,.4v.9c0,.3.1.5.3.5s0,0,.1,0c0,0,0,0,.1,0,1.1-.3,2.2-.5,3.1-.5s1.2.1,1.4.3c.2.2.4.7.4,1.3v1c-.8-.2-1.5-.3-2.1-.3s-1.5.1-2.1.4c-.6.3-1.1.6-1.4,1.1-.3.5-.5,1.1-.5,1.7,0,.9.3,1.7.9,2.2.6.6,1.4.8,2.4.8s1-.1,1.6-.3Z"/>
</g>
<g class="st6">
<path class="st0" d="M241.5,451.1c.2,0,.3-.1.3-.2,0,0,0-.2,0-.5v-1c0-.3-.1-.4-.3-.4s-.3,0-.5,0c-.2,0-.4,0-.6,0-.5,0-.9,0-1.1-.3-.2-.2-.3-.5-.3-.9v-4.8h2.3c.2,0,.3,0,.4-.1s.1-.2.1-.4v-1.2c0-.2,0-.3-.1-.4,0,0-.2-.1-.4-.1h-2.3v-2.3c0-.2,0-.3-.1-.4,0,0-.2-.1-.4-.1h-1.4c-.2,0-.3,0-.4.1,0,0-.1.2-.2.4l-.4,2.3-1.1.2c-.2,0-.3,0-.4.2,0,0-.1.2-.1.4v.8c0,.2,0,.3.1.4,0,0,.2.1.4.1h1v4.9c0,1.1.3,1.9.8,2.5.5.5,1.4.8,2.5.8s1.4,0,2-.3Z"/>
</g>
<g class="st6">
<path class="st0" d="M246.6,449.3c-.2-.2-.3-.5-.3-1,0-.9.6-1.4,1.7-1.4s1,0,1.6.1v1.7c-.3.2-.6.5-1,.6s-.7.2-1,.2-.7-.1-1-.3ZM248.3,451.2c.5-.2,1-.5,1.4-.9v.5c.2.2.2.3.3.3,0,0,.2.1.4.1h1.4c.2,0,.3,0,.4-.1,0,0,.1-.2.1-.4v-6.6c0-1.2-.3-2.1-1-2.6-.6-.6-1.6-.8-3-.8s-1.3,0-2,.2c-.7.1-1.2.3-1.7.5-.2,0-.3.2-.4.2,0,0,0,.2,0,.4v.9c0,.3.1.5.3.5s0,0,.1,0c0,0,0,0,.1,0,1.1-.3,2.2-.5,3.1-.5s1.2.1,1.4.3c.2.2.4.7.4,1.3v1c-.8-.2-1.5-.3-2.1-.3s-1.5.1-2.1.4c-.6.3-1.1.6-1.4,1.1-.3.5-.5,1.1-.5,1.7,0,.9.3,1.7.9,2.2.6.6,1.4.8,2.4.8s1-.1,1.6-.3Z"/>
</g>
<g class="st6">
<path class="st0" d="M263,450.8c.6-.4,1.1-1.1,1.5-1.9.4-.8.6-1.8.6-2.9,0-1.6-.4-2.9-1.1-3.8-.8-.9-1.8-1.4-3.1-1.4s-1,.1-1.5.3c-.5.2-1,.5-1.4.8v-4.9c0-.2,0-.3-.1-.4,0,0-.2-.1-.4-.1h-1.9c-.2,0-.3,0-.4.1,0,0-.1.2-.1.4v13.8c0,.2,0,.3.1.4,0,0,.2.1.4.1h1.5c.3,0,.5-.2.6-.4v-.6c.5.4.9.7,1.5.9.5.2,1.1.3,1.7.3s1.6-.2,2.2-.7ZM257.9,448.7v-5.3c.6-.4,1.3-.5,2-.5s1.3.2,1.7.8.5,1.3.5,2.4-.2,1.9-.5,2.4-.9.8-1.6.8-1.4-.2-2-.5Z"/>
</g>
<g class="st6">
<path class="st0" d="M269.8,449.3c-.2-.2-.3-.5-.3-1,0-.9.6-1.4,1.7-1.4s1,0,1.6.1v1.7c-.3.2-.6.5-1,.6s-.7.2-1,.2-.7-.1-1-.3ZM271.5,451.2c.5-.2,1-.5,1.4-.9v.5c.2.2.2.3.3.3,0,0,.2.1.4.1h1.4c.2,0,.3,0,.4-.1,0,0,.1-.2.1-.4v-6.6c0-1.2-.3-2.1-1-2.6-.6-.6-1.6-.8-3-.8s-1.3,0-2,.2c-.7.1-1.2.3-1.7.5-.2,0-.3.2-.4.2,0,0,0,.2,0,.4v.9c0,.3.1.5.3.5s0,0,.1,0c0,0,0,0,.1,0,1.1-.3,2.2-.5,3.1-.5s1.2.1,1.4.3c.2.2.4.7.4,1.3v1c-.8-.2-1.5-.3-2.1-.3s-1.5.1-2.1.4c-.6.3-1.1.6-1.4,1.1-.3.5-.5,1.1-.5,1.7,0,.9.3,1.7.9,2.2.6.6,1.4.8,2.4.8s1-.1,1.6-.3Z"/>
</g>
<g class="st6">
<path class="st0" d="M284.1,450.7c.8-.6,1.1-1.4,1.1-2.4s-.2-1.3-.5-1.7c-.3-.4-.9-.8-1.8-1.2l-1.5-.6c-.5-.2-.8-.4-1-.5-.2-.1-.2-.3-.2-.6s.1-.5.4-.6c.3-.1.6-.2,1.2-.2s1.4,0,1.9.2c.3,0,.5.1.6.1.2,0,.3-.1.3-.5v-.9c0-.2,0-.4,0-.5,0,0-.2-.2-.3-.2-.8-.3-1.7-.5-2.7-.5s-2.2.3-2.9.8c-.7.5-1.1,1.3-1.1,2.2s.2,1.3.5,1.8c.4.5,1,.9,1.8,1.2l1.6.6c.5.2.8.3.9.5.1.2.2.4.2.6,0,.7-.5,1-1.4,1s-1,0-1.5-.1c-.5,0-1-.2-1.5-.3-.1,0-.2,0-.3,0-.2,0-.3.1-.3.5v.9c0,.2,0,.3,0,.4,0,0,.2.2.4.2.9.4,2,.6,3.2.6s2.2-.3,3-.9Z"/>
</g>
<g class="st6">
<path class="st0" d="M289.3,445c0-.8.3-1.3.7-1.7.4-.4.9-.6,1.6-.6,1.1,0,1.7.7,1.7,2v.3h-3.9ZM295.1,451c.2,0,.3-.1.4-.2,0,0,0-.2,0-.4v-.9c0-.3-.1-.5-.3-.5s0,0-.1,0h-.1c-1,.3-1.8.4-2.6.4s-1.8-.2-2.3-.6-.8-1-.8-1.9h5.8c.2,0,.3,0,.4,0s.1-.2.2-.4c0-.5,0-1,0-1.4,0-1.3-.4-2.4-1.1-3.1-.7-.7-1.7-1.1-3-1.1s-2.8.5-3.7,1.4c-.9,1-1.3,2.3-1.3,4s.4,3.1,1.3,4c.9.9,2.2,1.4,3.9,1.4s2.2-.2,3.2-.6Z"/>
</g>
<g class="st6">
<path class="st0" d="M303.8,450.7c.8-.6,1.1-1.4,1.1-2.4s-.2-1.3-.5-1.7c-.3-.4-.9-.8-1.8-1.2l-1.5-.6c-.5-.2-.8-.4-1-.5-.2-.1-.2-.3-.2-.6s.1-.5.4-.6c.3-.1.6-.2,1.2-.2s1.4,0,1.9.2c.3,0,.5.1.6.1.2,0,.3-.1.3-.5v-.9c0-.2,0-.4,0-.5,0,0-.2-.2-.3-.2-.8-.3-1.7-.5-2.7-.5s-2.2.3-2.9.8c-.7.5-1.1,1.3-1.1,2.2s.2,1.3.5,1.8c.4.5,1,.9,1.8,1.2l1.6.6c.5.2.8.3.9.5.1.2.2.4.2.6,0,.7-.5,1-1.4,1s-1,0-1.5-.1c-.5,0-1-.2-1.5-.3-.1,0-.2,0-.3,0-.2,0-.3.1-.3.5v.9c0,.2,0,.3,0,.4,0,0,.2.2.4.2.9.4,2,.6,3.2.6s2.2-.3,3-.9Z"/>
</g>
</g>
<g class="st3">
<g>
<path class="st13" d="M689.3,383.8v34.2c-.1,4.4-3.7,8-8.1,8h-150.3c-4.4,0-8-3.6-8-8v-34.2c.1-4.4,3.7-8,8.1-8h150.2c4.4,0,8,3.6,8,8Z"/>
<path class="st16" d="M689.3,383.8v34.2c-.1,4.4-3.7,8-8.1,8h-150.3c-4.4,0-8-3.6-8-8v-34.2c.1-4.4,3.7-8,8.1-8h150.2c4.4,0,8,3.6,8,8Z"/>
</g>
</g>
<g class="st7">
<g class="st6">
<path class="st0" d="M549.5,407.8c.2,0,.4,0,.5-.1,0,0,.2-.2.2-.4l.8-2.6h4.8l.8,2.6c0,.2.2.3.2.4.1,0,.2.1.4.1h2.2c.3,0,.4-.1.4-.3s0-.1,0-.2c0,0,0-.2-.1-.3l-4.4-12.5c0-.2-.1-.3-.2-.4,0,0-.3-.1-.5-.1h-2.2c-.2,0-.3,0-.3,0,0,0-.1,0-.2.2,0,0-.1.2-.2.3l-4.4,12.5c0,.1,0,.2-.1.3,0,.1,0,.2,0,.2,0,.2.1.3.4.3h2.1ZM551.7,402.5l1.7-5.8,1.8,5.8h-3.5Z"/>
</g>
<g class="st6">
<path class="st0" d="M564.9,405.3v-5.3c.6-.4,1.3-.5,2-.5s1.3.2,1.7.8.5,1.3.5,2.4-.2,1.9-.5,2.4-.9.8-1.6.8-1.4-.2-2-.5ZM564.4,412c.2,0,.3,0,.4-.1,0,0,.1-.2.1-.4v-4.4c.3.3.8.5,1.3.7.5.2,1,.3,1.6.3.8,0,1.6-.2,2.2-.7.6-.4,1.1-1.1,1.5-1.9.4-.8.6-1.8.6-2.9,0-1.6-.4-2.9-1.1-3.8-.8-.9-1.8-1.4-3.1-1.4s-1.2.1-1.7.3c-.6.2-1,.5-1.4.9v-.5c-.2-.3-.4-.4-.7-.4h-1.4c-.2,0-.3,0-.4.1,0,0-.1.2-.1.4v13.3c0,.2,0,.3.1.4,0,0,.2.1.4.1h1.9Z"/>
</g>
<g class="st6">
<path class="st0" d="M577.2,405.3v-5.3c.6-.4,1.3-.5,2-.5s1.3.2,1.7.8.5,1.3.5,2.4-.2,1.9-.5,2.4-.9.8-1.6.8-1.4-.2-2-.5ZM576.7,412c.2,0,.3,0,.4-.1,0,0,.1-.2.1-.4v-4.4c.3.3.8.5,1.3.7.5.2,1,.3,1.6.3.8,0,1.6-.2,2.2-.7.6-.4,1.1-1.1,1.5-1.9.4-.8.6-1.8.6-2.9,0-1.6-.4-2.9-1.1-3.8-.8-.9-1.8-1.4-3.1-1.4s-1.2.1-1.7.3c-.6.2-1,.5-1.4.9v-.5c-.2-.3-.4-.4-.7-.4h-1.4c-.2,0-.3,0-.4.1,0,0-.1.2-.1.4v13.3c0,.2,0,.3.1.4,0,0,.2.1.4.1h1.9Z"/>
</g>
<g class="st6">
<path class="st0" d="M590.6,407.8c.2,0,.3-.1.4-.2,0,0,0-.2,0-.5v-1c0-.2,0-.3,0-.3,0,0-.1-.1-.3-.1s-.1,0-.2,0c0,0-.2,0-.3,0-.5,0-.7-.3-.7-1v-11.2c0-.2,0-.3-.1-.4,0,0-.2-.1-.4-.1h-1.9c-.2,0-.3,0-.4.1,0,0-.1.2-.1.4v11.4c0,2,.9,3,2.7,3s1,0,1.4-.2Z"/>
</g>
<g class="st6">
<path class="st0" d="M595.8,395.6c.3-.3.5-.7.5-1.1s-.2-.9-.5-1.1-.7-.4-1.2-.4-.9.1-1.2.4c-.3.3-.5.7-.5,1.1s.1.9.5,1.1c.3.3.7.4,1.2.4s.9-.1,1.2-.4ZM595.5,407.8c.2,0,.3,0,.4-.1,0,0,.1-.2.1-.4v-9.2c0-.2,0-.3-.1-.4,0,0-.2-.1-.4-.1h-1.9c-.2,0-.3,0-.4.1,0,0-.1.2-.1.4v9.2c0,.2,0,.3.1.4,0,0,.2.1.4.1h1.9Z"/>
</g>
<g class="st6">
<path class="st0" d="M604.6,408c.4,0,.8-.2,1.2-.3.2,0,.3-.1.3-.2,0,0,0-.2,0-.4v-1c0-.3-.1-.4-.3-.4s-.2,0-.3,0c-.6.2-1.1.2-1.6.2-.9,0-1.6-.2-2-.7-.4-.5-.6-1.2-.6-2.2v-.3c0-1,.2-1.8.7-2.2.4-.5,1.1-.7,2.1-.7s1,0,1.6.2c0,0,0,0,.1,0,0,0,0,0,.1,0,.2,0,.3-.2.3-.5v-.9c0-.2,0-.4,0-.5,0,0-.2-.2-.4-.2-.7-.3-1.5-.4-2.3-.4-1.6,0-2.9.5-3.8,1.4-.9,1-1.4,2.3-1.4,4s.4,3,1.3,3.9c.9.9,2.1,1.4,3.7,1.4s.8,0,1.2-.1Z"/>
</g>
<g class="st6">
<path class="st0" d="M610.7,405.9c-.2-.2-.3-.5-.3-1,0-.9.6-1.4,1.7-1.4s1,0,1.6.1v1.7c-.3.2-.6.5-1,.6s-.7.2-1,.2-.7-.1-1-.3ZM612.4,407.8c.5-.2,1-.5,1.4-.9v.5c.2.2.2.3.3.3,0,0,.2.1.4.1h1.4c.2,0,.3,0,.4-.1,0,0,.1-.2.1-.4v-6.6c0-1.2-.3-2.1-1-2.6-.6-.6-1.6-.8-3-.8s-1.3,0-2,.2c-.7.1-1.2.3-1.7.5-.2,0-.3.2-.4.2,0,0,0,.2,0,.4v.9c0,.3.1.5.3.5s0,0,.1,0c0,0,0,0,.1,0,1.1-.3,2.2-.5,3.1-.5s1.2.1,1.4.3c.2.2.4.7.4,1.3v1c-.8-.2-1.5-.3-2.1-.3s-1.5.1-2.1.4c-.6.3-1.1.6-1.4,1.1-.3.5-.5,1.1-.5,1.7,0,.9.3,1.7.9,2.2.6.6,1.4.8,2.4.8s1-.1,1.6-.3Z"/>
</g>
<g class="st6">
<path class="st0" d="M624.7,407.7c.2,0,.3-.1.3-.2,0,0,0-.2,0-.5v-1c0-.3-.1-.4-.3-.4s-.3,0-.5,0c-.2,0-.4,0-.6,0-.5,0-.9,0-1.1-.3-.2-.2-.3-.5-.3-.9v-4.8h2.3c.2,0,.3,0,.4-.1s.1-.2.1-.4v-1.2c0-.2,0-.3-.1-.4,0,0-.2-.1-.4-.1h-2.3v-2.3c0-.2,0-.3-.1-.4,0,0-.2-.1-.4-.1h-1.4c-.2,0-.3,0-.4.1,0,0-.1.2-.2.4l-.4,2.3-1.1.2c-.2,0-.3,0-.4.2,0,0-.1.2-.1.4v.8c0,.2,0,.3.1.4,0,0,.2.1.4.1h1v4.9c0,1.1.3,1.9.8,2.5.5.5,1.4.8,2.5.8s1.4,0,2-.3Z"/>
</g>
<g class="st6">
<path class="st0" d="M630.1,395.6c.3-.3.5-.7.5-1.1s-.2-.9-.5-1.1-.7-.4-1.2-.4-.9.1-1.2.4c-.3.3-.5.7-.5,1.1s.1.9.5,1.1c.3.3.7.4,1.2.4s.9-.1,1.2-.4ZM629.9,407.8c.2,0,.3,0,.4-.1,0,0,.1-.2.1-.4v-9.2c0-.2,0-.3-.1-.4,0,0-.2-.1-.4-.1h-1.9c-.2,0-.3,0-.4.1,0,0-.1.2-.1.4v9.2c0,.2,0,.3.1.4,0,0,.2.1.4.1h1.9Z"/>
</g>
<g class="st6">
<path class="st0" d="M635.7,402.7c0-2.1.7-3.2,2.1-3.2s2.1,1,2.1,3.2-.7,3.2-2.1,3.2-2.1-1.1-2.1-3.2ZM641.5,406.7c.9-.9,1.3-2.3,1.3-4s-.4-3-1.3-4c-.9-.9-2.1-1.4-3.7-1.4s-2.8.5-3.7,1.4c-.9.9-1.3,2.3-1.3,4s.5,3,1.3,4c.9.9,2.1,1.4,3.7,1.4s2.8-.5,3.7-1.4Z"/>
</g>
<g class="st6">
<path class="st0" d="M647.6,407.8c.2,0,.3,0,.4-.1,0,0,.1-.2.1-.4v-7.1c.7-.5,1.4-.7,2.2-.7s.9.1,1.1.4c.2.2.4.7.4,1.2v6.2c0,.2,0,.3.1.4,0,0,.2.1.4.1h1.9c.2,0,.3,0,.4-.1,0,0,.1-.2.1-.4v-7.1c0-1-.3-1.7-.8-2.2-.5-.5-1.2-.8-2.2-.8-1.4,0-2.6.5-3.8,1.4l-.2-.7c0-.3-.2-.4-.6-.4h-1.4c-.2,0-.3,0-.4.1,0,0-.1.2-.1.4v9.2c0,.2,0,.3.1.4,0,0,.2.1.4.1h1.9Z"/>
</g>
<g class="st6">
<path class="st0" d="M663.3,407.3c.8-.6,1.1-1.4,1.1-2.4s-.2-1.3-.5-1.7c-.3-.4-.9-.8-1.8-1.2l-1.5-.6c-.5-.2-.8-.4-1-.5-.2-.1-.2-.3-.2-.6s.1-.5.4-.6c.3-.1.6-.2,1.2-.2s1.4,0,1.9.2c.3,0,.5.1.6.1.2,0,.3-.1.3-.5v-.9c0-.2,0-.4,0-.5,0,0-.2-.2-.3-.2-.8-.3-1.7-.5-2.7-.5s-2.2.3-2.9.8c-.7.5-1.1,1.3-1.1,2.2s.2,1.3.5,1.8c.4.5,1,.9,1.8,1.2l1.6.6c.5.2.8.3.9.5.1.2.2.4.2.6,0,.7-.5,1-1.4,1s-1,0-1.5-.1c-.5,0-1-.2-1.5-.3-.1,0-.2,0-.3,0-.2,0-.3.1-.3.5v.9c0,.2,0,.3,0,.4,0,0,.2.2.4.2.9.4,2,.6,3.2.6s2.2-.3,3-.9Z"/>
</g>
</g>
<g>
<path class="st18" d="M452.1,74c-19.6-11.3-43.8-11.3-63.4,0l-145.3,83.9c-19.6,11.3-31.7,32.3-31.7,54.9v167.8c0,22.7,12.1,43.6,31.7,54.9l145.3,83.9c19.6,11.3,43.8,11.3,63.4,0l145.3-83.9c19.6-11.3,31.7-32.3,31.7-54.9v-167.8c0-22.7-12.1-43.6-31.7-54.9l-145.3-83.9Z"/>
<path class="st10" d="M438.6,293.4l-12.7,25.4,120.5,69.8,12.7-25.4-120.5-69.8ZM422.7,269.8c-2-1.1-4.4-1.1-6.3,0l-21.1,12.2c-2,1.1-3.2,3.2-3.2,5.5v24.4c0,2.3,1.2,4.4,3.2,5.5l21.1,12.2c2,1.1,4.4,1.1,6.3,0l21.1-12.2c2-1.1,3.2-3.2,3.2-5.5v-24.4c0-2.3-1.2-4.4-3.2-5.5l-21.1-12.2ZM411.6,163.4c7.9-4.5,17.5-4.5,25.4,0l98.2,56.7c7.9,4.5,12.7,12.9,12.7,22v113.4c0,9.1-4.8,17.4-12.7,22l-98.2,56.7c-7.9,4.5-17.5,4.5-25.4,0l-98.2-56.7c-7.9-4.5-12.7-12.9-12.7-22v-113.4c0-9.1,4.8-17.4,12.7-22l98.2-56.7ZM395.7,135.2l-103.1,59.5c-15.7,9.1-25.4,25.8-25.4,44v119c0,18.1,9.7,34.9,25.4,44l103.1,59.5c15.7,9.1,35,9.1,50.8,0l103.1-59.5c15.7-9.1,25.4-25.8,25.4-44v-119c0-18.1-9.7-34.9-25.4-44l-103.1-59.5c-15.7-9.1-35-9.1-50.8,0Z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 20 KiB

+6 -10
View File
@@ -216,13 +216,6 @@ EOT
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
@@ -394,7 +387,7 @@ EOL
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}\""
tmux new-session -d -s ${var.session_name} -c ${var.folder} "export ${local.env_var_name}=\"${var.ai_api_key}\"; 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
@@ -409,7 +402,7 @@ EOL
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}\""
tmux new-session -d -s ${var.session_name} -c ${var.folder} "export ${local.env_var_name}=\"${var.ai_api_key}\"; 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
@@ -435,6 +428,7 @@ EOL
screen -U -dmS ${var.session_name} bash -c "
cd ${var.folder}
export PATH=\"$HOME/bin:$HOME/.local/bin:$PATH\"
export ${local.env_var_name}=\"${var.ai_api_key}\"
aider --architect --yes-always ${local.model_flag} ${var.ai_model} --message \"${local.combined_prompt}\"
/bin/bash
"
@@ -461,6 +455,7 @@ EOL
screen -U -dmS ${var.session_name} bash -c "
cd ${var.folder}
export PATH=\"$HOME/bin:$HOME/.local/bin:$PATH\"
export ${local.env_var_name}=\"${var.ai_api_key}\"
aider --architect --yes-always ${local.model_flag} ${var.ai_model} --message \"${local.combined_prompt}\"
/bin/bash
"
@@ -494,7 +489,7 @@ resource "coder_app" "aider_cli" {
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"
tmux new-session -s ${var.session_name} -c ${var.folder} "export ${local.env_var_name}=\"${var.ai_api_key}\"; 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
@@ -505,6 +500,7 @@ resource "coder_app" "aider_cli" {
else
cd "${var.folder}"
echo "Starting Aider directly..."
export ${local.env_var_name}="${var.ai_api_key}"
aider ${local.model_flag} ${var.ai_model} --message "${local.combined_prompt}"
fi
EOT
+19
View File
@@ -114,6 +114,25 @@ module "amazon-q" {
}
```
## Variables
| Name | Required | Default | Description |
| -------------------------------- | -------- | ------------------------ | ----------------------------------------------------------------------------------------------- |
| `agent_id` | Yes | — | The ID of a Coder agent. |
| `experiment_auth_tarball` | Yes | — | Base64-encoded, zstd-compressed tarball of a pre-authenticated Amazon Q config directory. |
| `install_amazon_q` | No | `true` | Whether to install Amazon Q. |
| `amazon_q_version` | No | `latest` | Version to install. |
| `experiment_use_screen` | No | `false` | Use GNU screen for background operation. |
| `experiment_use_tmux` | No | `false` | Use tmux for background operation. |
| `experiment_report_tasks` | No | `false` | Enable task reporting to Coder. |
| `experiment_pre_install_script` | No | `null` | Custom script to run before install. |
| `experiment_post_install_script` | No | `null` | Custom script to run after install. |
| `icon` | No | `/icon/amazon-q.svg` | The icon to use for the app. |
| `folder` | No | `/home/coder` | The folder to run Amazon Q in. |
| `order` | No | `null` | The order determines the position of app in the UI presentation. |
| `system_prompt` | No | See [main.tf](./main.tf) | The system prompt to use for Amazon Q. This should instruct the agent how to do task reporting. |
| `ai_prompt` | No | See [main.tf](./main.tf) | The initial task prompt to send to Amazon Q. |
## Notes
- Only one of `experiment_use_screen` or `experiment_use_tmux` can be true at a time.
+16 -33
View File
@@ -14,7 +14,7 @@ Run the [Claude Code](https://docs.anthropic.com/en/docs/agents-and-tools/claude
```tf
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "1.4.0"
version = "1.3.1"
agent_id = coder_agent.example.id
folder = "/home/coder"
install_claude_code = true
@@ -72,51 +72,34 @@ 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"
version = "1.3.1"
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_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.
@@ -124,7 +107,7 @@ Run Claude Code as a standalone app in your workspace. This will install Claude
```tf
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "1.4.0"
version = "1.3.1"
agent_id = coder_agent.example.id
folder = "/home/coder"
install_claude_code = true
+27 -179
View File
@@ -84,76 +84,11 @@ variable "experiment_post_install_script" {
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,28 +98,12 @@ 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
}
# Check if the specified folder exists
if [ ! -d "${var.folder}" ]; then
echo "Warning: The specified folder '${var.folder}' does not exist."
echo "Creating the folder..."
@@ -193,6 +112,8 @@ resource "coder_script" "claude_code" {
mkdir -p "${var.folder}"
echo "Folder created successfully."
fi
# Run pre-install script if provided
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
@@ -200,30 +121,11 @@ resource "coder_script" "claude_code" {
/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,6 +136,7 @@ resource "coder_script" "claude_code" {
coder exp mcp configure claude-code ${var.folder}
fi
# Run post-install script if provided
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
@@ -241,97 +144,46 @@ resource "coder_script" "claude_code" {
/tmp/post_install.sh
fi
# Handle terminal multiplexer selection (tmux or screen)
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
# Run with tmux if enabled
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..."
# Check if tmux is installed
if ! command_exists tmux; then
echo "Error: tmux is not installed. Please install tmux manually."
exit 1
fi
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
# Create a new tmux session in detached mode
tmux new-session -d -s claude-code -c ${var.folder} "claude --dangerously-skip-permissions \"$CODER_MCP_CLAUDE_TASK_PROMPT\""
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"
@@ -346,7 +198,6 @@ 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
@@ -356,6 +207,7 @@ EOF
exec bash
'
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
@@ -379,10 +231,6 @@ resource "coder_app" "claude_code" {
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"
+21 -13
View File
@@ -66,6 +66,27 @@ data "coder_parameter" "ai_prompt" {
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"
@@ -75,19 +96,6 @@ module "goose" {
install_goose = true
goose_version = "v1.0.16"
# Set prompts and API key directly in the module
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
task_prompt = data.coder_parameter.ai_prompt.value
anthropic_api_key = var.anthropic_api_key # or use a coder_parameter
# Enable experimental features
experiment_report_tasks = true
-41
View File
@@ -114,25 +114,6 @@ variable "experiment_additional_extensions" {
default = null
}
variable "system_prompt" {
type = string
description = "System prompt for Goose."
default = ""
}
variable "task_prompt" {
type = string
description = "Task prompt for Goose."
default = ""
}
variable "anthropic_api_key" {
type = string
description = "Anthropic API key for Goose."
default = ""
sensitive = true
}
locals {
base_extensions = <<-EOT
coder:
@@ -169,28 +150,6 @@ EOT
encoded_post_install_script = var.experiment_post_install_script != null ? base64encode(var.experiment_post_install_script) : ""
}
# Set environment variables for Goose
resource "coder_env" "goose_system_prompt" {
count = var.system_prompt != "" ? 1 : 0
agent_id = var.agent_id
name = "GOOSE_SYSTEM_PROMPT"
value = var.system_prompt
}
resource "coder_env" "goose_task_prompt" {
count = var.task_prompt != "" ? 1 : 0
agent_id = var.agent_id
name = "GOOSE_TASK_PROMPT"
value = var.task_prompt
}
resource "coder_env" "anthropic_api_key" {
count = var.anthropic_api_key != "" ? 1 : 0
agent_id = var.agent_id
name = "ANTHROPIC_API_KEY"
value = var.anthropic_api_key
}
# Install and Initialize Goose
resource "coder_script" "goose" {
agent_id = var.agent_id
@@ -1,74 +0,0 @@
---
display_name: Windows RDP Desktop
description: Enable RDP on Windows and add a one-click Coder Desktop button for seamless access
icon: ../../../../.icons/desktop.svg
maintainer_github: coder
verified: true
supported_os: [windows]
tags: [rdp, windows, desktop, remote]
---
# Windows RDP Desktop
This module enables Remote Desktop Protocol (RDP) on Windows workspaces and adds a one-click button to launch RDP sessions directly through [Coder Desktop](https://coder.com/docs/user-guides/desktop). It provides a complete, standalone solution for RDP access, eliminating the need for manual configuration or port forwarding through the Coder CLI.
> **Note**: [Coder Desktop](https://coder.com/docs/user-guides/desktop) is required on client devices to use the Local Windows RDP access feature.
```tf
module "rdp_desktop" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/local-windows-rdp/coder"
version = "1.0.0"
agent_id = coder_agent.main.id
agent_name = coder_agent.main.name
}
```
## Features
-**Standalone Solution**: Automatically configures RDP on Windows workspaces
-**One-click Access**: Launch RDP sessions directly through Coder Desktop
-**No Port Forwarding**: Uses Coder Desktop URI handling
-**Auto-configuration**: Sets up Windows firewall, services, and authentication
-**Secure**: Configurable credentials with sensitive variable handling
-**Customizable**: Display name, credentials, and UI ordering options
## What This Module Does
1. **Enables RDP** on the Windows workspace
2. **Sets the administrator password** for RDP authentication
3. **Configures Windows Firewall** to allow RDP connections
4. **Starts RDP services** automatically
5. **Creates a Coder Desktop button** for one-click access
## Examples
### Basic Usage
Uses default credentials (Username: `Administrator`, Password: `coderRDP!`):
```tf
module "rdp_desktop" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/local-windows-rdp/coder"
version = "1.0.0"
agent_id = coder_agent.main.id
agent_name = coder_agent.main.name
}
```
### Custom display name
Specify a custom display name for the `coder_app` button:
```tf
module "rdp_desktop" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/local-windows-rdp/coder"
version = "1.0.0"
agent_id = coder_agent.windows.id
agent_name = "windows"
display_name = "Windows Desktop"
order = 1
}
```
@@ -1,120 +0,0 @@
# PowerShell script to configure RDP for Coder Desktop access
# This script enables RDP, sets the admin password, and configures necessary settings
Write-Output "[Coder RDP Setup] Starting RDP configuration..."
# Function to set the administrator password
function Set-AdminPassword {
param (
[string]$adminUsername,
[string]$adminPassword
)
Write-Output "[Coder RDP Setup] Setting password for user: $adminUsername"
try {
# Convert password to secure string
$securePassword = ConvertTo-SecureString -AsPlainText $adminPassword -Force
# Set the password for the user
Get-LocalUser -Name $adminUsername | Set-LocalUser -Password $securePassword
# Enable the user account (in case it's disabled)
Get-LocalUser -Name $adminUsername | Enable-LocalUser
Write-Output "[Coder RDP Setup] Successfully set password for $adminUsername"
} catch {
Write-Error "[Coder RDP Setup] Failed to set password: $_"
exit 1
}
}
# Function to enable and configure RDP
function Enable-RDP {
Write-Output "[Coder RDP Setup] Enabling Remote Desktop..."
try {
# Enable RDP
Set-ItemProperty -Path 'HKLM:\System\CurrentControlSet\Control\Terminal Server' -Name "fDenyTSConnections" -Value 0 -Force
# Disable Network Level Authentication (NLA) for easier access
Set-ItemProperty -Path 'HKLM:\System\CurrentControlSet\Control\Terminal Server\WinStations\RDP-Tcp' -Name "UserAuthentication" -Value 0 -Force
# Set security layer to RDP Security Layer
Set-ItemProperty -Path 'HKLM:\System\CurrentControlSet\Control\Terminal Server\WinStations\RDP-Tcp' -Name "SecurityLayer" -Value 1 -Force
Write-Output "[Coder RDP Setup] RDP enabled successfully"
} catch {
Write-Error "[Coder RDP Setup] Failed to enable RDP: $_"
exit 1
}
}
# Function to configure Windows Firewall for RDP
function Configure-Firewall {
Write-Output "[Coder RDP Setup] Configuring Windows Firewall for RDP..."
try {
# Enable RDP firewall rules
Enable-NetFirewallRule -DisplayGroup "Remote Desktop" -ErrorAction SilentlyContinue
# If the above fails, try alternative method
if ($LASTEXITCODE -ne 0) {
netsh advfirewall firewall set rule group="remote desktop" new enable=Yes
}
Write-Output "[Coder RDP Setup] Firewall configured successfully"
} catch {
Write-Warning "[Coder RDP Setup] Failed to configure firewall rules: $_"
# Continue anyway as RDP might still work
}
}
# Function to ensure RDP service is running
function Start-RDPService {
Write-Output "[Coder RDP Setup] Starting Remote Desktop Services..."
try {
# Start the Terminal Services
Set-Service -Name "TermService" -StartupType Automatic -ErrorAction SilentlyContinue
Start-Service -Name "TermService" -ErrorAction SilentlyContinue
# Start Remote Desktop Services UserMode Port Redirector
Set-Service -Name "UmRdpService" -StartupType Automatic -ErrorAction SilentlyContinue
Start-Service -Name "UmRdpService" -ErrorAction SilentlyContinue
Write-Output "[Coder RDP Setup] RDP services started successfully"
} catch {
Write-Warning "[Coder RDP Setup] Some RDP services may not have started: $_"
# Continue anyway
}
}
# Main execution
try {
# Template variables from Terraform
$username = "${username}"
$password = "${password}"
# Validate inputs
if ([string]::IsNullOrWhiteSpace($username) -or [string]::IsNullOrWhiteSpace($password)) {
Write-Error "[Coder RDP Setup] Username or password is empty"
exit 1
}
# Execute configuration steps
Set-AdminPassword -adminUsername $username -adminPassword $password
Enable-RDP
Configure-Firewall
Start-RDPService
Write-Output "[Coder RDP Setup] RDP configuration completed successfully!"
Write-Output "[Coder RDP Setup] You can now connect using:"
Write-Output " Username: $username"
Write-Output " Password: [hidden]"
Write-Output " Port: 3389 (default)"
} catch {
Write-Error "[Coder RDP Setup] An unexpected error occurred: $_"
exit 1
}
@@ -1,184 +0,0 @@
import { describe, expect, it } from "bun:test";
import {
type TerraformState,
runTerraformApply,
runTerraformInit,
testRequiredVariables,
} from "~test";
type TestVariables = Readonly<{
agent_id: string;
agent_name: string;
username?: string;
password?: string;
display_name?: string;
order?: number;
}>;
function findRdpApp(state: TerraformState) {
for (const resource of state.resources) {
const isRdpAppResource =
resource.type === "coder_app" && resource.name === "rdp_desktop";
if (!isRdpAppResource) {
continue;
}
for (const instance of resource.instances) {
if (instance.attributes.slug === "rdp-desktop") {
return instance.attributes;
}
}
}
return null;
}
function findRdpScript(state: TerraformState) {
for (const resource of state.resources) {
const isRdpScriptResource =
resource.type === "coder_script" && resource.name === "rdp_setup";
if (!isRdpScriptResource) {
continue;
}
for (const instance of resource.instances) {
if (instance.attributes.display_name === "Configure RDP") {
return instance.attributes;
}
}
}
return null;
}
describe("local-windows-rdp", async () => {
await runTerraformInit(import.meta.dir);
testRequiredVariables<TestVariables>(import.meta.dir, {
agent_id: "test-agent-id",
agent_name: "test-agent",
});
it("should create RDP app with default values", async () => {
const state = await runTerraformApply<TestVariables>(import.meta.dir, {
agent_id: "test-agent-id",
agent_name: "main",
});
const app = findRdpApp(state);
// Verify the app was created
expect(app).not.toBeNull();
expect(app?.slug).toBe("rdp-desktop");
expect(app?.display_name).toBe("RDP Desktop");
expect(app?.icon).toBe("/icon/desktop.svg");
expect(app?.external).toBe(true);
// Verify the URI format
expect(app?.url).toStartWith("coder://");
expect(app?.url).toContain("/v0/open/ws/");
expect(app?.url).toContain("/agent/main/rdp");
expect(app?.url).toContain("username=Administrator");
expect(app?.url).toContain("password=coderRDP!");
});
it("should create RDP configuration script", async () => {
const state = await runTerraformApply<TestVariables>(import.meta.dir, {
agent_id: "test-agent-id",
agent_name: "main",
});
const script = findRdpScript(state);
// Verify the script was created
expect(script).not.toBeNull();
expect(script?.display_name).toBe("Configure RDP");
expect(script?.icon).toBe("/icon/desktop.svg");
expect(script?.run_on_start).toBe(true);
expect(script?.run_on_stop).toBe(false);
// Verify the script contains PowerShell configuration
expect(script?.script).toContain("Set-AdminPassword");
expect(script?.script).toContain("Enable-RDP");
expect(script?.script).toContain("Configure-Firewall");
expect(script?.script).toContain("Start-RDPService");
});
it("should create RDP app with custom values", async () => {
const state = await runTerraformApply<TestVariables>(import.meta.dir, {
agent_id: "custom-agent-id",
agent_name: "windows-agent",
username: "CustomUser",
password: "CustomPass123!",
display_name: "Custom RDP",
order: 5,
});
const app = findRdpApp(state);
// Verify custom values
expect(app?.display_name).toBe("Custom RDP");
expect(app?.order).toBe(5);
// Verify custom credentials in URI
expect(app?.url).toContain("/agent/windows-agent/rdp");
expect(app?.url).toContain("username=CustomUser");
expect(app?.url).toContain("password=CustomPass123!");
});
it("should pass custom credentials to PowerShell script", async () => {
const state = await runTerraformApply<TestVariables>(import.meta.dir, {
agent_id: "test-agent-id",
agent_name: "main",
username: "TestAdmin",
password: "TestPassword123!",
});
const script = findRdpScript(state);
// Verify custom credentials are in the script
expect(script?.script).toContain('$username = "TestAdmin"');
expect(script?.script).toContain('$password = "TestPassword123!"');
});
it("should handle sensitive password variable", async () => {
const state = await runTerraformApply<TestVariables>(import.meta.dir, {
agent_id: "test-agent-id",
agent_name: "main",
password: "SensitivePass123!",
});
const app = findRdpApp(state);
// Verify password is included in URI even when sensitive
expect(app?.url).toContain("password=SensitivePass123!");
});
it("should use correct default agent name", async () => {
const state = await runTerraformApply<TestVariables>(import.meta.dir, {
agent_id: "test-agent-id",
agent_name: "main",
});
const app = findRdpApp(state);
expect(app?.url).toContain("/agent/main/rdp");
});
it("should construct proper Coder URI format", async () => {
const state = await runTerraformApply<TestVariables>(import.meta.dir, {
agent_id: "test-agent-id",
agent_name: "test-agent",
username: "TestUser",
password: "TestPass",
});
const app = findRdpApp(state);
// Verify complete URI structure
expect(app?.url).toMatch(
/^coder:\/\/[^\/]+\/v0\/open\/ws\/[^\/]+\/agent\/test-agent\/rdp\?username=TestUser&password=TestPass$/,
);
});
});
@@ -1,81 +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."
}
variable "agent_name" {
type = string
description = "The name of the Coder agent."
}
variable "username" {
type = string
description = "The username for RDP authentication."
default = "Administrator"
}
variable "password" {
type = string
description = "The password for RDP authentication."
default = "coderRDP!"
sensitive = true
}
variable "display_name" {
type = string
description = "The display name for the RDP app button."
default = "RDP Desktop"
}
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
}
locals {
# Extract server name from workspace access URL
server_name = regex("https?:\\/\\/([^\\/]+)", data.coder_workspace.me.access_url)[0]
}
data "coder_workspace" "me" {}
resource "coder_script" "rdp_setup" {
agent_id = var.agent_id
display_name = "Configure RDP"
icon = "/icon/desktop.svg"
script = templatefile("${path.module}/configure-rdp.ps1", {
username = var.username
password = var.password
})
run_on_start = true
}
resource "coder_app" "rdp_desktop" {
agent_id = var.agent_id
slug = "rdp-desktop"
display_name = var.display_name
url = "coder://${local.server_name}/v0/open/ws/${data.coder_workspace.me.name}/agent/${var.agent_name}/rdp?username=${var.username}&password=${var.password}"
icon = "/icon/desktop.svg"
external = true
order = var.order
group = var.group
}
+5 -5
View File
@@ -15,7 +15,7 @@ Automatically install [Visual Studio Code Server](https://code.visualstudio.com/
module "vscode-web" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/vscode-web/coder"
version = "1.3.0"
version = "1.2.0"
agent_id = coder_agent.example.id
accept_license = true
}
@@ -31,7 +31,7 @@ module "vscode-web" {
module "vscode-web" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/vscode-web/coder"
version = "1.3.0"
version = "1.2.0"
agent_id = coder_agent.example.id
install_prefix = "/home/coder/.vscode-web"
folder = "/home/coder"
@@ -45,7 +45,7 @@ module "vscode-web" {
module "vscode-web" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/vscode-web/coder"
version = "1.3.0"
version = "1.2.0"
agent_id = coder_agent.example.id
extensions = ["github.copilot", "ms-python.python", "ms-toolsai.jupyter"]
accept_license = true
@@ -60,7 +60,7 @@ Configure VS Code's [settings.json](https://code.visualstudio.com/docs/getstarte
module "vscode-web" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/vscode-web/coder"
version = "1.3.0"
version = "1.2.0"
agent_id = coder_agent.example.id
extensions = ["dracula-theme.theme-dracula"]
settings = {
@@ -78,7 +78,7 @@ By default, this module installs the latest. To pin a specific version, retrieve
module "vscode-web" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/vscode-web/coder"
version = "1.3.0"
version = "1.2.0"
agent_id = coder_agent.example.id
commit_id = "e54c774e0add60467559eb0d1e229c6452cf8447"
accept_license = true
@@ -121,12 +121,6 @@ variable "use_cached" {
default = false
}
variable "disable_trust" {
type = bool
description = "Disables workspace trust protection for VS Code Web."
default = false
}
variable "extensions_dir" {
type = string
description = "Override the directory to store extensions in."
@@ -175,7 +169,6 @@ resource "coder_script" "vscode-web" {
SETTINGS : replace(jsonencode(var.settings), "\"", "\\\""),
OFFLINE : var.offline,
USE_CACHED : var.use_cached,
DISABLE_TRUST : var.disable_trust,
EXTENSIONS_DIR : var.extensions_dir,
FOLDER : var.folder,
AUTO_INSTALL_EXTENSIONS : var.auto_install_extensions,
+2 -8
View File
@@ -16,16 +16,10 @@ if [ -n "${SERVER_BASE_PATH}" ]; then
SERVER_BASE_PATH_ARG="--server-base-path=${SERVER_BASE_PATH}"
fi
# Set disable workspace trust
DISABLE_TRUST_ARG=""
if [ "${DISABLE_TRUST}" = true ]; then
DISABLE_TRUST_ARG="--disable-workspace-trust"
fi
run_vscode_web() {
echo "👷 Running $VSCODE_WEB serve-local $EXTENSION_ARG $SERVER_BASE_PATH_ARG $DISABLE_TRUST_ARG --port ${PORT} --host 127.0.0.1 --accept-server-license-terms --without-connection-token --telemetry-level ${TELEMETRY_LEVEL} in the background..."
echo "👷 Running $VSCODE_WEB serve-local $EXTENSION_ARG $SERVER_BASE_PATH_ARG --port ${PORT} --host 127.0.0.1 --accept-server-license-terms --without-connection-token --telemetry-level ${TELEMETRY_LEVEL} in the background..."
echo "Check logs at ${LOG_PATH}!"
"$VSCODE_WEB" serve-local "$EXTENSION_ARG" "$SERVER_BASE_PATH_ARG" "$DISABLE_TRUST_ARG" --port "${PORT}" --host 127.0.0.1 --accept-server-license-terms --without-connection-token --telemetry-level "${TELEMETRY_LEVEL}" > "${LOG_PATH}" 2>&1 &
"$VSCODE_WEB" serve-local "$EXTENSION_ARG" "$SERVER_BASE_PATH_ARG" --port "${PORT}" --host 127.0.0.1 --accept-server-license-terms --without-connection-token --telemetry-level "${TELEMETRY_LEVEL}" > "${LOG_PATH}" 2>&1 &
}
# Check if the settings file exists...
+4 -4
View File
@@ -16,7 +16,7 @@ Enable Remote Desktop + a web based client on Windows workspaces, powered by [de
module "windows_rdp" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/windows-rdp/coder"
version = "1.2.1"
version = "1.2.0"
agent_id = resource.coder_agent.main.id
resource_id = resource.aws_instance.dev.id
}
@@ -34,7 +34,7 @@ module "windows_rdp" {
module "windows_rdp" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/windows-rdp/coder"
version = "1.2.1"
version = "1.2.0"
agent_id = resource.coder_agent.main.id
resource_id = resource.aws_instance.dev.id
}
@@ -46,7 +46,7 @@ module "windows_rdp" {
module "windows_rdp" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/windows-rdp/coder"
version = "1.2.1"
version = "1.2.0"
agent_id = resource.coder_agent.main.id
resource_id = resource.google_compute_instance.dev[0].id
}
@@ -58,7 +58,7 @@ module "windows_rdp" {
module "windows_rdp" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/windows-rdp/coder"
version = "1.2.1"
version = "1.2.0"
agent_id = resource.coder_agent.main.id
resource_id = resource.aws_instance.dev.id
devolutions_gateway_version = "2025.1.6" # Specify a specific version
@@ -16,11 +16,6 @@ function Configure-RDP {
New-ItemProperty -Path 'HKLM:\System\CurrentControlSet\Control\Terminal Server\WinStations\RDP-Tcp' -Name "SecurityLayer" -Value 1 -PropertyType DWORD -Force
# Enable RDP through Windows Firewall
Enable-NetFirewallRule -DisplayGroup "Remote Desktop"
# Disable UDP. It doesn't work via `coder port-forward` and is broken due to MTU issues in Coder Connect.
# Requires a restart to take effect. c.f. https://github.com/coder/internal/issues/608#issuecomment-2965923672
New-ItemProperty -Path 'HKLM:\SOFTWARE\Policies\Microsoft\Windows NT\Terminal Services' -Name "SelectTransport" -Value 1 -PropertyType DWORD -Force
Restart-Service -Name "TermService" -Force
}
function Install-DevolutionsGateway {