mirror of
https://github.com/coder/registry.git
synced 2026-06-03 04:58:15 +00:00
Compare commits
60 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 68f881e220 | |||
| 94d938156d | |||
| 1d30ac954d | |||
| a468ec68ea | |||
| 52c1d47161 | |||
| b206a6870c | |||
| ac48f0d166 | |||
| 49ef1203e4 | |||
| b5837a704d | |||
| 5764ff2fdc | |||
| df2f4321a1 | |||
| 8677e7d52b | |||
| d6ae51fad0 | |||
| eca289be3a | |||
| 7aa75f9451 | |||
| efac32ad9f | |||
| a69accbad5 | |||
| 29e5307121 | |||
| 545a245530 | |||
| c554463d4d | |||
| 4ea87a6e01 | |||
| f5a571679a | |||
| 0e1dcd3a80 | |||
| 4238f38353 | |||
| 858799ce20 | |||
| 32246a99c1 | |||
| bb667d2209 | |||
| f08bb30b53 | |||
| 32b039a838 | |||
| 4dcaea7bf9 | |||
| c2bc5cd314 | |||
| c73b923e40 | |||
| 08ed594bfd | |||
| fd074a5643 | |||
| 40863c0aa7 | |||
| d9b223ac3c | |||
| 1749f9ca05 | |||
| 61554aaa8c | |||
| f4fcae7c0f | |||
| 05b9bb1ae4 | |||
| 45b72c7241 | |||
| 2646b36cb1 | |||
| 3202e4899a | |||
| c4a5184725 | |||
| 63d56eadc9 | |||
| 507b73a07e | |||
| 814f765313 | |||
| 92a154f54a | |||
| 7aa7dea5ad | |||
| 59b0472125 | |||
| 673caf2e95 | |||
| ab5ff4b4be | |||
| f5a68b500b | |||
| a5edad7f17 | |||
| fb657b875d | |||
| 016d4dc523 | |||
| c8d99cfba3 | |||
| 74c8698566 | |||
| 03333991a4 | |||
| 2b0dba4ed1 |
@@ -4,3 +4,7 @@ updates:
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
groups:
|
||||
github-actions:
|
||||
patterns:
|
||||
- "*"
|
||||
|
||||
@@ -1,275 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Tag Release Script
|
||||
# Automatically detects modules that need tagging and creates release tags
|
||||
# Usage: ./tag_release.sh
|
||||
# Operates on the current checked-out commit
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
MODULES_TO_TAG=()
|
||||
|
||||
usage() {
|
||||
echo "Usage: $0"
|
||||
echo ""
|
||||
echo "This script will:"
|
||||
echo " 1. Scan all modules in the registry"
|
||||
echo " 2. Check which modules need new release tags"
|
||||
echo " 3. Extract version information from README files"
|
||||
echo " 4. Generate a report for confirmation"
|
||||
echo " 5. Create and push release tags after confirmation"
|
||||
echo ""
|
||||
echo "The script operates on the current checked-out commit."
|
||||
echo "Make sure you have checked out the commit you want to tag before running."
|
||||
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
|
||||
}
|
||||
|
||||
extract_version_from_readme() {
|
||||
local readme_path="$1"
|
||||
local namespace="$2"
|
||||
local module_name="$3"
|
||||
|
||||
[ ! -f "$readme_path" ] && return 1
|
||||
|
||||
local version_line
|
||||
version_line=$(grep -E "source\s*=\s*\"registry\.coder\.com/${namespace}/${module_name}" "$readme_path" | head -1 || echo "")
|
||||
|
||||
if [ -n "$version_line" ]; then
|
||||
local version
|
||||
version=$(echo "$version_line" | sed -n 's/.*version\s*=\s*"\([^"]*\)".*/\1/p')
|
||||
if [ -n "$version" ]; then
|
||||
echo "$version"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
local fallback_version
|
||||
fallback_version=$(grep -E 'version\s*=\s*"[0-9]+\.[0-9]+\.[0-9]+"' "$readme_path" | head -1 | sed 's/.*version\s*=\s*"\([^"]*\)".*/\1/' || echo "")
|
||||
|
||||
if [ -n "$fallback_version" ]; then
|
||||
echo "$fallback_version"
|
||||
return 0
|
||||
fi
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
check_module_needs_tagging() {
|
||||
local namespace="$1"
|
||||
local module_name="$2"
|
||||
local readme_version="$3"
|
||||
|
||||
local tag_name="release/${namespace}/${module_name}/v${readme_version}"
|
||||
|
||||
if git rev-parse --verify "$tag_name" > /dev/null 2>&1; then
|
||||
return 1
|
||||
else
|
||||
return 0
|
||||
fi
|
||||
}
|
||||
|
||||
detect_modules_needing_tags() {
|
||||
MODULES_TO_TAG=()
|
||||
|
||||
echo "🔍 Scanning all modules for missing release tags..."
|
||||
echo ""
|
||||
|
||||
local all_modules
|
||||
all_modules=$(find registry -mindepth 3 -maxdepth 3 -type d -path "*/modules/*" | sort -u || echo "")
|
||||
|
||||
[ -z "$all_modules" ] && {
|
||||
echo "❌ No modules found to check"
|
||||
return 1
|
||||
}
|
||||
|
||||
local total_checked=0
|
||||
local needs_tagging=0
|
||||
|
||||
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)
|
||||
|
||||
total_checked=$((total_checked + 1))
|
||||
|
||||
local readme_path="$module_path/README.md"
|
||||
local readme_version
|
||||
|
||||
if ! readme_version=$(extract_version_from_readme "$readme_path" "$namespace" "$module_name"); then
|
||||
echo "⚠️ $namespace/$module_name: No version found in README, skipping"
|
||||
continue
|
||||
fi
|
||||
|
||||
if ! validate_version "$readme_version"; then
|
||||
echo "⚠️ $namespace/$module_name: Invalid version format '$readme_version', skipping"
|
||||
continue
|
||||
fi
|
||||
|
||||
if check_module_needs_tagging "$namespace" "$module_name" "$readme_version"; then
|
||||
echo "📦 $namespace/$module_name: v$readme_version (needs tag)"
|
||||
MODULES_TO_TAG+=("$module_path:$namespace:$module_name:$readme_version")
|
||||
needs_tagging=$((needs_tagging + 1))
|
||||
else
|
||||
echo "✅ $namespace/$module_name: v$readme_version (already tagged)"
|
||||
fi
|
||||
|
||||
done <<< "$all_modules"
|
||||
|
||||
echo ""
|
||||
echo "📊 Summary: $needs_tagging of $total_checked modules need tagging"
|
||||
echo ""
|
||||
|
||||
[ $needs_tagging -eq 0 ] && {
|
||||
echo "🎉 All modules are up to date! No tags needed."
|
||||
return 0
|
||||
}
|
||||
|
||||
echo "## Tags to be created:"
|
||||
for module_info in "${MODULES_TO_TAG[@]}"; do
|
||||
IFS=':' read -r module_path namespace module_name version <<< "$module_info"
|
||||
echo "- \`release/$namespace/$module_name/v$version\`"
|
||||
done
|
||||
echo ""
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
create_and_push_tags() {
|
||||
[ ${#MODULES_TO_TAG[@]} -eq 0 ] && {
|
||||
echo "❌ No modules to tag found"
|
||||
return 1
|
||||
}
|
||||
|
||||
local current_commit
|
||||
current_commit=$(git rev-parse HEAD)
|
||||
|
||||
echo "🏷️ Creating release tags for commit: $current_commit"
|
||||
echo ""
|
||||
|
||||
local created_tags=0
|
||||
local failed_tags=0
|
||||
|
||||
for module_info in "${MODULES_TO_TAG[@]}"; do
|
||||
IFS=':' read -r module_path namespace module_name version <<< "$module_info"
|
||||
|
||||
local tag_name="release/$namespace/$module_name/v$version"
|
||||
local tag_message="Release $namespace/$module_name v$version"
|
||||
|
||||
echo "Creating tag: $tag_name"
|
||||
|
||||
if git tag -a "$tag_name" -m "$tag_message" "$current_commit"; then
|
||||
echo "✅ Created: $tag_name"
|
||||
created_tags=$((created_tags + 1))
|
||||
else
|
||||
echo "❌ Failed to create: $tag_name"
|
||||
failed_tags=$((failed_tags + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "📊 Tag creation summary:"
|
||||
echo " Created: $created_tags"
|
||||
echo " Failed: $failed_tags"
|
||||
echo ""
|
||||
|
||||
[ $created_tags -eq 0 ] && {
|
||||
echo "❌ No tags were created successfully"
|
||||
return 1
|
||||
}
|
||||
|
||||
echo "🚀 Pushing tags to origin..."
|
||||
|
||||
local tags_to_push=()
|
||||
for module_info in "${MODULES_TO_TAG[@]}"; do
|
||||
IFS=':' read -r module_path namespace module_name version <<< "$module_info"
|
||||
local tag_name="release/$namespace/$module_name/v$version"
|
||||
|
||||
if git rev-parse --verify "$tag_name" > /dev/null 2>&1; then
|
||||
tags_to_push+=("$tag_name")
|
||||
fi
|
||||
done
|
||||
|
||||
local pushed_tags=0
|
||||
local failed_pushes=0
|
||||
|
||||
if [ ${#tags_to_push[@]} -eq 0 ]; then
|
||||
echo "❌ No valid tags found to push"
|
||||
else
|
||||
if git push --atomic origin "${tags_to_push[@]}"; then
|
||||
echo "✅ Successfully pushed all ${#tags_to_push[@]} tags"
|
||||
pushed_tags=${#tags_to_push[@]}
|
||||
else
|
||||
echo "❌ Failed to push tags"
|
||||
failed_pushes=${#tags_to_push[@]}
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "📊 Push summary:"
|
||||
echo " Pushed: $pushed_tags"
|
||||
echo " Failed: $failed_pushes"
|
||||
echo ""
|
||||
|
||||
if [ $pushed_tags -gt 0 ]; then
|
||||
echo "🎉 Successfully created and pushed $pushed_tags release tags!"
|
||||
echo ""
|
||||
echo "📝 Next steps:"
|
||||
echo " - Tags will be automatically published to registry.coder.com"
|
||||
echo " - Monitor the registry website for updates"
|
||||
echo " - Check GitHub releases for any issues"
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
main() {
|
||||
[ $# -gt 0 ] && usage
|
||||
|
||||
echo "🚀 Coder Registry Tag Release Script"
|
||||
echo "Operating on commit: $(git rev-parse HEAD)"
|
||||
echo ""
|
||||
|
||||
if ! git rev-parse --git-dir > /dev/null 2>&1; then
|
||||
echo "❌ Not in a git repository"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
detect_modules_needing_tags || exit 1
|
||||
|
||||
[ ${#MODULES_TO_TAG[@]} -eq 0 ] && {
|
||||
echo "✨ No modules need tagging. All done!"
|
||||
exit 0
|
||||
}
|
||||
|
||||
echo ""
|
||||
echo "❓ Do you want to proceed with creating and pushing these release tags?"
|
||||
echo " This will create git tags and push them to the remote repository."
|
||||
echo ""
|
||||
read -p "Continue? [y/N]: " -r response
|
||||
|
||||
case "$response" in
|
||||
[yY] | [yY][eE][sS])
|
||||
echo ""
|
||||
create_and_push_tags
|
||||
;;
|
||||
*)
|
||||
echo ""
|
||||
echo "🚫 Operation cancelled by user"
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -11,7 +11,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Run check.sh
|
||||
run: |
|
||||
|
||||
@@ -12,7 +12,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
- name: Set up Terraform
|
||||
uses: coder/coder/.github/actions/setup-tf@main
|
||||
- name: Set up Bun
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
- name: Install Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
- name: Validate formatting
|
||||
run: bun fmt:ci
|
||||
- name: Check for typos
|
||||
uses: crate-ci/typos@v1.34.0
|
||||
uses: crate-ci/typos@v1.35.5
|
||||
with:
|
||||
config: .github/typos.toml
|
||||
validate-readme-files:
|
||||
@@ -59,7 +59,7 @@ jobs:
|
||||
needs: validate-style
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
|
||||
@@ -14,6 +14,7 @@ on:
|
||||
paths:
|
||||
- ".github/workflows/deploy-registry.yaml"
|
||||
- "registry/**/templates/**"
|
||||
- "registry/**/README.md"
|
||||
- ".icons/**"
|
||||
|
||||
jobs:
|
||||
@@ -27,14 +28,14 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
- name: Authenticate with Google Cloud
|
||||
uses: google-github-actions/auth@b7593ed2efd1c1617e1b0254da33b86225adb2a5
|
||||
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@6a7c903a70c8625ed6700fa299f5ddb4ca6022e9
|
||||
uses: google-github-actions/setup-gcloud@26f734c2779b00b7dda794207734c511110a4368
|
||||
- name: Deploy to dev.registry.coder.com
|
||||
run: gcloud builds triggers run 29818181-126d-4f8a-a937-f228b27d3d34 --branch main
|
||||
- name: Deploy to registry.coder.com
|
||||
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
name: lint
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: stable
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
name: Create Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "release/*/*/v*.*.*"
|
||||
|
||||
jobs:
|
||||
create-release:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: read
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Extract tag information
|
||||
id: tag_info
|
||||
run: |
|
||||
TAG=${GITHUB_REF#refs/tags/}
|
||||
echo "tag=$TAG" >> $GITHUB_OUTPUT
|
||||
|
||||
IFS='/' read -ra PARTS <<< "$TAG"
|
||||
NAMESPACE="${PARTS[1]}"
|
||||
MODULE="${PARTS[2]}"
|
||||
VERSION="${PARTS[3]}"
|
||||
|
||||
echo "namespace=$NAMESPACE" >> $GITHUB_OUTPUT
|
||||
echo "module=$MODULE" >> $GITHUB_OUTPUT
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "module_path=registry/$NAMESPACE/modules/$MODULE" >> $GITHUB_OUTPUT
|
||||
|
||||
RELEASE_TITLE="$NAMESPACE/$MODULE $VERSION"
|
||||
echo "release_title=$RELEASE_TITLE" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Find previous tag
|
||||
id: prev_tag
|
||||
env:
|
||||
NAMESPACE: ${{ steps.tag_info.outputs.namespace }}
|
||||
MODULE: ${{ steps.tag_info.outputs.module }}
|
||||
CURRENT_TAG: ${{ steps.tag_info.outputs.tag }}
|
||||
run: |
|
||||
PREV_TAG=$(git tag -l "release/$NAMESPACE/$MODULE/v*" | sort -V | grep -B1 "$CURRENT_TAG" | head -1)
|
||||
|
||||
if [ -z "$PREV_TAG" ] || [ "$PREV_TAG" = "$CURRENT_TAG" ]; then
|
||||
echo "No previous tag found, using initial commit"
|
||||
PREV_TAG=$(git rev-list --max-parents=0 HEAD)
|
||||
fi
|
||||
|
||||
echo "prev_tag=$PREV_TAG" >> $GITHUB_OUTPUT
|
||||
echo "Previous tag: $PREV_TAG"
|
||||
|
||||
- name: Generate changelog
|
||||
id: changelog
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
MODULE_PATH: ${{ steps.tag_info.outputs.module_path }}
|
||||
PREV_TAG: ${{ steps.prev_tag.outputs.prev_tag }}
|
||||
CURRENT_TAG: ${{ steps.tag_info.outputs.tag }}
|
||||
run: |
|
||||
echo "Generating changelog for $MODULE_PATH between $PREV_TAG and $CURRENT_TAG"
|
||||
|
||||
COMMITS=$(git log --oneline --no-merges "$PREV_TAG..$CURRENT_TAG" -- "$MODULE_PATH")
|
||||
|
||||
if [ -z "$COMMITS" ]; then
|
||||
echo "No commits found for this module"
|
||||
echo "changelog=No changes found for this module." >> $GITHUB_OUTPUT
|
||||
exit 0
|
||||
fi
|
||||
|
||||
FULL_CHANGELOG=$(gh api repos/:owner/:repo/releases/generate-notes \
|
||||
--field tag_name="$CURRENT_TAG" \
|
||||
--field previous_tag_name="$PREV_TAG" \
|
||||
--jq '.body')
|
||||
|
||||
MODULE_COMMIT_SHAS=$(git log --format="%H" --no-merges "$PREV_TAG..$CURRENT_TAG" -- "$MODULE_PATH")
|
||||
|
||||
FILTERED_CHANGELOG="## What's Changed\n\n"
|
||||
|
||||
for sha in $MODULE_COMMIT_SHAS; do
|
||||
SHORT_SHA=${sha:0:7}
|
||||
|
||||
COMMIT_LINES=$(echo "$FULL_CHANGELOG" | grep -E "$SHORT_SHA|$(git log --format='%s' -n 1 $sha)" || true)
|
||||
|
||||
if [ -n "$COMMIT_LINES" ]; then
|
||||
FILTERED_CHANGELOG="${FILTERED_CHANGELOG}${COMMIT_LINES}\n"
|
||||
else
|
||||
COMMIT_MSG=$(git log --format="%s" -n 1 $sha)
|
||||
AUTHOR=$(gh api repos/:owner/:repo/commits/$sha --jq '.author.login // .commit.author.name')
|
||||
FILTERED_CHANGELOG="${FILTERED_CHANGELOG}* $COMMIT_MSG by @$AUTHOR\n"
|
||||
fi
|
||||
done
|
||||
|
||||
echo "changelog<<EOF" >> $GITHUB_OUTPUT
|
||||
echo -e "$FILTERED_CHANGELOG" >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create Release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAG_NAME: ${{ steps.tag_info.outputs.tag }}
|
||||
RELEASE_TITLE: ${{ steps.tag_info.outputs.release_title }}
|
||||
CHANGELOG: ${{ steps.changelog.outputs.changelog }}
|
||||
run: |
|
||||
gh release create "$TAG_NAME" \
|
||||
--title "$RELEASE_TITLE" \
|
||||
--notes "$CHANGELOG"
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
issues: write
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<svg width="25" height="25" viewBox="0 0 25 25" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="25" height="25"/>
|
||||
<path d="M5.50694 21.1637C5.17323 21.1637 4.89218 21.1064 4.66378 20.9926C4.436 20.8787 4.26333 20.7052 4.1476 20.4763C4.03187 20.2473 3.97247 19.9672 3.97247 19.6382V16.2463C3.97247 15.8281 3.88859 15.5239 3.72265 15.3341C3.55549 15.1449 3.25912 15.0449 2.83418 15.0353C2.70191 15.0353 2.59598 14.9859 2.51577 14.8853C2.43433 14.7859 2.39453 14.6708 2.39453 14.5425C2.39453 14.4033 2.43433 14.2882 2.51577 14.1984C2.5966 14.1087 2.70375 14.0593 2.83418 14.0496C3.25912 14.0394 3.55549 13.94 3.72265 13.7508C3.88981 13.5616 3.97247 13.2622 3.97247 12.8537V9.46177C3.97247 8.96352 4.10474 8.58456 4.36742 8.3261C4.6301 8.06763 5.01035 7.9375 5.50694 7.9375H9.55926C9.71173 7.9375 9.83725 7.98269 9.9389 8.07185C10.0399 8.16162 10.0914 8.27669 10.0914 8.41466C10.0914 8.5448 10.0485 8.65626 9.96278 8.75145C9.87706 8.84664 9.76316 8.89423 9.62049 8.89423H5.8578C5.6441 8.89423 5.48245 8.94906 5.37162 9.05871C5.26018 9.16836 5.20446 9.33766 5.20446 9.5678V12.9754C5.20446 13.2742 5.14323 13.5454 5.02199 13.7894C4.90075 14.034 4.73848 14.2256 4.53581 14.3659C4.33313 14.5051 4.09616 14.575 3.82246 14.575V14.5147C4.09616 14.5147 4.33313 14.5846 4.53581 14.7238C4.73848 14.863 4.90075 15.0552 5.02199 15.3004C5.14323 15.5444 5.20446 15.8155 5.20446 16.1143V19.537C5.20446 19.7671 5.26018 19.9358 5.37162 20.0461C5.48306 20.1569 5.64533 20.2106 5.8578 20.2106H9.62049C9.76194 20.2106 9.87583 20.2582 9.96278 20.3527C10.0497 20.4479 10.0914 20.56 10.0914 20.6895C10.0914 20.8191 10.0412 20.9299 9.9389 21.0251C9.83725 21.1203 9.71112 21.1673 9.55926 21.1673H5.50694V21.1643V21.1637Z" fill="#F8F7F7" stroke="#F8F7F7" stroke-width="0.259057" stroke-miterlimit="10"/>
|
||||
<path d="M15.4423 21.1634C15.2898 21.1634 15.1643 21.1158 15.0626 21.0212C14.961 20.926 14.9102 20.8139 14.9102 20.6856C14.9102 20.5573 14.953 20.444 15.0387 20.3488C15.1245 20.2536 15.2384 20.2067 15.381 20.2067H19.1437C19.3574 20.2067 19.5191 20.153 19.6299 20.0422C19.7413 19.9325 19.7971 19.7632 19.7971 19.5331V16.1104C19.7971 15.8116 19.8583 15.5405 19.9795 15.2965C20.1008 15.0519 20.263 14.8603 20.4657 14.7199C20.6684 14.5807 20.9054 14.5108 21.1791 14.5108V14.5711C20.9054 14.5711 20.6684 14.5012 20.4657 14.362C20.263 14.2229 20.1008 14.0307 19.9795 13.7855C19.8583 13.5415 19.7971 13.2703 19.7971 12.9715V9.5639C19.7971 9.33496 19.7413 9.16566 19.6299 9.0548C19.5185 8.94515 19.3562 8.89033 19.1437 8.89033H15.381C15.2396 8.89033 15.1257 8.84273 15.0387 8.74754C14.953 8.65355 14.9102 8.54089 14.9102 8.41076C14.9102 8.27158 14.9604 8.15771 15.0626 8.06795C15.1637 7.97818 15.2898 7.93359 15.4423 7.93359H19.4946C19.9912 7.93359 20.3702 8.06373 20.6341 8.32219C20.898 8.58065 21.029 8.95961 21.029 9.45786V12.8498C21.029 13.2583 21.1129 13.5583 21.2789 13.7469C21.446 13.9361 21.7424 14.0361 22.1673 14.0457C22.2996 14.0554 22.4055 14.1048 22.4858 14.1945C22.5672 14.2843 22.607 14.3994 22.607 14.5385C22.607 14.6687 22.5672 14.7826 22.4858 14.8814C22.4055 14.9808 22.2978 15.0314 22.1673 15.0314C21.7424 15.041 21.4466 15.141 21.2789 15.3302C21.1117 15.5194 21.029 15.823 21.029 16.2424V19.6343C21.029 19.9639 20.9709 20.2422 20.8539 20.4723C20.737 20.7025 20.5655 20.8736 20.3377 20.9887C20.1093 21.1025 19.8283 21.1598 19.4946 21.1598H15.4423V21.1628V21.1634Z" fill="#F8F7F7" stroke="#F8F7F7" stroke-width="0.259057" stroke-miterlimit="10"/>
|
||||
<path d="M16.4845 15.8401C17.2224 15.8401 17.8206 15.2515 17.8206 14.5255C17.8206 13.7996 17.2224 13.2109 16.4845 13.2109C15.7467 13.2109 15.1484 13.7996 15.1484 14.5255C15.1484 15.2515 15.7467 15.8401 16.4845 15.8401Z" fill="#F8F7F7" stroke="#F8F7F7" stroke-width="0.259057" stroke-miterlimit="10"/>
|
||||
<path d="M9.00014 15.8401C9.73798 15.8401 10.3362 15.2515 10.3362 14.5255C10.3362 13.7996 9.73798 13.2109 9.00014 13.2109C8.2623 13.2109 7.66406 13.7996 7.66406 14.5255C7.66406 15.2515 8.2623 15.8401 9.00014 15.8401Z" fill="#F8F7F7" stroke="#F8F7F7" stroke-width="0.259057" stroke-miterlimit="10"/>
|
||||
<path d="M12.0442 4.13327L11.942 6.81971C11.942 6.97033 11.7974 7.04564 11.5084 7.04564C11.2194 7.04564 11.0749 6.97033 11.0749 6.81971C11.0492 6.15036 11.0284 5.63103 11.0112 5.26291C11.0027 4.88637 10.9941 4.61826 10.9855 4.45921C10.9769 4.30016 10.9727 4.20376 10.9727 4.17062V4.12062C10.9727 3.92843 11.1515 3.83203 11.5084 3.83203C11.8654 3.83203 12.0442 3.93264 12.0442 4.13327ZM14.213 4.13327L14.1108 6.81971C14.1108 6.97033 13.9663 7.04564 13.6773 7.04564C13.3883 7.04564 13.2437 6.97033 13.2437 6.81971C13.218 6.15036 13.1972 5.63103 13.1801 5.26291C13.1715 4.88637 13.1629 4.61826 13.1543 4.45921C13.1458 4.30016 13.1415 4.20376 13.1415 4.17062V4.12062C13.1415 3.92843 13.3203 3.83203 13.6773 3.83203C14.0342 3.83203 14.213 3.93264 14.213 4.13327Z" fill="#F8F7F7" stroke="#F8F7F7" stroke-width="0.259057" stroke-miterlimit="10"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.8 KiB |
@@ -0,0 +1,15 @@
|
||||
<svg width="721" height="721" viewBox="0 0 721 721" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_1637_2935)">
|
||||
<g clip-path="url(#clip1_1637_2935)">
|
||||
<path d="M304.246 295.411V249.828C304.246 245.989 305.687 243.109 309.044 241.191L400.692 188.412C413.167 181.215 428.042 177.858 443.394 177.858C500.971 177.858 537.44 222.482 537.44 269.982C537.44 273.34 537.44 277.179 536.959 281.018L441.954 225.358C436.197 222 430.437 222 424.68 225.358L304.246 295.411ZM518.245 472.945V364.024C518.245 357.304 515.364 352.507 509.608 349.149L389.174 279.096L428.519 256.543C431.877 254.626 434.757 254.626 438.115 256.543L529.762 309.323C556.154 324.679 573.905 357.304 573.905 388.971C573.905 425.436 552.315 459.024 518.245 472.941V472.945ZM275.937 376.982L236.592 353.952C233.235 352.034 231.794 349.154 231.794 345.315V239.756C231.794 188.416 271.139 149.548 324.4 149.548C344.555 149.548 363.264 156.268 379.102 168.262L284.578 222.964C278.822 226.321 275.942 231.119 275.942 237.838V376.986L275.937 376.982ZM360.626 425.922L304.246 394.255V327.083L360.626 295.416L417.002 327.083V394.255L360.626 425.922ZM396.852 571.789C376.698 571.789 357.989 565.07 342.151 553.075L436.674 498.374C442.431 495.017 445.311 490.219 445.311 483.499V344.352L485.138 367.382C488.495 369.299 489.936 372.179 489.936 376.018V481.577C489.936 532.917 450.109 571.785 396.852 571.785V571.789ZM283.134 464.79L191.486 412.01C165.094 396.654 147.343 364.029 147.343 332.362C147.343 295.416 169.415 262.309 203.48 248.393V357.791C203.48 364.51 206.361 369.308 212.117 372.665L332.074 442.237L292.729 464.79C289.372 466.707 286.491 466.707 283.134 464.79ZM277.859 543.48C223.639 543.48 183.813 502.695 183.813 452.314C183.813 448.475 184.294 444.636 184.771 440.797L279.295 495.498C285.051 498.856 290.812 498.856 296.568 495.498L417.002 425.927V471.509C417.002 475.349 415.562 478.229 412.204 480.146L320.557 532.926C308.081 540.122 293.206 543.48 277.854 543.48H277.859ZM396.852 600.576C454.911 600.576 503.37 559.313 514.41 504.612C568.149 490.696 602.696 440.315 602.696 388.976C602.696 355.387 588.303 322.762 562.392 299.25C564.791 289.173 566.231 279.096 566.231 269.024C566.231 200.411 510.571 149.067 446.274 149.067C433.322 149.067 420.846 150.984 408.37 155.305C386.775 134.192 357.026 120.758 324.4 120.758C266.342 120.758 217.883 162.02 206.843 216.721C153.104 230.637 118.557 281.018 118.557 332.357C118.557 365.946 132.95 398.571 158.861 422.083C156.462 432.16 155.022 442.237 155.022 452.309C155.022 520.922 210.682 572.266 274.978 572.266C287.931 572.266 300.407 570.349 312.883 566.028C334.473 587.141 364.222 600.576 396.852 600.576Z" fill="white"/>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_1637_2935">
|
||||
<rect width="720" height="720" fill="white" transform="translate(0.606934 0.899902)"/>
|
||||
</clipPath>
|
||||
<clipPath id="clip1_1637_2935">
|
||||
<rect width="484.139" height="479.818" fill="white" transform="translate(118.557 120.758)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.9 KiB |
Executable
+137
@@ -0,0 +1,137 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
width="800"
|
||||
height="800"
|
||||
viewBox="0 0 211.66666 211.66666"
|
||||
version="1.1"
|
||||
id="svg924"
|
||||
inkscape:export-filename="/home/daniela/Documents/proxmox/Proxmox/Marketing/Logo/proxmox-logo/Screen/Full Lockup/stacked/proxmox-logo-color-stacked-bgblack.png"
|
||||
inkscape:export-xdpi="360"
|
||||
inkscape:export-ydpi="360"
|
||||
inkscape:version="1.0.2 (e86c870879, 2021-01-15)"
|
||||
sodipodi:docname="proxmox-logo-stacked-inverted-color-bgtrans.svg">
|
||||
<defs
|
||||
id="defs918" />
|
||||
<sodipodi:namedview
|
||||
id="base"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1.0"
|
||||
inkscape:pageopacity="0"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:zoom="0.38489655"
|
||||
inkscape:cx="541.41545"
|
||||
inkscape:cy="189.70435"
|
||||
inkscape:document-units="mm"
|
||||
inkscape:current-layer="layer1"
|
||||
inkscape:document-rotation="0"
|
||||
showgrid="false"
|
||||
inkscape:pagecheckerboard="true"
|
||||
fit-margin-top="0"
|
||||
fit-margin-left="0"
|
||||
fit-margin-right="0"
|
||||
fit-margin-bottom="0"
|
||||
inkscape:window-width="1720"
|
||||
inkscape:window-height="1343"
|
||||
inkscape:window-x="1720"
|
||||
inkscape:window-y="27"
|
||||
inkscape:window-maximized="0"
|
||||
units="px" />
|
||||
<metadata
|
||||
id="metadata921">
|
||||
<rdf:RDF>
|
||||
<cc:Work
|
||||
rdf:about="">
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||
<dc:title></dc:title>
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<g
|
||||
inkscape:label="Layer 1"
|
||||
inkscape:groupmode="layer"
|
||||
id="layer1"
|
||||
transform="translate(-11.346916,-31.368461)">
|
||||
<g
|
||||
id="g209">
|
||||
<g
|
||||
transform="matrix(0.84666672,0,0,0.84666672,544.05161,-814.30036)"
|
||||
id="g1288"
|
||||
style="fill:#ffffff">
|
||||
<g
|
||||
id="g1286"
|
||||
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:25.8336px;line-height:125%;font-family:Helion;-inkscape-font-specification:Helion;text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#ffffff;fill-opacity:1;stroke:none"
|
||||
transform="matrix(1.2435137,0,0,1.2435137,-791.06481,553.75862)">
|
||||
<path
|
||||
inkscape:connector-curvature="0"
|
||||
id="path1272"
|
||||
d="m 168.85142,500.9595 h -11.85747 c -0.46554,0.0129 -0.85842,0.18085 -1.17864,0.50374 -0.32023,0.32294 -0.48707,0.72335 -0.50052,1.20125 v 16.3783 c 1.27229,-0.0318 2.33145,-0.472 3.17749,-1.32073 0.84604,-0.84873 1.2852,-1.91543 1.3175,-3.2001 h 9.04164 c 1.28573,-0.0317 2.35673,-0.47199 3.21302,-1.32072 0.85624,-0.84873 1.30079,-1.91543 1.33364,-3.2001 v -4.49499 c -0.0328,-1.28573 -0.4774,-2.35673 -1.33364,-3.21301 -0.85629,-0.85625 -1.92729,-1.3008 -3.21302,-1.33364 z m -9.04164,9.60997 v -5.06332 h 7.93081 c 0.0463,-0.0231 0.23141,0.0232 0.55542,0.13885 0.32398,0.11573 0.50912,0.43972 0.55541,0.97198 v 2.81583 c 0.0231,0.0474 -0.0231,0.23681 -0.13885,0.56833 -0.11573,0.33154 -0.43972,0.52098 -0.97198,0.56833 z"
|
||||
style="fill:#ffffff;fill-opacity:1" />
|
||||
<path
|
||||
inkscape:connector-curvature="0"
|
||||
id="path1274"
|
||||
d="m 194.05931,508.89031 v -3.38416 c -0.0318,-1.28573 -0.47201,-2.35673 -1.32072,-3.21301 -0.84875,-0.85625 -1.91545,-1.3008 -3.2001,-1.33364 h -11.85747 c -0.47684,0.0129 -0.87295,0.18085 -1.18833,0.50374 -0.31538,0.32294 -0.47899,0.72335 -0.49083,1.20125 v 16.3783 c 1.27336,-0.0318 2.33683,-0.472 3.1904,-1.32073 0.85357,-0.84873 1.29705,-1.91543 1.33042,-3.2001 v -1.13666 h 5.14082 l 2.60916,3.71999 c 0.4187,0.60063 0.94398,1.07208 1.57583,1.41437 0.63182,0.34229 1.33793,0.51667 2.11833,0.52313 0.37618,-5.4e-4 0.74107,-0.0447 1.09468,-0.1324 0.35358,-0.0877 0.68618,-0.21582 0.99781,-0.38427 l -3.64249,-5.19249 c 1.05592,-0.22872 1.92133,-0.74647 2.59625,-1.55322 0.67487,-0.80675 1.02362,-1.77011 1.04624,-2.8901 z m -13.53663,0.5425 v -3.92666 h 7.87915 c 0.0474,-0.0231 0.23679,0.0232 0.56833,0.13885 0.33151,0.11573 0.52096,0.43972 0.56833,0.97198 v 1.705 c 0.0237,0.0463 -0.0237,0.23143 -0.14208,0.55541 -0.11842,0.324 -0.44995,0.50914 -0.99458,0.55542 z"
|
||||
style="fill:#ffffff;fill-opacity:1" />
|
||||
<path
|
||||
inkscape:connector-curvature="0"
|
||||
id="path1276"
|
||||
d="m 210.1751,500.9595 h -9.01581 c -1.28467,0.0328 -2.35137,0.47739 -3.2001,1.33364 -0.84873,0.85628 -1.28897,1.92728 -1.32072,3.21301 v 9.01581 c 0.0317,1.28467 0.47199,2.35137 1.32072,3.2001 0.84873,0.84873 1.91543,1.28897 3.2001,1.32073 h 9.01581 c 1.28465,-0.0318 2.35135,-0.472 3.2001,-1.32073 0.84871,-0.84873 1.28895,-1.91543 1.32072,-3.2001 v -9.01581 c -0.0318,-1.28573 -0.47201,-2.35673 -1.32072,-3.21301 -0.84875,-0.85625 -1.91545,-1.3008 -3.2001,-1.33364 z m 0,12.4258 c 0.0237,0.0474 -0.0237,0.23681 -0.14208,0.56833 -0.11842,0.33153 -0.44995,0.52098 -0.99458,0.56833 h -6.74249 c -0.0474,0.0237 -0.23681,-0.0237 -0.56833,-0.14208 -0.33153,-0.1184 -0.52098,-0.44993 -0.56833,-0.99458 v -6.76832 c -0.0237,-0.0463 0.0237,-0.23141 0.14208,-0.55541 0.1184,-0.32398 0.44993,-0.50912 0.99458,-0.55542 h 6.74249 c 0.0473,-0.0231 0.23679,0.0232 0.56833,0.13885 0.33151,0.11573 0.52096,0.43972 0.56833,0.97198 z"
|
||||
style="fill:#ffffff;fill-opacity:1" />
|
||||
<path
|
||||
inkscape:connector-curvature="0"
|
||||
id="path1278"
|
||||
d="m 237.4767,502.25116 c -0.39183,-0.39179 -0.84822,-0.69964 -1.36917,-0.92354 -0.52099,-0.22387 -1.08071,-0.33797 -1.67916,-0.34229 -0.6367,0.005 -1.22333,0.1308 -1.75989,0.37781 -0.53659,0.24705 -1.00052,0.58611 -1.39177,1.01719 l -3.90082,4.28832 -3.92666,-4.28832 c -0.4015,-0.44238 -0.86434,-0.78467 -1.38854,-1.02688 -0.5242,-0.24217 -1.1033,-0.36487 -1.73729,-0.36812 -0.59847,0.004 -1.15819,0.11842 -1.67916,0.34229 -0.52097,0.2239 -0.97736,0.53175 -1.36916,0.92354 l 7.05248,7.74998 -7.05248,7.74998 c 0.3918,0.40419 0.84819,0.71957 1.36916,0.94615 0.52097,0.22657 1.08069,0.34175 1.67916,0.34552 0.62538,-0.005 1.20878,-0.13079 1.75021,-0.37782 0.54142,-0.24703 1.00857,-0.58609 1.40145,-1.01718 l 3.90083,-4.28832 3.90082,4.28832 c 0.39125,0.43109 0.85518,0.77015 1.39177,1.01718 0.53656,0.24703 1.12319,0.37297 1.75989,0.37782 0.59845,-0.004 1.15817,-0.11895 1.67916,-0.34552 0.52096,-0.22658 0.97734,-0.54196 1.36917,-0.94615 l -7.05249,-7.74998 z"
|
||||
style="fill:#ffffff;fill-opacity:1" />
|
||||
<path
|
||||
inkscape:connector-curvature="0"
|
||||
id="path1280"
|
||||
d="m 260.98042,500.9595 h -2.84166 c -0.92947,0.0129 -1.75721,0.2648 -2.48322,0.75562 -0.72604,0.49085 -1.27607,1.14314 -1.6501,1.95687 l 0.0258,-0.0517 -2.66082,5.83832 -2.635,-5.83832 v 0.0517 c -0.36275,-0.81373 -0.90955,-1.46602 -1.64041,-1.95687 -0.73087,-0.49082 -1.56184,-0.74269 -2.49291,-0.75562 h -2.81583 c -0.48922,0.0129 -0.89286,0.18085 -1.21093,0.50374 -0.31808,0.32294 -0.48276,0.72335 -0.49406,1.20125 v 16.3783 c 1.27336,-0.0318 2.33683,-0.472 3.19041,-1.32073 0.85357,-0.84873 1.29704,-1.91543 1.33041,-3.2001 v -8.65414 c 0.002,-0.11785 0.0371,-0.2115 0.10656,-0.28094 0.0694,-0.0694 0.16307,-0.10493 0.28094,-0.10656 0.0673,0.002 0.13293,0.0237 0.19698,0.0646 0.064,0.0409 0.11032,0.0883 0.13885,0.14208 l 5.01166,11.05664 c 0.0947,0.19752 0.23464,0.3579 0.41979,0.48115 0.18512,0.12325 0.38964,0.18675 0.61354,0.19052 0.22226,-0.003 0.42354,-0.0619 0.60385,-0.1776 0.18028,-0.11571 0.32344,-0.27179 0.42948,-0.46823 L 257.4154,505.687 c 0.0393,-0.0538 0.0899,-0.10116 0.15177,-0.14208 0.0619,-0.0409 0.13184,-0.0624 0.2099,-0.0646 0.10547,0.002 0.19158,0.0371 0.25833,0.10656 0.0667,0.0694 0.10116,0.16309 0.10333,0.28094 v 8.65414 c 0.0333,1.28467 0.47682,2.35137 1.33042,3.2001 0.85355,0.84873 1.91702,1.28897 3.19041,1.32073 v -16.3783 c -0.0119,-0.4779 -0.17548,-0.87831 -0.49084,-1.20125 -0.3154,-0.32289 -0.71151,-0.49081 -1.18833,-0.50374 z"
|
||||
style="fill:#ffffff;fill-opacity:1" />
|
||||
<path
|
||||
inkscape:connector-curvature="0"
|
||||
id="path1282"
|
||||
d="m 278.79561,500.9595 h -9.01581 c -1.28467,0.0328 -2.35137,0.47739 -3.20009,1.33364 -0.84874,0.85628 -1.28898,1.92728 -1.32073,3.21301 v 9.01581 c 0.0317,1.28467 0.47199,2.35137 1.32073,3.2001 0.84872,0.84873 1.91542,1.28897 3.20009,1.32073 h 9.01581 c 1.28466,-0.0318 2.35135,-0.472 3.2001,-1.32073 0.84871,-0.84873 1.28896,-1.91543 1.32073,-3.2001 v -9.01581 c -0.0318,-1.28573 -0.47202,-2.35673 -1.32073,-3.21301 -0.84875,-0.85625 -1.91544,-1.3008 -3.2001,-1.33364 z m 0,12.4258 c 0.0237,0.0474 -0.0237,0.23681 -0.14208,0.56833 -0.11842,0.33153 -0.44994,0.52098 -0.99458,0.56833 h -6.74248 c -0.0474,0.0237 -0.23681,-0.0237 -0.56834,-0.14208 -0.33153,-0.1184 -0.52097,-0.44993 -0.56833,-0.99458 v -6.76832 c -0.0237,-0.0463 0.0237,-0.23141 0.14209,-0.55541 0.11839,-0.32398 0.44992,-0.50912 0.99458,-0.55542 h 6.74248 c 0.0473,-0.0231 0.23679,0.0232 0.56833,0.13885 0.33152,0.11573 0.52096,0.43972 0.56833,0.97198 z"
|
||||
style="fill:#ffffff;fill-opacity:1" />
|
||||
<path
|
||||
inkscape:connector-curvature="0"
|
||||
id="path1284"
|
||||
d="m 306.0972,502.25116 c -0.39182,-0.39179 -0.84821,-0.69964 -1.36916,-0.92354 -0.52099,-0.22387 -1.08071,-0.33797 -1.67916,-0.34229 -0.6367,0.005 -1.22333,0.1308 -1.75989,0.37781 -0.5366,0.24705 -1.00052,0.58611 -1.39177,1.01719 l -3.90083,4.28832 -3.92665,-4.28832 c -0.4015,-0.44238 -0.86435,-0.78467 -1.38854,-1.02688 -0.52421,-0.24217 -1.1033,-0.36487 -1.73729,-0.36812 -0.59847,0.004 -1.15819,0.11842 -1.67916,0.34229 -0.52097,0.2239 -0.97736,0.53175 -1.36917,0.92354 l 7.05249,7.74998 -7.05249,7.74998 c 0.39181,0.40419 0.8482,0.71957 1.36917,0.94615 0.52097,0.22657 1.08069,0.34175 1.67916,0.34552 0.62538,-0.005 1.20878,-0.13079 1.7502,-0.37782 0.54142,-0.24703 1.00857,-0.58609 1.40146,-1.01718 l 3.90082,-4.28832 3.90083,4.28832 c 0.39125,0.43109 0.85517,0.77015 1.39177,1.01718 0.53656,0.24703 1.12319,0.37297 1.75989,0.37782 0.59845,-0.004 1.15817,-0.11895 1.67916,-0.34552 0.52095,-0.22658 0.97734,-0.54196 1.36916,-0.94615 l -7.05248,-7.74998 z"
|
||||
style="fill:#ffffff;fill-opacity:1" />
|
||||
</g>
|
||||
</g>
|
||||
<path
|
||||
inkscape:connector-curvature="0"
|
||||
id="path1290"
|
||||
style="fill:#e57000;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.730891"
|
||||
d="m 141.89758,116.88641 25.41237,27.92599 c -2.7927,2.88547 -6.70224,4.65495 -10.98527,4.65495 -4.56076,0 -8.56315,-1.95514 -11.35592,-5.02707 l -14.05575,-15.45172 -10.9846,-12.10215 10.9846,-12.00854 14.05575,-15.452532 c 2.79277,-3.071175 6.79516,-5.027074 11.35592,-5.027074 4.28303,0 8.19257,1.768759 10.98527,4.561525 z"
|
||||
sodipodi:nodetypes="ccscccccscc" />
|
||||
<path
|
||||
inkscape:connector-curvature="0"
|
||||
id="path1292"
|
||||
style="fill:#e57000;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.730891"
|
||||
d="m 92.334245,116.8861 -25.41238,27.92603 c 2.7927,2.88547 6.702237,4.65495 10.985287,4.65495 4.560744,0 8.563138,-1.95514 11.355905,-5.02707 L 103.3188,128.98825 114.30338,116.8861 103.3188,104.87756 89.263057,89.425028 c -2.792767,-3.071215 -6.795161,-5.027074 -11.355905,-5.027074 -4.28305,0 -8.192587,1.768759 -10.985287,4.561526 z"
|
||||
sodipodi:nodetypes="ccscccccscc" />
|
||||
<path
|
||||
inkscape:connector-curvature="0"
|
||||
id="path1294"
|
||||
style="fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.66712"
|
||||
d="m 127.12922,130.73289 -10.02585,-11.04587 -10.0263,11.04587 -23.195348,25.48982 c 2.548811,2.54902 6.118169,4.16327 10.025148,4.16327 4.16359,0 7.64778,-1.69975 10.28111,-4.58817 l 12.91539,-14.10405 12.82882,14.10405 c 2.5493,2.80293 6.20218,4.58817 10.36513,4.58817 3.9091,0 7.47768,-1.61425 10.02652,-4.16327 z"
|
||||
sodipodi:nodetypes="ccccscccscc" />
|
||||
<path
|
||||
inkscape:connector-curvature="0"
|
||||
id="path1296"
|
||||
style="fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.66712"
|
||||
d="M 127.1286,103.03813 117.10275,114.084 107.07645,103.03813 83.881116,77.548321 c 2.548838,-2.548932 6.118156,-4.163226 10.025162,-4.163226 4.163603,0 7.647762,1.699732 10.281082,4.588143 l 12.91539,14.104094 12.82882,-14.104094 c 2.54934,-2.802999 6.20221,-4.588143 10.36513,-4.588143 3.90911,0 7.47772,1.614294 10.02653,4.163226 z"
|
||||
sodipodi:nodetypes="ccccscccscc" />
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 12 KiB |
@@ -0,0 +1,5 @@
|
||||
<svg width="400" height="400" viewBox="0 0 19 19" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3.41508 17.2983L7.88484 12.7653L9.51146 18.9412L11.8745 18.2949L9.52018 9.32758L0.69527 6.93747L0.066864 9.35199L6.13926 11.0015L1.68806 15.5279L3.41508 17.2983Z" fill="#F34E3F"/>
|
||||
<path d="M16.3044 12.0436L18.6675 11.3973L16.3132 2.43003L7.48824 0.0399246L6.85984 2.45444L14.312 4.47881L16.3044 12.0436Z" fill="#F34E3F"/>
|
||||
<path d="M12.9126 15.4902L15.2756 14.8439L12.9213 5.87659L4.09639 3.48648L3.46799 5.901L10.9201 7.92537L12.9126 15.4902Z" fill="#F34E3F"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 576 B |
+18
-13
@@ -24,7 +24,7 @@ The Coder Registry is a collection of Terraform modules and templates for Coder
|
||||
|
||||
### Install Dependencies
|
||||
|
||||
Install Bun:
|
||||
Install Bun (for formatting and scripts):
|
||||
|
||||
```bash
|
||||
curl -fsSL https://bun.sh/install | bash
|
||||
@@ -89,7 +89,7 @@ Create `registry/[your-username]/README.md`:
|
||||
---
|
||||
display_name: "Your Name"
|
||||
bio: "Brief description of who you are and what you do"
|
||||
avatar_url: "./.images/avatar.png"
|
||||
avatar: "./.images/avatar.png"
|
||||
github: "your-username"
|
||||
linkedin: "https://www.linkedin.com/in/your-username" # Optional
|
||||
website: "https://yourwebsite.com" # Optional
|
||||
@@ -102,7 +102,7 @@ status: "community"
|
||||
Brief description of who you are and what you do.
|
||||
```
|
||||
|
||||
> **Note**: The `avatar_url` must point to `./.images/avatar.png` or `./.images/avatar.svg`.
|
||||
> **Note**: The `avatar` must point to `./.images/avatar.png` or `./.images/avatar.svg`.
|
||||
|
||||
### 2. Generate Module Files
|
||||
|
||||
@@ -124,19 +124,23 @@ This script generates:
|
||||
- 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
|
||||
3. **Create at least one `.tftest.hcl`** to test your module with `terraform test`
|
||||
4. **Add any scripts** or additional files your module needs
|
||||
|
||||
### 4. Test and Submit
|
||||
|
||||
```bash
|
||||
# Test your module
|
||||
bun test -t 'module-name'
|
||||
# Test your module (from the module directory)
|
||||
terraform init -upgrade
|
||||
terraform test -verbose
|
||||
|
||||
# Or run all tests in the repo
|
||||
./scripts/terraform_test_all.sh
|
||||
|
||||
# Format code
|
||||
bun fmt
|
||||
bun run fmt
|
||||
|
||||
# Commit and create PR
|
||||
# Commit and create PR (do not push to main directly)
|
||||
git add .
|
||||
git commit -m "Add [module-name] module"
|
||||
git push origin your-branch
|
||||
@@ -335,11 +339,12 @@ coder templates push test-[template-name] -d .
|
||||
### 2. Test Your Changes
|
||||
|
||||
```bash
|
||||
# Test a specific module
|
||||
bun test -t 'module-name'
|
||||
# Test a specific module (from the module directory)
|
||||
terraform init -upgrade
|
||||
terraform test -verbose
|
||||
|
||||
# Test all modules
|
||||
bun test
|
||||
./scripts/terraform_test_all.sh
|
||||
```
|
||||
|
||||
### 3. Maintain Backward Compatibility
|
||||
@@ -388,7 +393,7 @@ Example: `https://github.com/coder/registry/compare/main...your-branch?template=
|
||||
### Every Module Must Have
|
||||
|
||||
- `main.tf` - Terraform code
|
||||
- `main.test.ts` - Working tests
|
||||
- One or more `.tftest.hcl` files - Working tests with `terraform test`
|
||||
- `README.md` - Documentation with frontmatter
|
||||
|
||||
### Every Template Must Have
|
||||
@@ -488,6 +493,6 @@ When reporting bugs, include:
|
||||
2. **No tests** or broken tests
|
||||
3. **Hardcoded values** instead of variables
|
||||
4. **Breaking changes** without defaults
|
||||
5. **Not running** `bun fmt` before submitting
|
||||
5. **Not running** formatting (`bun run fmt`) and tests (`terraform test`) before submitting
|
||||
|
||||
Happy contributing! 🚀
|
||||
|
||||
+53
-7
@@ -18,9 +18,9 @@ sudo apt install golang-go
|
||||
|
||||
Check that PRs have:
|
||||
|
||||
- [ ] All required files (`main.tf`, `main.test.ts`, `README.md`)
|
||||
- [ ] All required files (`main.tf`, `README.md`, at least one `.tftest.hcl`)
|
||||
- [ ] Proper frontmatter in README
|
||||
- [ ] Working tests (`bun test`)
|
||||
- [ ] Working tests (`terraform test`)
|
||||
- [ ] Formatted code (`bun run fmt`)
|
||||
- [ ] Avatar image for new namespaces (`avatar.png` or `avatar.svg` in `.images/`)
|
||||
|
||||
@@ -42,12 +42,58 @@ go build ./cmd/readmevalidation && ./readmevalidation
|
||||
|
||||
## Making a Release
|
||||
|
||||
### Create Release Tags
|
||||
### Automated Tag and Release Process
|
||||
|
||||
After merging a PR:
|
||||
After merging a PR, use the automated script to create and push release tags:
|
||||
|
||||
1. Get the new version from the PR (shown as `old → new`)
|
||||
2. Checkout the merge commit and create the tag:
|
||||
**Prerequisites:**
|
||||
|
||||
- Ensure all module versions are updated in their respective README files (the script uses this as the source of truth)
|
||||
- Make sure you have the necessary permissions to push tags to the repository
|
||||
|
||||
**Steps:**
|
||||
|
||||
1. **Checkout the merge commit:**
|
||||
|
||||
```bash
|
||||
git checkout MERGE_COMMIT_ID
|
||||
```
|
||||
|
||||
2. **Run the tag release script:**
|
||||
|
||||
```bash
|
||||
./scripts/tag_release.sh
|
||||
```
|
||||
|
||||
3. **Review and confirm:**
|
||||
- The script will automatically scan all modules in the registry
|
||||
- It will detect which modules need version bumps by comparing README versions to existing tags
|
||||
- A summary will be displayed showing which modules need tagging
|
||||
- Confirm the list is correct when prompted
|
||||
|
||||
4. **Automatic tagging:**
|
||||
- After confirmation, the script will automatically create all necessary release tags
|
||||
- Tags will be pushed to the remote repository
|
||||
- The script operates on the current checked-out commit
|
||||
|
||||
**Example output:**
|
||||
|
||||
```text
|
||||
🔍 Scanning all modules for missing release tags...
|
||||
|
||||
📦 coder/code-server: v4.1.2 (needs tag)
|
||||
✅ coder/dotfiles: v1.0.5 (already tagged)
|
||||
|
||||
## Tags to be created:
|
||||
- `release/coder/code-server/v4.1.2`
|
||||
|
||||
❓ Do you want to proceed with creating and pushing these release tags?
|
||||
Continue? [y/N]: y
|
||||
```
|
||||
|
||||
### Manual Process (Fallback)
|
||||
|
||||
If the automated script fails, you can manually tag and release modules:
|
||||
|
||||
```bash
|
||||
# Checkout the merge commit
|
||||
@@ -81,7 +127,7 @@ tags: ["tag1", "tag2"]
|
||||
```yaml
|
||||
display_name: "Your Name"
|
||||
bio: "Brief description of who you are and what you do"
|
||||
avatar_url: "./.images/avatar.png"
|
||||
avatar: "./.images/avatar.png"
|
||||
github: "username"
|
||||
linkedin: "https://www.linkedin.com/in/username" # Optional
|
||||
website: "https://yourwebsite.com" # Optional
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
)
|
||||
|
||||
func validateCoderModuleReadmeBody(body string) []error {
|
||||
var errs []error
|
||||
|
||||
trimmed := strings.TrimSpace(body)
|
||||
if baseErrs := validateReadmeBody(trimmed); len(baseErrs) != 0 {
|
||||
errs = append(errs, baseErrs...)
|
||||
}
|
||||
|
||||
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 code blocks 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 validateCoderModuleReadme(rm coderResourceReadme) []error {
|
||||
var errs []error
|
||||
for _, err := range validateCoderModuleReadmeBody(rm.body) {
|
||||
errs = append(errs, addFilePathToError(rm.filePath, err))
|
||||
}
|
||||
if fmErrs := validateCoderResourceFrontmatter("modules", rm.filePath, rm.frontmatter); len(fmErrs) != 0 {
|
||||
errs = append(errs, fmErrs...)
|
||||
}
|
||||
return errs
|
||||
}
|
||||
|
||||
func validateAllCoderModuleReadmes(resources []coderResourceReadme) error {
|
||||
var yamlValidationErrors []error
|
||||
for _, readme := range resources {
|
||||
errs := validateCoderModuleReadme(readme)
|
||||
if len(errs) > 0 {
|
||||
yamlValidationErrors = append(yamlValidationErrors, errs...)
|
||||
}
|
||||
}
|
||||
if len(yamlValidationErrors) != 0 {
|
||||
return validationPhaseError{
|
||||
phase: validationPhaseReadme,
|
||||
errors: yamlValidationErrors,
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateAllCoderModules() error {
|
||||
const resourceType = "modules"
|
||||
allReadmeFiles, err := aggregateCoderResourceReadmeFiles(resourceType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
logger.Info(context.Background(), "processing template README files", "resource_type", resourceType, "num_files", len(allReadmeFiles))
|
||||
resources, err := parseCoderResourceReadmeFiles(resourceType, allReadmeFiles)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = validateAllCoderModuleReadmes(resources)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
logger.Info(context.Background(), "processed README files as valid Coder resources", "resource_type", resourceType, "num_files", len(resources))
|
||||
|
||||
if err := validateCoderResourceRelativeURLs(resources); err != nil {
|
||||
return err
|
||||
}
|
||||
logger.Info(context.Background(), "all relative URLs for READMEs are valid", "resource_type", resourceType)
|
||||
return nil
|
||||
}
|
||||
+1
-1
@@ -14,7 +14,7 @@ func TestValidateCoderResourceReadmeBody(t *testing.T) {
|
||||
t.Run("Parses a valid README body with zero issues", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
errs := validateCoderResourceReadmeBody(testBody)
|
||||
errs := validateCoderModuleReadmeBody(testBody)
|
||||
for _, e := range errs {
|
||||
t.Error(e)
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"errors"
|
||||
"net/url"
|
||||
"os"
|
||||
@@ -17,6 +15,7 @@ import (
|
||||
|
||||
var (
|
||||
supportedResourceTypes = []string{"modules", "templates"}
|
||||
operatingSystems = []string{"windows", "macos", "linux"}
|
||||
|
||||
// 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
|
||||
@@ -25,11 +24,21 @@ var (
|
||||
)
|
||||
|
||||
type coderResourceFrontmatter struct {
|
||||
Description string `yaml:"description"`
|
||||
IconURL string `yaml:"icon"`
|
||||
DisplayName *string `yaml:"display_name"`
|
||||
Verified *bool `yaml:"verified"`
|
||||
Tags []string `yaml:"tags"`
|
||||
Description string `yaml:"description"`
|
||||
IconURL string `yaml:"icon"`
|
||||
DisplayName *string `yaml:"display_name"`
|
||||
Verified *bool `yaml:"verified"`
|
||||
Tags []string `yaml:"tags"`
|
||||
OperatingSystems []string `yaml:"supported_os"`
|
||||
}
|
||||
|
||||
// A slice version of the struct tags from coderResourceFrontmatter. Might be worth using reflection to generate this
|
||||
// list at runtime in the future, but this should be okay for now
|
||||
var supportedCoderResourceStructKeys = []string{
|
||||
"description", "icon", "display_name", "verified", "tags", "supported_os",
|
||||
// TODO: This is an old, officially deprecated key from the archived coder/modules repo. We can remove this once we
|
||||
// make sure that the Registry Server is no longer checking this field.
|
||||
"maintainer_github",
|
||||
}
|
||||
|
||||
// coderResourceReadme represents a README describing a Terraform resource used
|
||||
@@ -42,6 +51,17 @@ type coderResourceReadme struct {
|
||||
frontmatter coderResourceFrontmatter
|
||||
}
|
||||
|
||||
func validateSupportedOperatingSystems(systems []string) []error {
|
||||
var errs []error
|
||||
for _, s := range systems {
|
||||
if slices.Contains(operatingSystems, s) {
|
||||
continue
|
||||
}
|
||||
errs = append(errs, xerrors.Errorf("detected unknown operating system %q", s))
|
||||
}
|
||||
return errs
|
||||
}
|
||||
|
||||
func validateCoderResourceDisplayName(displayName *string) error {
|
||||
if displayName != nil && *displayName == "" {
|
||||
return xerrors.New("if defined, display_name must not be empty string")
|
||||
@@ -67,7 +87,7 @@ func validateCoderResourceIconURL(iconURL string) []error {
|
||||
return []error{xerrors.New("icon URL cannot be empty")}
|
||||
}
|
||||
|
||||
errs := []error{}
|
||||
var errs []error
|
||||
|
||||
// If the URL does not have a relative path.
|
||||
if !strings.HasPrefix(iconURL, ".") && !strings.HasPrefix(iconURL, "/") {
|
||||
@@ -98,7 +118,7 @@ func validateCoderResourceTags(tags []string) error {
|
||||
|
||||
// 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{}
|
||||
var invalidTags []string
|
||||
for _, t := range tags {
|
||||
if t != url.QueryEscape(t) {
|
||||
invalidTags = append(invalidTags, t)
|
||||
@@ -111,119 +131,50 @@ func validateCoderResourceTags(tags []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateCoderResourceReadmeBody(body string) []error {
|
||||
func validateCoderResourceFrontmatter(resourceType string, filePath string, fm coderResourceFrontmatter) []error {
|
||||
if !slices.Contains(supportedResourceTypes, resourceType) {
|
||||
return []error{xerrors.Errorf("cannot process unknown resource type %q", resourceType)}
|
||||
}
|
||||
|
||||
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 err := validateCoderResourceDisplayName(fm.DisplayName); err != nil {
|
||||
errs = append(errs, addFilePathToError(filePath, err))
|
||||
}
|
||||
if err := validateCoderResourceDescription(fm.Description); err != nil {
|
||||
errs = append(errs, addFilePathToError(filePath, err))
|
||||
}
|
||||
if err := validateCoderResourceTags(fm.Tags); err != nil {
|
||||
errs = append(errs, addFilePathToError(filePath, err))
|
||||
}
|
||||
|
||||
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"))
|
||||
}
|
||||
for _, err := range validateCoderResourceIconURL(fm.IconURL) {
|
||||
errs = append(errs, addFilePathToError(filePath, err))
|
||||
}
|
||||
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"))
|
||||
for _, err := range validateSupportedOperatingSystems(fm.OperatingSystems) {
|
||||
errs = append(errs, addFilePathToError(filePath, err))
|
||||
}
|
||||
|
||||
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) {
|
||||
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)
|
||||
return coderResourceReadme{}, []error{xerrors.Errorf("%q: failed to parse frontmatter: %v", rm.filePath, err)}
|
||||
}
|
||||
|
||||
keyErrs := validateFrontmatterYamlKeys(fm, supportedCoderResourceStructKeys)
|
||||
if len(keyErrs) != 0 {
|
||||
var remapped []error
|
||||
for _, e := range keyErrs {
|
||||
remapped = append(remapped, addFilePathToError(rm.filePath, e))
|
||||
}
|
||||
return coderResourceReadme{}, remapped
|
||||
}
|
||||
|
||||
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{}, []error{xerrors.Errorf("%q: failed to parse: %v", rm.filePath, err)}
|
||||
}
|
||||
|
||||
return coderResourceReadme{
|
||||
@@ -234,13 +185,17 @@ func parseCoderResourceReadme(resourceType string, rm readme) (coderResourceRead
|
||||
}, nil
|
||||
}
|
||||
|
||||
func parseCoderResourceReadmeFiles(resourceType string, rms []readme) (map[string]coderResourceReadme, error) {
|
||||
func parseCoderResourceReadmeFiles(resourceType string, rms []readme) ([]coderResourceReadme, error) {
|
||||
if !slices.Contains(supportedResourceTypes, resourceType) {
|
||||
return nil, xerrors.Errorf("cannot process unknown resource type %q", resourceType)
|
||||
}
|
||||
|
||||
resources := map[string]coderResourceReadme{}
|
||||
var yamlParsingErrs []error
|
||||
for _, rm := range rms {
|
||||
p, err := parseCoderResourceReadme(resourceType, rm)
|
||||
if err != nil {
|
||||
yamlParsingErrs = append(yamlParsingErrs, err)
|
||||
p, errs := parseCoderResourceReadme(resourceType, rm)
|
||||
if len(errs) != 0 {
|
||||
yamlParsingErrs = append(yamlParsingErrs, errs...)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -253,30 +208,27 @@ func parseCoderResourceReadmeFiles(resourceType string, rms []readme) (map[strin
|
||||
}
|
||||
}
|
||||
|
||||
yamlValidationErrors := []error{}
|
||||
for _, readme := range resources {
|
||||
errs := validateCoderResourceReadme(readme)
|
||||
if len(errs) > 0 {
|
||||
yamlValidationErrors = append(yamlValidationErrors, errs...)
|
||||
}
|
||||
var serialized []coderResourceReadme
|
||||
for _, r := range resources {
|
||||
serialized = append(serialized, r)
|
||||
}
|
||||
if len(yamlValidationErrors) != 0 {
|
||||
return nil, validationPhaseError{
|
||||
phase: validationPhaseReadme,
|
||||
errors: yamlValidationErrors,
|
||||
}
|
||||
}
|
||||
|
||||
return resources, nil
|
||||
slices.SortFunc(serialized, func(r1 coderResourceReadme, r2 coderResourceReadme) int {
|
||||
return strings.Compare(r1.filePath, r2.filePath)
|
||||
})
|
||||
return serialized, 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 {
|
||||
func validateCoderResourceRelativeURLs(_ []coderResourceReadme) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func aggregateCoderResourceReadmeFiles(resourceType string) ([]readme, error) {
|
||||
if !slices.Contains(supportedResourceTypes, resourceType) {
|
||||
return nil, xerrors.Errorf("cannot process unknown resource type %q", resourceType)
|
||||
}
|
||||
|
||||
registryFiles, err := os.ReadDir(rootRegistryPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -325,27 +277,3 @@ func aggregateCoderResourceReadmeFiles(resourceType string) ([]readme, error) {
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
)
|
||||
|
||||
func validateCoderTemplateReadmeBody(body string) []error {
|
||||
var errs []error
|
||||
|
||||
trimmed := strings.TrimSpace(body)
|
||||
if baseErrs := validateReadmeBody(trimmed); len(baseErrs) != 0 {
|
||||
errs = append(errs, baseErrs...)
|
||||
}
|
||||
|
||||
var nextLine string
|
||||
foundParagraph := false
|
||||
isInsideCodeBlock := false
|
||||
lineNum := 0
|
||||
|
||||
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
|
||||
if strings.HasPrefix(nextLine, "```hcl") {
|
||||
errs = append(errs, xerrors.New("all .hcl language references must be converted to .tf"))
|
||||
}
|
||||
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 !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 validateCoderTemplateReadme(rm coderResourceReadme) []error {
|
||||
var errs []error
|
||||
for _, err := range validateCoderTemplateReadmeBody(rm.body) {
|
||||
errs = append(errs, addFilePathToError(rm.filePath, err))
|
||||
}
|
||||
if fmErrs := validateCoderResourceFrontmatter("templates", rm.filePath, rm.frontmatter); len(fmErrs) != 0 {
|
||||
errs = append(errs, fmErrs...)
|
||||
}
|
||||
return errs
|
||||
}
|
||||
|
||||
func validateAllCoderTemplateReadmes(resources []coderResourceReadme) error {
|
||||
var yamlValidationErrors []error
|
||||
for _, readme := range resources {
|
||||
errs := validateCoderTemplateReadme(readme)
|
||||
if len(errs) > 0 {
|
||||
yamlValidationErrors = append(yamlValidationErrors, errs...)
|
||||
}
|
||||
}
|
||||
if len(yamlValidationErrors) != 0 {
|
||||
return validationPhaseError{
|
||||
phase: validationPhaseReadme,
|
||||
errors: yamlValidationErrors,
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateAllCoderTemplates() error {
|
||||
const resourceType = "templates"
|
||||
allReadmeFiles, err := aggregateCoderResourceReadmeFiles(resourceType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
logger.Info(context.Background(), "processing template README files", "resource_type", resourceType, "num_files", len(allReadmeFiles))
|
||||
resources, err := parseCoderResourceReadmeFiles(resourceType, allReadmeFiles)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = validateAllCoderTemplateReadmes(resources)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
logger.Info(context.Background(), "processed README files as valid Coder resources", "resource_type", resourceType, "num_files", len(resources))
|
||||
|
||||
if err := validateCoderResourceRelativeURLs(resources); err != nil {
|
||||
return err
|
||||
}
|
||||
logger.Info(context.Background(), "all relative URLs for READMEs are valid", "resource_type", resourceType)
|
||||
return nil
|
||||
}
|
||||
@@ -19,11 +19,16 @@ type contributorProfileFrontmatter struct {
|
||||
Bio string `yaml:"bio"`
|
||||
ContributorStatus string `yaml:"status"`
|
||||
AvatarURL *string `yaml:"avatar"`
|
||||
GithubUsername *string `yaml:"github"`
|
||||
LinkedinURL *string `yaml:"linkedin"`
|
||||
WebsiteURL *string `yaml:"website"`
|
||||
SupportEmail *string `yaml:"support_email"`
|
||||
}
|
||||
|
||||
// A slice version of the struct tags from contributorProfileFrontmatter. Might be worth using reflection to generate
|
||||
// this list at runtime in the future, but this should be okay for now
|
||||
var supportedContributorProfileStructKeys = []string{"display_name", "bio", "status", "avatar", "linkedin", "github", "website", "support_email"}
|
||||
|
||||
type contributorProfileReadme struct {
|
||||
frontmatter contributorProfileFrontmatter
|
||||
namespace string
|
||||
@@ -50,6 +55,22 @@ func validateContributorLinkedinURL(linkedinURL *string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateGithubUsername(username *string) error {
|
||||
if username == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
name := *username
|
||||
trimmed := strings.TrimSpace(name)
|
||||
if trimmed == "" {
|
||||
return xerrors.New("username must have non-whitespace characters")
|
||||
}
|
||||
if name != trimmed {
|
||||
return xerrors.Errorf("username %q has extra whitespace", trimmed)
|
||||
}
|
||||
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.
|
||||
@@ -58,7 +79,7 @@ func validateContributorSupportEmail(email *string) []error {
|
||||
return nil
|
||||
}
|
||||
|
||||
errs := []error{}
|
||||
var errs []error
|
||||
|
||||
username, server, ok := strings.Cut(*email, "@")
|
||||
if !ok {
|
||||
@@ -119,7 +140,7 @@ func validateContributorAvatarURL(avatarURL *string) []error {
|
||||
return []error{xerrors.New("avatar URL must be omitted or non-empty string")}
|
||||
}
|
||||
|
||||
errs := []error{}
|
||||
var errs []error
|
||||
// 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))
|
||||
@@ -145,7 +166,7 @@ func validateContributorAvatarURL(avatarURL *string) []error {
|
||||
}
|
||||
|
||||
func validateContributorReadme(rm contributorProfileReadme) []error {
|
||||
allErrs := []error{}
|
||||
var allErrs []error
|
||||
|
||||
if err := validateContributorDisplayName(rm.frontmatter.DisplayName); err != nil {
|
||||
allErrs = append(allErrs, addFilePathToError(rm.filePath, err))
|
||||
@@ -153,6 +174,9 @@ func validateContributorReadme(rm contributorProfileReadme) []error {
|
||||
if err := validateContributorLinkedinURL(rm.frontmatter.LinkedinURL); err != nil {
|
||||
allErrs = append(allErrs, addFilePathToError(rm.filePath, err))
|
||||
}
|
||||
if err := validateGithubUsername(rm.frontmatter.GithubUsername); err != nil {
|
||||
allErrs = append(allErrs, addFilePathToError(rm.filePath, err))
|
||||
}
|
||||
if err := validateContributorWebsite(rm.frontmatter.WebsiteURL); err != nil {
|
||||
allErrs = append(allErrs, addFilePathToError(rm.filePath, err))
|
||||
}
|
||||
@@ -170,15 +194,24 @@ func validateContributorReadme(rm contributorProfileReadme) []error {
|
||||
return allErrs
|
||||
}
|
||||
|
||||
func parseContributorProfile(rm readme) (contributorProfileReadme, error) {
|
||||
func parseContributorProfile(rm readme) (contributorProfileReadme, []error) {
|
||||
fm, _, err := separateFrontmatter(rm.rawText)
|
||||
if err != nil {
|
||||
return contributorProfileReadme{}, xerrors.Errorf("%q: failed to parse frontmatter: %v", rm.filePath, err)
|
||||
return contributorProfileReadme{}, []error{xerrors.Errorf("%q: failed to parse frontmatter: %v", rm.filePath, err)}
|
||||
}
|
||||
|
||||
keyErrs := validateFrontmatterYamlKeys(fm, supportedContributorProfileStructKeys)
|
||||
if len(keyErrs) != 0 {
|
||||
var remapped []error
|
||||
for _, e := range keyErrs {
|
||||
remapped = append(remapped, addFilePathToError(rm.filePath, e))
|
||||
}
|
||||
return contributorProfileReadme{}, remapped
|
||||
}
|
||||
|
||||
yml := contributorProfileFrontmatter{}
|
||||
if err := yaml.Unmarshal([]byte(fm), &yml); err != nil {
|
||||
return contributorProfileReadme{}, xerrors.Errorf("%q: failed to parse: %v", rm.filePath, err)
|
||||
return contributorProfileReadme{}, []error{xerrors.Errorf("%q: failed to parse: %v", rm.filePath, err)}
|
||||
}
|
||||
|
||||
return contributorProfileReadme{
|
||||
@@ -190,11 +223,11 @@ func parseContributorProfile(rm readme) (contributorProfileReadme, error) {
|
||||
|
||||
func parseContributorFiles(readmeEntries []readme) (map[string]contributorProfileReadme, error) {
|
||||
profilesByNamespace := map[string]contributorProfileReadme{}
|
||||
yamlParsingErrors := []error{}
|
||||
var yamlParsingErrors []error
|
||||
for _, rm := range readmeEntries {
|
||||
p, err := parseContributorProfile(rm)
|
||||
if err != nil {
|
||||
yamlParsingErrors = append(yamlParsingErrors, err)
|
||||
p, errs := parseContributorProfile(rm)
|
||||
if len(errs) != 0 {
|
||||
yamlParsingErrors = append(yamlParsingErrors, errs...)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -211,7 +244,7 @@ func parseContributorFiles(readmeEntries []readme) (map[string]contributorProfil
|
||||
}
|
||||
}
|
||||
|
||||
yamlValidationErrors := []error{}
|
||||
var yamlValidationErrors []error
|
||||
for _, p := range profilesByNamespace {
|
||||
if errors := validateContributorReadme(p); len(errors) > 0 {
|
||||
yamlValidationErrors = append(yamlValidationErrors, errors...)
|
||||
@@ -234,8 +267,8 @@ func aggregateContributorReadmeFiles() ([]readme, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
allReadmeFiles := []readme{}
|
||||
errs := []error{}
|
||||
var allReadmeFiles []readme
|
||||
var errs []error
|
||||
dirPath := ""
|
||||
for _, e := range dirEntries {
|
||||
if !e.IsDir() {
|
||||
|
||||
@@ -31,7 +31,11 @@ func main() {
|
||||
if err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
err = validateAllCoderResourceFilesOfType("modules")
|
||||
err = validateAllCoderModules()
|
||||
if err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
err = validateAllCoderTemplates()
|
||||
if err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
@@ -39,7 +40,9 @@ const (
|
||||
|
||||
var (
|
||||
supportedAvatarFileFormats = []string{".png", ".jpeg", ".jpg", ".gif", ".svg"}
|
||||
// Matches markdown headers, must be at the beginning of a line, such as "# " or "### ".
|
||||
// Matches markdown headers placed at the beginning of a line (e.g., "# " or "### "). To make the logic for
|
||||
// validateReadmeBody easier, this pattern deliberately matches on invalid headers (header levels must be in the
|
||||
// range 1–6 to be valid). The function has checks to see if the level is correct.
|
||||
readmeHeaderRe = regexp.MustCompile(`^(#+)(\s*)`)
|
||||
)
|
||||
|
||||
@@ -168,3 +171,25 @@ func validateReadmeBody(body string) []error {
|
||||
|
||||
return errs
|
||||
}
|
||||
|
||||
func validateFrontmatterYamlKeys(frontmatter string, allowedKeys []string) []error {
|
||||
if len(allowedKeys) == 0 {
|
||||
return []error{xerrors.New("Set of allowed keys is empty")}
|
||||
}
|
||||
|
||||
var key string
|
||||
var cutOk bool
|
||||
var line string
|
||||
|
||||
var errs []error
|
||||
lineScanner := bufio.NewScanner(strings.NewReader(frontmatter))
|
||||
for lineScanner.Scan() {
|
||||
line = lineScanner.Text()
|
||||
key, _, cutOk = strings.Cut(line, ":")
|
||||
if !cutOk || slices.Contains(allowedKeys, key) {
|
||||
continue
|
||||
}
|
||||
errs = append(errs, xerrors.Errorf("detected unknown key %q", key))
|
||||
}
|
||||
return errs
|
||||
}
|
||||
|
||||
@@ -4,24 +4,32 @@ import (
|
||||
"errors"
|
||||
"os"
|
||||
"path"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
)
|
||||
|
||||
var supportedUserNameSpaceDirectories = append(supportedResourceTypes, ".icons", ".images")
|
||||
var supportedUserNameSpaceDirectories = append(supportedResourceTypes, ".images")
|
||||
|
||||
// validNameRe validates that names contain only alphanumeric characters and hyphens
|
||||
var validNameRe = regexp.MustCompile(`^[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?$`)
|
||||
|
||||
|
||||
// validateCoderResourceSubdirectory validates that the structure of a module or template within a namespace follows all
|
||||
// expected file conventions
|
||||
func validateCoderResourceSubdirectory(dirPath string) []error {
|
||||
subDir, err := os.Stat(dirPath)
|
||||
resourceDir, 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.
|
||||
// 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() {
|
||||
if !resourceDir.IsDir() {
|
||||
return []error{xerrors.Errorf("%q: path is not a directory", dirPath)}
|
||||
}
|
||||
|
||||
@@ -30,14 +38,21 @@ func validateCoderResourceSubdirectory(dirPath string) []error {
|
||||
return []error{addFilePathToError(dirPath, err)}
|
||||
}
|
||||
|
||||
errs := []error{}
|
||||
var 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.
|
||||
// The .coder subdirectories are sometimes generated as part of our 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
|
||||
}
|
||||
|
||||
// Validate module/template name
|
||||
if !validNameRe.MatchString(f.Name()) {
|
||||
errs = append(errs, xerrors.Errorf("%q: name contains invalid characters (only alphanumeric characters and hyphens are allowed)", path.Join(dirPath, f.Name())))
|
||||
continue
|
||||
}
|
||||
|
||||
resourceReadmePath := path.Join(dirPath, f.Name(), "README.md")
|
||||
if _, err := os.Stat(resourceReadmePath); err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
@@ -59,49 +74,59 @@ func validateCoderResourceSubdirectory(dirPath string) []error {
|
||||
return errs
|
||||
}
|
||||
|
||||
// validateRegistryDirectory validates that the contents of `/registry` follow all expected file conventions. This
|
||||
// includes the top-level structure of the individual namespace directories.
|
||||
func validateRegistryDirectory() []error {
|
||||
userDirs, err := os.ReadDir(rootRegistryPath)
|
||||
namespaceDirs, 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))
|
||||
var allErrs []error
|
||||
for _, nDir := range namespaceDirs {
|
||||
namespacePath := path.Join(rootRegistryPath, nDir.Name())
|
||||
if !nDir.IsDir() {
|
||||
allErrs = append(allErrs, xerrors.Errorf("detected non-directory file %q at base of main Registry directory", namespacePath))
|
||||
continue
|
||||
}
|
||||
|
||||
contributorReadmePath := path.Join(dirPath, "README.md")
|
||||
// Validate namespace name
|
||||
if !validNameRe.MatchString(nDir.Name()) {
|
||||
allErrs = append(allErrs, xerrors.Errorf("%q: namespace name contains invalid characters (only alphanumeric characters and hyphens are allowed)", namespacePath))
|
||||
continue
|
||||
}
|
||||
|
||||
contributorReadmePath := path.Join(namespacePath, "README.md")
|
||||
if _, err := os.Stat(contributorReadmePath); err != nil {
|
||||
allErrs = append(allErrs, err)
|
||||
}
|
||||
|
||||
files, err := os.ReadDir(dirPath)
|
||||
files, err := os.ReadDir(namespacePath)
|
||||
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.
|
||||
// TODO: Decide if there's anything more formal that we want to ensure about non-directories at the top
|
||||
// level of each user namespace.
|
||||
if !f.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
segment := f.Name()
|
||||
filePath := path.Join(dirPath, segment)
|
||||
filePath := path.Join(namespacePath, 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) {
|
||||
continue
|
||||
}
|
||||
|
||||
if slices.Contains(supportedResourceTypes, segment) {
|
||||
if errs := validateCoderResourceSubdirectory(filePath); len(errs) != 0 {
|
||||
allErrs = append(allErrs, errs...)
|
||||
}
|
||||
if errs := validateCoderResourceSubdirectory(filePath); len(errs) != 0 {
|
||||
allErrs = append(allErrs, errs...)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -109,6 +134,9 @@ func validateRegistryDirectory() []error {
|
||||
return allErrs
|
||||
}
|
||||
|
||||
// validateRepoStructure validates that the structure of the repo is "correct enough" to do all necessary validation
|
||||
// checks. It is NOT an exhaustive validation of the entire repo structure – it only checks the parts of the repo that
|
||||
// are relevant for the main validation steps
|
||||
func validateRepoStructure() error {
|
||||
var errs []error
|
||||
if vrdErrs := validateRegistryDirectory(); len(vrdErrs) != 0 {
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
run "plan_with_required_vars" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "example-agent-id"
|
||||
}
|
||||
}
|
||||
|
||||
run "app_url_uses_port" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "example-agent-id"
|
||||
port = 19999
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = resource.coder_app.MODULE_NAME.url == "http://localhost:19999"
|
||||
error_message = "Expected MODULE_NAME app URL to include configured port"
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
"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",
|
||||
"test": "./scripts/terraform_test_all.sh",
|
||||
"update-version": "./update-version.sh"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
display_name: "Jay Kumar"
|
||||
bio: "I'm a Software Engineer :)"
|
||||
avatar_url: "./.images/avatar.png"
|
||||
avatar: "./.images/avatar.jpeg"
|
||||
github: "35C4n0r"
|
||||
linkedin: "https://www.linkedin.com/in/jaykum4r"
|
||||
support_email: "work.jaykumar@gmail.com"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
display_name: "Tmux"
|
||||
description: "Tmux for coder agent :)"
|
||||
display_name: "tmux"
|
||||
description: "tmux with session persistence and plugins"
|
||||
icon: "../../../../.icons/tmux.svg"
|
||||
verified: false
|
||||
tags: ["tmux", "terminal", "persistent"]
|
||||
@@ -15,7 +15,7 @@ up a default or custom tmux configuration with session save/restore capabilities
|
||||
```tf
|
||||
module "tmux" {
|
||||
source = "registry.coder.com/anomaly/tmux/coder"
|
||||
version = "1.0.0"
|
||||
version = "1.0.1"
|
||||
agent_id = coder_agent.example.id
|
||||
}
|
||||
```
|
||||
@@ -39,7 +39,7 @@ module "tmux" {
|
||||
```tf
|
||||
module "tmux" {
|
||||
source = "registry.coder.com/anomaly/tmux/coder"
|
||||
version = "1.0.0"
|
||||
version = "1.0.1"
|
||||
agent_id = coder_agent.example.id
|
||||
tmux_config = "" # Optional: custom tmux.conf content
|
||||
save_interval = 1 # Optional: save interval in minutes
|
||||
@@ -78,7 +78,7 @@ This module can provision multiple tmux sessions, each as a separate app in the
|
||||
```tf
|
||||
module "tmux" {
|
||||
source = "registry.coder.com/anomaly/tmux/coder"
|
||||
version = "1.0.0"
|
||||
version = "1.0.1"
|
||||
agent_id = var.agent_id
|
||||
sessions = ["default", "dev", "anomaly"]
|
||||
tmux_config = <<-EOT
|
||||
@@ -91,11 +91,9 @@ module "tmux" {
|
||||
```
|
||||
|
||||
> [!IMPORTANT]
|
||||
>
|
||||
> - If you provide a custom `tmux_config`, it will completely replace the default configuration. Ensure you include plugin
|
||||
> and TPM initialization lines if you want plugin support and session persistence.
|
||||
> - The script will attempt to install dependencies using `sudo` where required.
|
||||
> - If `git` is not installed, TPM installation will fail.
|
||||
> - If you are using custom config, you'll be responsible for setting up persistence and plugins.
|
||||
> - The `order`, `group`, and `icon` variables allow you to customize how tmux apps appear in the Coder UI.
|
||||
> - In case of session restart or shh reconnection, the tmux session will be automatically restored :)
|
||||
> If you provide a custom `tmux_config`, it will completely replace the default configuration. Ensure you include plugin and TPM initialization lines if you want plugin support and session persistence.
|
||||
> The script will attempt to install dependencies using `sudo` where required.
|
||||
> If `git` is not installed, TPM installation will fail.
|
||||
> If you are using custom config, you'll be responsible for setting up persistence and plugins.
|
||||
> The `order`, `group`, and `icon` variables allow you to customize how tmux apps appear in the Coder UI.
|
||||
> In case of session restart or shh reconnection, the tmux session will be automatically restored :)
|
||||
|
||||
@@ -5,7 +5,7 @@ github: coder
|
||||
avatar: ./.images/avatar.svg
|
||||
linkedin: https://www.linkedin.com/company/coderhq
|
||||
website: https://discord.gg/coder
|
||||
status: community
|
||||
status: official
|
||||
---
|
||||
|
||||
å
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
---
|
||||
display_name: Auggie CLI
|
||||
icon: ../../../../.icons/auggie.svg
|
||||
description: Run Auggie CLI in your workspace for AI-powered coding assistance with AgentAPI integration
|
||||
verified: true
|
||||
tags: [agent, auggie, ai, tasks, augment]
|
||||
---
|
||||
|
||||
# Auggie CLI
|
||||
|
||||
Run Auggie CLI in your workspace to access Augment's AI coding assistant with advanced context understanding and codebase integration. This module integrates with [AgentAPI](https://github.com/coder/agentapi).
|
||||
|
||||
```tf
|
||||
module "auggie" {
|
||||
source = "registry.coder.com/coder-labs/auggie/coder"
|
||||
version = "0.1.0"
|
||||
agent_id = coder_agent.example.id
|
||||
folder = "/home/coder/project"
|
||||
}
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Node.js and npm must be sourced/available before the auggie module installs** - ensure they are installed in your workspace image or via earlier provisioning steps
|
||||
- You must add the [Coder Login](https://registry.coder.com/modules/coder/coder-login) module to your template
|
||||
- **Augment session token for authentication (required for tasks). [Instructions](https://docs.augmentcode.com/cli/setup-auggie/authentication) to get the session token**
|
||||
|
||||
## Examples
|
||||
|
||||
### Usage with Tasks and Configuration
|
||||
|
||||
```tf
|
||||
data "coder_parameter" "ai_prompt" {
|
||||
type = "string"
|
||||
name = "AI Prompt"
|
||||
default = ""
|
||||
description = "Initial task prompt for Auggie CLI"
|
||||
mutable = true
|
||||
}
|
||||
|
||||
module "coder-login" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/coder-login/coder"
|
||||
version = "1.0.31"
|
||||
agent_id = coder_agent.example.id
|
||||
}
|
||||
|
||||
module "auggie" {
|
||||
source = "registry.coder.com/coder-labs/auggie/coder"
|
||||
version = "0.1.0"
|
||||
agent_id = coder_agent.example.id
|
||||
folder = "/home/coder/project"
|
||||
|
||||
# Authentication
|
||||
augment_session_token = <<-EOF
|
||||
{"accessToken":"xxxx-yyyy-zzzz-jjjj","tenantURL":"https://d1.api.augmentcode.com/","scopes":["read","write"]}
|
||||
EOF # Required for tasks
|
||||
|
||||
# Version
|
||||
auggie_version = "0.3.0"
|
||||
|
||||
# Task configuration
|
||||
ai_prompt = data.coder_parameter.ai_prompt.value
|
||||
continue_previous_conversation = true
|
||||
interaction_mode = "quiet"
|
||||
auggie_model = "gpt5"
|
||||
report_tasks = true
|
||||
|
||||
# MCP configuration for additional integrations
|
||||
mcp = <<-EOF
|
||||
{
|
||||
"mcpServers": {
|
||||
"filesystem": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/home/coder/project"]
|
||||
}
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
# Workspace guidelines
|
||||
rules = <<-EOT
|
||||
# Project Guidelines
|
||||
|
||||
## Code Style
|
||||
- Use TypeScript for all new JavaScript files
|
||||
- Follow consistent naming conventions
|
||||
- Add comprehensive comments for complex logic
|
||||
|
||||
## Testing
|
||||
- Write unit tests for all new functions
|
||||
- Ensure test coverage above 80%
|
||||
|
||||
## Documentation
|
||||
- Update README.md for any new features
|
||||
- Document API changes in CHANGELOG.md
|
||||
EOT
|
||||
}
|
||||
```
|
||||
|
||||
### Using Multiple MCP Configuration Files
|
||||
|
||||
```tf
|
||||
module "auggie" {
|
||||
source = "registry.coder.com/coder-labs/auggie/coder"
|
||||
version = "0.1.0"
|
||||
agent_id = coder_agent.example.id
|
||||
folder = "/home/coder/project"
|
||||
|
||||
# Multiple MCP configuration files
|
||||
mcp_files = [
|
||||
"/path/to/filesystem-mcp.json",
|
||||
"/path/to/database-mcp.json",
|
||||
"/path/to/api-mcp.json"
|
||||
]
|
||||
|
||||
mcp = <<-EOF
|
||||
{
|
||||
"mcpServers": {
|
||||
"Test MCP": {
|
||||
"command": "uv",
|
||||
"args": [
|
||||
"--directory",
|
||||
"/home/coder/test-mcp",
|
||||
"run",
|
||||
"server.py"
|
||||
],
|
||||
"timeout": 600
|
||||
}
|
||||
}
|
||||
}
|
||||
EOF
|
||||
}
|
||||
```
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
If you have any issues, please take a look at the log files below.
|
||||
|
||||
```bash
|
||||
# Installation logs
|
||||
cat ~/.auggie-module/install.log
|
||||
|
||||
# Startup logs
|
||||
cat ~/.auggie-module/agentapi-start.log
|
||||
|
||||
# Pre/post install script logs
|
||||
cat ~/.auggie-module/pre_install.log
|
||||
cat ~/.auggie-module/post_install.log
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> To use tasks with Auggie CLI, create a `coder_parameter` named `"AI Prompt"` and pass its value to the auggie module's `ai_prompt` variable. The `folder` variable is required for the module to function correctly.
|
||||
|
||||
## References
|
||||
|
||||
- [Auggie CLI Reference](https://docs.augmentcode.com/cli/reference)
|
||||
- [Auggie CLI MCP Integration](https://docs.augmentcode.com/cli/integrations#mcp-integrations)
|
||||
- [Augment Code Documentation](https://docs.augmentcode.com/)
|
||||
- [AgentAPI Documentation](https://github.com/coder/agentapi)
|
||||
- [Coder AI Agents Guide](https://coder.com/docs/tutorials/ai-agents)
|
||||
@@ -0,0 +1,186 @@
|
||||
run "test_auggie_basic" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent-123"
|
||||
folder = "/home/coder/projects"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = coder_env.auggie_session_auth.name == "AUGMENT_SESSION_AUTH"
|
||||
error_message = "Auggie session auth environment variable should be set correctly"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.folder == "/home/coder/projects"
|
||||
error_message = "Folder variable should be set correctly"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.agent_id == "test-agent-123"
|
||||
error_message = "Agent ID variable should be set correctly"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.install_auggie == true
|
||||
error_message = "Install auggie should default to true"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.install_agentapi == true
|
||||
error_message = "Install agentapi should default to true"
|
||||
}
|
||||
}
|
||||
|
||||
run "test_auggie_with_session_token" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent-456"
|
||||
folder = "/home/coder/workspace"
|
||||
augment_session_token = "test-session-token-123"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = coder_env.auggie_session_auth.value == "test-session-token-123"
|
||||
error_message = "Auggie session token value should match the input"
|
||||
}
|
||||
}
|
||||
|
||||
run "test_auggie_with_custom_options" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent-789"
|
||||
folder = "/home/coder/custom"
|
||||
order = 5
|
||||
group = "development"
|
||||
icon = "/icon/custom.svg"
|
||||
auggie_model = "gpt-4"
|
||||
ai_prompt = "Help me write better code"
|
||||
interaction_mode = "compact"
|
||||
continue_previous_conversation = true
|
||||
install_auggie = false
|
||||
install_agentapi = false
|
||||
auggie_version = "1.0.0"
|
||||
agentapi_version = "v0.6.0"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.order == 5
|
||||
error_message = "Order variable should be set to 5"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.group == "development"
|
||||
error_message = "Group variable should be set to 'development'"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.icon == "/icon/custom.svg"
|
||||
error_message = "Icon variable should be set to custom icon"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.auggie_model == "gpt-4"
|
||||
error_message = "Auggie model variable should be set to 'gpt-4'"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.ai_prompt == "Help me write better code"
|
||||
error_message = "AI prompt variable should be set correctly"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.interaction_mode == "compact"
|
||||
error_message = "Interaction mode should be set to 'compact'"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.continue_previous_conversation == true
|
||||
error_message = "Continue previous conversation should be set to true"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.auggie_version == "1.0.0"
|
||||
error_message = "Auggie version should be set to '1.0.0'"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.agentapi_version == "v0.6.0"
|
||||
error_message = "AgentAPI version should be set to 'v0.6.0'"
|
||||
}
|
||||
}
|
||||
|
||||
run "test_auggie_with_mcp_and_rules" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent-mcp"
|
||||
folder = "/home/coder/mcp-test"
|
||||
mcp = jsonencode({
|
||||
mcpServers = {
|
||||
test = {
|
||||
command = "test-server"
|
||||
args = ["--config", "test.json"]
|
||||
}
|
||||
}
|
||||
})
|
||||
mcp_files = [
|
||||
"/path/to/mcp1.json",
|
||||
"/path/to/mcp2.json"
|
||||
]
|
||||
rules = "# General coding rules\n- Write clean code\n- Add comments"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.mcp != ""
|
||||
error_message = "MCP configuration should be provided"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = length(var.mcp_files) == 2
|
||||
error_message = "Should have 2 MCP files"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.rules != ""
|
||||
error_message = "Rules should be provided"
|
||||
}
|
||||
}
|
||||
|
||||
run "test_auggie_with_scripts" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent-scripts"
|
||||
folder = "/home/coder/scripts"
|
||||
pre_install_script = "echo 'Pre-install script'"
|
||||
post_install_script = "echo 'Post-install script'"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.pre_install_script == "echo 'Pre-install script'"
|
||||
error_message = "Pre-install script should be set correctly"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.post_install_script == "echo 'Post-install script'"
|
||||
error_message = "Post-install script should be set correctly"
|
||||
}
|
||||
}
|
||||
|
||||
run "test_auggie_interaction_mode_validation" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent-validation"
|
||||
folder = "/home/coder/test"
|
||||
interaction_mode = "print"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = contains(["interactive", "print", "quiet", "compact"], var.interaction_mode)
|
||||
error_message = "Interaction mode should be one of the valid options"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
import {
|
||||
test,
|
||||
afterEach,
|
||||
describe,
|
||||
setDefaultTimeout,
|
||||
beforeAll,
|
||||
expect,
|
||||
} from "bun:test";
|
||||
import { execContainer, readFileContainer, runTerraformInit } from "~test";
|
||||
import {
|
||||
loadTestFile,
|
||||
writeExecutable,
|
||||
setup as setupUtil,
|
||||
execModuleScript,
|
||||
expectAgentAPIStarted,
|
||||
} from "../../../coder/modules/agentapi/test-util";
|
||||
import dedent from "dedent";
|
||||
|
||||
let cleanupFunctions: (() => Promise<void>)[] = [];
|
||||
const registerCleanup = (cleanup: () => Promise<void>) => {
|
||||
cleanupFunctions.push(cleanup);
|
||||
};
|
||||
afterEach(async () => {
|
||||
const cleanupFnsCopy = cleanupFunctions.slice().reverse();
|
||||
cleanupFunctions = [];
|
||||
for (const cleanup of cleanupFnsCopy) {
|
||||
try {
|
||||
await cleanup();
|
||||
} catch (error) {
|
||||
console.error("Error during cleanup:", error);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
interface SetupProps {
|
||||
skipAgentAPIMock?: boolean;
|
||||
skipAuggieMock?: boolean;
|
||||
moduleVariables?: Record<string, string>;
|
||||
agentapiMockScript?: string;
|
||||
}
|
||||
|
||||
const setup = async (props?: SetupProps): Promise<{ id: string }> => {
|
||||
const projectDir = "/home/coder/project";
|
||||
const { id } = await setupUtil({
|
||||
moduleDir: import.meta.dir,
|
||||
moduleVariables: {
|
||||
install_auggie: props?.skipAuggieMock ? "true" : "false",
|
||||
install_agentapi: props?.skipAgentAPIMock ? "true" : "false",
|
||||
folder: projectDir,
|
||||
...props?.moduleVariables,
|
||||
},
|
||||
registerCleanup,
|
||||
projectDir,
|
||||
skipAgentAPIMock: props?.skipAgentAPIMock,
|
||||
agentapiMockScript: props?.agentapiMockScript,
|
||||
});
|
||||
if (!props?.skipAuggieMock) {
|
||||
await writeExecutable({
|
||||
containerId: id,
|
||||
filePath: "/usr/bin/auggie",
|
||||
content: await loadTestFile(import.meta.dir, "auggie-mock.sh"),
|
||||
});
|
||||
}
|
||||
return { id };
|
||||
};
|
||||
|
||||
setDefaultTimeout(60 * 1000);
|
||||
|
||||
describe("auggie", async () => {
|
||||
beforeAll(async () => {
|
||||
await runTerraformInit(import.meta.dir);
|
||||
});
|
||||
|
||||
test("happy-path", async () => {
|
||||
const { id } = await setup();
|
||||
await execModuleScript(id);
|
||||
await expectAgentAPIStarted(id);
|
||||
});
|
||||
|
||||
test("install-auggie-version", async () => {
|
||||
const version_to_install = "0.3.0";
|
||||
const { id } = await setup({
|
||||
skipAuggieMock: true,
|
||||
moduleVariables: {
|
||||
install_auggie: "true",
|
||||
auggie_version: version_to_install,
|
||||
pre_install_script: dedent`
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Install Node.js and npm via system package manager
|
||||
if ! command -v node >/dev/null 2>&1; then
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y nodejs npm
|
||||
fi
|
||||
|
||||
# Configure npm to use user directory (avoids permission issues)
|
||||
mkdir -p "$HOME/.npm-global"
|
||||
npm config set prefix "$HOME/.npm-global"
|
||||
|
||||
# Persist npm user directory configuration
|
||||
echo 'export PATH="$HOME/.npm-global/bin:$PATH"' >> ~/.bashrc
|
||||
echo "prefix=$HOME/.npm-global" > ~/.npmrc
|
||||
`,
|
||||
},
|
||||
});
|
||||
await execModuleScript(id);
|
||||
const resp = await execContainer(id, [
|
||||
"bash",
|
||||
"-c",
|
||||
`cat /home/coder/.auggie-module/install.log`,
|
||||
]);
|
||||
expect(resp.stdout).toContain(version_to_install);
|
||||
});
|
||||
|
||||
test("check-latest-auggie-version-works", async () => {
|
||||
const { id } = await setup({
|
||||
skipAuggieMock: true,
|
||||
skipAgentAPIMock: true,
|
||||
moduleVariables: {
|
||||
install_auggie: "true",
|
||||
pre_install_script: dedent`
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Install Node.js and npm via system package manager
|
||||
if ! command -v node >/dev/null 2>&1; then
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y nodejs npm
|
||||
fi
|
||||
|
||||
# Configure npm to use user directory (avoids permission issues)
|
||||
mkdir -p "$HOME/.npm-global"
|
||||
npm config set prefix "$HOME/.npm-global"
|
||||
|
||||
# Persist npm user directory configuration
|
||||
echo 'export PATH="$HOME/.npm-global/bin:$PATH"' >> ~/.bashrc
|
||||
echo "prefix=$HOME/.npm-global" > ~/.npmrc
|
||||
`,
|
||||
},
|
||||
});
|
||||
await execModuleScript(id);
|
||||
await expectAgentAPIStarted(id);
|
||||
});
|
||||
|
||||
test("auggie-session-token", async () => {
|
||||
const sessionToken = "test-session-token-123";
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
augment_session_token: sessionToken,
|
||||
},
|
||||
});
|
||||
await execModuleScript(id);
|
||||
|
||||
const envCheck = await execContainer(id, [
|
||||
"bash",
|
||||
"-c",
|
||||
`env | grep AUGMENT_SESSION_AUTH || echo "AUGMENT_SESSION_AUTH not found"`,
|
||||
]);
|
||||
expect(envCheck.stdout).toContain("AUGMENT_SESSION_AUTH");
|
||||
});
|
||||
|
||||
test("auggie-mcp-config", async () => {
|
||||
const mcpConfig = JSON.stringify({
|
||||
mcpServers: {
|
||||
test: {
|
||||
command: "test-cmd",
|
||||
type: "stdio"
|
||||
}
|
||||
}
|
||||
});
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
mcp: mcpConfig,
|
||||
},
|
||||
});
|
||||
await execModuleScript(id);
|
||||
|
||||
const resp = await readFileContainer(
|
||||
id,
|
||||
"/home/coder/.auggie-module/agentapi-start.log",
|
||||
);
|
||||
expect(resp).toContain("--mcp-config");
|
||||
});
|
||||
|
||||
test("auggie-rules", async () => {
|
||||
const rules = "Always use TypeScript for new files";
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
install_auggie: "false", // Don't need to install auggie to test rules file creation
|
||||
rules: rules,
|
||||
},
|
||||
});
|
||||
await execModuleScript(id);
|
||||
|
||||
const rulesFile = await readFileContainer(id, "/home/coder/.augment/rules.md");
|
||||
expect(rulesFile).toContain(rules);
|
||||
});
|
||||
|
||||
test("auggie-ai-task-prompt", async () => {
|
||||
const prompt = "This is a task prompt for Auggie.";
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
ai_prompt: prompt,
|
||||
},
|
||||
});
|
||||
await execModuleScript(id);
|
||||
|
||||
const resp = await execContainer(id, [
|
||||
"bash",
|
||||
"-c",
|
||||
`cat /home/coder/.auggie-module/agentapi-start.log`,
|
||||
]);
|
||||
expect(resp.stdout).toContain(prompt);
|
||||
});
|
||||
|
||||
test("auggie-interaction-mode", async () => {
|
||||
const mode = "compact";
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
interaction_mode: mode,
|
||||
ai_prompt: "test prompt",
|
||||
},
|
||||
});
|
||||
await execModuleScript(id);
|
||||
|
||||
const startLog = await execContainer(id, [
|
||||
"bash",
|
||||
"-c",
|
||||
"cat /home/coder/.auggie-module/agentapi-start.log",
|
||||
]);
|
||||
expect(startLog.stdout).toContain(`--${mode}`);
|
||||
});
|
||||
|
||||
test("auggie-model", async () => {
|
||||
const model = "gpt-4";
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
auggie_model: model,
|
||||
ai_prompt: "test prompt",
|
||||
},
|
||||
});
|
||||
await execModuleScript(id);
|
||||
|
||||
const startLog = await execContainer(id, [
|
||||
"bash",
|
||||
"-c",
|
||||
"cat /home/coder/.auggie-module/agentapi-start.log",
|
||||
]);
|
||||
expect(startLog.stdout).toContain(`--model ${model}`);
|
||||
});
|
||||
|
||||
test("auggie-continue-previous-conversation", async () => {
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
continue_previous_conversation: "true",
|
||||
ai_prompt: "test prompt",
|
||||
},
|
||||
});
|
||||
await execModuleScript(id);
|
||||
|
||||
const startLog = await execContainer(id, [
|
||||
"bash",
|
||||
"-c",
|
||||
"cat /home/coder/.auggie-module/agentapi-start.log",
|
||||
]);
|
||||
expect(startLog.stdout).toContain("--continue");
|
||||
});
|
||||
|
||||
test("pre-post-install-scripts", async () => {
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
pre_install_script: "#!/bin/bash\necho 'auggie-pre-install-script'",
|
||||
post_install_script: "#!/bin/bash\necho 'auggie-post-install-script'",
|
||||
},
|
||||
});
|
||||
await execModuleScript(id);
|
||||
|
||||
const preInstallLog = await readFileContainer(
|
||||
id,
|
||||
"/home/coder/.auggie-module/pre_install.log",
|
||||
);
|
||||
expect(preInstallLog).toContain("auggie-pre-install-script");
|
||||
|
||||
const postInstallLog = await readFileContainer(
|
||||
id,
|
||||
"/home/coder/.auggie-module/post_install.log",
|
||||
);
|
||||
expect(postInstallLog).toContain("auggie-post-install-script");
|
||||
});
|
||||
|
||||
test("folder-variable", async () => {
|
||||
const folder = "/home/coder/auggie-test-folder";
|
||||
const { id } = await setup({
|
||||
skipAuggieMock: false,
|
||||
moduleVariables: {
|
||||
folder,
|
||||
},
|
||||
});
|
||||
await execModuleScript(id);
|
||||
|
||||
const resp = await readFileContainer(
|
||||
id,
|
||||
"/home/coder/.auggie-module/agentapi-start.log",
|
||||
);
|
||||
expect(resp).toContain(folder);
|
||||
});
|
||||
|
||||
test("coder-mcp-config-created", async () => {
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
install_auggie: "false", // Don't need to install auggie to test MCP config creation
|
||||
},
|
||||
});
|
||||
await execModuleScript(id);
|
||||
|
||||
const mcpConfig = await readFileContainer(id, "/home/coder/.augment/coder_mcp.json");
|
||||
expect(mcpConfig).toContain("mcpServers");
|
||||
expect(mcpConfig).toContain("coder");
|
||||
expect(mcpConfig).toContain("CODER_MCP_APP_STATUS_SLUG");
|
||||
expect(mcpConfig).toContain("CODER_MCP_AI_AGENTAPI_URL");
|
||||
});
|
||||
|
||||
test("mcp-files-array", async () => {
|
||||
const mcpFiles = ["/path/to/mcp1.json", "/path/to/mcp2.json"];
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
mcp_files: JSON.stringify(mcpFiles),
|
||||
ai_prompt: "test prompt",
|
||||
},
|
||||
});
|
||||
await execModuleScript(id);
|
||||
|
||||
const startLog = await execContainer(id, [
|
||||
"bash",
|
||||
"-c",
|
||||
"cat /home/coder/.auggie-module/agentapi-start.log",
|
||||
]);
|
||||
expect(startLog.stdout).toContain("mcp1.json");
|
||||
expect(startLog.stdout).toContain("mcp2.json");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,230 @@
|
||||
terraform {
|
||||
required_version = ">= 1.0"
|
||||
|
||||
required_providers {
|
||||
coder = {
|
||||
source = "coder/coder"
|
||||
version = ">= 2.7"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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/auggie.svg"
|
||||
}
|
||||
|
||||
variable "folder" {
|
||||
type = string
|
||||
description = "The folder to run Auggie in."
|
||||
}
|
||||
|
||||
variable "install_auggie" {
|
||||
type = bool
|
||||
description = "Whether to install Auggie CLI."
|
||||
default = true
|
||||
}
|
||||
|
||||
variable "auggie_version" {
|
||||
type = string
|
||||
description = "The version of Auggie to install."
|
||||
default = "" # empty string means the latest available version
|
||||
validation {
|
||||
condition = var.auggie_version == "" || can(regex("^v?[0-9]+\\.[0-9]+\\.[0-9]+", var.auggie_version))
|
||||
error_message = "auggie_version must be empty (for latest) or a valid semantic version like 'v1.2.3' or '1.2.3'."
|
||||
}
|
||||
}
|
||||
|
||||
variable "install_agentapi" {
|
||||
type = bool
|
||||
description = "Whether to install AgentAPI."
|
||||
default = true
|
||||
}
|
||||
|
||||
variable "agentapi_version" {
|
||||
type = string
|
||||
description = "The version of AgentAPI to install."
|
||||
default = "v0.6.0"
|
||||
validation {
|
||||
condition = can(regex("^v[0-9]+\\.[0-9]+\\.[0-9]+", var.agentapi_version))
|
||||
error_message = "agentapi_version must be a valid semantic version starting with 'v', like 'v0.3.3'."
|
||||
}
|
||||
}
|
||||
|
||||
variable "pre_install_script" {
|
||||
type = string
|
||||
description = "Custom script to run before installing Auggie."
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "post_install_script" {
|
||||
type = string
|
||||
description = "Custom script to run after installing Auggie."
|
||||
default = null
|
||||
}
|
||||
|
||||
# ----------------------------------------------
|
||||
|
||||
variable "ai_prompt" {
|
||||
type = string
|
||||
description = "Task prompt for the Auggie CLI"
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "mcp" {
|
||||
type = string
|
||||
description = "MCP configuration as a JSON string for the auggie cli, check https://docs.augmentcode.com/cli/integrations#mcp-integrations"
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "mcp_files" {
|
||||
type = list(string)
|
||||
description = "MCP configuration from a JSON file for the auggie cli, check https://docs.augmentcode.com/cli/integrations#mcp-integrations"
|
||||
default = []
|
||||
}
|
||||
|
||||
variable "rules" {
|
||||
type = string
|
||||
description = "Additional rules to append to workspace guidelines (markdown format)"
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "continue_previous_conversation" {
|
||||
type = bool
|
||||
description = "Whether to resume the previous conversation."
|
||||
default = false
|
||||
}
|
||||
|
||||
variable "interaction_mode" {
|
||||
type = string
|
||||
description = "Interaction mode with the Auggie CLI. Options: interactive, print, quiet, compact. https://docs.augmentcode.com/cli/reference#cli-flags"
|
||||
default = "interactive"
|
||||
validation {
|
||||
condition = contains(["interactive", "print", "quiet", "compact"], var.interaction_mode)
|
||||
error_message = "interaction_mode must be one of: interactive, print, quiet, compact."
|
||||
}
|
||||
}
|
||||
|
||||
variable "augment_session_token" {
|
||||
type = string
|
||||
description = "Auggie session token for authentication. https://docs.augmentcode.com/cli/setup-auggie/authentication"
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "auggie_model" {
|
||||
type = string
|
||||
description = "The model to use for Auggie, find available models using auggie --list-models"
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "report_tasks" {
|
||||
type = bool
|
||||
description = "Whether to enable task reporting to Coder UI via AgentAPI"
|
||||
default = false
|
||||
}
|
||||
|
||||
variable "cli_app" {
|
||||
type = bool
|
||||
description = "Whether to create a CLI app for Auggie"
|
||||
default = false
|
||||
}
|
||||
|
||||
variable "web_app_display_name" {
|
||||
type = string
|
||||
description = "Display name for the web app"
|
||||
default = "Auggie"
|
||||
}
|
||||
|
||||
variable "cli_app_display_name" {
|
||||
type = string
|
||||
description = "Display name for the CLI app"
|
||||
default = "Auggie CLI"
|
||||
}
|
||||
|
||||
resource "coder_env" "auggie_session_auth" {
|
||||
agent_id = var.agent_id
|
||||
name = "AUGMENT_SESSION_AUTH"
|
||||
value = var.augment_session_token
|
||||
}
|
||||
|
||||
locals {
|
||||
app_slug = "auggie"
|
||||
install_script = file("${path.module}/scripts/install.sh")
|
||||
start_script = file("${path.module}/scripts/start.sh")
|
||||
module_dir_name = ".auggie-module"
|
||||
}
|
||||
|
||||
module "agentapi" {
|
||||
source = "registry.coder.com/coder/agentapi/coder"
|
||||
version = "1.1.1"
|
||||
|
||||
agent_id = var.agent_id
|
||||
web_app_slug = local.app_slug
|
||||
web_app_order = var.order
|
||||
web_app_group = var.group
|
||||
web_app_icon = var.icon
|
||||
web_app_display_name = var.web_app_display_name
|
||||
cli_app = var.cli_app
|
||||
cli_app_slug = var.cli_app ? "${local.app_slug}-cli" : null
|
||||
cli_app_display_name = var.cli_app ? var.cli_app_display_name : null
|
||||
module_dir_name = local.module_dir_name
|
||||
install_agentapi = var.install_agentapi
|
||||
agentapi_version = var.agentapi_version
|
||||
pre_install_script = var.pre_install_script
|
||||
post_install_script = var.post_install_script
|
||||
start_script = <<-EOT
|
||||
#!/bin/bash
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
|
||||
echo -n '${base64encode(local.start_script)}' | base64 -d > /tmp/start.sh
|
||||
chmod +x /tmp/start.sh
|
||||
ARG_AUGGIE_START_DIRECTORY='${var.folder}' \
|
||||
ARG_TASK_PROMPT='${base64encode(var.ai_prompt)}' \
|
||||
ARG_MCP_FILES='${jsonencode(var.mcp_files)}' \
|
||||
ARG_AUGGIE_RULES='${base64encode(var.rules)}' \
|
||||
ARG_AUGGIE_CONTINUE_PREVIOUS_CONVERSATION='${var.continue_previous_conversation}' \
|
||||
ARG_AUGGIE_INTERACTION_MODE='${var.interaction_mode}' \
|
||||
ARG_AUGMENT_SESSION_AUTH='${var.augment_session_token}' \
|
||||
ARG_AUGGIE_MODEL='${var.auggie_model}' \
|
||||
ARG_REPORT_TASKS='${var.report_tasks}' \
|
||||
/tmp/start.sh
|
||||
EOT
|
||||
|
||||
install_script = <<-EOT
|
||||
#!/bin/bash
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
|
||||
echo -n '${base64encode(local.install_script)}' | base64 -d > /tmp/install.sh
|
||||
chmod +x /tmp/install.sh
|
||||
ARG_AUGGIE_INSTALL='${var.install_auggie}' \
|
||||
ARG_AUGGIE_VERSION='${var.auggie_version}' \
|
||||
ARG_MCP_APP_STATUS_SLUG='${local.app_slug}' \
|
||||
ARG_AUGGIE_RULES='${base64encode(var.rules)}' \
|
||||
ARG_MCP_CONFIG='${var.mcp != null ? base64encode(replace(var.mcp, "'", "'\\''")) : ""}' \
|
||||
/tmp/install.sh
|
||||
EOT
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
source "$HOME"/.bashrc
|
||||
|
||||
BOLD='\033[0;1m'
|
||||
|
||||
# Function to check if a command exists
|
||||
command_exists() {
|
||||
command -v "$1" > /dev/null 2>&1
|
||||
}
|
||||
|
||||
ARG_AUGGIE_INSTALL=${ARG_AUGGIE_INSTALL:-true}
|
||||
ARG_AUGGIE_VERSION=${ARG_AUGGIE_VERSION:-}
|
||||
ARG_MCP_APP_STATUS_SLUG=${ARG_MCP_APP_STATUS_SLUG:-}
|
||||
ARG_AUGGIE_RULES=$(echo -n "${ARG_AUGGIE_RULES:-}" | base64 -d)
|
||||
ARG_MCP_CONFIG=${ARG_MCP_CONFIG:-}
|
||||
|
||||
echo "--------------------------------"
|
||||
|
||||
printf "install auggie: %s\n" "$ARG_AUGGIE_INSTALL"
|
||||
printf "auggie_version: %s\n" "$ARG_AUGGIE_VERSION"
|
||||
printf "app_slug: %s\n" "$ARG_MCP_APP_STATUS_SLUG"
|
||||
printf "rules: %s\n" "$ARG_AUGGIE_RULES"
|
||||
|
||||
echo "--------------------------------"
|
||||
|
||||
|
||||
function check_dependencies() {
|
||||
if ! command_exists node; then
|
||||
printf "Error: Node.js is not installed. Please install Node.js manually or use the pre_install_script to install it.\n"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command_exists npm; then
|
||||
printf "Error: npm is not installed. Please install npm manually or use the pre_install_script to install it.\n"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf "Node.js version: %s\n" "$(node --version)"
|
||||
printf "npm version: %s\n" "$(npm --version)"
|
||||
}
|
||||
|
||||
function install_auggie() {
|
||||
if [ "${ARG_AUGGIE_INSTALL}" = "true" ]; then
|
||||
check_dependencies
|
||||
|
||||
printf "%s Installing Auggie CLI\n" "${BOLD}"
|
||||
|
||||
NPM_GLOBAL_PREFIX="${HOME}/.npm-global"
|
||||
if [ ! -d "$NPM_GLOBAL_PREFIX" ]; then
|
||||
mkdir -p "$NPM_GLOBAL_PREFIX"
|
||||
fi
|
||||
|
||||
npm config set prefix "$NPM_GLOBAL_PREFIX"
|
||||
|
||||
export PATH="$NPM_GLOBAL_PREFIX/bin:$PATH"
|
||||
|
||||
if [ -n "$ARG_AUGGIE_VERSION" ]; then
|
||||
npm install -g "@augmentcode/auggie@$ARG_AUGGIE_VERSION"
|
||||
else
|
||||
npm install -g "@augmentcode/auggie"
|
||||
fi
|
||||
|
||||
if ! grep -q "export PATH=\"\$HOME/.npm-global/bin:\$PATH\"" "$HOME/.bashrc"; then
|
||||
echo 'export PATH="$HOME/.npm-global/bin:$PATH"' >> "$HOME/.bashrc"
|
||||
fi
|
||||
|
||||
printf "%s Successfully installed Auggie CLI. Version: %s\n" "${BOLD}" "$(auggie --version)"
|
||||
else
|
||||
printf "Skipping Auggie CLI installation (install_auggie=false)\n"
|
||||
fi
|
||||
}
|
||||
|
||||
|
||||
function create_coder_mcp() {
|
||||
AUGGIE_CODER_MCP_FILE="$HOME/.augment/coder_mcp.json"
|
||||
CODER_MCP=$(
|
||||
cat << EOF
|
||||
{
|
||||
"mcpServers":{
|
||||
"coder": {
|
||||
"args": ["exp", "mcp", "server"],
|
||||
"command": "coder",
|
||||
"env": {
|
||||
"CODER_MCP_APP_STATUS_SLUG": "${ARG_MCP_APP_STATUS_SLUG}",
|
||||
"CODER_MCP_AI_AGENTAPI_URL": "http://localhost:3284",
|
||||
"CODER_AGENT_URL": "${CODER_AGENT_URL:-}",
|
||||
"CODER_AGENT_TOKEN": "${CODER_AGENT_TOKEN:-}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
EOF
|
||||
)
|
||||
mkdir -p "$(dirname "$AUGGIE_CODER_MCP_FILE")"
|
||||
echo "$CODER_MCP" > "$AUGGIE_CODER_MCP_FILE"
|
||||
printf "Coder MCP config created at: %s\n" "$AUGGIE_CODER_MCP_FILE"
|
||||
}
|
||||
|
||||
function create_user_mcp() {
|
||||
if [ -n "$ARG_MCP_CONFIG" ]; then
|
||||
USER_MCP_CONFIG_FILE="$HOME/.augment/user_mcp.json"
|
||||
USER_MCP_CONTENT=$(echo -n "$ARG_MCP_CONFIG" | base64 -d)
|
||||
mkdir -p "$(dirname "$USER_MCP_CONFIG_FILE")"
|
||||
echo "$USER_MCP_CONTENT" > "$USER_MCP_CONFIG_FILE"
|
||||
printf "User MCP config created at: %s\n" "$USER_MCP_CONFIG_FILE"
|
||||
else
|
||||
printf "No user MCP config provided, skipping user MCP config creation.\n"
|
||||
fi
|
||||
}
|
||||
|
||||
function create_rules_file() {
|
||||
AUGGIE_RULES_FILE="$HOME/.augment/rules.md"
|
||||
if [ -n "$ARG_AUGGIE_RULES" ]; then
|
||||
mkdir -p "$(dirname "$AUGGIE_RULES_FILE")"
|
||||
echo -n "$ARG_AUGGIE_RULES" > "$AUGGIE_RULES_FILE"
|
||||
printf "Rules file created at: %s\n" "$AUGGIE_RULES_FILE"
|
||||
else
|
||||
printf "No rules provided, skipping rules file creation.\n"
|
||||
fi
|
||||
}
|
||||
|
||||
install_auggie
|
||||
create_coder_mcp
|
||||
create_user_mcp
|
||||
create_rules_file
|
||||
@@ -0,0 +1,104 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
source "$HOME"/.bashrc
|
||||
|
||||
command_exists() {
|
||||
command -v "$1" > /dev/null 2>&1
|
||||
}
|
||||
|
||||
if [ -f "$HOME/.nvm/nvm.sh" ]; then
|
||||
source "$HOME"/.nvm/nvm.sh
|
||||
else
|
||||
export PATH="$HOME/.npm-global/bin:$PATH"
|
||||
fi
|
||||
|
||||
ARG_AUGGIE_START_DIRECTORY=${ARG_AUGGIE_START_DIRECTORY:-"$HOME"}
|
||||
ARG_TASK_PROMPT=$(echo -n "${ARG_TASK_PROMPT:-}" | base64 -d)
|
||||
ARG_MCP_FILES=${ARG_MCP_FILES:-[]}
|
||||
ARG_AUGGIE_RULES=${ARG_AUGGIE_RULES:-}
|
||||
ARG_AUGMENT_SESSION_AUTH=${ARG_AUGMENT_SESSION_AUTH:-}
|
||||
ARG_AUGGIE_CONTINUE_PREVIOUS_CONVERSATION=${ARG_AUGGIE_CONTINUE_PREVIOUS_CONVERSATION:-false}
|
||||
ARG_AUGGIE_INTERACTION_MODE=${ARG_AUGGIE_INTERACTION_MODE:-"interactive"}
|
||||
ARG_AUGGIE_MODEL=${ARG_AUGGIE_MODEL:-}
|
||||
ARG_REPORT_TASKS=${ARG_REPORT_TASKS:-false}
|
||||
|
||||
ARGS=()
|
||||
|
||||
echo "--------------------------------"
|
||||
|
||||
printf "auggie_start_directory: %s\n" "$ARG_AUGGIE_START_DIRECTORY"
|
||||
printf "task_prompt: %s\n" "$ARG_TASK_PROMPT"
|
||||
printf "mcp_files: %s\n" "$ARG_MCP_FILES"
|
||||
printf "auggie_rules: %s\n" "$ARG_AUGGIE_RULES"
|
||||
printf "continue_previous_conversation: %s\n" "$ARG_AUGGIE_CONTINUE_PREVIOUS_CONVERSATION"
|
||||
printf "auggie_interaction_mode: %s\n" "$ARG_AUGGIE_INTERACTION_MODE"
|
||||
printf "augment_session_auth: %s\n" "$ARG_AUGMENT_SESSION_AUTH"
|
||||
printf "auggie_model: %s\n" "$ARG_AUGGIE_MODEL"
|
||||
printf "report_tasks: %s\n" "$ARG_REPORT_TASKS"
|
||||
|
||||
echo "--------------------------------"
|
||||
|
||||
|
||||
function validate_auggie_installation() {
|
||||
if command_exists auggie; then
|
||||
printf "Auggie is installed\n"
|
||||
else
|
||||
printf "Error: Auggie is not installed. Please enable install_auggie or install it manually\n"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
function build_auggie_args() {
|
||||
if [ -n "$ARG_AUGGIE_INTERACTION_MODE" ]; then
|
||||
if [ "$ARG_AUGGIE_INTERACTION_MODE" != "interactive" ]; then
|
||||
ARGS+=(--"$ARG_AUGGIE_INTERACTION_MODE")
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -n "$ARG_AUGGIE_MODEL" ]; then
|
||||
ARGS+=(--model "$ARG_AUGGIE_MODEL")
|
||||
fi
|
||||
|
||||
if [ -f "$HOME/.augment/user_mcp.json" ]; then
|
||||
ARGS+=(--mcp-config "$HOME/.augment/user_mcp.json")
|
||||
fi
|
||||
|
||||
if [ -n "$ARG_MCP_FILES" ] && [ "$ARG_MCP_FILES" != "[]" ]; then
|
||||
for file in $(echo "$ARG_MCP_FILES" | jq -r '.[]'); do
|
||||
ARGS+=(--mcp-config "$file")
|
||||
done
|
||||
fi
|
||||
|
||||
ARGS+=(--mcp-config "$HOME/.augment/coder_mcp.json")
|
||||
|
||||
if [ -n "$ARG_AUGGIE_RULES" ]; then
|
||||
AUGGIE_RULES_FILE="$HOME/.augment/rules.md"
|
||||
ARGS+=(--rules "$AUGGIE_RULES_FILE")
|
||||
fi
|
||||
|
||||
if [ "$ARG_AUGGIE_CONTINUE_PREVIOUS_CONVERSATION" == "true" ]; then
|
||||
ARGS+=(--continue)
|
||||
fi
|
||||
|
||||
if [ -n "$ARG_TASK_PROMPT" ]; then
|
||||
if [ "$ARG_REPORT_TASKS" == "true" ]; then
|
||||
PROMPT="Every step of the way, report your progress using coder_report_task tool with proper summary and statuses. Your task at hand: $ARG_TASK_PROMPT"
|
||||
else
|
||||
PROMPT="$ARG_TASK_PROMPT"
|
||||
fi
|
||||
ARGS+=(--instruction "$PROMPT")
|
||||
fi
|
||||
}
|
||||
|
||||
function start_agentapi_server() {
|
||||
mkdir -p "$ARG_AUGGIE_START_DIRECTORY"
|
||||
cd "$ARG_AUGGIE_START_DIRECTORY"
|
||||
ARGS+=(--workspace-root "$ARG_AUGGIE_START_DIRECTORY")
|
||||
printf "Running auggie with args: %s\n" "$(printf '%q ' "${ARGS[@]}")"
|
||||
agentapi server --term-width 67 --term-height 1190 -- auggie "${ARGS[@]}"
|
||||
}
|
||||
|
||||
validate_auggie_installation
|
||||
build_auggie_args
|
||||
start_agentapi_server
|
||||
@@ -0,0 +1,14 @@
|
||||
#!/bin/bash
|
||||
|
||||
if [[ "$1" == "--version" ]]; then
|
||||
echo "HELLO: $(bash -c env)"
|
||||
echo "auggie version v1.0.0"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
set -e
|
||||
|
||||
while true; do
|
||||
echo "$(date) - auggie-mock"
|
||||
sleep 15
|
||||
done
|
||||
@@ -0,0 +1,146 @@
|
||||
---
|
||||
display_name: Codex CLI
|
||||
icon: ../../../../.icons/openai.svg
|
||||
description: Run Codex CLI in your workspace with AgentAPI integration
|
||||
verified: true
|
||||
tags: [agent, codex, ai, openai, tasks]
|
||||
---
|
||||
|
||||
# Codex CLI
|
||||
|
||||
Run Codex CLI in your workspace to access OpenAI's models through the Codex interface, with custom pre/post install scripts. This module integrates with [AgentAPI](https://github.com/coder/agentapi) for Coder Tasks compatibility.
|
||||
|
||||
```tf
|
||||
module "codex" {
|
||||
source = "registry.coder.com/coder-labs/codex/coder"
|
||||
version = "2.0.0"
|
||||
agent_id = coder_agent.example.id
|
||||
openai_api_key = var.openai_api_key
|
||||
folder = "/home/coder/project"
|
||||
}
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- You must add the [Coder Login](https://registry.coder.com/modules/coder/coder-login) module to your template
|
||||
- OpenAI API key for Codex access
|
||||
|
||||
## Examples
|
||||
|
||||
### Run standalone
|
||||
|
||||
```tf
|
||||
module "codex" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder-labs/codex/coder"
|
||||
version = "2.0.0"
|
||||
agent_id = coder_agent.example.id
|
||||
openai_api_key = "..."
|
||||
folder = "/home/coder/project"
|
||||
}
|
||||
```
|
||||
|
||||
### Tasks integration
|
||||
|
||||
```tf
|
||||
data "coder_parameter" "ai_prompt" {
|
||||
type = "string"
|
||||
name = "AI Prompt"
|
||||
default = ""
|
||||
description = "Initial prompt for the Codex CLI"
|
||||
mutable = true
|
||||
}
|
||||
|
||||
module "coder-login" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/coder-login/coder"
|
||||
version = "1.0.31"
|
||||
agent_id = coder_agent.example.id
|
||||
}
|
||||
|
||||
module "codex" {
|
||||
source = "registry.coder.com/coder-labs/codex/coder"
|
||||
version = "2.0.0"
|
||||
agent_id = coder_agent.example.id
|
||||
openai_api_key = "..."
|
||||
ai_prompt = data.coder_parameter.ai_prompt.value
|
||||
folder = "/home/coder/project"
|
||||
|
||||
# Custom configuration for full auto mode
|
||||
base_config_toml = <<-EOT
|
||||
approval_policy = "never"
|
||||
preferred_auth_method = "apikey"
|
||||
EOT
|
||||
}
|
||||
```
|
||||
|
||||
> [!WARNING]
|
||||
> This module configures Codex with a `workspace-write` sandbox that allows AI tasks to read/write files in the specified folder. While the sandbox provides security boundaries, Codex can still modify files within the workspace. Use this module _only_ in trusted environments and be aware of the security implications.
|
||||
|
||||
## How it Works
|
||||
|
||||
- **Install**: The module installs Codex CLI and sets up the environment
|
||||
- **System Prompt**: If `codex_system_prompt` is set, writes the prompt to `AGENTS.md` in the `~/.codex/` directory
|
||||
- **Start**: Launches Codex CLI in the specified directory, wrapped by AgentAPI
|
||||
- **Configuration**: Sets `OPENAI_API_KEY` environment variable and passes `--model` flag to Codex CLI (if variables provided)
|
||||
|
||||
## Configuration
|
||||
|
||||
### Default Configuration
|
||||
|
||||
When no custom `base_config_toml` is provided, the module uses these secure defaults:
|
||||
|
||||
```toml
|
||||
sandbox_mode = "workspace-write"
|
||||
approval_policy = "never"
|
||||
preferred_auth_method = "apikey"
|
||||
|
||||
[sandbox_workspace_write]
|
||||
network_access = true
|
||||
```
|
||||
|
||||
### Custom Configuration
|
||||
|
||||
For custom Codex configuration, use `base_config_toml` and/or `additional_mcp_servers`:
|
||||
|
||||
```tf
|
||||
module "codex" {
|
||||
source = "registry.coder.com/coder-labs/codex/coder"
|
||||
version = "2.0.0"
|
||||
# ... other variables ...
|
||||
|
||||
# Override default configuration
|
||||
base_config_toml = <<-EOT
|
||||
sandbox_mode = "danger-full-access"
|
||||
approval_policy = "never"
|
||||
preferred_auth_method = "apikey"
|
||||
EOT
|
||||
|
||||
# Add extra MCP servers
|
||||
additional_mcp_servers = <<-EOT
|
||||
[mcp_servers.GitHub]
|
||||
command = "npx"
|
||||
args = ["-y", "@modelcontextprotocol/server-github"]
|
||||
type = "stdio"
|
||||
EOT
|
||||
}
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> If no custom configuration is provided, the module uses secure defaults. The Coder MCP server is always included automatically. For containerized workspaces (Docker/Kubernetes), you may need `sandbox_mode = "danger-full-access"` to avoid permission issues. For advanced options, see [Codex config docs](https://github.com/openai/codex/blob/main/codex-rs/config.md).
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- Check installation and startup logs in `~/.codex-module/`
|
||||
- Ensure your OpenAI API key has access to the specified model
|
||||
|
||||
> [!IMPORTANT]
|
||||
> To use tasks with Codex CLI, ensure you have the `openai_api_key` variable set, and **you create a `coder_parameter` named `"AI Prompt"` and pass its value to the codex module's `ai_prompt` variable**. [Tasks Template Example](https://registry.coder.com/templates/coder-labs/tasks-docker).
|
||||
> The module automatically configures Codex with your API key and model preferences.
|
||||
> folder is a required variable for the module to function correctly.
|
||||
|
||||
## References
|
||||
|
||||
- [Codex CLI Documentation](https://github.com/openai/codex)
|
||||
- [AgentAPI Documentation](https://github.com/coder/agentapi)
|
||||
- [Coder AI Agents Guide](https://coder.com/docs/tutorials/ai-agents)
|
||||
@@ -0,0 +1,368 @@
|
||||
import {
|
||||
test,
|
||||
afterEach,
|
||||
describe,
|
||||
setDefaultTimeout,
|
||||
beforeAll,
|
||||
expect,
|
||||
} from "bun:test";
|
||||
import { execContainer, readFileContainer, runTerraformInit } from "~test";
|
||||
import {
|
||||
loadTestFile,
|
||||
writeExecutable,
|
||||
setup as setupUtil,
|
||||
execModuleScript,
|
||||
expectAgentAPIStarted,
|
||||
} from "../../../coder/modules/agentapi/test-util";
|
||||
import dedent from "dedent";
|
||||
|
||||
let cleanupFunctions: (() => Promise<void>)[] = [];
|
||||
const registerCleanup = (cleanup: () => Promise<void>) => {
|
||||
cleanupFunctions.push(cleanup);
|
||||
};
|
||||
afterEach(async () => {
|
||||
const cleanupFnsCopy = cleanupFunctions.slice().reverse();
|
||||
cleanupFunctions = [];
|
||||
for (const cleanup of cleanupFnsCopy) {
|
||||
try {
|
||||
await cleanup();
|
||||
} catch (error) {
|
||||
console.error("Error during cleanup:", error);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
interface SetupProps {
|
||||
skipAgentAPIMock?: boolean;
|
||||
skipCodexMock?: boolean;
|
||||
moduleVariables?: Record<string, string>;
|
||||
agentapiMockScript?: string;
|
||||
}
|
||||
|
||||
const setup = async (props?: SetupProps): Promise<{ id: string }> => {
|
||||
const projectDir = "/home/coder/project";
|
||||
const { id } = await setupUtil({
|
||||
moduleDir: import.meta.dir,
|
||||
moduleVariables: {
|
||||
install_codex: props?.skipCodexMock ? "true" : "false",
|
||||
install_agentapi: props?.skipAgentAPIMock ? "true" : "false",
|
||||
codex_model: "gpt-4-turbo",
|
||||
folder: "/home/coder",
|
||||
...props?.moduleVariables,
|
||||
},
|
||||
registerCleanup,
|
||||
projectDir,
|
||||
skipAgentAPIMock: props?.skipAgentAPIMock,
|
||||
agentapiMockScript: props?.agentapiMockScript,
|
||||
});
|
||||
if (!props?.skipCodexMock) {
|
||||
await writeExecutable({
|
||||
containerId: id,
|
||||
filePath: "/usr/bin/codex",
|
||||
content: await loadTestFile(import.meta.dir, "codex-mock.sh"),
|
||||
});
|
||||
}
|
||||
return { id };
|
||||
};
|
||||
|
||||
setDefaultTimeout(60 * 1000);
|
||||
|
||||
describe("codex", async () => {
|
||||
beforeAll(async () => {
|
||||
await runTerraformInit(import.meta.dir);
|
||||
});
|
||||
|
||||
test("happy-path", async () => {
|
||||
const { id } = await setup();
|
||||
await execModuleScript(id);
|
||||
await expectAgentAPIStarted(id);
|
||||
});
|
||||
|
||||
test("install-codex-version", async () => {
|
||||
const version_to_install = "0.10.0";
|
||||
const { id } = await setup({
|
||||
skipCodexMock: true,
|
||||
moduleVariables: {
|
||||
install_codex: "true",
|
||||
codex_version: version_to_install,
|
||||
},
|
||||
});
|
||||
await execModuleScript(id);
|
||||
const resp = await execContainer(id, [
|
||||
"bash",
|
||||
"-c",
|
||||
`cat /home/coder/.codex-module/install.log`,
|
||||
]);
|
||||
expect(resp.stdout).toContain(version_to_install);
|
||||
});
|
||||
|
||||
test("check-latest-codex-version-works", async () => {
|
||||
const { id } = await setup({
|
||||
skipCodexMock: true,
|
||||
skipAgentAPIMock: true,
|
||||
moduleVariables: {
|
||||
install_codex: "true",
|
||||
},
|
||||
});
|
||||
await execModuleScript(id);
|
||||
await expectAgentAPIStarted(id);
|
||||
});
|
||||
|
||||
test("base-config-toml", async () => {
|
||||
const baseConfig = dedent`
|
||||
sandbox_mode = "danger-full-access"
|
||||
approval_policy = "never"
|
||||
preferred_auth_method = "apikey"
|
||||
|
||||
[custom_section]
|
||||
new_feature = true
|
||||
`.trim();
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
base_config_toml: baseConfig,
|
||||
},
|
||||
});
|
||||
await execModuleScript(id);
|
||||
const resp = await readFileContainer(id, "/home/coder/.codex/config.toml");
|
||||
expect(resp).toContain("sandbox_mode = \"danger-full-access\"");
|
||||
expect(resp).toContain("preferred_auth_method = \"apikey\"");
|
||||
expect(resp).toContain("[custom_section]");
|
||||
expect(resp).toContain("[mcp_servers.Coder]");
|
||||
});
|
||||
|
||||
test("codex-api-key", async () => {
|
||||
const apiKey = "test-api-key-123";
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
openai_api_key: apiKey,
|
||||
},
|
||||
});
|
||||
await execModuleScript(id);
|
||||
|
||||
const resp = await readFileContainer(
|
||||
id,
|
||||
"/home/coder/.codex-module/agentapi-start.log",
|
||||
);
|
||||
expect(resp).toContain("OpenAI API Key: Provided");
|
||||
});
|
||||
|
||||
test("pre-post-install-scripts", async () => {
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
pre_install_script: "#!/bin/bash\necho 'pre-install-script'",
|
||||
post_install_script: "#!/bin/bash\necho 'post-install-script'",
|
||||
},
|
||||
});
|
||||
await execModuleScript(id);
|
||||
const preInstallLog = await readFileContainer(
|
||||
id,
|
||||
"/home/coder/.codex-module/pre_install.log",
|
||||
);
|
||||
expect(preInstallLog).toContain("pre-install-script");
|
||||
const postInstallLog = await readFileContainer(
|
||||
id,
|
||||
"/home/coder/.codex-module/post_install.log",
|
||||
);
|
||||
expect(postInstallLog).toContain("post-install-script");
|
||||
});
|
||||
|
||||
test("folder-variable", async () => {
|
||||
const folder = "/tmp/codex-test-folder";
|
||||
const { id } = await setup({
|
||||
skipCodexMock: false,
|
||||
moduleVariables: {
|
||||
folder,
|
||||
},
|
||||
});
|
||||
await execModuleScript(id);
|
||||
const resp = await readFileContainer(
|
||||
id,
|
||||
"/home/coder/.codex-module/install.log",
|
||||
);
|
||||
expect(resp).toContain(folder);
|
||||
});
|
||||
|
||||
test("additional-mcp-servers", async () => {
|
||||
const additional = dedent`
|
||||
[mcp_servers.GitHub]
|
||||
command = "npx"
|
||||
args = ["-y", "@modelcontextprotocol/server-github"]
|
||||
type = "stdio"
|
||||
description = "GitHub integration"
|
||||
|
||||
[mcp_servers.FileSystem]
|
||||
command = "npx"
|
||||
args = ["-y", "@modelcontextprotocol/server-filesystem", "/workspace"]
|
||||
type = "stdio"
|
||||
description = "File system access"
|
||||
`.trim();
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
additional_mcp_servers: additional,
|
||||
},
|
||||
});
|
||||
await execModuleScript(id);
|
||||
const resp = await readFileContainer(id, "/home/coder/.codex/config.toml");
|
||||
expect(resp).toContain("[mcp_servers.GitHub]");
|
||||
expect(resp).toContain("[mcp_servers.FileSystem]");
|
||||
expect(resp).toContain("[mcp_servers.Coder]");
|
||||
expect(resp).toContain("GitHub integration");
|
||||
});
|
||||
|
||||
test("full-custom-config", async () => {
|
||||
const baseConfig = dedent`
|
||||
sandbox_mode = "read-only"
|
||||
approval_policy = "untrusted"
|
||||
preferred_auth_method = "chatgpt"
|
||||
custom_setting = "test-value"
|
||||
|
||||
[advanced_settings]
|
||||
timeout = 30000
|
||||
debug = true
|
||||
logging_level = "verbose"
|
||||
`.trim();
|
||||
|
||||
const additionalMCP = dedent`
|
||||
[mcp_servers.CustomTool]
|
||||
command = "/usr/local/bin/custom-tool"
|
||||
args = ["--serve", "--port", "8080"]
|
||||
type = "stdio"
|
||||
description = "Custom development tool"
|
||||
|
||||
[mcp_servers.DatabaseMCP]
|
||||
command = "python"
|
||||
args = ["-m", "database_mcp_server"]
|
||||
type = "stdio"
|
||||
description = "Database query interface"
|
||||
`.trim();
|
||||
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
base_config_toml: baseConfig,
|
||||
additional_mcp_servers: additionalMCP,
|
||||
},
|
||||
});
|
||||
await execModuleScript(id);
|
||||
const resp = await readFileContainer(id, "/home/coder/.codex/config.toml");
|
||||
|
||||
// Check base config
|
||||
expect(resp).toContain("sandbox_mode = \"read-only\"");
|
||||
expect(resp).toContain("preferred_auth_method = \"chatgpt\"");
|
||||
expect(resp).toContain("custom_setting = \"test-value\"");
|
||||
expect(resp).toContain("[advanced_settings]");
|
||||
expect(resp).toContain("logging_level = \"verbose\"");
|
||||
|
||||
// Check MCP servers
|
||||
expect(resp).toContain("[mcp_servers.Coder]");
|
||||
expect(resp).toContain("[mcp_servers.CustomTool]");
|
||||
expect(resp).toContain("[mcp_servers.DatabaseMCP]");
|
||||
expect(resp).toContain("Custom development tool");
|
||||
expect(resp).toContain("Database query interface");
|
||||
});
|
||||
|
||||
test("minimal-default-config", async () => {
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
// No base_config_toml or additional_mcp_servers - should use defaults
|
||||
},
|
||||
});
|
||||
await execModuleScript(id);
|
||||
const resp = await readFileContainer(id, "/home/coder/.codex/config.toml");
|
||||
|
||||
// Check default base config
|
||||
expect(resp).toContain("sandbox_mode = \"workspace-write\"");
|
||||
expect(resp).toContain("approval_policy = \"never\"");
|
||||
expect(resp).toContain("[sandbox_workspace_write]");
|
||||
expect(resp).toContain("network_access = true");
|
||||
|
||||
// Check only Coder MCP server is present
|
||||
expect(resp).toContain("[mcp_servers.Coder]");
|
||||
expect(resp).toContain("Report ALL tasks and statuses");
|
||||
|
||||
// Ensure no additional MCP servers
|
||||
const mcpServerCount = (resp.match(/\[mcp_servers\./g) || []).length;
|
||||
expect(mcpServerCount).toBe(1);
|
||||
});
|
||||
|
||||
test("codex-system-prompt", async () => {
|
||||
const prompt = "This is a system prompt for Codex.";
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
codex_system_prompt: prompt,
|
||||
},
|
||||
});
|
||||
await execModuleScript(id);
|
||||
const resp = await readFileContainer(id, "/home/coder/.codex/AGENTS.md");
|
||||
expect(resp).toContain(prompt);
|
||||
});
|
||||
|
||||
test("codex-system-prompt-skip-append-if-exists", async () => {
|
||||
const prompt_1 = "This is a system prompt for Codex.";
|
||||
const prompt_2 = "This is a system prompt for Goose.";
|
||||
const prompt_3 = dedent`
|
||||
This is a system prompt for Codex.
|
||||
This is a system prompt for Gemini.
|
||||
`.trim();
|
||||
const pre_install_script = dedent`
|
||||
#!/bin/bash
|
||||
mkdir -p /home/coder/.codex
|
||||
echo -e "${prompt_3}" >> /home/coder/.codex/AGENTS.md
|
||||
`.trim();
|
||||
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
pre_install_script,
|
||||
codex_system_prompt: prompt_2,
|
||||
},
|
||||
});
|
||||
await execModuleScript(id);
|
||||
const resp = await readFileContainer(id, "/home/coder/.codex/AGENTS.md");
|
||||
expect(resp).toContain(prompt_1);
|
||||
expect(resp).toContain(prompt_2);
|
||||
|
||||
// Re-run with a prompt that already exists, it should not append again
|
||||
const { id: id_2 } = await setup({
|
||||
moduleVariables: {
|
||||
pre_install_script,
|
||||
codex_system_prompt: prompt_1,
|
||||
},
|
||||
});
|
||||
await execModuleScript(id_2);
|
||||
const resp_2 = await readFileContainer(id_2, "/home/coder/.codex/AGENTS.md");
|
||||
expect(resp_2).toContain(prompt_1);
|
||||
const count = (resp_2.match(new RegExp(prompt_1, "g")) || []).length;
|
||||
expect(count).toBe(1);
|
||||
});
|
||||
|
||||
test("codex-ai-task-prompt", async () => {
|
||||
const prompt = "This is a system prompt for Codex.";
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
ai_prompt: prompt,
|
||||
},
|
||||
});
|
||||
await execModuleScript(id);
|
||||
const resp = await execContainer(id, [
|
||||
"bash",
|
||||
"-c",
|
||||
`cat /home/coder/.codex-module/agentapi-start.log`,
|
||||
]);
|
||||
expect(resp.stdout).toContain(prompt);
|
||||
});
|
||||
|
||||
test("start-without-prompt", async () => {
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
codex_system_prompt: "", // Explicitly disable system prompt
|
||||
},
|
||||
});
|
||||
await execModuleScript(id);
|
||||
const prompt = await execContainer(id, [
|
||||
"ls",
|
||||
"-l",
|
||||
"/home/coder/.codex/AGENTS.md",
|
||||
]);
|
||||
expect(prompt.exitCode).not.toBe(0);
|
||||
expect(prompt.stderr).toContain("No such file or directory");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
terraform {
|
||||
required_version = ">= 1.0"
|
||||
|
||||
required_providers {
|
||||
coder = {
|
||||
source = "coder/coder"
|
||||
version = ">= 2.7"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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/openai.svg"
|
||||
}
|
||||
|
||||
variable "folder" {
|
||||
type = string
|
||||
description = "The folder to run Codex in."
|
||||
}
|
||||
|
||||
variable "install_codex" {
|
||||
type = bool
|
||||
description = "Whether to install Codex."
|
||||
default = true
|
||||
}
|
||||
|
||||
variable "codex_version" {
|
||||
type = string
|
||||
description = "The version of Codex to install."
|
||||
default = "" # empty string means the latest available version
|
||||
}
|
||||
|
||||
variable "base_config_toml" {
|
||||
type = string
|
||||
description = "Complete base TOML configuration for Codex (without mcp_servers section). If empty, uses minimal default configuration with workspace-write sandbox mode and never approval policy. For advanced options, see https://github.com/openai/codex/blob/main/codex-rs/config.md"
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "additional_mcp_servers" {
|
||||
type = string
|
||||
description = "Additional MCP servers configuration in TOML format. These will be merged with the required Coder MCP server in the [mcp_servers] section."
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "openai_api_key" {
|
||||
type = string
|
||||
description = "OpenAI API key for Codex CLI"
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "install_agentapi" {
|
||||
type = bool
|
||||
description = "Whether to install AgentAPI."
|
||||
default = true
|
||||
}
|
||||
|
||||
variable "agentapi_version" {
|
||||
type = string
|
||||
description = "The version of AgentAPI to install."
|
||||
default = "v0.5.0"
|
||||
}
|
||||
|
||||
variable "codex_model" {
|
||||
type = string
|
||||
description = "The model for Codex to use. Defaults to gpt-5."
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "pre_install_script" {
|
||||
type = string
|
||||
description = "Custom script to run before installing Codex."
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "post_install_script" {
|
||||
type = string
|
||||
description = "Custom script to run after installing Codex."
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "ai_prompt" {
|
||||
type = string
|
||||
description = "Initial task prompt for Codex CLI when launched via Tasks"
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "codex_system_prompt" {
|
||||
type = string
|
||||
description = "System instructions written to AGENTS.md in the ~/.codex directory"
|
||||
default = "You are a helpful coding assistant. Start every response with `Codex says:`"
|
||||
}
|
||||
|
||||
resource "coder_env" "openai_api_key" {
|
||||
agent_id = var.agent_id
|
||||
name = "OPENAI_API_KEY"
|
||||
value = var.openai_api_key
|
||||
}
|
||||
|
||||
locals {
|
||||
app_slug = "codex"
|
||||
install_script = file("${path.module}/scripts/install.sh")
|
||||
start_script = file("${path.module}/scripts/start.sh")
|
||||
module_dir_name = ".codex-module"
|
||||
}
|
||||
|
||||
module "agentapi" {
|
||||
source = "registry.coder.com/coder/agentapi/coder"
|
||||
version = "1.1.1"
|
||||
|
||||
agent_id = var.agent_id
|
||||
web_app_slug = local.app_slug
|
||||
web_app_order = var.order
|
||||
web_app_group = var.group
|
||||
web_app_icon = var.icon
|
||||
web_app_display_name = "Codex"
|
||||
cli_app_slug = "${local.app_slug}-cli"
|
||||
cli_app_display_name = "Codex CLI"
|
||||
module_dir_name = local.module_dir_name
|
||||
install_agentapi = var.install_agentapi
|
||||
agentapi_version = var.agentapi_version
|
||||
pre_install_script = var.pre_install_script
|
||||
post_install_script = var.post_install_script
|
||||
start_script = <<-EOT
|
||||
#!/bin/bash
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
|
||||
echo -n '${base64encode(local.start_script)}' | base64 -d > /tmp/start.sh
|
||||
chmod +x /tmp/start.sh
|
||||
ARG_OPENAI_API_KEY='${var.openai_api_key}' \
|
||||
ARG_CODEX_MODEL='${var.codex_model}' \
|
||||
ARG_CODEX_START_DIRECTORY='${var.folder}' \
|
||||
ARG_CODEX_TASK_PROMPT='${base64encode(var.ai_prompt)}' \
|
||||
/tmp/start.sh
|
||||
EOT
|
||||
|
||||
install_script = <<-EOT
|
||||
#!/bin/bash
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
|
||||
echo -n '${base64encode(local.install_script)}' | base64 -d > /tmp/install.sh
|
||||
chmod +x /tmp/install.sh
|
||||
ARG_INSTALL='${var.install_codex}' \
|
||||
ARG_CODEX_VERSION='${var.codex_version}' \
|
||||
ARG_BASE_CONFIG_TOML='${base64encode(var.base_config_toml)}' \
|
||||
ARG_ADDITIONAL_MCP_SERVERS='${base64encode(var.additional_mcp_servers)}' \
|
||||
ARG_CODER_MCP_APP_STATUS_SLUG='${local.app_slug}' \
|
||||
ARG_CODEX_START_DIRECTORY='${var.folder}' \
|
||||
ARG_CODEX_INSTRUCTION_PROMPT='${base64encode(var.codex_system_prompt)}' \
|
||||
/tmp/install.sh
|
||||
EOT
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
#!/bin/bash
|
||||
source "$HOME"/.bashrc
|
||||
|
||||
BOLD='\033[0;1m'
|
||||
|
||||
command_exists() {
|
||||
command -v "$1" > /dev/null 2>&1
|
||||
}
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
set -o nounset
|
||||
|
||||
ARG_BASE_CONFIG_TOML=$(echo -n "$ARG_BASE_CONFIG_TOML" | base64 -d)
|
||||
ARG_ADDITIONAL_MCP_SERVERS=$(echo -n "$ARG_ADDITIONAL_MCP_SERVERS" | base64 -d)
|
||||
ARG_CODEX_INSTRUCTION_PROMPT=$(echo -n "$ARG_CODEX_INSTRUCTION_PROMPT" | base64 -d)
|
||||
|
||||
echo "=== Codex Module Configuration ==="
|
||||
printf "Install Codex: %s\n" "$ARG_INSTALL"
|
||||
printf "Codex Version: %s\n" "$ARG_CODEX_VERSION"
|
||||
printf "App Slug: %s\n" "$ARG_CODER_MCP_APP_STATUS_SLUG"
|
||||
printf "Start Directory: %s\n" "$ARG_CODEX_START_DIRECTORY"
|
||||
printf "Has Base Config: %s\n" "$([ -n "$ARG_BASE_CONFIG_TOML" ] && echo "Yes" || echo "No")"
|
||||
printf "Has Additional MCP: %s\n" "$([ -n "$ARG_ADDITIONAL_MCP_SERVERS" ] && echo "Yes" || echo "No")"
|
||||
printf "Has System Prompt: %s\n" "$([ -n "$ARG_CODEX_INSTRUCTION_PROMPT" ] && echo "Yes" || echo "No")"
|
||||
echo "======================================"
|
||||
|
||||
set +o nounset
|
||||
|
||||
function install_node() {
|
||||
if ! command_exists npm; then
|
||||
printf "npm not found, checking for Node.js installation...\n"
|
||||
if ! command_exists node; then
|
||||
printf "Node.js not found, installing Node.js via NVM...\n"
|
||||
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
|
||||
|
||||
printf "Node.js installed: %s\n" "$(node --version)"
|
||||
printf "npm installed: %s\n" "$(npm --version)"
|
||||
else
|
||||
printf "Node.js is installed but npm is not available. Please install npm manually.\n"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
function install_codex() {
|
||||
if [ "${ARG_INSTALL}" = "true" ]; then
|
||||
install_node
|
||||
|
||||
if ! command_exists nvm; then
|
||||
printf "which node: %s\n" "$(which node)"
|
||||
printf "which npm: %s\n" "$(which npm)"
|
||||
|
||||
mkdir -p "$HOME"/.npm-global
|
||||
|
||||
npm config set prefix "$HOME/.npm-global"
|
||||
|
||||
export PATH="$HOME/.npm-global/bin:$PATH"
|
||||
|
||||
if ! grep -q "export PATH=$HOME/.npm-global/bin:\$PATH" ~/.bashrc; then
|
||||
echo "export PATH=$HOME/.npm-global/bin:\$PATH" >> ~/.bashrc
|
||||
fi
|
||||
fi
|
||||
|
||||
printf "%s Installing Codex CLI\n" "${BOLD}"
|
||||
|
||||
if [ -n "$ARG_CODEX_VERSION" ]; then
|
||||
npm install -g "@openai/codex@$ARG_CODEX_VERSION"
|
||||
else
|
||||
npm install -g "@openai/codex"
|
||||
fi
|
||||
printf "%s Successfully installed Codex CLI. Version: %s\n" "${BOLD}" "$(codex --version)"
|
||||
fi
|
||||
}
|
||||
|
||||
write_minimal_default_config() {
|
||||
local config_path="$1"
|
||||
cat << EOF > "$config_path"
|
||||
# Minimal Default Codex Configuration
|
||||
sandbox_mode = "workspace-write"
|
||||
approval_policy = "never"
|
||||
preferred_auth_method = "apikey"
|
||||
|
||||
[sandbox_workspace_write]
|
||||
network_access = true
|
||||
|
||||
EOF
|
||||
}
|
||||
|
||||
append_mcp_servers_section() {
|
||||
local config_path="$1"
|
||||
|
||||
cat << EOF >> "$config_path"
|
||||
|
||||
# MCP Servers Configuration
|
||||
[mcp_servers.Coder]
|
||||
command = "coder"
|
||||
args = ["exp", "mcp", "server"]
|
||||
env = { "CODER_MCP_APP_STATUS_SLUG" = "${ARG_CODER_MCP_APP_STATUS_SLUG}", "CODER_MCP_AI_AGENTAPI_URL" = "http://localhost:3284", "CODER_AGENT_URL" = "${CODER_AGENT_URL}", "CODER_AGENT_TOKEN" = "${CODER_AGENT_TOKEN}" }
|
||||
description = "Report ALL tasks and statuses (in progress, done, failed) you are working on."
|
||||
type = "stdio"
|
||||
|
||||
EOF
|
||||
|
||||
if [ -n "$ARG_ADDITIONAL_MCP_SERVERS" ]; then
|
||||
printf "Adding additional MCP servers\n"
|
||||
echo "$ARG_ADDITIONAL_MCP_SERVERS" >> "$config_path"
|
||||
fi
|
||||
}
|
||||
|
||||
function populate_config_toml() {
|
||||
CONFIG_PATH="$HOME/.codex/config.toml"
|
||||
mkdir -p "$(dirname "$CONFIG_PATH")"
|
||||
|
||||
if [ -n "$ARG_BASE_CONFIG_TOML" ]; then
|
||||
printf "Using provided base configuration\n"
|
||||
echo "$ARG_BASE_CONFIG_TOML" > "$CONFIG_PATH"
|
||||
else
|
||||
printf "Using minimal default configuration\n"
|
||||
write_minimal_default_config "$CONFIG_PATH"
|
||||
fi
|
||||
|
||||
append_mcp_servers_section "$CONFIG_PATH"
|
||||
}
|
||||
|
||||
function add_instruction_prompt_if_exists() {
|
||||
if [ -n "${ARG_CODEX_INSTRUCTION_PROMPT:-}" ]; then
|
||||
AGENTS_PATH="$HOME/.codex/AGENTS.md"
|
||||
printf "Creating AGENTS.md in .codex directory: %s\\n" "${AGENTS_PATH}"
|
||||
|
||||
mkdir -p "$HOME/.codex"
|
||||
|
||||
if [ -f "${AGENTS_PATH}" ] && grep -Fq "${ARG_CODEX_INSTRUCTION_PROMPT}" "${AGENTS_PATH}"; then
|
||||
printf "AGENTS.md already contains the instruction prompt. Skipping append.\n"
|
||||
else
|
||||
printf "Appending instruction prompt to AGENTS.md in .codex directory\n"
|
||||
echo -e "\n${ARG_CODEX_INSTRUCTION_PROMPT}" >> "${AGENTS_PATH}"
|
||||
fi
|
||||
|
||||
if [ ! -d "${ARG_CODEX_START_DIRECTORY}" ]; then
|
||||
printf "Creating start directory '%s'\\n" "${ARG_CODEX_START_DIRECTORY}"
|
||||
mkdir -p "${ARG_CODEX_START_DIRECTORY}" || {
|
||||
printf "Error: Could not create directory '%s'.\\n" "${ARG_CODEX_START_DIRECTORY}"
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
else
|
||||
printf "AGENTS.md instruction prompt is not set.\n"
|
||||
fi
|
||||
}
|
||||
|
||||
install_codex
|
||||
codex --version
|
||||
populate_config_toml
|
||||
add_instruction_prompt_if_exists
|
||||
@@ -0,0 +1,73 @@
|
||||
#!/bin/bash
|
||||
|
||||
source "$HOME"/.bashrc
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
command_exists() {
|
||||
command -v "$1" > /dev/null 2>&1
|
||||
}
|
||||
|
||||
if [ -f "$HOME/.nvm/nvm.sh" ]; then
|
||||
source "$HOME"/.nvm/nvm.sh
|
||||
else
|
||||
export PATH="$HOME/.npm-global/bin:$PATH"
|
||||
fi
|
||||
|
||||
printf "Version: %s\n" "$(codex --version)"
|
||||
set -o nounset
|
||||
ARG_CODEX_TASK_PROMPT=$(echo -n "$ARG_CODEX_TASK_PROMPT" | base64 -d)
|
||||
|
||||
echo "=== Codex Launch Configuration ==="
|
||||
printf "OpenAI API Key: %s\n" "$([ -n "$ARG_OPENAI_API_KEY" ] && echo "Provided" || echo "Not provided")"
|
||||
printf "Codex Model: %s\n" "${ARG_CODEX_MODEL:-"Default"}"
|
||||
printf "Start Directory: %s\n" "$ARG_CODEX_START_DIRECTORY"
|
||||
printf "Has Task Prompt: %s\n" "$([ -n "$ARG_CODEX_TASK_PROMPT" ] && echo "Yes" || echo "No")"
|
||||
echo "======================================"
|
||||
set +o nounset
|
||||
CODEX_ARGS=()
|
||||
|
||||
if command_exists codex; then
|
||||
printf "Codex is installed\n"
|
||||
else
|
||||
printf "Error: Codex is not installed. Please enable install_codex or install it manually\n"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -d "${ARG_CODEX_START_DIRECTORY}" ]; then
|
||||
printf "Directory '%s' exists. Changing to it.\\n" "${ARG_CODEX_START_DIRECTORY}"
|
||||
cd "${ARG_CODEX_START_DIRECTORY}" || {
|
||||
printf "Error: Could not change to directory '%s'.\\n" "${ARG_CODEX_START_DIRECTORY}"
|
||||
exit 1
|
||||
}
|
||||
else
|
||||
printf "Directory '%s' does not exist. Creating and changing to it.\\n" "${ARG_CODEX_START_DIRECTORY}"
|
||||
mkdir -p "${ARG_CODEX_START_DIRECTORY}" || {
|
||||
printf "Error: Could not create directory '%s'.\\n" "${ARG_CODEX_START_DIRECTORY}"
|
||||
exit 1
|
||||
}
|
||||
cd "${ARG_CODEX_START_DIRECTORY}" || {
|
||||
printf "Error: Could not change to directory '%s'.\\n" "${ARG_CODEX_START_DIRECTORY}"
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
|
||||
if [ -n "$ARG_CODEX_MODEL" ]; then
|
||||
CODEX_ARGS+=("--model" "$ARG_CODEX_MODEL")
|
||||
fi
|
||||
|
||||
|
||||
|
||||
if [ -n "$ARG_CODEX_TASK_PROMPT" ]; then
|
||||
printf "Running the task prompt %s\n" "$ARG_CODEX_TASK_PROMPT"
|
||||
PROMPT="Complete the task at hand in one go. Every step of the way, report your progress using coder_report_task tool with proper summary and statuses. Your task at hand: $ARG_CODEX_TASK_PROMPT"
|
||||
CODEX_ARGS+=("$PROMPT")
|
||||
else
|
||||
printf "No task prompt given.\n"
|
||||
fi
|
||||
|
||||
|
||||
# Terminal dimensions optimized for Coder Tasks UI sidebar:
|
||||
# - Width 67: fits comfortably in sidebar
|
||||
# - Height 1190: adjusted due to Codex terminal height bug
|
||||
printf "Starting Codex with arguments: %s\n" "${CODEX_ARGS[*]}"
|
||||
agentapi server --term-width 67 --term-height 1190 -- codex "${CODEX_ARGS[@]}"
|
||||
@@ -0,0 +1,14 @@
|
||||
#!/bin/bash
|
||||
|
||||
if [[ "$1" == "--version" ]]; then
|
||||
echo "HELLO: $(bash -c env)"
|
||||
echo "codex version v1.0.0"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
set -e
|
||||
|
||||
while true; do
|
||||
echo "$(date) - codex-mock"
|
||||
sleep 15
|
||||
done
|
||||
@@ -0,0 +1,135 @@
|
||||
---
|
||||
display_name: Cursor CLI
|
||||
icon: ../../../../.icons/cursor.svg
|
||||
description: Run Cursor Agent CLI in your workspace for AI pair programming
|
||||
verified: true
|
||||
tags: [agent, cursor, ai, tasks]
|
||||
---
|
||||
|
||||
# Cursor CLI
|
||||
|
||||
Run the Cursor Agent CLI in your workspace for interactive coding assistance and automated task execution.
|
||||
|
||||
```tf
|
||||
module "cursor_cli" {
|
||||
source = "registry.coder.com/coder-labs/cursor-cli/coder"
|
||||
version = "0.1.1"
|
||||
agent_id = coder_agent.example.id
|
||||
folder = "/home/coder/project"
|
||||
}
|
||||
```
|
||||
|
||||
## Basic setup
|
||||
|
||||
A full example with MCP, rules, and pre/post install scripts:
|
||||
|
||||
```tf
|
||||
|
||||
data "coder_parameter" "ai_prompt" {
|
||||
type = "string"
|
||||
name = "AI Prompt"
|
||||
default = ""
|
||||
description = "Build a Minesweeper in Python."
|
||||
mutable = true
|
||||
}
|
||||
|
||||
module "coder-login" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/coder-login/coder"
|
||||
version = "1.0.31"
|
||||
agent_id = coder_agent.main.id
|
||||
}
|
||||
|
||||
module "cursor_cli" {
|
||||
source = "registry.coder.com/coder-labs/cursor-cli/coder"
|
||||
version = "0.1.1"
|
||||
agent_id = coder_agent.example.id
|
||||
folder = "/home/coder/project"
|
||||
|
||||
# Optional
|
||||
install_cursor_cli = true
|
||||
force = true
|
||||
model = "gpt-5"
|
||||
ai_prompt = data.coder_parameter.ai_prompt.value
|
||||
api_key = "xxxx-xxxx-xxxx" # Required while using tasks, see note below
|
||||
|
||||
# Minimal MCP server (writes `folder/.cursor/mcp.json`):
|
||||
mcp = jsonencode({
|
||||
mcpServers = {
|
||||
playwright = {
|
||||
command = "npx"
|
||||
args = ["-y", "@playwright/mcp@latest", "--headless", "--isolated", "--no-sandbox"]
|
||||
}
|
||||
desktop-commander = {
|
||||
command = "npx"
|
||||
args = ["-y", "@wonderwhy-er/desktop-commander"]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
# Use a pre_install_script to install the CLI
|
||||
pre_install_script = <<-EOT
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
|
||||
apt-get install -y nodejs
|
||||
EOT
|
||||
|
||||
# Use post_install_script to wait for the repo to be ready
|
||||
post_install_script = <<-EOT
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
TARGET="$${FOLDER}/.git/config"
|
||||
echo "[cursor-cli] waiting for $${TARGET}..."
|
||||
for i in $(seq 1 600); do
|
||||
[ -f "$TARGET" ] && { echo "ready"; exit 0; }
|
||||
sleep 1
|
||||
done
|
||||
echo "timeout waiting for $${TARGET}" >&2
|
||||
EOT
|
||||
|
||||
# Provide a map of file name to content; files are written to `folder/.cursor/rules/<name>`.
|
||||
rules_files = {
|
||||
"python.mdc" = <<-EOT
|
||||
---
|
||||
description: RPC Service boilerplate
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
- Use our internal RPC pattern when defining services
|
||||
- Always use snake_case for service names.
|
||||
|
||||
@service-template.ts
|
||||
EOT
|
||||
|
||||
"frontend.mdc" = <<-EOT
|
||||
---
|
||||
description: RPC Service boilerplate
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
- Use our internal RPC pattern when defining services
|
||||
- Always use snake_case for service names.
|
||||
|
||||
@service-template.ts
|
||||
EOT
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> A `.cursor` directory will be created in the specified `folder`, containing the MCP configuration, rules.
|
||||
> To use this module with tasks, please pass the API Key obtained from Cursor to the `api_key` variable. To obtain the api key follow the instructions [here](https://docs.cursor.com/en/cli/reference/authentication#step-1%3A-generate-an-api-key)
|
||||
|
||||
## References
|
||||
|
||||
- See Cursor CLI docs: `https://docs.cursor.com/en/cli/overview`
|
||||
- For MCP project config, see `https://docs.cursor.com/en/context/mcp#using-mcp-json`. This module writes your `mcp_json` into `folder/.cursor/mcp.json`.
|
||||
- For Rules, see `https://docs.cursor.com/en/context/rules#project-rules`. Provide `rules_files` (map of file name to content) to populate `folder/.cursor/rules/`.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- Ensure the CLI is installed (enable `install_cursor_cli = true` or preinstall it in your image)
|
||||
- Logs are written to `~/.cursor-cli-module/`
|
||||
@@ -0,0 +1,152 @@
|
||||
run "test_cursor_cli_basic" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent-123"
|
||||
folder = "/home/coder/projects"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = coder_env.status_slug.name == "CODER_MCP_APP_STATUS_SLUG"
|
||||
error_message = "Status slug environment variable should be set correctly"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = coder_env.status_slug.value == "cursorcli"
|
||||
error_message = "Status slug value should be 'cursorcli'"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.folder == "/home/coder/projects"
|
||||
error_message = "Folder variable should be set correctly"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.agent_id == "test-agent-123"
|
||||
error_message = "Agent ID variable should be set correctly"
|
||||
}
|
||||
}
|
||||
|
||||
run "test_cursor_cli_with_api_key" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent-456"
|
||||
folder = "/home/coder/workspace"
|
||||
api_key = "test-api-key-123"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = coder_env.cursor_api_key[0].name == "CURSOR_API_KEY"
|
||||
error_message = "Cursor API key environment variable should be set correctly"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = coder_env.cursor_api_key[0].value == "test-api-key-123"
|
||||
error_message = "Cursor API key value should match the input"
|
||||
}
|
||||
}
|
||||
|
||||
run "test_cursor_cli_with_custom_options" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent-789"
|
||||
folder = "/home/coder/custom"
|
||||
order = 5
|
||||
group = "development"
|
||||
icon = "/icon/custom.svg"
|
||||
model = "sonnet-4"
|
||||
ai_prompt = "Help me write better code"
|
||||
force = false
|
||||
install_cursor_cli = false
|
||||
install_agentapi = false
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.order == 5
|
||||
error_message = "Order variable should be set to 5"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.group == "development"
|
||||
error_message = "Group variable should be set to 'development'"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.icon == "/icon/custom.svg"
|
||||
error_message = "Icon variable should be set to custom icon"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.model == "sonnet-4"
|
||||
error_message = "Model variable should be set to 'sonnet-4'"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.ai_prompt == "Help me write better code"
|
||||
error_message = "AI prompt variable should be set correctly"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.force == false
|
||||
error_message = "Force variable should be set to false"
|
||||
}
|
||||
}
|
||||
|
||||
run "test_cursor_cli_with_mcp_and_rules" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent-mcp"
|
||||
folder = "/home/coder/mcp-test"
|
||||
mcp = jsonencode({
|
||||
mcpServers = {
|
||||
test = {
|
||||
command = "test-server"
|
||||
args = ["--config", "test.json"]
|
||||
}
|
||||
}
|
||||
})
|
||||
rules_files = {
|
||||
"general.md" = "# General coding rules\n- Write clean code\n- Add comments"
|
||||
"security.md" = "# Security rules\n- Never commit secrets\n- Validate inputs"
|
||||
}
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.mcp != null
|
||||
error_message = "MCP configuration should be provided"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.rules_files != null
|
||||
error_message = "Rules files should be provided"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = length(var.rules_files) == 2
|
||||
error_message = "Should have 2 rules files"
|
||||
}
|
||||
}
|
||||
|
||||
run "test_cursor_cli_with_scripts" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent-scripts"
|
||||
folder = "/home/coder/scripts"
|
||||
pre_install_script = "echo 'Pre-install script'"
|
||||
post_install_script = "echo 'Post-install script'"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.pre_install_script == "echo 'Pre-install script'"
|
||||
error_message = "Pre-install script should be set correctly"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.post_install_script == "echo 'Post-install script'"
|
||||
error_message = "Post-install script should be set correctly"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
import { afterEach, beforeAll, describe, expect, setDefaultTimeout, test } from "bun:test";
|
||||
import { execContainer, runTerraformInit, writeFileContainer } from "~test";
|
||||
import {
|
||||
execModuleScript,
|
||||
expectAgentAPIStarted,
|
||||
loadTestFile,
|
||||
setup as setupUtil
|
||||
} from "../../../coder/modules/agentapi/test-util";
|
||||
import { setupContainer, writeExecutable } from "../../../coder/modules/agentapi/test-util";
|
||||
|
||||
let cleanupFns: (() => Promise<void>)[] = [];
|
||||
const registerCleanup = (fn: () => Promise<void>) => cleanupFns.push(fn);
|
||||
|
||||
afterEach(async () => {
|
||||
const fns = cleanupFns.slice().reverse();
|
||||
cleanupFns = [];
|
||||
for (const fn of fns) {
|
||||
try {
|
||||
await fn();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
interface SetupProps {
|
||||
skipAgentAPIMock?: boolean;
|
||||
skipCursorCliMock?: boolean;
|
||||
moduleVariables?: Record<string, string>;
|
||||
agentapiMockScript?: string;
|
||||
}
|
||||
|
||||
const setup = async (props?: SetupProps): Promise<{ id: string }> => {
|
||||
const projectDir = "/home/coder/project";
|
||||
const { id } = await setupUtil({
|
||||
moduleDir: import.meta.dir,
|
||||
moduleVariables: {
|
||||
enable_agentapi: "true",
|
||||
install_cursor_cli: props?.skipCursorCliMock ? "true" : "false",
|
||||
install_agentapi: props?.skipAgentAPIMock ? "true" : "false",
|
||||
folder: projectDir,
|
||||
...props?.moduleVariables,
|
||||
},
|
||||
registerCleanup,
|
||||
projectDir,
|
||||
skipAgentAPIMock: props?.skipAgentAPIMock,
|
||||
agentapiMockScript: props?.agentapiMockScript,
|
||||
});
|
||||
if (!props?.skipCursorCliMock) {
|
||||
await writeExecutable({
|
||||
containerId: id,
|
||||
filePath: "/usr/bin/cursor-agent",
|
||||
content: await loadTestFile(import.meta.dir, "cursor-cli-mock.sh"),
|
||||
});
|
||||
}
|
||||
return { id };
|
||||
};
|
||||
|
||||
setDefaultTimeout(180 * 1000);
|
||||
|
||||
describe("cursor-cli", async () => {
|
||||
beforeAll(async () => {
|
||||
await runTerraformInit(import.meta.dir);
|
||||
});
|
||||
|
||||
test("agentapi-happy-path", async () => {
|
||||
const { id } = await setup({});
|
||||
const resp = await execModuleScript(id);
|
||||
expect(resp.exitCode).toBe(0);
|
||||
|
||||
await expectAgentAPIStarted(id);
|
||||
});
|
||||
|
||||
test("agentapi-mcp-json", async () => {
|
||||
const mcpJson = '{"mcpServers": {"test": {"command": "test-cmd", "type": "stdio"}}}';
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
mcp: mcpJson,
|
||||
}
|
||||
});
|
||||
const resp = await execModuleScript(id);
|
||||
expect(resp.exitCode).toBe(0);
|
||||
|
||||
const mcpContent = await execContainer(id, [
|
||||
"bash",
|
||||
"-c",
|
||||
`cat '/home/coder/project/.cursor/mcp.json'`,
|
||||
]);
|
||||
expect(mcpContent.exitCode).toBe(0);
|
||||
expect(mcpContent.stdout).toContain("mcpServers");
|
||||
expect(mcpContent.stdout).toContain("test");
|
||||
expect(mcpContent.stdout).toContain("test-cmd");
|
||||
expect(mcpContent.stdout).toContain("/tmp/mcp-hack.sh");
|
||||
expect(mcpContent.stdout).toContain("coder");
|
||||
});
|
||||
|
||||
test("agentapi-rules-files", async () => {
|
||||
const rulesContent = "Always use TypeScript";
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
rules_files: JSON.stringify({ "typescript.md": rulesContent }),
|
||||
}
|
||||
});
|
||||
const resp = await execModuleScript(id);
|
||||
expect(resp.exitCode).toBe(0);
|
||||
|
||||
const rulesFile = await execContainer(id, [
|
||||
"bash",
|
||||
"-c",
|
||||
`cat '/home/coder/project/.cursor/rules/typescript.md'`,
|
||||
]);
|
||||
expect(rulesFile.exitCode).toBe(0);
|
||||
expect(rulesFile.stdout).toContain(rulesContent);
|
||||
});
|
||||
|
||||
test("agentapi-api-key", async () => {
|
||||
const apiKey = "test-cursor-api-key-123";
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
api_key: apiKey,
|
||||
}
|
||||
});
|
||||
const resp = await execModuleScript(id);
|
||||
expect(resp.exitCode).toBe(0);
|
||||
|
||||
const envCheck = await execContainer(id, [
|
||||
"bash",
|
||||
"-c",
|
||||
`env | grep CURSOR_API_KEY || echo "CURSOR_API_KEY not found"`,
|
||||
]);
|
||||
expect(envCheck.stdout).toContain("CURSOR_API_KEY");
|
||||
});
|
||||
|
||||
test("agentapi-model-and-force-flags", async () => {
|
||||
const model = "sonnet-4";
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
model: model,
|
||||
force: "true",
|
||||
ai_prompt: "test prompt",
|
||||
}
|
||||
});
|
||||
const resp = await execModuleScript(id);
|
||||
expect(resp.exitCode).toBe(0);
|
||||
|
||||
const startLog = await execContainer(id, [
|
||||
"bash",
|
||||
"-c",
|
||||
"cat /home/coder/.cursor-cli-module/agentapi-start.log || cat /home/coder/.cursor-cli-module/start.log || true",
|
||||
]);
|
||||
expect(startLog.stdout).toContain(`-m ${model}`);
|
||||
expect(startLog.stdout).toContain("-f");
|
||||
expect(startLog.stdout).toContain("test prompt");
|
||||
});
|
||||
|
||||
test("agentapi-pre-post-install-scripts", async () => {
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
pre_install_script: "#!/bin/bash\necho 'cursor-pre-install-script'",
|
||||
post_install_script: "#!/bin/bash\necho 'cursor-post-install-script'",
|
||||
}
|
||||
});
|
||||
const resp = await execModuleScript(id);
|
||||
expect(resp.exitCode).toBe(0);
|
||||
|
||||
const preInstallLog = await execContainer(id, [
|
||||
"bash",
|
||||
"-c",
|
||||
"cat /home/coder/.cursor-cli-module/pre_install.log || true",
|
||||
]);
|
||||
expect(preInstallLog.stdout).toContain("cursor-pre-install-script");
|
||||
|
||||
const postInstallLog = await execContainer(id, [
|
||||
"bash",
|
||||
"-c",
|
||||
"cat /home/coder/.cursor-cli-module/post_install.log || true",
|
||||
]);
|
||||
expect(postInstallLog.stdout).toContain("cursor-post-install-script");
|
||||
});
|
||||
|
||||
test("agentapi-folder-variable", async () => {
|
||||
const folder = "/tmp/cursor-test-folder";
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
folder: folder,
|
||||
}
|
||||
});
|
||||
const resp = await execModuleScript(id);
|
||||
expect(resp.exitCode).toBe(0);
|
||||
|
||||
const installLog = await execContainer(id, [
|
||||
"bash",
|
||||
"-c",
|
||||
"cat /home/coder/.cursor-cli-module/install.log || true",
|
||||
]);
|
||||
expect(installLog.stdout).toContain(folder);
|
||||
});
|
||||
|
||||
test("install-test-cursor-cli-latest", async () => {
|
||||
const { id } = await setup({
|
||||
skipCursorCliMock: true,
|
||||
skipAgentAPIMock: true,
|
||||
});
|
||||
const resp = await execModuleScript(id);
|
||||
expect(resp.exitCode).toBe(0);
|
||||
|
||||
await expectAgentAPIStarted(id);
|
||||
})
|
||||
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
terraform {
|
||||
required_version = ">= 1.0"
|
||||
|
||||
required_providers {
|
||||
coder = {
|
||||
source = "coder/coder"
|
||||
version = ">= 2.7"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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/cursor.svg"
|
||||
}
|
||||
|
||||
variable "folder" {
|
||||
type = string
|
||||
description = "The folder to run Cursor CLI in."
|
||||
}
|
||||
|
||||
variable "install_cursor_cli" {
|
||||
type = bool
|
||||
description = "Whether to install Cursor CLI."
|
||||
default = true
|
||||
}
|
||||
|
||||
variable "install_agentapi" {
|
||||
type = bool
|
||||
description = "Whether to install AgentAPI."
|
||||
default = true
|
||||
}
|
||||
|
||||
variable "agentapi_version" {
|
||||
type = string
|
||||
description = "The version of AgentAPI to install."
|
||||
default = "v0.5.0"
|
||||
}
|
||||
|
||||
variable "force" {
|
||||
type = bool
|
||||
description = "Force allow commands unless explicitly denied"
|
||||
default = true
|
||||
}
|
||||
|
||||
variable "model" {
|
||||
type = string
|
||||
description = "Model to use (e.g., sonnet-4, sonnet-4-thinking, gpt-5)"
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "ai_prompt" {
|
||||
type = string
|
||||
description = "AI prompt/task passed to cursor-agent."
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "api_key" {
|
||||
type = string
|
||||
description = "API key for Cursor CLI."
|
||||
default = ""
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
variable "mcp" {
|
||||
type = string
|
||||
description = "Workspace-specific MCP JSON to write to folder/.cursor/mcp.json. See https://docs.cursor.com/en/context/mcp#using-mcp-json"
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "rules_files" {
|
||||
type = map(string)
|
||||
description = "Optional map of rule file name to content. Files will be written to folder/.cursor/rules/<name>. See https://docs.cursor.com/en/context/rules#project-rules"
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "pre_install_script" {
|
||||
type = string
|
||||
description = "Optional script to run before installing Cursor CLI."
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "post_install_script" {
|
||||
type = string
|
||||
description = "Optional script to run after installing Cursor CLI."
|
||||
default = null
|
||||
}
|
||||
|
||||
locals {
|
||||
app_slug = "cursorcli"
|
||||
install_script = file("${path.module}/scripts/install.sh")
|
||||
start_script = file("${path.module}/scripts/start.sh")
|
||||
module_dir_name = ".cursor-cli-module"
|
||||
}
|
||||
|
||||
# Expose status slug and API key to the agent environment
|
||||
resource "coder_env" "status_slug" {
|
||||
agent_id = var.agent_id
|
||||
name = "CODER_MCP_APP_STATUS_SLUG"
|
||||
value = local.app_slug
|
||||
}
|
||||
|
||||
resource "coder_env" "cursor_api_key" {
|
||||
count = var.api_key != "" ? 1 : 0
|
||||
agent_id = var.agent_id
|
||||
name = "CURSOR_API_KEY"
|
||||
value = var.api_key
|
||||
}
|
||||
|
||||
module "agentapi" {
|
||||
source = "registry.coder.com/coder/agentapi/coder"
|
||||
version = "1.1.1"
|
||||
|
||||
agent_id = var.agent_id
|
||||
web_app_slug = local.app_slug
|
||||
web_app_order = var.order
|
||||
web_app_group = var.group
|
||||
web_app_icon = var.icon
|
||||
web_app_display_name = "Cursor CLI"
|
||||
cli_app_slug = local.app_slug
|
||||
cli_app_display_name = "Cursor CLI"
|
||||
module_dir_name = local.module_dir_name
|
||||
install_agentapi = var.install_agentapi
|
||||
agentapi_version = var.agentapi_version
|
||||
pre_install_script = var.pre_install_script
|
||||
post_install_script = var.post_install_script
|
||||
start_script = <<-EOT
|
||||
#!/bin/bash
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
|
||||
echo -n '${base64encode(local.start_script)}' | base64 -d > /tmp/start.sh
|
||||
chmod +x /tmp/start.sh
|
||||
ARG_FORCE='${var.force}' \
|
||||
ARG_MODEL='${var.model}' \
|
||||
ARG_AI_PROMPT='${base64encode(var.ai_prompt)}' \
|
||||
ARG_MODULE_DIR_NAME='${local.module_dir_name}' \
|
||||
ARG_FOLDER='${var.folder}' \
|
||||
/tmp/start.sh
|
||||
EOT
|
||||
|
||||
install_script = <<-EOT
|
||||
#!/bin/bash
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
|
||||
echo -n '${base64encode(local.install_script)}' | base64 -d > /tmp/install.sh
|
||||
chmod +x /tmp/install.sh
|
||||
ARG_INSTALL='${var.install_cursor_cli}' \
|
||||
ARG_WORKSPACE_MCP_JSON='${var.mcp != null ? base64encode(replace(var.mcp, "'", "'\\''")) : ""}' \
|
||||
ARG_WORKSPACE_RULES_JSON='${var.rules_files != null ? base64encode(jsonencode(var.rules_files)) : ""}' \
|
||||
ARG_MODULE_DIR_NAME='${local.module_dir_name}' \
|
||||
ARG_FOLDER='${var.folder}' \
|
||||
ARG_CODER_MCP_APP_STATUS_SLUG='${local.app_slug}' \
|
||||
/tmp/install.sh
|
||||
EOT
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
|
||||
command_exists() {
|
||||
command -v "$1" > /dev/null 2>&1
|
||||
}
|
||||
|
||||
# Inputs
|
||||
ARG_INSTALL=${ARG_INSTALL:-true}
|
||||
ARG_MODULE_DIR_NAME=${ARG_MODULE_DIR_NAME:-.cursor-cli-module}
|
||||
ARG_FOLDER=${ARG_FOLDER:-$HOME}
|
||||
ARG_CODER_MCP_APP_STATUS_SLUG=${ARG_CODER_MCP_APP_STATUS_SLUG:-}
|
||||
|
||||
mkdir -p "$HOME/$ARG_MODULE_DIR_NAME"
|
||||
|
||||
ARG_WORKSPACE_MCP_JSON=$(echo -n "$ARG_WORKSPACE_MCP_JSON" | base64 -d)
|
||||
ARG_WORKSPACE_RULES_JSON=$(echo -n "$ARG_WORKSPACE_RULES_JSON" | base64 -d)
|
||||
|
||||
echo "--------------------------------"
|
||||
echo "install: $ARG_INSTALL"
|
||||
echo "folder: $ARG_FOLDER"
|
||||
echo "coder_mcp_app_status_slug: $ARG_CODER_MCP_APP_STATUS_SLUG"
|
||||
echo "module_dir_name: $ARG_MODULE_DIR_NAME"
|
||||
echo "--------------------------------"
|
||||
|
||||
# Install Cursor via official installer if requested
|
||||
function install_cursor_cli() {
|
||||
if [ "$ARG_INSTALL" = "true" ]; then
|
||||
echo "Installing Cursor via official installer..."
|
||||
set +e
|
||||
curl https://cursor.com/install -fsS | bash 2>&1
|
||||
CURL_EXIT=${PIPESTATUS[0]}
|
||||
set -e
|
||||
if [ $CURL_EXIT -ne 0 ]; then
|
||||
echo "Cursor installer failed with exit code $CURL_EXIT"
|
||||
fi
|
||||
|
||||
# Ensure binaries are discoverable; create stable symlink to cursor-agent
|
||||
CANDIDATES=(
|
||||
"$(command -v cursor-agent || true)"
|
||||
"$HOME/.cursor/bin/cursor-agent"
|
||||
)
|
||||
FOUND_BIN=""
|
||||
for c in "${CANDIDATES[@]}"; do
|
||||
if [ -n "$c" ] && [ -x "$c" ]; then
|
||||
FOUND_BIN="$c"
|
||||
break
|
||||
fi
|
||||
done
|
||||
mkdir -p "$HOME/.local/bin"
|
||||
if [ -n "$FOUND_BIN" ]; then
|
||||
ln -sf "$FOUND_BIN" "$HOME/.local/bin/cursor-agent"
|
||||
fi
|
||||
echo "Installed cursor-agent at: $(command -v cursor-agent || true) (resolved: $FOUND_BIN)"
|
||||
fi
|
||||
}
|
||||
|
||||
# Write MCP config to user's home if provided (ARG_FOLDER/.cursor/mcp.json)
|
||||
function write_mcp_config() {
|
||||
TARGET_DIR="$ARG_FOLDER/.cursor"
|
||||
TARGET_FILE="$TARGET_DIR/mcp.json"
|
||||
mkdir -p "$TARGET_DIR"
|
||||
|
||||
CURSOR_MCP_HACK_SCRIPT=$(
|
||||
cat << EOF
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
|
||||
# --- Set environment variables ---
|
||||
export CODER_MCP_APP_STATUS_SLUG="${ARG_CODER_MCP_APP_STATUS_SLUG}"
|
||||
export CODER_MCP_AI_AGENTAPI_URL="http://localhost:3284"
|
||||
export CODER_AGENT_URL="${CODER_AGENT_URL}"
|
||||
export CODER_AGENT_TOKEN="${CODER_AGENT_TOKEN}"
|
||||
|
||||
# --- Launch the MCP server ---
|
||||
exec coder exp mcp server
|
||||
EOF
|
||||
)
|
||||
echo "$CURSOR_MCP_HACK_SCRIPT" > "/tmp/mcp-hack.sh"
|
||||
chmod +x /tmp/mcp-hack.sh
|
||||
|
||||
CODER_MCP=$(
|
||||
cat << EOF
|
||||
{
|
||||
"coder": {
|
||||
"args": [],
|
||||
"command": "/tmp/mcp-hack.sh",
|
||||
"description": "Report ALL tasks and statuses (in progress, done, failed) you are working on.",
|
||||
"name": "Coder",
|
||||
"timeout": 3000,
|
||||
"type": "stdio",
|
||||
"trust": true
|
||||
}
|
||||
}
|
||||
EOF
|
||||
)
|
||||
|
||||
echo "${ARG_WORKSPACE_MCP_JSON:-{}}" | jq --argjson base "$CODER_MCP" \
|
||||
'.mcpServers = ((.mcpServers // {}) + $base)' > "$TARGET_FILE"
|
||||
echo "Wrote workspace MCP to $TARGET_FILE"
|
||||
}
|
||||
|
||||
# Write rules files to user's home (FOLDER/.cursor/rules)
|
||||
function write_rules_file() {
|
||||
if [ -n "$ARG_WORKSPACE_RULES_JSON" ]; then
|
||||
RULES_DIR="$ARG_FOLDER/.cursor/rules"
|
||||
mkdir -p "$RULES_DIR"
|
||||
echo "$ARG_WORKSPACE_RULES_JSON" | jq -r 'to_entries[] | @base64' | while read -r entry; do
|
||||
_jq() { echo "${entry}" | base64 -d | jq -r ${1}; }
|
||||
NAME=$(_jq '.key')
|
||||
CONTENT=$(_jq '.value')
|
||||
echo "$CONTENT" > "$RULES_DIR/$NAME"
|
||||
echo "Wrote rule: $RULES_DIR/$NAME"
|
||||
done
|
||||
fi
|
||||
}
|
||||
|
||||
install_cursor_cli
|
||||
write_mcp_config
|
||||
write_rules_file
|
||||
@@ -0,0 +1,67 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
|
||||
command_exists() {
|
||||
command -v "$1" > /dev/null 2>&1
|
||||
}
|
||||
|
||||
ARG_AI_PROMPT=$(echo -n "${ARG_AI_PROMPT:-}" | base64 -d)
|
||||
ARG_FORCE=${ARG_FORCE:-false}
|
||||
ARG_MODEL=${ARG_MODEL:-}
|
||||
ARG_OUTPUT_FORMAT=${ARG_OUTPUT_FORMAT:-json}
|
||||
ARG_MODULE_DIR_NAME=${ARG_MODULE_DIR_NAME:-.cursor-cli-module}
|
||||
ARG_FOLDER=${ARG_FOLDER:-$HOME}
|
||||
|
||||
echo "--------------------------------"
|
||||
echo "install: $ARG_INSTALL"
|
||||
echo "version: $ARG_VERSION"
|
||||
echo "folder: $ARG_FOLDER"
|
||||
echo "ai_prompt: $ARG_AI_PROMPT"
|
||||
echo "force: $ARG_FORCE"
|
||||
echo "model: $ARG_MODEL"
|
||||
echo "output_format: $ARG_OUTPUT_FORMAT"
|
||||
echo "module_dir_name: $ARG_MODULE_DIR_NAME"
|
||||
echo "folder: $ARG_FOLDER"
|
||||
echo "--------------------------------"
|
||||
|
||||
mkdir -p "$HOME/$ARG_MODULE_DIR_NAME"
|
||||
|
||||
# Find cursor agent cli
|
||||
if command_exists cursor-agent; then
|
||||
CURSOR_CMD=cursor-agent
|
||||
elif [ -x "$HOME/.local/bin/cursor-agent" ]; then
|
||||
CURSOR_CMD="$HOME/.local/bin/cursor-agent"
|
||||
else
|
||||
echo "Error: cursor-agent not found. Install it or set install_cursor_cli=true."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Ensure working directory exists
|
||||
if [ -d "$ARG_FOLDER" ]; then
|
||||
cd "$ARG_FOLDER"
|
||||
else
|
||||
mkdir -p "$ARG_FOLDER"
|
||||
cd "$ARG_FOLDER"
|
||||
fi
|
||||
|
||||
ARGS=()
|
||||
|
||||
# global flags
|
||||
if [ -n "$ARG_MODEL" ]; then
|
||||
ARGS+=("-m" "$ARG_MODEL")
|
||||
fi
|
||||
if [ "$ARG_FORCE" = "true" ]; then
|
||||
ARGS+=("-f")
|
||||
fi
|
||||
|
||||
if [ -n "$ARG_AI_PROMPT" ]; then
|
||||
printf "AI prompt provided\n"
|
||||
ARGS+=("Complete the task at hand in one go. Every step of the way, report your progress using coder_report_task tool with proper summary and statuses. Your task at hand: $ARG_AI_PROMPT")
|
||||
fi
|
||||
|
||||
# Log and run in background, redirecting all output to the log file
|
||||
printf "Running: %q %s\n" "$CURSOR_CMD" "$(printf '%q ' "${ARGS[@]}")"
|
||||
|
||||
agentapi server --type cursor --term-width 67 --term-height 1190 -- "$CURSOR_CMD" "${ARGS[@]}"
|
||||
+2
-2
@@ -2,13 +2,13 @@
|
||||
|
||||
if [[ "$1" == "--version" ]]; then
|
||||
echo "HELLO: $(bash -c env)"
|
||||
echo "gemini version v2.5.0"
|
||||
echo "cursor-agent version v2.5.0"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
set -e
|
||||
|
||||
while true; do
|
||||
echo "$(date) - gemini-mock"
|
||||
echo "$(date) - cursor-agent-mock"
|
||||
sleep 15
|
||||
done
|
||||
@@ -1,36 +1,41 @@
|
||||
---
|
||||
display_name: Gemini CLI
|
||||
description: Run Gemini CLI in your workspace for AI pair programming
|
||||
icon: ../../../../.icons/gemini.svg
|
||||
description: Run Gemini CLI in your workspace with AgentAPI integration
|
||||
verified: true
|
||||
tags: [agent, gemini, ai, google, tasks]
|
||||
---
|
||||
|
||||
# Gemini CLI
|
||||
|
||||
Run [Gemini CLI](https://ai.google.dev/gemini-api/docs/cli) in your workspace to access Google's Gemini AI models, and custom pre/post install scripts. This module integrates with [AgentAPI](https://github.com/coder/agentapi) for Coder Tasks compatibility.
|
||||
Run [Gemini CLI](https://github.com/google-gemini/gemini-cli) in your workspace to access Google's Gemini AI models for interactive coding assistance and automated task execution.
|
||||
|
||||
```tf
|
||||
module "gemini" {
|
||||
source = "registry.coder.com/coder-labs/gemini/coder"
|
||||
version = "1.0.0"
|
||||
agent_id = coder_agent.example.id
|
||||
gemini_api_key = var.gemini_api_key
|
||||
gemini_model = "gemini-2.5-pro"
|
||||
install_gemini = true
|
||||
gemini_version = "latest"
|
||||
agentapi_version = "latest"
|
||||
source = "registry.coder.com/coder-labs/gemini/coder"
|
||||
version = "1.1.0"
|
||||
agent_id = coder_agent.example.id
|
||||
folder = "/home/coder/project"
|
||||
}
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- **Interactive AI Assistance**: Run Gemini CLI directly in your terminal for coding help
|
||||
- **Automated Task Execution**: Execute coding tasks automatically via AgentAPI integration
|
||||
- **Multiple AI Models**: Support for Gemini 2.5 Pro, Flash, and other Google AI models
|
||||
- **API Key Integration**: Seamless authentication with Gemini API
|
||||
- **MCP Server Integration**: Built-in Coder MCP server for task reporting
|
||||
- **Persistent Sessions**: Maintain context across workspace sessions
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- You must add the [Coder Login](https://registry.coder.com/modules/coder-login/coder) module to your template
|
||||
- Node.js and npm will be installed automatically if not present
|
||||
- The [Coder Login](https://registry.coder.com/modules/coder/coder-login) module is required
|
||||
|
||||
## Usage Example
|
||||
## Examples
|
||||
|
||||
- Example 1:
|
||||
### Basic setup
|
||||
|
||||
```tf
|
||||
variable "gemini_api_key" {
|
||||
@@ -40,39 +45,97 @@ variable "gemini_api_key" {
|
||||
}
|
||||
|
||||
module "gemini" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder-labs/gemini/coder"
|
||||
version = "1.0.0"
|
||||
agent_id = coder_agent.example.id
|
||||
gemini_api_key = var.gemini_api_key # we recommend providing this parameter inorder to have a smoother experience (i.e. no google sign-in)
|
||||
gemini_model = "gemini-2.5-flash"
|
||||
install_gemini = true
|
||||
gemini_version = "latest"
|
||||
gemini_instruction_prompt = "Start every response with `Gemini says:`"
|
||||
source = "registry.coder.com/coder-labs/gemini/coder"
|
||||
version = "1.1.0"
|
||||
agent_id = coder_agent.example.id
|
||||
gemini_api_key = var.gemini_api_key
|
||||
folder = "/home/coder/project"
|
||||
}
|
||||
```
|
||||
|
||||
## How it Works
|
||||
This basic setup will:
|
||||
|
||||
- **Install**: The module installs Gemini CLI using npm (installs Node.js via NVM if needed)
|
||||
- **Instruction Prompt**: If `GEMINI_INSTRUCTION_PROMPT` and `GEMINI_START_DIRECTORY` are set, creates the directory (if needed) and writes the prompt to `GEMINI.md`
|
||||
- **Start**: Launches Gemini CLI in the specified directory, wrapped by AgentAPI
|
||||
- **Environment**: Sets `GEMINI_API_KEY`, `GOOGLE_GENAI_USE_VERTEXAI`, `GEMINI_MODEL` for the CLI (if variables provided)
|
||||
- Install Gemini CLI in the workspace
|
||||
- Configure authentication with your API key
|
||||
- Set Gemini to run in `/home/coder/project` directory
|
||||
- Enable interactive use from the terminal
|
||||
- Set up MCP server integration for task reporting
|
||||
|
||||
### Automated task execution (Experimental)
|
||||
|
||||
> This functionality is in early access 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/ai-coder)
|
||||
|
||||
```tf
|
||||
variable "gemini_api_key" {
|
||||
type = string
|
||||
description = "Gemini API key"
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
module "coder-login" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/coder-login/coder"
|
||||
version = "~> 1.0"
|
||||
agent_id = coder_agent.example.id
|
||||
}
|
||||
|
||||
data "coder_parameter" "ai_prompt" {
|
||||
type = "string"
|
||||
name = "AI Prompt"
|
||||
default = ""
|
||||
description = "Task prompt for automated Gemini execution"
|
||||
mutable = true
|
||||
}
|
||||
|
||||
module "gemini" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder-labs/gemini/coder"
|
||||
version = "1.1.0"
|
||||
agent_id = coder_agent.example.id
|
||||
gemini_api_key = var.gemini_api_key
|
||||
gemini_model = "gemini-2.5-flash"
|
||||
folder = "/home/coder/project"
|
||||
task_prompt = data.coder_parameter.ai_prompt.value
|
||||
enable_yolo_mode = true # Auto-approve all tool calls for automation
|
||||
gemini_system_prompt = <<-EOT
|
||||
You are a helpful coding assistant. Always explain your code changes clearly.
|
||||
YOU MUST REPORT ALL TASKS TO CODER.
|
||||
EOT
|
||||
}
|
||||
```
|
||||
|
||||
> [!WARNING]
|
||||
> YOLO mode automatically approves all tool calls without user confirmation. The agent has access to your machine's file system and terminal. Only enable in trusted, isolated environments.
|
||||
|
||||
### Using Vertex AI (Enterprise)
|
||||
|
||||
For enterprise users who prefer Google's Vertex AI platform:
|
||||
|
||||
```tf
|
||||
module "gemini" {
|
||||
source = "registry.coder.com/coder-labs/gemini/coder"
|
||||
version = "1.1.0"
|
||||
agent_id = coder_agent.example.id
|
||||
gemini_api_key = var.gemini_api_key
|
||||
folder = "/home/coder/project"
|
||||
use_vertexai = true
|
||||
}
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- If Gemini CLI is not found, ensure `install_gemini = true` and your API key is valid
|
||||
- Node.js and npm are installed automatically if missing (using NVM)
|
||||
- Check logs in `/home/coder/.gemini-module/` for install/start output
|
||||
- We highly recommend using the `gemini_api_key` variable, this also ensures smooth tasks running without needing to sign in to Google.
|
||||
- If Gemini CLI is not found, ensure your API key is valid (`install_gemini` defaults to `true`)
|
||||
- Check logs in `~/.gemini-module/` for install/start output
|
||||
- Use the `gemini_api_key` variable to avoid requiring Google sign-in
|
||||
|
||||
> [!IMPORTANT]
|
||||
> To use tasks with Gemini CLI, ensure you have the `gemini_api_key` variable set, and **you pass the `AI Prompt` Parameter**.
|
||||
> By default we inject the "theme": "Default" and "selectedAuthType": "gemini-api-key" to your ~/.gemini/settings.json along with the coder mcp server.
|
||||
> In `gemini_instruction_prompt` and `AI Prompt` text we recommend using (\`\`) backticks instead of quotes to avoid escaping issues. Eg: gemini_instruction_prompt = "Start every response with \`Gemini says:\` "
|
||||
The module creates log files in the workspace's `~/.gemini-module` directory for debugging purposes.
|
||||
|
||||
## References
|
||||
|
||||
- [Gemini CLI Documentation](https://ai.google.dev/gemini-api/docs/cli)
|
||||
- [Gemini CLI Documentation](https://github.com/google-gemini/gemini-cli/blob/main/docs/index.md)
|
||||
- [AgentAPI Documentation](https://github.com/coder/agentapi)
|
||||
- [Coder AI Agents Guide](https://coder.com/docs/tutorials/ai-agents)
|
||||
- [Coder AI Agents Guide](https://coder.com/docs/ai-coder)
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
} from "bun:test";
|
||||
import { execContainer, readFileContainer, runTerraformInit } from "~test";
|
||||
import {
|
||||
loadTestFile,
|
||||
writeExecutable,
|
||||
setup as setupUtil,
|
||||
execModuleScript,
|
||||
@@ -54,10 +53,24 @@ const setup = async (props?: SetupProps): Promise<{ id: string }> => {
|
||||
agentapiMockScript: props?.agentapiMockScript,
|
||||
});
|
||||
if (!props?.skipGeminiMock) {
|
||||
const geminiMockContent = `#!/bin/bash
|
||||
|
||||
if [[ "$1" == "--version" ]]; then
|
||||
echo "HELLO: $(bash -c env)"
|
||||
echo "gemini version v2.5.0"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
set -e
|
||||
|
||||
while true; do
|
||||
echo "$(date) - gemini-mock"
|
||||
sleep 15
|
||||
done`;
|
||||
await writeExecutable({
|
||||
containerId: id,
|
||||
filePath: "/usr/bin/gemini",
|
||||
content: await loadTestFile(import.meta.dir, "gemini-mock.sh"),
|
||||
content: geminiMockContent,
|
||||
});
|
||||
}
|
||||
return { id };
|
||||
@@ -70,7 +83,7 @@ describe("gemini", async () => {
|
||||
await runTerraformInit(import.meta.dir);
|
||||
});
|
||||
|
||||
test("happy-path", async () => {
|
||||
test("agent-api", async () => {
|
||||
const { id } = await setup();
|
||||
await execModuleScript(id);
|
||||
await expectAgentAPIStarted(id);
|
||||
@@ -94,6 +107,18 @@ describe("gemini", async () => {
|
||||
expect(resp.stdout).toContain(version_to_install);
|
||||
});
|
||||
|
||||
test("install-gemini-latest", async () => {
|
||||
const { id } = await setup({
|
||||
skipGeminiMock: true,
|
||||
moduleVariables: {
|
||||
install_gemini: "true",
|
||||
gemini_version: "",
|
||||
},
|
||||
});
|
||||
await execModuleScript(id);
|
||||
await expectAgentAPIStarted(id);
|
||||
});
|
||||
|
||||
test("gemini-settings-json", async () => {
|
||||
const settings = '{"foo": "bar"}';
|
||||
const { id } = await setup({
|
||||
@@ -117,7 +142,7 @@ describe("gemini", async () => {
|
||||
await execModuleScript(id);
|
||||
|
||||
const resp = await readFileContainer(id, "/home/coder/.gemini-module/agentapi-start.log");
|
||||
expect(resp).toContain("gemini_api_key provided !");
|
||||
expect(resp).toContain("Using direct Gemini API with API key");
|
||||
});
|
||||
|
||||
test("use-vertexai", async () => {
|
||||
@@ -197,6 +222,20 @@ describe("gemini", async () => {
|
||||
expect(resp).toContain(prompt);
|
||||
});
|
||||
|
||||
test("task-prompt", async () => {
|
||||
const taskPrompt = "Create a simple Hello World function";
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
task_prompt: taskPrompt,
|
||||
},
|
||||
});
|
||||
await execModuleScript(id, {
|
||||
GEMINI_TASK_PROMPT: taskPrompt,
|
||||
});
|
||||
const resp = await readFileContainer(id, "/home/coder/.gemini-module/agentapi-start.log");
|
||||
expect(resp).toContain("Running automated task:");
|
||||
});
|
||||
|
||||
test("start-without-prompt", async () => {
|
||||
const { id } = await setup();
|
||||
await execModuleScript(id);
|
||||
|
||||
@@ -74,14 +74,14 @@ variable "use_vertexai" {
|
||||
|
||||
variable "install_agentapi" {
|
||||
type = bool
|
||||
description = "Whether to install AgentAPI."
|
||||
description = "Whether to install AgentAPI for web UI and task automation."
|
||||
default = true
|
||||
}
|
||||
|
||||
variable "agentapi_version" {
|
||||
type = string
|
||||
description = "The version of AgentAPI to install."
|
||||
default = "v0.3.0"
|
||||
default = "v0.2.3"
|
||||
}
|
||||
|
||||
variable "gemini_model" {
|
||||
@@ -102,12 +102,10 @@ variable "post_install_script" {
|
||||
default = null
|
||||
}
|
||||
|
||||
data "coder_parameter" "ai_prompt" {
|
||||
type = "string"
|
||||
name = "AI Prompt"
|
||||
variable "task_prompt" {
|
||||
type = string
|
||||
description = "Task prompt for automated Gemini execution"
|
||||
default = ""
|
||||
description = "Initial prompt for the Gemini CLI"
|
||||
mutable = true
|
||||
}
|
||||
|
||||
variable "additional_extensions" {
|
||||
@@ -122,12 +120,24 @@ variable "gemini_system_prompt" {
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "enable_yolo_mode" {
|
||||
type = bool
|
||||
description = "Enable YOLO mode to automatically approve all tool calls without user confirmation. Use with caution."
|
||||
default = false
|
||||
}
|
||||
|
||||
resource "coder_env" "gemini_api_key" {
|
||||
agent_id = var.agent_id
|
||||
name = "GEMINI_API_KEY"
|
||||
value = var.gemini_api_key
|
||||
}
|
||||
|
||||
resource "coder_env" "google_api_key" {
|
||||
agent_id = var.agent_id
|
||||
name = "GOOGLE_API_KEY"
|
||||
value = var.gemini_api_key
|
||||
}
|
||||
|
||||
resource "coder_env" "gemini_use_vertex_ai" {
|
||||
agent_id = var.agent_id
|
||||
name = "GOOGLE_GENAI_USE_VERTEXAI"
|
||||
@@ -166,7 +176,7 @@ EOT
|
||||
|
||||
module "agentapi" {
|
||||
source = "registry.coder.com/coder/agentapi/coder"
|
||||
version = "1.0.0"
|
||||
version = "1.1.1"
|
||||
|
||||
agent_id = var.agent_id
|
||||
web_app_slug = local.app_slug
|
||||
@@ -181,22 +191,7 @@ module "agentapi" {
|
||||
agentapi_version = var.agentapi_version
|
||||
pre_install_script = var.pre_install_script
|
||||
post_install_script = var.post_install_script
|
||||
start_script = <<-EOT
|
||||
#!/bin/bash
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
|
||||
echo -n '${base64encode(local.start_script)}' | base64 -d > /tmp/start.sh
|
||||
chmod +x /tmp/start.sh
|
||||
GEMINI_API_KEY='${var.gemini_api_key}' \
|
||||
GOOGLE_GENAI_USE_VERTEXAI='${var.use_vertexai}' \
|
||||
GEMINI_MODEL='${var.gemini_model}' \
|
||||
GEMINI_START_DIRECTORY='${var.folder}' \
|
||||
GEMINI_TASK_PROMPT='${base64encode(data.coder_parameter.ai_prompt.value)}' \
|
||||
/tmp/start.sh
|
||||
EOT
|
||||
|
||||
install_script = <<-EOT
|
||||
install_script = <<-EOT
|
||||
#!/bin/bash
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
@@ -209,7 +204,23 @@ module "agentapi" {
|
||||
BASE_EXTENSIONS='${base64encode(replace(local.base_extensions, "'", "'\\''"))}' \
|
||||
ADDITIONAL_EXTENSIONS='${base64encode(replace(var.additional_extensions != null ? var.additional_extensions : "", "'", "'\\''"))}' \
|
||||
GEMINI_START_DIRECTORY='${var.folder}' \
|
||||
GEMINI_INSTRUCTION_PROMPT='${base64encode(var.gemini_system_prompt)}' \
|
||||
GEMINI_SYSTEM_PROMPT='${base64encode(var.gemini_system_prompt)}' \
|
||||
/tmp/install.sh
|
||||
EOT
|
||||
start_script = <<-EOT
|
||||
#!/bin/bash
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
|
||||
echo -n '${base64encode(local.start_script)}' | base64 -d > /tmp/start.sh
|
||||
chmod +x /tmp/start.sh
|
||||
GEMINI_API_KEY='${var.gemini_api_key}' \
|
||||
GOOGLE_API_KEY='${var.gemini_api_key}' \
|
||||
GOOGLE_GENAI_USE_VERTEXAI='${var.use_vertexai}' \
|
||||
GEMINI_YOLO_MODE='${var.enable_yolo_mode}' \
|
||||
GEMINI_MODEL='${var.gemini_model}' \
|
||||
GEMINI_START_DIRECTORY='${var.folder}' \
|
||||
GEMINI_TASK_PROMPT='${var.task_prompt}' \
|
||||
/tmp/start.sh
|
||||
EOT
|
||||
}
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
BOLD='\033[0;1m'
|
||||
|
||||
# Function to check if a command exists
|
||||
command_exists() {
|
||||
command -v "$1" >/dev/null 2>&1
|
||||
}
|
||||
@@ -12,7 +11,7 @@ set -o nounset
|
||||
ARG_GEMINI_CONFIG=$(echo -n "$ARG_GEMINI_CONFIG" | base64 -d)
|
||||
BASE_EXTENSIONS=$(echo -n "$BASE_EXTENSIONS" | base64 -d)
|
||||
ADDITIONAL_EXTENSIONS=$(echo -n "$ADDITIONAL_EXTENSIONS" | base64 -d)
|
||||
GEMINI_INSTRUCTION_PROMPT=$(echo -n "$GEMINI_INSTRUCTION_PROMPT" | base64 -d)
|
||||
GEMINI_SYSTEM_PROMPT=$(echo -n "$GEMINI_SYSTEM_PROMPT" | base64 -d)
|
||||
|
||||
echo "--------------------------------"
|
||||
printf "gemini_config: %s\n" "$ARG_GEMINI_CONFIG"
|
||||
@@ -23,7 +22,6 @@ echo "--------------------------------"
|
||||
set +o nounset
|
||||
|
||||
function install_node() {
|
||||
# borrowed from claude-code module
|
||||
if ! command_exists npm; then
|
||||
printf "npm not found, checking for Node.js installation...\n"
|
||||
if ! command_exists node; then
|
||||
@@ -52,24 +50,15 @@ function install_node() {
|
||||
|
||||
function install_gemini() {
|
||||
if [ "${ARG_INSTALL}" = "true" ]; then
|
||||
# we need node to install and run gemini-cli
|
||||
install_node
|
||||
|
||||
# If nvm does not exist, we will create a global npm directory (this os to prevent the possibility of EACCESS issues on npm -g)
|
||||
if ! command_exists nvm; then
|
||||
printf "which node: %s\n" "$(which node)"
|
||||
printf "which npm: %s\n" "$(which npm)"
|
||||
|
||||
# Create a directory for global packages
|
||||
mkdir -p "$HOME"/.npm-global
|
||||
|
||||
# Configure npm to use it
|
||||
npm config set prefix "$HOME/.npm-global"
|
||||
|
||||
# Add to PATH for current session
|
||||
export PATH="$HOME/.npm-global/bin:$PATH"
|
||||
|
||||
# Add to shell profile for future sessions
|
||||
if ! grep -q "export PATH=$HOME/.npm-global/bin:\$PATH" ~/.bashrc; then
|
||||
echo "export PATH=$HOME/.npm-global/bin:\$PATH" >> ~/.bashrc
|
||||
fi
|
||||
@@ -108,7 +97,6 @@ function append_extensions_to_settings_json() {
|
||||
fi
|
||||
if [ ! -f "$SETTINGS_PATH" ]; then
|
||||
printf "%s does not exist. Creating with merged mcpServers structure.\n" "$SETTINGS_PATH"
|
||||
# If ADDITIONAL_EXTENSIONS is not set or empty, use '{}'
|
||||
ADD_EXT_JSON='{}'
|
||||
if [ -n "${ADDITIONAL_EXTENSIONS:-}" ]; then
|
||||
ADD_EXT_JSON="$ADDITIONAL_EXTENSIONS"
|
||||
@@ -116,10 +104,7 @@ function append_extensions_to_settings_json() {
|
||||
printf '{"mcpServers":%s}\n' "$(jq -s 'add' <(echo "$BASE_EXTENSIONS") <(echo "$ADD_EXT_JSON"))" > "$SETTINGS_PATH"
|
||||
fi
|
||||
|
||||
# Prepare temp files
|
||||
TMP_SETTINGS=$(mktemp)
|
||||
|
||||
# If ADDITIONAL_EXTENSIONS is not set or empty, use '{}'
|
||||
ADD_EXT_JSON='{}'
|
||||
if [ -n "${ADDITIONAL_EXTENSIONS:-}" ]; then
|
||||
printf "[append_extensions_to_settings_json] ADDITIONAL_EXTENSIONS is set.\n"
|
||||
@@ -133,14 +118,13 @@ function append_extensions_to_settings_json() {
|
||||
'.mcpServers = (.mcpServers // {} + $base + $add)' \
|
||||
"$SETTINGS_PATH" > "$TMP_SETTINGS" && mv "$TMP_SETTINGS" "$SETTINGS_PATH"
|
||||
|
||||
# Add theme and selectedAuthType fields
|
||||
jq '.theme = "Default" | .selectedAuthType = "gemini-api-key"' "$SETTINGS_PATH" > "$TMP_SETTINGS" && mv "$TMP_SETTINGS" "$SETTINGS_PATH"
|
||||
|
||||
printf "[append_extensions_to_settings_json] Merge complete.\n"
|
||||
}
|
||||
|
||||
function add_instruction_prompt_if_exists() {
|
||||
if [ -n "${GEMINI_INSTRUCTION_PROMPT:-}" ]; then
|
||||
function add_system_prompt_if_exists() {
|
||||
if [ -n "${GEMINI_SYSTEM_PROMPT:-}" ]; then
|
||||
if [ -d "${GEMINI_START_DIRECTORY}" ]; then
|
||||
printf "Directory '%s' exists. Changing to it.\\n" "${GEMINI_START_DIRECTORY}"
|
||||
cd "${GEMINI_START_DIRECTORY}" || {
|
||||
@@ -160,16 +144,21 @@ function add_instruction_prompt_if_exists() {
|
||||
fi
|
||||
touch GEMINI.md
|
||||
printf "Setting GEMINI.md\n"
|
||||
echo "${GEMINI_INSTRUCTION_PROMPT}" > GEMINI.md
|
||||
echo "${GEMINI_SYSTEM_PROMPT}" > GEMINI.md
|
||||
else
|
||||
printf "GEMINI.md is not set.\n"
|
||||
fi
|
||||
}
|
||||
|
||||
function configure_mcp() {
|
||||
export CODER_MCP_APP_STATUS_SLUG="gemini"
|
||||
export CODER_MCP_AI_AGENTAPI_URL="http://localhost:3284"
|
||||
coder exp mcp configure gemini "${GEMINI_START_DIRECTORY}"
|
||||
}
|
||||
|
||||
# Install Gemini
|
||||
install_gemini
|
||||
gemini --version
|
||||
populate_settings_json
|
||||
add_instruction_prompt_if_exists
|
||||
add_system_prompt_if_exists
|
||||
configure_mcp
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#!/bin/bash
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
|
||||
# Load shell environment
|
||||
source "$HOME"/.bashrc
|
||||
|
||||
command_exists() {
|
||||
@@ -15,7 +16,8 @@ fi
|
||||
|
||||
printf "Version: %s\n" "$(gemini --version)"
|
||||
|
||||
GEMINI_TASK_PROMPT=$(echo -n "$GEMINI_TASK_PROMPT" | base64 -d)
|
||||
MODULE_DIR="$HOME/.gemini-module"
|
||||
mkdir -p "$MODULE_DIR"
|
||||
|
||||
if command_exists gemini; then
|
||||
printf "Gemini is installed\n"
|
||||
@@ -43,20 +45,30 @@ else
|
||||
fi
|
||||
|
||||
if [ -n "$GEMINI_TASK_PROMPT" ]; then
|
||||
printf "Running the task prompt %s\n" "$GEMINI_TASK_PROMPT"
|
||||
printf "Running automated task: %s\n" "$GEMINI_TASK_PROMPT"
|
||||
PROMPT="Every step of the way, report tasks to Coder with proper descriptions and statuses. Your task at hand: $GEMINI_TASK_PROMPT"
|
||||
PROMPT_FILE="$MODULE_DIR/prompt.txt"
|
||||
echo -n "$PROMPT" >"$PROMPT_FILE"
|
||||
GEMINI_ARGS=(--prompt-interactive "$PROMPT")
|
||||
else
|
||||
printf "No task prompt given.\n"
|
||||
printf "Starting Gemini CLI in interactive mode.\n"
|
||||
GEMINI_ARGS=()
|
||||
fi
|
||||
|
||||
if [ -n "$GEMINI_API_KEY" ]; then
|
||||
printf "gemini_api_key provided !\n"
|
||||
else
|
||||
printf "gemini_api_key not provided\n"
|
||||
if [ -n "$GEMINI_YOLO_MODE" ] && [ "$GEMINI_YOLO_MODE" = "true" ]; then
|
||||
printf "YOLO mode enabled - will auto-approve all tool calls\n"
|
||||
GEMINI_ARGS+=(--yolo)
|
||||
fi
|
||||
|
||||
# use low width to fit in the tasks UI sidebar. height is adjusted so that width x height ~= 80x1000 characters
|
||||
# are visible in the terminal screen by default.
|
||||
agentapi server --term-width 67 --term-height 1190 -- gemini "${GEMINI_ARGS[@]}"
|
||||
if [ -n "$GEMINI_API_KEY" ] || [ -n "$GOOGLE_API_KEY" ]; then
|
||||
if [ -n "$GOOGLE_GENAI_USE_VERTEXAI" ] && [ "$GOOGLE_GENAI_USE_VERTEXAI" = "true" ]; then
|
||||
printf "Using Vertex AI with API key\n"
|
||||
else
|
||||
printf "Using direct Gemini API with API key\n"
|
||||
fi
|
||||
else
|
||||
printf "No API key provided (neither GEMINI_API_KEY nor GOOGLE_API_KEY)\n"
|
||||
fi
|
||||
|
||||
agentapi server --term-width 67 --term-height 1190 -- \
|
||||
bash -c "$(printf '%q ' gemini "${GEMINI_ARGS[@]}")"
|
||||
@@ -0,0 +1,90 @@
|
||||
---
|
||||
display_name: Amp CLI
|
||||
icon: ../../../../.icons/sourcegraph-amp.svg
|
||||
description: Sourcegraph's AI coding agent with deep codebase understanding and intelligent code search capabilities
|
||||
verified: false
|
||||
tags: [agent, sourcegraph, amp, ai, tasks]
|
||||
---
|
||||
|
||||
# Sourcegraph Amp CLI
|
||||
|
||||
Run [Amp CLI](https://ampcode.com/) in your workspace to access Sourcegraph's AI-powered code search and analysis tools, with AgentAPI integration for seamless Coder Tasks support.
|
||||
|
||||
```tf
|
||||
module "amp-cli" {
|
||||
source = "registry.coder.com/coder-labs/sourcegraph-amp/coder"
|
||||
version = "1.0.2"
|
||||
agent_id = coder_agent.example.id
|
||||
sourcegraph_amp_api_key = var.sourcegraph_amp_api_key
|
||||
install_sourcegraph_amp = true
|
||||
agentapi_version = "latest"
|
||||
}
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Include the [Coder Login](https://registry.coder.com/modules/coder-login/coder) module in your template
|
||||
- Node.js and npm are automatically installed (via NVM) if not already available
|
||||
|
||||
## Usage Example
|
||||
|
||||
```tf
|
||||
data "coder_parameter" "ai_prompt" {
|
||||
name = "AI Prompt"
|
||||
description = "Write an initial prompt for Amp to work on."
|
||||
type = "string"
|
||||
default = ""
|
||||
mutable = true
|
||||
|
||||
}
|
||||
|
||||
# Set system prompt for Amp CLI via environment variables
|
||||
resource "coder_agent" "main" {
|
||||
# ...
|
||||
env = {
|
||||
SOURCEGRAPH_AMP_SYSTEM_PROMPT = <<-EOT
|
||||
You are an Amp assistant that helps developers debug and write code efficiently.
|
||||
|
||||
Always log task status to Coder.
|
||||
EOT
|
||||
SOURCEGRAPH_AMP_TASK_PROMPT = data.coder_parameter.ai_prompt.value
|
||||
}
|
||||
}
|
||||
|
||||
variable "sourcegraph_amp_api_key" {
|
||||
type = string
|
||||
description = "Sourcegraph Amp API key. Get one at https://ampcode.com/settings"
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
module "amp-cli" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder-labs/sourcegraph-amp/coder"
|
||||
version = "1.0.2"
|
||||
agent_id = coder_agent.example.id
|
||||
sourcegraph_amp_api_key = var.sourcegraph_amp_api_key # recommended for authenticated usage
|
||||
install_sourcegraph_amp = true
|
||||
}
|
||||
```
|
||||
|
||||
## How it Works
|
||||
|
||||
- **Install**: Installs Sourcegraph Amp CLI using npm (installs Node.js via NVM if required)
|
||||
- **Start**: Launches Amp CLI in the specified directory, wrapped with AgentAPI to enable tasks and AI interactions
|
||||
- **Environment Variables**: Sets `SOURCEGRAPH_AMP_API_KEY` and `SOURCEGRAPH_AMP_START_DIRECTORY` for the CLI execution
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- If `amp` is not found, ensure `install_sourcegraph_amp = true` and your API key is valid
|
||||
- Logs are written under `/home/coder/.sourcegraph-amp-module/` (`install.log`, `agentapi-start.log`) for debugging
|
||||
- If AgentAPI fails to start, verify that your container has network access and executable permissions for the scripts
|
||||
|
||||
> [!IMPORTANT]
|
||||
> For using **Coder Tasks** with Amp CLI, make sure to pass the `AI Prompt` parameter and set `sourcegraph_amp_api_key`.
|
||||
> This ensures task reporting and status updates work seamlessly.
|
||||
|
||||
## References
|
||||
|
||||
- [Amp CLI Documentation](https://ampcode.com/manual)
|
||||
- [AgentAPI Documentation](https://github.com/coder/agentapi)
|
||||
- [Coder AI Agents Guide](https://coder.com/docs/tutorials/ai-agents)
|
||||
@@ -0,0 +1,157 @@
|
||||
import {
|
||||
test,
|
||||
afterEach,
|
||||
describe,
|
||||
setDefaultTimeout,
|
||||
beforeAll,
|
||||
expect,
|
||||
} from "bun:test";
|
||||
import { execContainer, readFileContainer, runTerraformInit } from "~test";
|
||||
import {
|
||||
loadTestFile,
|
||||
writeExecutable,
|
||||
setup as setupUtil,
|
||||
execModuleScript,
|
||||
expectAgentAPIStarted,
|
||||
} from "../../../coder/modules/agentapi/test-util";
|
||||
|
||||
let cleanupFunctions: (() => Promise<void>)[] = [];
|
||||
const registerCleanup = (cleanup: () => Promise<void>) => {
|
||||
cleanupFunctions.push(cleanup);
|
||||
};
|
||||
afterEach(async () => {
|
||||
const cleanupFnsCopy = cleanupFunctions.slice().reverse();
|
||||
cleanupFunctions = [];
|
||||
for (const cleanup of cleanupFnsCopy) {
|
||||
try {
|
||||
await cleanup();
|
||||
} catch (error) {
|
||||
console.error("Error during cleanup:", error);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
interface SetupProps {
|
||||
skipAgentAPIMock?: boolean;
|
||||
skipAmpMock?: boolean;
|
||||
moduleVariables?: Record<string, string>;
|
||||
agentapiMockScript?: string;
|
||||
}
|
||||
|
||||
const setup = async (props?: SetupProps): Promise<{ id: string }> => {
|
||||
const projectDir = "/home/coder/project";
|
||||
const { id } = await setupUtil({
|
||||
moduleDir: import.meta.dir,
|
||||
moduleVariables: {
|
||||
install_sourcegraph_amp: props?.skipAmpMock ? "true" : "false",
|
||||
install_agentapi: props?.skipAgentAPIMock ? "true" : "false",
|
||||
sourcegraph_amp_model: "test-model",
|
||||
...props?.moduleVariables,
|
||||
},
|
||||
registerCleanup,
|
||||
projectDir,
|
||||
skipAgentAPIMock: props?.skipAgentAPIMock,
|
||||
agentapiMockScript: props?.agentapiMockScript,
|
||||
});
|
||||
|
||||
// Place the AMP mock CLI binary inside the container
|
||||
if (!props?.skipAmpMock) {
|
||||
await writeExecutable({
|
||||
containerId: id,
|
||||
filePath: "/usr/bin/amp",
|
||||
content: await loadTestFile(`${import.meta.dir}`, "amp-mock.sh"),
|
||||
});
|
||||
}
|
||||
|
||||
return { id };
|
||||
};
|
||||
|
||||
setDefaultTimeout(60 * 1000);
|
||||
|
||||
describe("sourcegraph-amp", async () => {
|
||||
beforeAll(async () => {
|
||||
await runTerraformInit(import.meta.dir);
|
||||
});
|
||||
|
||||
test("happy-path", async () => {
|
||||
const { id } = await setup();
|
||||
await execModuleScript(id);
|
||||
await expectAgentAPIStarted(id);
|
||||
});
|
||||
|
||||
test("api-key", async () => {
|
||||
const apiKey = "test-api-key-123";
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
sourcegraph_amp_api_key: apiKey,
|
||||
},
|
||||
});
|
||||
await execModuleScript(id);
|
||||
const resp = await readFileContainer(
|
||||
id,
|
||||
"/home/coder/.sourcegraph-amp-module/agentapi-start.log",
|
||||
);
|
||||
expect(resp).toContain("sourcegraph_amp_api_key provided !");
|
||||
});
|
||||
|
||||
test("custom-folder", async () => {
|
||||
const folder = "/tmp/sourcegraph-amp-test";
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
folder,
|
||||
},
|
||||
});
|
||||
await execModuleScript(id);
|
||||
const resp = await readFileContainer(
|
||||
id,
|
||||
"/home/coder/.sourcegraph-amp-module/install.log",
|
||||
);
|
||||
expect(resp).toContain(folder);
|
||||
});
|
||||
|
||||
test("pre-post-install-scripts", async () => {
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
pre_install_script: "#!/bin/bash\necho 'pre-install-script'",
|
||||
post_install_script: "#!/bin/bash\necho 'post-install-script'",
|
||||
},
|
||||
});
|
||||
await execModuleScript(id);
|
||||
const preLog = await readFileContainer(
|
||||
id,
|
||||
"/home/coder/.sourcegraph-amp-module/pre_install.log",
|
||||
);
|
||||
expect(preLog).toContain("pre-install-script");
|
||||
const postLog = await readFileContainer(
|
||||
id,
|
||||
"/home/coder/.sourcegraph-amp-module/post_install.log",
|
||||
);
|
||||
expect(postLog).toContain("post-install-script");
|
||||
});
|
||||
|
||||
test("system-prompt", async () => {
|
||||
const prompt = "this is a system prompt for AMP";
|
||||
const { id } = await setup();
|
||||
await execModuleScript(id, {
|
||||
SOURCEGRAPH_AMP_SYSTEM_PROMPT: prompt,
|
||||
});
|
||||
const resp = await readFileContainer(
|
||||
id,
|
||||
"/home/coder/.sourcegraph-amp-module/SYSTEM_PROMPT.md",
|
||||
);
|
||||
expect(resp).toContain(prompt);
|
||||
});
|
||||
|
||||
test("task-prompt", async () => {
|
||||
const prompt = "this is a task prompt for AMP";
|
||||
const { id } = await setup();
|
||||
await execModuleScript(id, {
|
||||
SOURCEGRAPH_AMP_TASK_PROMPT: prompt,
|
||||
});
|
||||
const resp = await readFileContainer(
|
||||
id,
|
||||
"/home/coder/.sourcegraph-amp-module/agentapi-start.log",
|
||||
);
|
||||
expect(resp).toContain(`sourcegraph amp task prompt provided : ${prompt}`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,195 @@
|
||||
terraform {
|
||||
required_version = ">= 1.0"
|
||||
|
||||
required_providers {
|
||||
coder = {
|
||||
source = "coder/coder"
|
||||
version = ">= 2.7"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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/sourcegraph-amp.svg"
|
||||
}
|
||||
|
||||
variable "folder" {
|
||||
type = string
|
||||
description = "The folder to run sourcegraph_amp in."
|
||||
default = "/home/coder"
|
||||
}
|
||||
|
||||
variable "install_sourcegraph_amp" {
|
||||
type = bool
|
||||
description = "Whether to install sourcegraph-amp."
|
||||
default = true
|
||||
}
|
||||
|
||||
variable "sourcegraph_amp_api_key" {
|
||||
type = string
|
||||
description = "sourcegraph-amp API Key"
|
||||
default = ""
|
||||
}
|
||||
|
||||
resource "coder_env" "sourcegraph_amp_api_key" {
|
||||
agent_id = var.agent_id
|
||||
name = "SOURCEGRAPH_AMP_API_KEY"
|
||||
value = var.sourcegraph_amp_api_key
|
||||
}
|
||||
|
||||
variable "install_agentapi" {
|
||||
type = bool
|
||||
description = "Whether to install AgentAPI."
|
||||
default = true
|
||||
}
|
||||
|
||||
variable "agentapi_version" {
|
||||
type = string
|
||||
description = "The version of AgentAPI to install."
|
||||
default = "v0.3.0"
|
||||
}
|
||||
|
||||
variable "pre_install_script" {
|
||||
type = string
|
||||
description = "Custom script to run before installing sourcegraph_amp"
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "post_install_script" {
|
||||
type = string
|
||||
description = "Custom script to run after installing sourcegraph_amp."
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "base_amp_config" {
|
||||
type = string
|
||||
description = <<-EOT
|
||||
Base AMP configuration in JSON format. Can be overridden to customize AMP settings.
|
||||
|
||||
If empty, defaults enable thinking and todos for autonomous operation. Additional options include:
|
||||
- "amp.permissions": [] (tool permissions)
|
||||
- "amp.tools.stopTimeout": 600 (extend timeout for long operations)
|
||||
- "amp.terminal.commands.nodeSpawn.loadProfile": "daily" (environment loading)
|
||||
- "amp.tools.disable": ["builtin:open"] (disable tools for containers)
|
||||
- "amp.git.commit.ampThread.enabled": true (link commits to threads)
|
||||
- "amp.git.commit.coauthor.enabled": true (add Amp as co-author)
|
||||
|
||||
Reference: https://ampcode.com/manual
|
||||
EOT
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "additional_mcp_servers" {
|
||||
type = string
|
||||
description = "Additional MCP servers configuration in JSON format to append to amp.mcpServers."
|
||||
default = null
|
||||
}
|
||||
|
||||
locals {
|
||||
app_slug = "amp"
|
||||
|
||||
default_base_config = {
|
||||
"amp.anthropic.thinking.enabled" = true
|
||||
"amp.todos.enabled" = true
|
||||
}
|
||||
|
||||
# Use provided config or default, then extract base settings (excluding mcpServers)
|
||||
user_config = var.base_amp_config != "" ? jsondecode(var.base_amp_config) : local.default_base_config
|
||||
base_amp_settings = { for k, v in local.user_config : k => v if k != "amp.mcpServers" }
|
||||
|
||||
coder_mcp = {
|
||||
"coder" = {
|
||||
"command" = "coder"
|
||||
"args" = ["exp", "mcp", "server"]
|
||||
"env" = {
|
||||
"CODER_MCP_APP_STATUS_SLUG" = local.app_slug
|
||||
"CODER_MCP_AI_AGENTAPI_URL" = "http://localhost:3284"
|
||||
}
|
||||
"type" = "stdio"
|
||||
}
|
||||
}
|
||||
|
||||
additional_mcp = var.additional_mcp_servers != null ? jsondecode(var.additional_mcp_servers) : {}
|
||||
|
||||
merged_mcp_servers = merge(
|
||||
lookup(local.user_config, "amp.mcpServers", {}),
|
||||
local.coder_mcp,
|
||||
local.additional_mcp
|
||||
)
|
||||
|
||||
final_config = merge(local.base_amp_settings, {
|
||||
"amp.mcpServers" = local.merged_mcp_servers
|
||||
})
|
||||
|
||||
install_script = file("${path.module}/scripts/install.sh")
|
||||
start_script = file("${path.module}/scripts/start.sh")
|
||||
module_dir_name = ".sourcegraph-amp-module"
|
||||
}
|
||||
|
||||
module "agentapi" {
|
||||
source = "registry.coder.com/coder/agentapi/coder"
|
||||
version = "1.0.1"
|
||||
|
||||
agent_id = var.agent_id
|
||||
web_app_slug = local.app_slug
|
||||
web_app_order = var.order
|
||||
web_app_group = var.group
|
||||
web_app_icon = var.icon
|
||||
web_app_display_name = "Sourcegraph Amp"
|
||||
cli_app_slug = "${local.app_slug}-cli"
|
||||
cli_app_display_name = "Sourcegraph Amp CLI"
|
||||
module_dir_name = local.module_dir_name
|
||||
install_agentapi = var.install_agentapi
|
||||
agentapi_version = var.agentapi_version
|
||||
pre_install_script = var.pre_install_script
|
||||
post_install_script = var.post_install_script
|
||||
start_script = <<-EOT
|
||||
#!/bin/bash
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
|
||||
echo -n '${base64encode(local.start_script)}' | base64 -d > /tmp/start.sh
|
||||
chmod +x /tmp/start.sh
|
||||
SOURCEGRAPH_AMP_API_KEY='${var.sourcegraph_amp_api_key}' \
|
||||
SOURCEGRAPH_AMP_START_DIRECTORY='${var.folder}' \
|
||||
/tmp/start.sh
|
||||
EOT
|
||||
|
||||
install_script = <<-EOT
|
||||
#!/bin/bash
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
|
||||
echo -n '${base64encode(local.install_script)}' | base64 -d > /tmp/install.sh
|
||||
chmod +x /tmp/install.sh
|
||||
ARG_INSTALL_SOURCEGRAPH_AMP='${var.install_sourcegraph_amp}' \
|
||||
SOURCEGRAPH_AMP_START_DIRECTORY='${var.folder}' \
|
||||
ARG_AMP_CONFIG="$(echo -n '${base64encode(jsonencode(local.final_config))}' | base64 -d)" \
|
||||
/tmp/install.sh
|
||||
EOT
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
# ANSI colors
|
||||
BOLD='\033[1m'
|
||||
|
||||
echo "--------------------------------"
|
||||
echo "Install flag: $ARG_INSTALL_SOURCEGRAPH_AMP"
|
||||
echo "Workspace: $SOURCEGRAPH_AMP_START_DIRECTORY"
|
||||
echo "--------------------------------"
|
||||
|
||||
# Helper function to check if a command exists
|
||||
command_exists() {
|
||||
command -v "$1" > /dev/null 2>&1
|
||||
}
|
||||
|
||||
function install_node() {
|
||||
if ! command_exists npm; then
|
||||
printf "npm not found, checking for Node.js installation...\n"
|
||||
if ! command_exists node; then
|
||||
printf "Node.js not found, installing Node.js via NVM...\n"
|
||||
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
|
||||
|
||||
# Temporarily disable nounset (-u) for nvm to avoid PROVIDED_VERSION error
|
||||
set +u
|
||||
nvm install --lts
|
||||
nvm use --lts
|
||||
nvm alias default node
|
||||
set -u
|
||||
|
||||
printf "Node.js installed: %s\n" "$(node --version)"
|
||||
printf "npm installed: %s\n" "$(npm --version)"
|
||||
else
|
||||
printf "Node.js is installed but npm is not available. Please install npm manually.\n"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
function install_sourcegraph_amp() {
|
||||
if [ "${ARG_INSTALL_SOURCEGRAPH_AMP}" = "true" ]; then
|
||||
install_node
|
||||
|
||||
# If nvm is not used, set up user npm global directory
|
||||
if ! command_exists nvm; then
|
||||
mkdir -p "$HOME/.npm-global"
|
||||
npm config set prefix "$HOME/.npm-global"
|
||||
export PATH="$HOME/.npm-global/bin:$PATH"
|
||||
if ! grep -q "export PATH=$HOME/.npm-global/bin:\$PATH" ~/.bashrc; then
|
||||
echo "export PATH=$HOME/.npm-global/bin:\$PATH" >> ~/.bashrc
|
||||
fi
|
||||
fi
|
||||
|
||||
printf "%s Installing Sourcegraph AMP CLI...\n" "${BOLD}"
|
||||
npm install -g @sourcegraph/amp@0.0.1754179307-gba1f97
|
||||
printf "%s Successfully installed Sourcegraph AMP CLI. Version: %s\n" "${BOLD}" "$(amp --version)"
|
||||
fi
|
||||
}
|
||||
|
||||
function setup_system_prompt() {
|
||||
if [ -n "${SOURCEGRAPH_AMP_SYSTEM_PROMPT:-}" ]; then
|
||||
echo "Setting Sourcegraph AMP system prompt..."
|
||||
mkdir -p "$HOME/.sourcegraph-amp-module"
|
||||
echo "$SOURCEGRAPH_AMP_SYSTEM_PROMPT" > "$HOME/.sourcegraph-amp-module/SYSTEM_PROMPT.md"
|
||||
echo "System prompt saved to $HOME/.sourcegraph-amp-module/SYSTEM_PROMPT.md"
|
||||
else
|
||||
echo "No system prompt provided for Sourcegraph AMP."
|
||||
fi
|
||||
}
|
||||
|
||||
function configure_amp_settings() {
|
||||
echo "Configuring AMP settings..."
|
||||
SETTINGS_PATH="$HOME/.config/amp/settings.json"
|
||||
mkdir -p "$(dirname "$SETTINGS_PATH")"
|
||||
|
||||
if [ -z "${ARG_AMP_CONFIG:-}" ]; then
|
||||
echo "No AMP config provided, skipping configuration"
|
||||
return
|
||||
fi
|
||||
|
||||
echo "Writing AMP configuration to $SETTINGS_PATH"
|
||||
printf '%s\n' "$ARG_AMP_CONFIG" > "$SETTINGS_PATH"
|
||||
|
||||
echo "AMP configuration complete"
|
||||
}
|
||||
|
||||
install_sourcegraph_amp
|
||||
setup_system_prompt
|
||||
configure_amp_settings
|
||||
@@ -0,0 +1,49 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
# Load user environment
|
||||
# shellcheck source=/dev/null
|
||||
source "$HOME/.bashrc"
|
||||
# shellcheck source=/dev/null
|
||||
if [ -f "$HOME/.nvm/nvm.sh" ]; then
|
||||
source "$HOME"/.nvm/nvm.sh
|
||||
else
|
||||
export PATH="$HOME/.npm-global/bin:$PATH"
|
||||
fi
|
||||
|
||||
function ensure_command() {
|
||||
command -v "$1" &> /dev/null || {
|
||||
echo "Error: '$1' not found." >&2
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
ensure_command amp
|
||||
echo "AMP version: $(amp --version)"
|
||||
|
||||
dir="$SOURCEGRAPH_AMP_START_DIRECTORY"
|
||||
if [[ -d "$dir" ]]; then
|
||||
echo "Using existing directory: $dir"
|
||||
else
|
||||
echo "Creating directory: $dir"
|
||||
mkdir -p "$dir"
|
||||
fi
|
||||
cd "$dir"
|
||||
|
||||
if [ -n "$SOURCEGRAPH_AMP_API_KEY" ]; then
|
||||
printf "sourcegraph_amp_api_key provided !\n"
|
||||
export AMP_API_KEY=$SOURCEGRAPH_AMP_API_KEY
|
||||
else
|
||||
printf "sourcegraph_amp_api_key not provided\n"
|
||||
fi
|
||||
|
||||
if [ -n "${SOURCEGRAPH_AMP_TASK_PROMPT:-}" ]; then
|
||||
printf "sourcegraph amp task prompt provided : $SOURCEGRAPH_AMP_TASK_PROMPT"
|
||||
PROMPT="Every step of the way, report tasks to Coder with proper descriptions and statuses. Your task at hand: $SOURCEGRAPH_AMP_TASK_PROMPT"
|
||||
|
||||
# Pipe the prompt into amp, which will be run inside agentapi
|
||||
agentapi server --term-width=67 --term-height=1190 -- bash -c "echo \"$PROMPT\" | amp"
|
||||
else
|
||||
printf "No task prompt given.\n"
|
||||
agentapi server --term-width=67 --term-height=1190 -- amp
|
||||
fi
|
||||
@@ -0,0 +1,14 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Mock behavior of the AMP CLI
|
||||
if [[ "$1" == "--version" ]]; then
|
||||
echo "AMP CLI mock version v1.0.0"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Simulate AMP running in a loop for AgentAPI to connect
|
||||
set -e
|
||||
while true; do
|
||||
echo "$(date) - AMP mock is running..."
|
||||
sleep 15
|
||||
done
|
||||
@@ -8,6 +8,10 @@ tags: [docker, container, dockerfile]
|
||||
|
||||
# Remote Development on Docker Containers (Build from Dockerfile)
|
||||
|
||||
> [!NOTE]
|
||||
> This template is designed to be a starting point for testing purposes.
|
||||
> In a production environment, you would want to move away from storing the Dockerfile in-template and move towards using a centralized image registry.
|
||||
|
||||
Build and provision Docker containers from a Dockerfile as [Coder workspaces](https://coder.com/docs/workspaces) with this example template.
|
||||
|
||||
This template builds a custom Docker image from the included Dockerfile, allowing you to customize the development environment by modifying the Dockerfile rather than using a pre-built image.
|
||||
@@ -18,7 +22,22 @@ This template builds a custom Docker image from the included Dockerfile, allowin
|
||||
|
||||
### Infrastructure
|
||||
|
||||
The VM you run Coder on must have a running Docker socket and the `coder` user must be added to the Docker group:
|
||||
#### Running Coder inside Docker
|
||||
|
||||
If you installed Coder as a container within Docker, you will have to do the following things:
|
||||
|
||||
- Make the the Docker socket available to the container
|
||||
- **(recommended) Mount `/var/run/docker.sock` via `--mount`/`volume`**
|
||||
- _(advanced) Restrict the Docker socket via https://github.com/Tecnativa/docker-socket-proxy_
|
||||
- Set `--group-add`/`group_add` to the GID of the Docker group on the **host** machine
|
||||
- You can get the GID by running `getent group docker` on the **host** machine
|
||||
|
||||
If you are using `docker-compose`, here is an example on how to do those things (don't forget to edit `group_add`!):
|
||||
https://github.com/coder/coder/blob/0bfe0d63aec83ae438bdcb77e306effd100dba3d/docker-compose.yaml#L16-L23
|
||||
|
||||
#### Running Coder outside of Docker
|
||||
|
||||
If you installed Coder as a system package, the VM you run Coder on must have a running Docker socket and the `coder` user must be added to the Docker group:
|
||||
|
||||
```sh
|
||||
# Add coder user to Docker group
|
||||
|
||||
@@ -63,7 +63,7 @@ Visit this URL for your Coder deployment:
|
||||
https://coder.example.com/templates/new?exampleId=scratch
|
||||
```
|
||||
|
||||
After creating the template, paste the contents from [main.tf](./main.tf) into the template editor and save.
|
||||
After creating the template, paste the contents from [main.tf](https://github.com/coder/registry/blob/main/registry/coder-labs/templates/tasks-docker/main.tf) into the template editor and save.
|
||||
|
||||
Alternatively, you can use the Coder CLI to [push the template](https://coder.com/docs/reference/cli/templates_push)
|
||||
|
||||
|
||||
@@ -181,7 +181,7 @@ resource "coder_env" "claude_task_prompt" {
|
||||
resource "coder_env" "app_status_slug" {
|
||||
agent_id = coder_agent.main.id
|
||||
name = "CODER_MCP_APP_STATUS_SLUG"
|
||||
value = "claude-code"
|
||||
value = "ccw"
|
||||
}
|
||||
resource "coder_env" "claude_system_prompt" {
|
||||
agent_id = coder_agent.main.id
|
||||
|
||||
@@ -16,7 +16,7 @@ The AgentAPI module is a building block for modules that need to run an AgentAPI
|
||||
```tf
|
||||
module "agentapi" {
|
||||
source = "registry.coder.com/coder/agentapi/coder"
|
||||
version = "1.1.0"
|
||||
version = "1.1.1"
|
||||
|
||||
agent_id = var.agent_id
|
||||
web_app_slug = local.app_slug
|
||||
|
||||
@@ -236,4 +236,17 @@ describe("agentapi", async () => {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("agentapi-allowed-hosts", async () => {
|
||||
// verify that the agentapi binary has access to the AGENTAPI_ALLOWED_HOSTS environment variable
|
||||
// set in main.sh
|
||||
const { id } = await setup();
|
||||
await execModuleScript(id);
|
||||
await expectAgentAPIStarted(id);
|
||||
const agentApiStartLog = await readFileContainer(
|
||||
id,
|
||||
"/home/coder/agentapi-mock.log",
|
||||
);
|
||||
expect(agentApiStartLog).toContain("AGENTAPI_ALLOWED_HOSTS: *");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -95,5 +95,7 @@ export LC_ALL=en_US.UTF-8
|
||||
cd "${WORKDIR}"
|
||||
|
||||
export AGENTAPI_CHAT_BASE_PATH="${AGENTAPI_CHAT_BASE_PATH:-}"
|
||||
# Disable host header check since AgentAPI is proxied by Coder (which does its own validation)
|
||||
export AGENTAPI_ALLOWED_HOSTS="*"
|
||||
nohup "$module_path/scripts/agentapi-start.sh" true "${AGENTAPI_PORT}" &>"$module_path/agentapi-start.log" &
|
||||
"$module_path/scripts/agentapi-wait-for-start.sh" "${AGENTAPI_PORT}"
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const http = require("http");
|
||||
const fs = require("fs");
|
||||
const args = process.argv.slice(2);
|
||||
const portIdx = args.findIndex((arg) => arg === "--port") + 1;
|
||||
const port = portIdx ? args[portIdx] : 3284;
|
||||
|
||||
console.log(`starting server on port ${port}`);
|
||||
fs.writeFileSync("/home/coder/agentapi-mock.log", `AGENTAPI_ALLOWED_HOSTS: ${process.env.AGENTAPI_ALLOWED_HOSTS}`);
|
||||
|
||||
http
|
||||
.createServer(function (_request, response) {
|
||||
|
||||
@@ -13,7 +13,7 @@ Run the [Claude Code](https://docs.anthropic.com/en/docs/agents-and-tools/claude
|
||||
```tf
|
||||
module "claude-code" {
|
||||
source = "registry.coder.com/coder/claude-code/coder"
|
||||
version = "2.0.6"
|
||||
version = "2.1.0"
|
||||
agent_id = coder_agent.example.id
|
||||
folder = "/home/coder"
|
||||
install_claude_code = true
|
||||
@@ -28,7 +28,6 @@ module "claude-code" {
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js and npm must be installed in your workspace to install Claude Code
|
||||
- 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.
|
||||
@@ -84,7 +83,7 @@ resource "coder_agent" "main" {
|
||||
module "claude-code" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/claude-code/coder"
|
||||
version = "2.0.6"
|
||||
version = "2.1.0"
|
||||
agent_id = coder_agent.example.id
|
||||
folder = "/home/coder"
|
||||
install_claude_code = true
|
||||
@@ -102,7 +101,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 = "2.0.6"
|
||||
version = "2.1.0"
|
||||
agent_id = coder_agent.example.id
|
||||
folder = "/home/coder"
|
||||
install_claude_code = true
|
||||
|
||||
@@ -10,6 +10,7 @@ import path from "path";
|
||||
import {
|
||||
execContainer,
|
||||
findResourceInstance,
|
||||
readFileContainer,
|
||||
removeContainer,
|
||||
runContainer,
|
||||
runTerraformApply,
|
||||
@@ -319,4 +320,21 @@ describe("claude-code", async () => {
|
||||
agentApiUrl: "http://localhost:3284",
|
||||
});
|
||||
});
|
||||
|
||||
// verify that the agentapi binary has access to the AGENTAPI_ALLOWED_HOSTS environment variable
|
||||
// set in main.tf
|
||||
test("agentapi-allowed-hosts", async () => {
|
||||
const { id } = await setup();
|
||||
|
||||
const respModuleScript = await execModuleScript(id);
|
||||
expect(respModuleScript.exitCode).toBe(0);
|
||||
|
||||
await expectAgentAPIStarted(id);
|
||||
|
||||
const agentApiStartLog = await readFileContainer(
|
||||
id,
|
||||
"/home/coder/agentapi-mock.log",
|
||||
);
|
||||
expect(agentApiStartLog).toContain("AGENTAPI_ALLOWED_HOSTS: *");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -111,7 +111,7 @@ locals {
|
||||
encoded_post_install_script = var.experiment_post_install_script != null ? base64encode(var.experiment_post_install_script) : ""
|
||||
agentapi_start_script_b64 = base64encode(file("${path.module}/scripts/agentapi-start.sh"))
|
||||
agentapi_wait_for_start_script_b64 = base64encode(file("${path.module}/scripts/agentapi-wait-for-start.sh"))
|
||||
remove_last_session_id_script_b64 = base64encode(file("${path.module}/scripts/remove-last-session-id.js"))
|
||||
remove_last_session_id_script_b64 = base64encode(file("${path.module}/scripts/remove-last-session-id.sh"))
|
||||
claude_code_app_slug = "ccw"
|
||||
}
|
||||
|
||||
@@ -129,6 +129,21 @@ resource "coder_script" "claude_code" {
|
||||
command -v "$1" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
function install_claude_code_cli() {
|
||||
echo "Installing Claude Code via official installer"
|
||||
set +e
|
||||
curl -fsSL claude.ai/install.sh | bash -s -- "${var.claude_code_version}" 2>&1
|
||||
CURL_EXIT=$${PIPESTATUS[0]}
|
||||
set -e
|
||||
if [ $CURL_EXIT -ne 0 ]; then
|
||||
echo "Claude Code installer failed with exit code $$CURL_EXIT"
|
||||
fi
|
||||
|
||||
# Ensure binaries are discoverable.
|
||||
export PATH="~/.local/bin:$PATH"
|
||||
echo "Installed Claude Code successfully. Version: $(claude --version || echo 'unknown')"
|
||||
}
|
||||
|
||||
if [ ! -d "${local.workdir}" ]; then
|
||||
echo "Warning: The specified folder '${local.workdir}' does not exist."
|
||||
echo "Creating the folder..."
|
||||
@@ -143,37 +158,7 @@ resource "coder_script" "claude_code" {
|
||||
fi
|
||||
|
||||
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
|
||||
fi
|
||||
echo "Installing Claude Code..."
|
||||
npm install -g @anthropic-ai/claude-code@${var.claude_code_version}
|
||||
fi
|
||||
|
||||
if ! command_exists node; then
|
||||
echo "Error: Node.js is not installed. Please install Node.js manually."
|
||||
exit 1
|
||||
install_claude_code_cli
|
||||
fi
|
||||
|
||||
# Install AgentAPI if enabled
|
||||
@@ -214,7 +199,7 @@ resource "coder_script" "claude_code" {
|
||||
|
||||
echo -n "${local.agentapi_start_script_b64}" | base64 -d > "$module_path/scripts/agentapi-start.sh"
|
||||
echo -n "${local.agentapi_wait_for_start_script_b64}" | base64 -d > "$module_path/scripts/agentapi-wait-for-start.sh"
|
||||
echo -n "${local.remove_last_session_id_script_b64}" | base64 -d > "$module_path/scripts/remove-last-session-id.js"
|
||||
echo -n "${local.remove_last_session_id_script_b64}" | base64 -d > "$module_path/scripts/remove-last-session-id.sh"
|
||||
chmod +x "$module_path/scripts/agentapi-start.sh"
|
||||
chmod +x "$module_path/scripts/agentapi-wait-for-start.sh"
|
||||
|
||||
@@ -241,6 +226,10 @@ resource "coder_script" "claude_code" {
|
||||
export LC_ALL=en_US.UTF-8
|
||||
|
||||
cd "${local.workdir}"
|
||||
|
||||
# Disable host header check since AgentAPI is proxied by Coder (which does its own validation)
|
||||
export AGENTAPI_ALLOWED_HOSTS="*"
|
||||
|
||||
nohup "$module_path/scripts/agentapi-start.sh" use_prompt &> "$module_path/agentapi-start.log" &
|
||||
"$module_path/scripts/agentapi-wait-for-start.sh"
|
||||
EOT
|
||||
@@ -288,4 +277,4 @@ resource "coder_ai_task" "claude_code" {
|
||||
sidebar_app {
|
||||
id = coder_app.claude_code_web.id
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,10 +19,10 @@ if [ -f "$log_file_path" ]; then
|
||||
mv "$log_file_path" "$log_file_path"".$(date +%s)"
|
||||
fi
|
||||
|
||||
# see the remove-last-session-id.js script for details
|
||||
# see the remove-last-session-id.sh script for details
|
||||
# about why we need it
|
||||
# avoid exiting if the script fails
|
||||
node "$scripts_dir/remove-last-session-id.js" "$(pwd)" || true
|
||||
bash "$scripts_dir/remove-last-session-id.sh" "$(pwd)" 2>/dev/null || true
|
||||
|
||||
# we'll be manually handling errors from this point on
|
||||
set +o errexit
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
// If lastSessionId is present in .claude.json, claude --continue will start a
|
||||
// conversation starting from that session. The problem is that lastSessionId
|
||||
// doesn't always point to the last session. The field is updated by claude only
|
||||
// at the point of normal CLI exit. If Claude exits with an error, or if the user
|
||||
// restarts the Coder workspace, lastSessionId will be stale, and claude --continue
|
||||
// will start from an old session.
|
||||
//
|
||||
// If lastSessionId is missing, claude seems to accurately figure out where to
|
||||
// start using the conversation history - even if the CLI previously exited with
|
||||
// an error.
|
||||
//
|
||||
// This script removes the lastSessionId field from .claude.json.
|
||||
const path = require("path")
|
||||
const fs = require("fs")
|
||||
|
||||
const workingDirArg = process.argv[2]
|
||||
if (!workingDirArg) {
|
||||
console.log("No working directory provided - it must be the first argument")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const workingDir = path.resolve(workingDirArg)
|
||||
console.log("workingDir", workingDir)
|
||||
|
||||
|
||||
const claudeJsonPath = path.join(process.env.HOME, ".claude.json")
|
||||
console.log(".claude.json path", claudeJsonPath)
|
||||
if (!fs.existsSync(claudeJsonPath)) {
|
||||
console.log("No .claude.json file found")
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const claudeJson = JSON.parse(fs.readFileSync(claudeJsonPath, "utf8"))
|
||||
if ("projects" in claudeJson && workingDir in claudeJson.projects && "lastSessionId" in claudeJson.projects[workingDir]) {
|
||||
delete claudeJson.projects[workingDir].lastSessionId
|
||||
fs.writeFileSync(claudeJsonPath, JSON.stringify(claudeJson, null, 2))
|
||||
console.log("Removed lastSessionId from .claude.json")
|
||||
} else {
|
||||
console.log("No lastSessionId found in .claude.json - nothing to do")
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
# If lastSessionId is present in .claude.json, claude --continue will start a
|
||||
# conversation starting from that session. The problem is that lastSessionId
|
||||
# doesn't always point to the last session. The field is updated by claude only
|
||||
# at the point of normal CLI exit. If Claude exits with an error, or if the user
|
||||
# restarts the Coder workspace, lastSessionId will be stale, and claude --continue
|
||||
# will start from an old session.
|
||||
#
|
||||
# If lastSessionId is missing, claude seems to accurately figure out where to
|
||||
# start using the conversation history - even if the CLI previously exited with
|
||||
# an error.
|
||||
#
|
||||
# This script removes the lastSessionId field from .claude.json.
|
||||
if [ $# -eq 0 ]; then
|
||||
echo "No working directory provided - it must be the first argument"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Get absolute path of working directory
|
||||
working_dir=$(realpath "$1")
|
||||
echo "workingDir $working_dir"
|
||||
|
||||
# Path to .claude.json
|
||||
claude_json_path="$HOME/.claude.json"
|
||||
echo ".claude.json path $claude_json_path"
|
||||
|
||||
# Check if .claude.json exists
|
||||
if [ ! -f "$claude_json_path" ]; then
|
||||
echo "No .claude.json file found"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Use jq to check if lastSessionId exists for the working directory and remove it
|
||||
|
||||
if jq -e ".projects[\"$working_dir\"].lastSessionId" "$claude_json_path" > /dev/null 2>&1; then
|
||||
# Remove lastSessionId and update the file
|
||||
jq "del(.projects[\"$working_dir\"].lastSessionId)" "$claude_json_path" > "${claude_json_path}.tmp" && mv "${claude_json_path}.tmp" "$claude_json_path"
|
||||
echo "Removed lastSessionId from .claude.json"
|
||||
else
|
||||
echo "No lastSessionId found in .claude.json - nothing to do"
|
||||
fi
|
||||
@@ -20,6 +20,8 @@ if (
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
fs.writeFileSync("/home/coder/agentapi-mock.log", `AGENTAPI_ALLOWED_HOSTS: ${process.env.AGENTAPI_ALLOWED_HOSTS}`);
|
||||
|
||||
console.log(`starting server on port ${port}`);
|
||||
|
||||
http
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
run "required_vars" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "foo"
|
||||
}
|
||||
}
|
||||
|
||||
run "offline_and_use_cached_conflict" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "foo"
|
||||
use_cached = true
|
||||
offline = true
|
||||
}
|
||||
|
||||
expect_failures = [
|
||||
resource.coder_script.code-server
|
||||
]
|
||||
}
|
||||
|
||||
run "offline_disallows_extensions" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "foo"
|
||||
offline = true
|
||||
extensions = ["ms-python.python", "golang.go"]
|
||||
}
|
||||
|
||||
expect_failures = [
|
||||
resource.coder_script.code-server
|
||||
]
|
||||
}
|
||||
|
||||
run "url_with_folder_query" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "foo"
|
||||
folder = "/home/coder/project"
|
||||
port = 13337
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = resource.coder_app.code-server.url == "http://localhost:13337/?folder=%2Fhome%2Fcoder%2Fproject"
|
||||
error_message = "coder_app URL must include encoded folder query param"
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@ Uses the [Coder Remote VS Code Extension](https://github.com/coder/vscode-coder)
|
||||
module "cursor" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/cursor/coder"
|
||||
version = "1.2.1"
|
||||
version = "1.3.1"
|
||||
agent_id = coder_agent.example.id
|
||||
}
|
||||
```
|
||||
@@ -29,8 +29,39 @@ module "cursor" {
|
||||
module "cursor" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/cursor/coder"
|
||||
version = "1.2.1"
|
||||
version = "1.3.1"
|
||||
agent_id = coder_agent.example.id
|
||||
folder = "/home/coder/project"
|
||||
}
|
||||
```
|
||||
|
||||
### Configure MCP servers for Cursor
|
||||
|
||||
Provide a JSON-encoded string via the `mcp` input. When set, the module writes the value to `~/.cursor/mcp.json` using a `coder_script` on workspace start.
|
||||
|
||||
The following example configures Cursor to use the GitHub MCP server with authentication facilitated by the [`coder_external_auth`](https://coder.com/docs/admin/external-auth#configure-a-github-oauth-app) resource.
|
||||
|
||||
```tf
|
||||
module "cursor" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/cursor/coder"
|
||||
version = "1.3.1"
|
||||
agent_id = coder_agent.example.id
|
||||
folder = "/home/coder/project"
|
||||
mcp = jsonencode({
|
||||
mcpServers = {
|
||||
"github" : {
|
||||
"url" : "https://api.githubcopilot.com/mcp/",
|
||||
"headers" : {
|
||||
"Authorization" : "Bearer ${data.coder_external_auth.github.access_token}",
|
||||
},
|
||||
"type" : "http"
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
data "coder_external_auth" "github" {
|
||||
id = "github"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { describe, it, expect } from "bun:test";
|
||||
import {
|
||||
runTerraformApply,
|
||||
runTerraformInit,
|
||||
testRequiredVariables,
|
||||
runContainer,
|
||||
execContainer,
|
||||
removeContainer,
|
||||
findResourceInstance,
|
||||
readFileContainer,
|
||||
} from "~test";
|
||||
|
||||
describe("cursor", async () => {
|
||||
@@ -85,4 +90,26 @@ describe("cursor", async () => {
|
||||
expect(coder_app?.instances.length).toBe(1);
|
||||
expect(coder_app?.instances[0].attributes.order).toBe(22);
|
||||
});
|
||||
|
||||
it("writes ~/.cursor/mcp.json when mcp provided", async () => {
|
||||
const id = await runContainer("alpine");
|
||||
try {
|
||||
const mcp = JSON.stringify({ servers: { demo: { url: "http://localhost:1234" } } });
|
||||
const state = await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "foo",
|
||||
mcp,
|
||||
});
|
||||
const script = findResourceInstance(state, "coder_script", "cursor_mcp").script;
|
||||
const resp = await execContainer(id, ["sh", "-c", script]);
|
||||
if (resp.exitCode !== 0) {
|
||||
console.log(resp.stdout);
|
||||
console.log(resp.stderr);
|
||||
}
|
||||
expect(resp.exitCode).toBe(0);
|
||||
const content = await readFileContainer(id, "/root/.cursor/mcp.json");
|
||||
expect(content).toBe(mcp);
|
||||
} finally {
|
||||
await removeContainer(id);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -50,9 +50,20 @@ variable "display_name" {
|
||||
default = "Cursor Desktop"
|
||||
}
|
||||
|
||||
variable "mcp" {
|
||||
type = string
|
||||
description = "JSON-encoded string to configure MCP servers for Cursor. When set, writes ~/.cursor/mcp.json."
|
||||
default = ""
|
||||
}
|
||||
|
||||
data "coder_workspace" "me" {}
|
||||
|
||||
data "coder_workspace_owner" "me" {}
|
||||
|
||||
locals {
|
||||
mcp_b64 = var.mcp != "" ? base64encode(var.mcp) : ""
|
||||
}
|
||||
|
||||
resource "coder_app" "cursor" {
|
||||
agent_id = var.agent_id
|
||||
external = true
|
||||
@@ -75,6 +86,21 @@ resource "coder_app" "cursor" {
|
||||
])
|
||||
}
|
||||
|
||||
resource "coder_script" "cursor_mcp" {
|
||||
count = var.mcp != "" ? 1 : 0
|
||||
agent_id = var.agent_id
|
||||
display_name = "Cursor MCP"
|
||||
icon = "/icon/cursor.svg"
|
||||
run_on_start = true
|
||||
start_blocks_login = false
|
||||
script = <<-EOT
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
mkdir -p "$HOME/.cursor"
|
||||
echo -n "${local.mcp_b64}" | base64 -d > "$HOME/.cursor/mcp.json"
|
||||
EOT
|
||||
}
|
||||
|
||||
output "cursor_url" {
|
||||
value = coder_app.cursor.url
|
||||
description = "Cursor IDE Desktop URL."
|
||||
|
||||
@@ -13,7 +13,7 @@ Run the [Goose](https://block.github.io/goose/) agent in your workspace to gener
|
||||
```tf
|
||||
module "goose" {
|
||||
source = "registry.coder.com/coder/goose/coder"
|
||||
version = "2.0.1"
|
||||
version = "2.1.1"
|
||||
agent_id = coder_agent.example.id
|
||||
folder = "/home/coder"
|
||||
install_goose = true
|
||||
@@ -79,7 +79,7 @@ resource "coder_agent" "main" {
|
||||
module "goose" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/goose/coder"
|
||||
version = "2.0.1"
|
||||
version = "2.1.1"
|
||||
agent_id = coder_agent.example.id
|
||||
folder = "/home/coder"
|
||||
install_goose = true
|
||||
|
||||
@@ -134,6 +134,7 @@ describe("goose", async () => {
|
||||
console.log(resp.stderr);
|
||||
}
|
||||
expect(resp.exitCode).toBe(0);
|
||||
await expectAgentAPIStarted(id);
|
||||
});
|
||||
|
||||
test("config", async () => {
|
||||
@@ -251,4 +252,21 @@ describe("goose", async () => {
|
||||
expect(prompt.exitCode).not.toBe(0);
|
||||
expect(prompt.stderr).toContain("No such file or directory");
|
||||
});
|
||||
|
||||
test("subdomain-false", async () => {
|
||||
const { id } = await setup({
|
||||
agentapiMockScript: await loadTestFile(
|
||||
import.meta.dir,
|
||||
"agentapi-mock-print-args.js",
|
||||
),
|
||||
moduleVariables: {
|
||||
subdomain: "false",
|
||||
},
|
||||
});
|
||||
|
||||
await execModuleScript(id);
|
||||
|
||||
const agentapiMockOutput = await readFileContainer(id, agentapiStartLog);
|
||||
expect(agentapiMockOutput).toContain("AGENTAPI_CHAT_BASE_PATH=/@default/default.foo/apps/goose/chat");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -63,7 +63,13 @@ variable "install_agentapi" {
|
||||
variable "agentapi_version" {
|
||||
type = string
|
||||
description = "The version of AgentAPI to install."
|
||||
default = "v0.2.3"
|
||||
default = "v0.3.3"
|
||||
}
|
||||
|
||||
variable "subdomain" {
|
||||
type = bool
|
||||
description = "Whether to use a subdomain for AgentAPI."
|
||||
default = true
|
||||
}
|
||||
|
||||
variable "goose_provider" {
|
||||
@@ -133,7 +139,7 @@ EOT
|
||||
|
||||
module "agentapi" {
|
||||
source = "registry.coder.com/coder/agentapi/coder"
|
||||
version = "1.0.0"
|
||||
version = "1.1.1"
|
||||
|
||||
agent_id = var.agent_id
|
||||
web_app_slug = local.app_slug
|
||||
@@ -146,6 +152,7 @@ module "agentapi" {
|
||||
module_dir_name = local.module_dir_name
|
||||
install_agentapi = var.install_agentapi
|
||||
agentapi_version = var.agentapi_version
|
||||
agentapi_subdomain = var.subdomain
|
||||
pre_install_script = var.pre_install_script
|
||||
post_install_script = var.post_install_script
|
||||
start_script = local.start_script
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
const http = require("http");
|
||||
const args = process.argv.slice(2);
|
||||
console.log(args);
|
||||
console.log(`AGENTAPI_CHAT_BASE_PATH=${process.env["AGENTAPI_CHAT_BASE_PATH"]}`);
|
||||
const port = 3284;
|
||||
|
||||
console.log(`starting server on port ${port}`);
|
||||
|
||||
@@ -14,7 +14,7 @@ This module adds JetBrains IDE buttons to launch IDEs directly from the dashboar
|
||||
module "jetbrains" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/jetbrains/coder"
|
||||
version = "1.0.1"
|
||||
version = "1.0.3"
|
||||
agent_id = coder_agent.example.id
|
||||
folder = "/home/coder/project"
|
||||
}
|
||||
@@ -39,7 +39,7 @@ When `default` contains IDE codes, those IDEs are created directly without user
|
||||
module "jetbrains" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/jetbrains/coder"
|
||||
version = "1.0.1"
|
||||
version = "1.0.3"
|
||||
agent_id = coder_agent.example.id
|
||||
folder = "/home/coder/project"
|
||||
default = ["PY", "IU"] # Pre-configure GoLand and IntelliJ IDEA
|
||||
@@ -52,7 +52,7 @@ module "jetbrains" {
|
||||
module "jetbrains" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/jetbrains/coder"
|
||||
version = "1.0.1"
|
||||
version = "1.0.3"
|
||||
agent_id = coder_agent.example.id
|
||||
folder = "/home/coder/project"
|
||||
# Show parameter with limited options
|
||||
@@ -66,7 +66,7 @@ module "jetbrains" {
|
||||
module "jetbrains" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/jetbrains/coder"
|
||||
version = "1.0.1"
|
||||
version = "1.0.3"
|
||||
agent_id = coder_agent.example.id
|
||||
folder = "/home/coder/project"
|
||||
default = ["IU", "PY"]
|
||||
@@ -81,7 +81,7 @@ module "jetbrains" {
|
||||
module "jetbrains" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/jetbrains/coder"
|
||||
version = "1.0.1"
|
||||
version = "1.0.3"
|
||||
agent_id = coder_agent.example.id
|
||||
folder = "/workspace/project"
|
||||
|
||||
@@ -107,7 +107,7 @@ module "jetbrains" {
|
||||
module "jetbrains_pycharm" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/jetbrains/coder"
|
||||
version = "1.0.1"
|
||||
version = "1.0.3"
|
||||
agent_id = coder_agent.example.id
|
||||
folder = "/workspace/project"
|
||||
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
run "requires_agent_and_folder" {
|
||||
command = plan
|
||||
|
||||
# Setting both required vars should plan
|
||||
variables {
|
||||
agent_id = "foo"
|
||||
folder = "/home/coder"
|
||||
}
|
||||
}
|
||||
|
||||
run "creates_parameter_when_default_empty_latest" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "foo"
|
||||
folder = "/home/coder"
|
||||
major_version = "latest"
|
||||
}
|
||||
|
||||
# When default is empty, a coder_parameter should be created
|
||||
assert {
|
||||
condition = can(data.coder_parameter.jetbrains_ides[0].type)
|
||||
error_message = "Expected data.coder_parameter.jetbrains_ides to exist when default is empty"
|
||||
}
|
||||
}
|
||||
|
||||
run "no_apps_when_default_empty" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "foo"
|
||||
folder = "/home/coder"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = length(resource.coder_app.jetbrains) == 0
|
||||
error_message = "Expected no coder_app resources when default is empty"
|
||||
}
|
||||
}
|
||||
|
||||
run "single_app_when_default_GO" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "foo"
|
||||
folder = "/home/coder"
|
||||
default = ["GO"]
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = length(resource.coder_app.jetbrains) == 1
|
||||
error_message = "Expected exactly one coder_app when default contains GO"
|
||||
}
|
||||
}
|
||||
|
||||
run "url_contains_required_params" {
|
||||
command = apply
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent-123"
|
||||
folder = "/custom/project/path"
|
||||
default = ["GO"]
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = anytrue([for app in values(resource.coder_app.jetbrains) : length(regexall("jetbrains://gateway/coder", app.url)) > 0])
|
||||
error_message = "URL must contain jetbrains scheme"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = anytrue([for app in values(resource.coder_app.jetbrains) : length(regexall("&folder=/custom/project/path", app.url)) > 0])
|
||||
error_message = "URL must include folder path"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = anytrue([for app in values(resource.coder_app.jetbrains) : length(regexall("ide_product_code=GO", app.url)) > 0])
|
||||
error_message = "URL must include product code"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = anytrue([for app in values(resource.coder_app.jetbrains) : length(regexall("ide_build_number=", app.url)) > 0])
|
||||
error_message = "URL must include build number"
|
||||
}
|
||||
}
|
||||
|
||||
run "includes_agent_name_when_set" {
|
||||
command = apply
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent-123"
|
||||
agent_name = "main-agent"
|
||||
folder = "/custom/project/path"
|
||||
default = ["GO"]
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = anytrue([for app in values(resource.coder_app.jetbrains) : length(regexall("&agent_name=main-agent", app.url)) > 0])
|
||||
error_message = "URL must include agent_name when provided"
|
||||
}
|
||||
}
|
||||
|
||||
run "parameter_order_when_default_empty" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "foo"
|
||||
folder = "/home/coder"
|
||||
coder_parameter_order = 5
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = data.coder_parameter.jetbrains_ides[0].order == 5
|
||||
error_message = "Expected coder_parameter order to be set to 5"
|
||||
}
|
||||
}
|
||||
|
||||
run "app_order_when_default_not_empty" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "foo"
|
||||
folder = "/home/coder"
|
||||
default = ["GO"]
|
||||
coder_app_order = 10
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = anytrue([for app in values(resource.coder_app.jetbrains) : app.order == 10])
|
||||
error_message = "Expected coder_app order to be set to 10"
|
||||
}
|
||||
}
|
||||
@@ -202,6 +202,7 @@ data "coder_parameter" "jetbrains_ides" {
|
||||
count = length(var.default) == 0 ? 1 : 0
|
||||
type = "list(string)"
|
||||
name = "jetbrains_ides"
|
||||
description = "Select which JetBrains IDEs to configure for use in this workspace."
|
||||
display_name = "JetBrains IDEs"
|
||||
icon = "/icon/jetbrains-toolbox.svg"
|
||||
mutable = true
|
||||
@@ -230,6 +231,7 @@ resource "coder_app" "jetbrains" {
|
||||
icon = local.options_metadata[each.key].icon
|
||||
external = true
|
||||
order = var.coder_app_order
|
||||
group = var.group
|
||||
url = join("", [
|
||||
"jetbrains://gateway/coder?&workspace=", # requires 2.6.3+ version of Toolbox
|
||||
data.coder_workspace.me.name,
|
||||
|
||||
@@ -16,7 +16,32 @@ A module that adds JupyterLab in your Coder template.
|
||||
module "jupyterlab" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/jupyterlab/coder"
|
||||
version = "1.1.1"
|
||||
version = "1.2.0"
|
||||
agent_id = coder_agent.example.id
|
||||
}
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
JupyterLab is automatically configured to work with Coder's iframe embedding. For advanced configuration, you can use the `config` parameter to provide additional JupyterLab server settings according to the [JupyterLab configuration documentation](https://jupyter-server.readthedocs.io/en/latest/users/configuration.html).
|
||||
|
||||
```tf
|
||||
module "jupyterlab" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/jupyterlab/coder"
|
||||
version = "1.2.0"
|
||||
agent_id = coder_agent.example.id
|
||||
config = {
|
||||
ServerApp = {
|
||||
# Required for Coder Tasks iFrame embedding - do not remove
|
||||
tornado_settings = {
|
||||
headers = {
|
||||
"Content-Security-Policy" = "frame-ancestors 'self' ${data.coder_workspace.me.access_url}"
|
||||
}
|
||||
}
|
||||
# Your additional configuration here
|
||||
root_dir = "/workspace/notebooks"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -3,6 +3,8 @@ import {
|
||||
execContainer,
|
||||
executeScriptInContainer,
|
||||
findResourceInstance,
|
||||
readFileContainer,
|
||||
removeContainer,
|
||||
runContainer,
|
||||
runTerraformApply,
|
||||
runTerraformInit,
|
||||
@@ -104,4 +106,57 @@ describe("jupyterlab", async () => {
|
||||
// const output = await executeScriptInContainerWithPip(state, "alpine");
|
||||
// ...
|
||||
// });
|
||||
|
||||
it("writes ~/.jupyter/jupyter_server_config.json when config provided", async () => {
|
||||
const id = await runContainer("alpine");
|
||||
try {
|
||||
const config = {
|
||||
ServerApp: {
|
||||
port: 8888,
|
||||
token: "test-token",
|
||||
password: "",
|
||||
allow_origin: "*"
|
||||
}
|
||||
};
|
||||
const configJson = JSON.stringify(config);
|
||||
const state = await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "foo",
|
||||
config: configJson,
|
||||
});
|
||||
const script = findResourceInstance(state, "coder_script", "jupyterlab_config").script;
|
||||
const resp = await execContainer(id, ["sh", "-c", script]);
|
||||
if (resp.exitCode !== 0) {
|
||||
console.log(resp.stdout);
|
||||
console.log(resp.stderr);
|
||||
}
|
||||
expect(resp.exitCode).toBe(0);
|
||||
const content = await readFileContainer(id, "/root/.jupyter/jupyter_server_config.json");
|
||||
// Parse both JSON strings and compare objects to avoid key ordering issues
|
||||
const actualConfig = JSON.parse(content);
|
||||
expect(actualConfig).toEqual(config);
|
||||
} finally {
|
||||
await removeContainer(id);
|
||||
}
|
||||
});
|
||||
|
||||
it("creates config script with CSP fallback when config is empty", async () => {
|
||||
const state = await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "foo",
|
||||
config: "{}",
|
||||
});
|
||||
const configScripts = state.resources.filter(
|
||||
(res) => res.type === "coder_script" && res.name === "jupyterlab_config"
|
||||
);
|
||||
expect(configScripts.length).toBe(1);
|
||||
});
|
||||
|
||||
it("creates config script with CSP fallback when config is not provided", async () => {
|
||||
const state = await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "foo",
|
||||
});
|
||||
const configScripts = state.resources.filter(
|
||||
(res) => res.type === "coder_script" && res.name === "jupyterlab_config"
|
||||
);
|
||||
expect(configScripts.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,6 +12,23 @@ terraform {
|
||||
data "coder_workspace" "me" {}
|
||||
data "coder_workspace_owner" "me" {}
|
||||
|
||||
locals {
|
||||
# Fallback config with CSP for Coder iframe embedding when user config is empty
|
||||
csp_fallback_config = {
|
||||
ServerApp = {
|
||||
tornado_settings = {
|
||||
headers = {
|
||||
"Content-Security-Policy" = "frame-ancestors 'self' ${data.coder_workspace.me.access_url}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Use user config if provided, otherwise fallback to CSP config
|
||||
config_json = var.config == "{}" ? jsonencode(local.csp_fallback_config) : var.config
|
||||
config_b64 = base64encode(local.config_json)
|
||||
}
|
||||
|
||||
# Add required variables for your modules and remove any unneeded variables
|
||||
variable "agent_id" {
|
||||
type = string
|
||||
@@ -57,6 +74,26 @@ variable "group" {
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "config" {
|
||||
type = string
|
||||
description = "A JSON string of JupyterLab server configuration settings. When set, writes ~/.jupyter/jupyter_server_config.json."
|
||||
default = "{}"
|
||||
}
|
||||
|
||||
resource "coder_script" "jupyterlab_config" {
|
||||
agent_id = var.agent_id
|
||||
display_name = "JupyterLab Config"
|
||||
icon = "/icon/jupyter.svg"
|
||||
run_on_start = true
|
||||
start_blocks_login = false
|
||||
script = <<-EOT
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
mkdir -p "$HOME/.jupyter"
|
||||
echo -n "${local.config_b64}" | base64 -d > "$HOME/.jupyter/jupyter_server_config.json"
|
||||
EOT
|
||||
}
|
||||
|
||||
resource "coder_script" "jupyterlab" {
|
||||
agent_id = var.agent_id
|
||||
display_name = "jupyterlab"
|
||||
@@ -79,4 +116,9 @@ resource "coder_app" "jupyterlab" {
|
||||
share = var.share
|
||||
order = var.order
|
||||
group = var.group
|
||||
healthcheck {
|
||||
url = "http://localhost:${var.port}/api"
|
||||
interval = 5
|
||||
threshold = 6
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ Automatically install [KasmVNC](https://kasmweb.com/kasmvnc) in a workspace, and
|
||||
module "kasmvnc" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/kasmvnc/coder"
|
||||
version = "1.2.1"
|
||||
version = "1.2.2"
|
||||
agent_id = coder_agent.example.id
|
||||
desktop_environment = "xfce"
|
||||
subdomain = true
|
||||
|
||||
@@ -240,7 +240,7 @@ get_http_dir() {
|
||||
|
||||
# Check the home directory for overriding values
|
||||
if [[ -e "$HOME/.vnc/kasmvnc.yaml" ]]; then
|
||||
d=($(grep -E "^\s*httpd_directory:.*$" /etc/kasmvnc/kasmvnc.yaml))
|
||||
d=($(grep -E "^\s*httpd_directory:.*$" "$HOME/.vnc/kasmvnc.yaml"))
|
||||
if [[ $${#d[@]} -eq 2 && -d "$${d[1]}" ]]; then
|
||||
httpd_directory="$${d[1]}"
|
||||
fi
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
---
|
||||
display_name: VSCode Desktop Core
|
||||
description: Building block for modules that need to link to an external VSCode-based IDE
|
||||
icon: ../../../../.icons/coder.svg
|
||||
verified: true
|
||||
tags: [internal, library]
|
||||
---
|
||||
|
||||
# VS Code Desktop Core
|
||||
|
||||
> [!CAUTION]
|
||||
> We do not recommend using this module directly. Instead, please consider using one of our [Desktop IDE modules](https://registry.coder.com/modules?search=tag%3Aide).
|
||||
|
||||
The VSCode Desktop Core module is a building block for modules that need to expose access to VSCode-based IDEs. It is intended primarily to be used as a library to create modules for VSCode-based IDEs.
|
||||
|
||||
```tf
|
||||
module "vscode-desktop-core" {
|
||||
source = "registry.coder.com/coder/vscode-desktop-core/coder"
|
||||
version = "1.0.0"
|
||||
|
||||
agent_id = var.agent_id
|
||||
|
||||
coder_app_icon = "/icon/code.svg"
|
||||
coder_app_slug = "vscode"
|
||||
coder_app_display_name = "VS Code Desktop"
|
||||
coder_app_order = var.order
|
||||
coder_app_group = var.group
|
||||
|
||||
folder = var.folder
|
||||
open_recent = var.open_recent
|
||||
protocol = "vscode"
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import {
|
||||
runTerraformApply,
|
||||
runTerraformInit,
|
||||
testRequiredVariables,
|
||||
} from "~test";
|
||||
|
||||
// hardcoded coder_app name in main.tf
|
||||
const appName = "vscode-desktop";
|
||||
|
||||
const defaultVariables = {
|
||||
agent_id: "foo",
|
||||
coder_app_icon: "/icon/code.svg",
|
||||
coder_app_slug: "vscode",
|
||||
coder_app_display_name: "VS Code Desktop",
|
||||
protocol: "vscode",
|
||||
}
|
||||
|
||||
describe("vscode-desktop-core", async () => {
|
||||
await runTerraformInit(import.meta.dir);
|
||||
|
||||
testRequiredVariables(import.meta.dir, defaultVariables);
|
||||
|
||||
it("default output", async () => {
|
||||
const state = await runTerraformApply(import.meta.dir, defaultVariables);
|
||||
expect(state.outputs.ide_uri.value).toBe(
|
||||
`${defaultVariables.protocol}://coder.coder-remote/open?owner=default&workspace=default&url=https://mydeployment.coder.com&token=$SESSION_TOKEN`,
|
||||
);
|
||||
|
||||
const coder_app = state.resources.find(
|
||||
(res) => res.type === "coder_app" && res.name === appName,
|
||||
);
|
||||
|
||||
expect(coder_app).not.toBeNull();
|
||||
expect(coder_app?.instances.length).toBe(1);
|
||||
expect(coder_app?.instances[0].attributes.order).toBeNull();
|
||||
});
|
||||
|
||||
it("adds folder", async () => {
|
||||
const state = await runTerraformApply(import.meta.dir, {
|
||||
folder: "/foo/bar",
|
||||
|
||||
...defaultVariables
|
||||
});
|
||||
|
||||
expect(state.outputs.ide_uri.value).toBe(
|
||||
`${defaultVariables.protocol}://coder.coder-remote/open?owner=default&workspace=default&folder=/foo/bar&url=https://mydeployment.coder.com&token=$SESSION_TOKEN`,
|
||||
);
|
||||
});
|
||||
|
||||
it("adds folder and open_recent", async () => {
|
||||
const state = await runTerraformApply(import.meta.dir, {
|
||||
folder: "/foo/bar",
|
||||
open_recent: "true",
|
||||
|
||||
...defaultVariables,
|
||||
});
|
||||
expect(state.outputs.ide_uri.value).toBe(
|
||||
`${defaultVariables.protocol}://coder.coder-remote/open?owner=default&workspace=default&folder=/foo/bar&openRecent&url=https://mydeployment.coder.com&token=$SESSION_TOKEN`,
|
||||
);
|
||||
});
|
||||
|
||||
it("adds folder but not open_recent", async () => {
|
||||
const state = await runTerraformApply(import.meta.dir, {
|
||||
folder: "/foo/bar",
|
||||
openRecent: "false",
|
||||
|
||||
...defaultVariables,
|
||||
});
|
||||
expect(state.outputs.ide_uri.value).toBe(
|
||||
`${defaultVariables.protocol}://coder.coder-remote/open?owner=default&workspace=default&folder=/foo/bar&url=https://mydeployment.coder.com&token=$SESSION_TOKEN`,
|
||||
);
|
||||
});
|
||||
|
||||
it("adds open_recent", async () => {
|
||||
const state = await runTerraformApply(import.meta.dir, {
|
||||
open_recent: "true",
|
||||
|
||||
...defaultVariables,
|
||||
});
|
||||
expect(state.outputs.ide_uri.value).toBe(
|
||||
`${defaultVariables.protocol}://coder.coder-remote/open?owner=default&workspace=default&openRecent&url=https://mydeployment.coder.com&token=$SESSION_TOKEN`,
|
||||
);
|
||||
});
|
||||
|
||||
it("expect order to be set", async () => {
|
||||
const state = await runTerraformApply(import.meta.dir, {
|
||||
coder_app_order: "22",
|
||||
...defaultVariables
|
||||
});
|
||||
|
||||
const coder_app = state.resources.find(
|
||||
(res) => res.type === "coder_app" && res.name === appName,
|
||||
);
|
||||
|
||||
expect(coder_app).not.toBeNull();
|
||||
expect(coder_app?.instances.length).toBe(1);
|
||||
expect(coder_app?.instances[0].attributes.order).toBe(22);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
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 "folder" {
|
||||
type = string
|
||||
description = "The folder to open in the IDE."
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "open_recent" {
|
||||
type = bool
|
||||
description = "Open the most recent workspace or folder. Falls back to the folder if there is no recent workspace or folder to open."
|
||||
default = false
|
||||
}
|
||||
|
||||
variable "protocol" {
|
||||
type = string
|
||||
description = "The URI protocol for the IDE."
|
||||
}
|
||||
|
||||
variable "coder_app_icon" {
|
||||
type = string
|
||||
description = "The icon of the coder_app."
|
||||
}
|
||||
|
||||
variable "coder_app_slug" {
|
||||
type = string
|
||||
description = "The slug of the coder_app."
|
||||
}
|
||||
|
||||
variable "coder_app_display_name" {
|
||||
type = string
|
||||
description = "The display name of the coder_app."
|
||||
}
|
||||
|
||||
variable "coder_app_order" {
|
||||
type = number
|
||||
description = "The order of the coder_app."
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "coder_app_group" {
|
||||
type = string
|
||||
description = "The group of the coder_app."
|
||||
default = null
|
||||
}
|
||||
|
||||
data "coder_workspace" "me" {}
|
||||
data "coder_workspace_owner" "me" {}
|
||||
|
||||
resource "coder_app" "vscode-desktop" {
|
||||
agent_id = var.agent_id
|
||||
external = true
|
||||
|
||||
icon = var.coder_app_icon
|
||||
slug = var.coder_app_slug
|
||||
display_name = var.coder_app_display_name
|
||||
|
||||
order = var.coder_app_order
|
||||
group = var.coder_app_group
|
||||
|
||||
# While the call to "join" is not strictly necessary, it makes the URL more readable.
|
||||
url = join("", [
|
||||
"${var.protocol}://coder.coder-remote/open",
|
||||
"?owner=${data.coder_workspace_owner.me.name}",
|
||||
"&workspace=${data.coder_workspace.me.name}",
|
||||
var.folder != "" ? join("", ["&folder=", var.folder]) : "",
|
||||
var.open_recent ? "&openRecent" : "",
|
||||
"&url=${data.coder_workspace.me.access_url}",
|
||||
# NOTE: There is a protocol whitelist for the token replacement, so this will only work with the protocols hardcoded in the front-end.
|
||||
# (https://github.com/coder/coder/blob/6ba4b5bbc95e2e528d7f5b1e31fffa200ae1a6db/site/src/modules/apps/apps.ts#L18)
|
||||
"&token=$SESSION_TOKEN",
|
||||
])
|
||||
}
|
||||
|
||||
output "ide_uri" {
|
||||
value = coder_app.vscode-desktop.url
|
||||
description = "IDE URI."
|
||||
}
|
||||
@@ -14,7 +14,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.1"
|
||||
version = "1.4.1"
|
||||
agent_id = coder_agent.example.id
|
||||
accept_license = true
|
||||
}
|
||||
@@ -30,7 +30,7 @@ module "vscode-web" {
|
||||
module "vscode-web" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/vscode-web/coder"
|
||||
version = "1.3.1"
|
||||
version = "1.4.1"
|
||||
agent_id = coder_agent.example.id
|
||||
install_prefix = "/home/coder/.vscode-web"
|
||||
folder = "/home/coder"
|
||||
@@ -44,7 +44,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.1"
|
||||
version = "1.4.1"
|
||||
agent_id = coder_agent.example.id
|
||||
extensions = ["github.copilot", "ms-python.python", "ms-toolsai.jupyter"]
|
||||
accept_license = true
|
||||
@@ -59,7 +59,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.1"
|
||||
version = "1.4.1"
|
||||
agent_id = coder_agent.example.id
|
||||
extensions = ["dracula-theme.theme-dracula"]
|
||||
settings = {
|
||||
@@ -77,9 +77,24 @@ 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.1"
|
||||
version = "1.4.1"
|
||||
agent_id = coder_agent.example.id
|
||||
commit_id = "e54c774e0add60467559eb0d1e229c6452cf8447"
|
||||
accept_license = true
|
||||
}
|
||||
```
|
||||
|
||||
### Open an existing workspace on startup
|
||||
|
||||
To open an existing workspace on startup the `workspace` parameter can be used to represent a path on disk to a `code-workspace` file.
|
||||
Note: Either `workspace` or `folder` can be used, but not both simultaneously. The `code-workspace` file must already be present on disk.
|
||||
|
||||
```tf
|
||||
module "vscode-web" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/vscode-web/coder"
|
||||
version = "1.4.1"
|
||||
agent_id = coder_agent.example.id
|
||||
workspace = "/home/coder/coder.code-workspace"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -158,6 +158,12 @@ variable "platform" {
|
||||
}
|
||||
}
|
||||
|
||||
variable "workspace" {
|
||||
type = string
|
||||
description = "Path to a .code-workspace file to open in vscode-web."
|
||||
default = ""
|
||||
}
|
||||
|
||||
data "coder_workspace_owner" "me" {}
|
||||
data "coder_workspace" "me" {}
|
||||
|
||||
@@ -178,6 +184,7 @@ resource "coder_script" "vscode-web" {
|
||||
DISABLE_TRUST : var.disable_trust,
|
||||
EXTENSIONS_DIR : var.extensions_dir,
|
||||
FOLDER : var.folder,
|
||||
WORKSPACE : var.workspace,
|
||||
AUTO_INSTALL_EXTENSIONS : var.auto_install_extensions,
|
||||
SERVER_BASE_PATH : local.server_base_path,
|
||||
COMMIT_ID : var.commit_id,
|
||||
@@ -195,6 +202,11 @@ resource "coder_script" "vscode-web" {
|
||||
condition = !var.offline || !var.use_cached
|
||||
error_message = "Offline and Use Cached can not be used together"
|
||||
}
|
||||
|
||||
precondition {
|
||||
condition = (var.workspace == "" || var.folder == "")
|
||||
error_message = "Set only one of `workspace` or `folder`."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,6 +230,12 @@ resource "coder_app" "vscode-web" {
|
||||
|
||||
locals {
|
||||
server_base_path = var.subdomain ? "" : format("/@%s/%s/apps/%s/", data.coder_workspace_owner.me.name, data.coder_workspace.me.name, var.slug)
|
||||
url = var.folder == "" ? "http://localhost:${var.port}${local.server_base_path}" : "http://localhost:${var.port}${local.server_base_path}?folder=${var.folder}"
|
||||
healthcheck_url = var.subdomain ? "http://localhost:${var.port}/healthz" : "http://localhost:${var.port}${local.server_base_path}/healthz"
|
||||
url = (
|
||||
var.workspace != "" ?
|
||||
"http://localhost:${var.port}${local.server_base_path}?workspace=${urlencode(var.workspace)}" :
|
||||
var.folder != "" ?
|
||||
"http://localhost:${var.port}${local.server_base_path}?folder=${urlencode(var.folder)}" :
|
||||
"http://localhost:${var.port}${local.server_base_path}"
|
||||
)
|
||||
healthcheck_url = var.subdomain ? "http://localhost:${var.port}/healthz" : "http://localhost:${var.port}${local.server_base_path}/healthz"
|
||||
}
|
||||
|
||||
@@ -109,18 +109,27 @@ if [ "${AUTO_INSTALL_EXTENSIONS}" = true ]; then
|
||||
if ! command -v jq > /dev/null; then
|
||||
echo "jq is required to install extensions from a workspace file."
|
||||
else
|
||||
WORKSPACE_DIR="$HOME"
|
||||
if [ -n "${FOLDER}" ]; then
|
||||
WORKSPACE_DIR="${FOLDER}"
|
||||
fi
|
||||
|
||||
if [ -f "$WORKSPACE_DIR/.vscode/extensions.json" ]; then
|
||||
printf "🧩 Installing extensions from %s/.vscode/extensions.json...\n" "$WORKSPACE_DIR"
|
||||
# Use sed to remove single-line comments before parsing with jq
|
||||
extensions=$(sed 's|//.*||g' "$WORKSPACE_DIR"/.vscode/extensions.json | jq -r '.recommendations[]')
|
||||
# Prefer WORKSPACE if set and points to a file
|
||||
if [ -n "${WORKSPACE}" ] && [ -f "${WORKSPACE}" ]; then
|
||||
printf "🧩 Installing extensions from %s...\n" "${WORKSPACE}"
|
||||
# Strip single-line comments then parse .extensions.recommendations[]
|
||||
extensions=$(sed 's|//.*||g' "${WORKSPACE}" | jq -r '(.extensions.recommendations // [])[]')
|
||||
for extension in $extensions; do
|
||||
$VSCODE_WEB "$EXTENSION_ARG" --install-extension "$extension" --force
|
||||
done
|
||||
else
|
||||
# Fallback to folder-based .vscode/extensions.json (existing behavior)
|
||||
WORKSPACE_DIR="$HOME"
|
||||
if [ -n "${FOLDER}" ]; then
|
||||
WORKSPACE_DIR="${FOLDER}"
|
||||
fi
|
||||
if [ -f "$WORKSPACE_DIR/.vscode/extensions.json" ]; then
|
||||
printf "🧩 Installing extensions from %s/.vscode/extensions.json...\n" "$WORKSPACE_DIR"
|
||||
extensions=$(sed 's|//.*||g' "$WORKSPACE_DIR/.vscode/extensions.json" | jq -r '.recommendations[]')
|
||||
for extension in $extensions; do
|
||||
$VSCODE_WEB "$EXTENSION_ARG" --install-extension "$extension" --force
|
||||
done
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -19,7 +19,7 @@ Zed is a high-performance, multiplayer code editor from the creators of Atom and
|
||||
module "zed" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/zed/coder"
|
||||
version = "1.0.1"
|
||||
version = "1.1.0"
|
||||
agent_id = coder_agent.example.id
|
||||
}
|
||||
```
|
||||
@@ -32,7 +32,7 @@ module "zed" {
|
||||
module "zed" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/zed/coder"
|
||||
version = "1.0.1"
|
||||
version = "1.1.0"
|
||||
agent_id = coder_agent.example.id
|
||||
folder = "/home/coder/project"
|
||||
}
|
||||
@@ -44,7 +44,7 @@ module "zed" {
|
||||
module "zed" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/zed/coder"
|
||||
version = "1.0.1"
|
||||
version = "1.1.0"
|
||||
agent_id = coder_agent.example.id
|
||||
display_name = "Zed Editor"
|
||||
order = 1
|
||||
@@ -57,8 +57,36 @@ module "zed" {
|
||||
module "zed" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/zed/coder"
|
||||
version = "1.0.1"
|
||||
version = "1.1.0"
|
||||
agent_id = coder_agent.example.id
|
||||
agent_name = coder_agent.example.name
|
||||
}
|
||||
```
|
||||
|
||||
### Configure Zed settings including MCP servers
|
||||
|
||||
Zed stores settings at `~/.config/zed/settings.json` by default. If `XDG_CONFIG_HOME` is set on Linux, settings will be at `$XDG_CONFIG_HOME/zed/settings.json`.
|
||||
|
||||
You can declaratively set/merge settings with the `settings` input. Provide a JSON string (e.g., via `jsonencode(...)`). For example, to configure MCP servers:
|
||||
|
||||
```tf
|
||||
module "zed" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/zed/coder"
|
||||
version = "1.1.0"
|
||||
agent_id = coder_agent.example.id
|
||||
|
||||
settings = jsonencode({
|
||||
context_servers = {
|
||||
your-mcp-server = {
|
||||
source = "custom"
|
||||
command = "some-command"
|
||||
args = ["arg-1", "arg-2"]
|
||||
env = {}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
See Zed’s settings files documentation: https://zed.dev/docs/configuring-zed#settings-files
|
||||
|
||||
@@ -50,7 +50,14 @@ variable "display_name" {
|
||||
default = "Zed"
|
||||
}
|
||||
|
||||
variable "settings" {
|
||||
type = string
|
||||
description = "JSON encoded settings.json"
|
||||
default = ""
|
||||
}
|
||||
|
||||
data "coder_workspace" "me" {}
|
||||
|
||||
data "coder_workspace_owner" "me" {}
|
||||
|
||||
locals {
|
||||
@@ -60,6 +67,30 @@ locals {
|
||||
hostname = var.agent_name != "" ? "${local.agent_name}.${local.workspace_name}.${local.owner_name}.coder" : "${local.workspace_name}.coder"
|
||||
}
|
||||
|
||||
resource "coder_script" "zed_settings" {
|
||||
agent_id = var.agent_id
|
||||
display_name = "Configure Zed settings"
|
||||
icon = "/icon/zed.svg"
|
||||
run_on_start = true
|
||||
script = <<-EOT
|
||||
set -eu
|
||||
SETTINGS_JSON='${replace(var.settings, "\"", "\\\"")}'
|
||||
if [ -z "$${SETTINGS_JSON}" ] || [ "$${SETTINGS_JSON}" = "{}" ]; then
|
||||
exit 0
|
||||
fi
|
||||
CONFIG_HOME="$${XDG_CONFIG_HOME:-$HOME/.config}"
|
||||
ZED_DIR="$${CONFIG_HOME}/zed"
|
||||
mkdir -p "$${ZED_DIR}"
|
||||
SETTINGS_FILE="$${ZED_DIR}/settings.json"
|
||||
if command -v jq >/dev/null 2>&1 && [ -s "$${SETTINGS_FILE}" ]; then
|
||||
tmpfile="$(mktemp)"
|
||||
jq -s '.[0] * .[1]' "$${SETTINGS_FILE}" <(printf '%s\n' "$${SETTINGS_JSON}") > "$${tmpfile}" && mv "$${tmpfile}" "$${SETTINGS_FILE}"
|
||||
else
|
||||
printf '%s\n' "$${SETTINGS_JSON}" > "$${SETTINGS_FILE}"
|
||||
fi
|
||||
EOT
|
||||
}
|
||||
|
||||
resource "coder_app" "zed" {
|
||||
agent_id = var.agent_id
|
||||
display_name = var.display_name
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
run "default_output" {
|
||||
command = apply
|
||||
|
||||
variables {
|
||||
agent_id = "foo"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = output.zed_url == "zed://ssh/default.coder"
|
||||
error_message = "zed_url did not match expected default URL"
|
||||
}
|
||||
}
|
||||
|
||||
run "adds_folder" {
|
||||
command = apply
|
||||
|
||||
variables {
|
||||
agent_id = "foo"
|
||||
folder = "/foo/bar"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = output.zed_url == "zed://ssh/default.coder/foo/bar"
|
||||
error_message = "zed_url did not include provided folder path"
|
||||
}
|
||||
}
|
||||
|
||||
run "adds_agent_name" {
|
||||
command = apply
|
||||
|
||||
variables {
|
||||
agent_id = "foo"
|
||||
agent_name = "myagent"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = output.zed_url == "zed://ssh/myagent.default.default.coder"
|
||||
error_message = "zed_url did not include agent_name in hostname"
|
||||
}
|
||||
}
|
||||
@@ -14,11 +14,26 @@ Provision Devcontainers as [Coder workspaces](https://coder.com/docs/workspaces)
|
||||
|
||||
### Infrastructure
|
||||
|
||||
Coder must have access to a running Docker socket, and the `coder` user must be a member of the `docker` group:
|
||||
#### Running Coder inside Docker
|
||||
|
||||
```shell
|
||||
If you installed Coder as a container within Docker, you will have to do the following things:
|
||||
|
||||
- Make the the Docker socket available to the container
|
||||
- **(recommended) Mount `/var/run/docker.sock` via `--mount`/`volume`**
|
||||
- _(advanced) Restrict the Docker socket via https://github.com/Tecnativa/docker-socket-proxy_
|
||||
- Set `--group-add`/`group_add` to the GID of the Docker group on the **host** machine
|
||||
- You can get the GID by running `getent group docker` on the **host** machine
|
||||
|
||||
If you are using `docker-compose`, here is an example on how to do those things (don't forget to edit `group_add`!):
|
||||
https://github.com/coder/coder/blob/0bfe0d63aec83ae438bdcb77e306effd100dba3d/docker-compose.yaml#L16-L23
|
||||
|
||||
#### Running Coder outside of Docker
|
||||
|
||||
If you installed Coder as a system package, the VM you run Coder on must have a running Docker socket and the `coder` user must be added to the Docker group:
|
||||
|
||||
```sh
|
||||
# Add coder user to Docker group
|
||||
sudo usermod -aG docker coder
|
||||
sudo adduser coder docker
|
||||
|
||||
# Restart Coder server
|
||||
sudo systemctl restart coder
|
||||
|
||||
@@ -16,7 +16,22 @@ Provision Docker containers as [Coder workspaces](https://coder.com/docs/workspa
|
||||
|
||||
### Infrastructure
|
||||
|
||||
The VM you run Coder on must have a running Docker socket and the `coder` user must be added to the Docker group:
|
||||
#### Running Coder inside Docker
|
||||
|
||||
If you installed Coder as a container within Docker, you will have to do the following things:
|
||||
|
||||
- Make the the Docker socket available to the container
|
||||
- **(recommended) Mount `/var/run/docker.sock` via `--mount`/`volume`**
|
||||
- _(advanced) Restrict the Docker socket via https://github.com/Tecnativa/docker-socket-proxy_
|
||||
- Set `--group-add`/`group_add` to the GID of the Docker group on the **host** machine
|
||||
- You can get the GID by running `getent group docker` on the **host** machine
|
||||
|
||||
If you are using `docker-compose`, here is an example on how to do those things (don't forget to edit `group_add`!):
|
||||
https://github.com/coder/coder/blob/0bfe0d63aec83ae438bdcb77e306effd100dba3d/docker-compose.yaml#L16-L23
|
||||
|
||||
#### Running Coder outside of Docker
|
||||
|
||||
If you installed Coder as a system package, the VM you run Coder on must have a running Docker socket and the `coder` user must be added to the Docker group:
|
||||
|
||||
```sh
|
||||
# Add coder user to Docker group
|
||||
|
||||
@@ -8,6 +8,8 @@ tags: [vm, linux, gcp, devcontainer]
|
||||
|
||||
# Remote Development in a Devcontainer on Google Compute Engine
|
||||
|
||||
Provision a Devcontainer on Google Compute Engine instances as Coder workspaces
|
||||
|
||||

|
||||
|
||||
## Prerequisites
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user