Compare commits

...

5 Commits

Author SHA1 Message Date
DevCats c8441fc593 feat(claude-code): add subdomain variable and logic (#387)
Closes #

## Description

- Introduces `subdomain` variable
- Logic for subdomain and base path

Tested with and without subdomain to ensure no breaking changes
<!-- Briefly describe what this PR does and why -->

## Type of Change

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

## Module Information

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

**Path:** `registry/coder/modules/claude-code`  
**New version:** `v2.2.0`  
**Breaking change:** [X] Yes [ ] No

## Testing & Validation

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

## Related Issues

<!-- Link related issues or write "None" if not applicable -->
2025-08-26 20:36:27 -05:00
Atif Ali 62951f1fca chore: improve Prettier configuration (#392) 2025-08-27 01:57:43 +05:00
DevCats 6bebc02122 fix(ci): add fallback for GitHub API failures in release workflow (#388)
## Description

CI was failing on new module releases because there was no fallback to
gh api failures when there was no previous tag for the module was found.


https://github.com/coder/registry/actions/runs/17225186737/job/48868318539

<!-- Briefly describe what this PR does and why -->

## Type of Change

- [ ] New module
- [ ] Bug fix
- [ ] Feature/enhancement
- [ ] Documentation
- [X] Other
2025-08-26 08:19:49 -05:00
Rishi Mondal 97b036e7d4 AWS AMI Snapshot Module for Persistent Workspace State (#219)
## Description

This PR implements AMI-based snapshots for Coder workspaces on AWS,
enabling persistent state across workspace stop/start cycles. Users can
now create snapshots of their workspace state when stopping and restore
from selected snapshots when starting workspaces.

**Solves GitHub Issue #26** - AWS Snapshot functionality for persistent
workspace state.

## Type of Change

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

## Module Information

**Path:** `registry/mavrickrishi/modules/aws-ami-snapshot`
**New version:** `v1.0.0`
**Breaking change:** [ ] Yes [x] No

## Implementation Details

### All Requirements from Issue #26 Implemented:

 **Requirement 1: Create AMI snapshots on workspace stop**
- Uses `aws_ami_from_instance` resource triggered by
`coder_workspace.me.transition == "stop"`
- Snapshots created without reboot for graceful handling

 **Requirement 2: Tag AMIs with workspace metadata**
- Tags include: workspace owner, name, template, creation timestamp
- Comprehensive tagging for organization and filtering

 **Requirement 3: User parameters for snapshot control**
- `enable_snapshots` - Toggle snapshot creation (default: true)
- `snapshot_label` - Custom label for snapshots (optional)
- `use_previous_snapshot` - Dropdown to select from available snapshots

 **Requirement 4: Retrieve available snapshots**
- Uses `aws_ami_ids` data source with Coder-specific tag filters
- Formats snapshot metadata for selection dropdown

 **Requirement 5: Modify instance creation**
- `local.ami_id` variable selects user snapshot or default AMI
- Dynamic AMI selection logic implemented
- `lifecycle { ignore_changes = [ami] }` prevents Terraform conflicts

 **Requirement 6: Optional cleanup**
- `aws_dlm_lifecycle_policy` for snapshot retention management
- Configurable retention periods and counts
- Cost control through deprecation time

 **Requirement 7: Key considerations**
- IAM permissions documented
- Graceful workspace stop handling
- Cost control implementation
- Proper tagging for organization

## Testing & Validation

### Comprehensive Test Suite

Created comprehensive test script that validates **ALL** requirements
from issue #26:

<details>
<summary>🔧 Comprehensive Test Script (Click to expand)</summary>

```bash
#!/bin/bash

# Comprehensive test for AWS AMI Snapshot module
# Tests EVERY requirement from GitHub issue #26

set -e

echo "🎯 COMPREHENSIVE TEST: AWS AMI Snapshot Module"
echo "Testing ALL requirements from issue #26"
echo "=============================================="
echo ""

# Test variables
TEST_WORKSPACE="test-workspace-$(date +%s)"
TEST_OWNER="test-owner"
TEST_TEMPLATE="comprehensive-test"
REGION="${AWS_DEFAULT_REGION:-us-east-1}"

echo "📋 Test Configuration:"
echo "  Account: $(aws sts get-caller-identity --query Account --output text)"
echo "  Region: $REGION"
echo "  Workspace: $TEST_WORKSPACE"
echo "  Owner: $TEST_OWNER"
echo "  Template: $TEST_TEMPLATE"
echo ""

# ===== REQUIREMENT 1: Create AMI snapshots on workspace stop =====
echo "🔍 REQUIREMENT 1: AMI Snapshots on Workspace Stop"
echo "=================================================="

# Create test infrastructure
cat > test-comprehensive.tf << EOF
terraform {
  required_providers {
    aws = { source = "hashicorp/aws", version = "~> 5.0" }
    coder = { source = "coder/coder", version = ">= 0.17" }
  }
}

provider "aws" { region = "$REGION" }
provider "coder" {}

data "aws_ami" "ubuntu" {
  most_recent = true
  owners      = ["099720109477"]
  filter {
    name   = "name"
    values = ["ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-server-*"]
  }
}

resource "aws_instance" "test" {
  ami           = module.ami_snapshot.ami_id
  instance_type = "t3.micro"
  tags = { Name = "comprehensive-test" }
  lifecycle { ignore_changes = [ami] }
}

module "ami_snapshot" {
  source = "./registry/mavrickrishi/modules/aws-ami-snapshot"
  instance_id     = aws_instance.test.id
  default_ami_id  = data.aws_ami.ubuntu.id
  template_name   = "$TEST_TEMPLATE"
  
  # Test optional cleanup features
  enable_dlm_cleanup = false
  snapshot_retention_count = 5
  
  tags = {
    Environment = "test"
    TestType = "comprehensive"
  }
}

output "instance_id" { value = aws_instance.test.id }
output "ami_id" { value = module.ami_snapshot.ami_id }
output "is_using_snapshot" { value = module.ami_snapshot.is_using_snapshot }
output "available_snapshots" { value = module.ami_snapshot.available_snapshots }
output "snapshot_info" { value = module.ami_snapshot.snapshot_info }
EOF

echo " Test 1.1: aws_ami_from_instance resource exists in module"
echo "  💻 Running: grep aws_ami_from_instance registry/mavrickrishi/modules/aws-ami-snapshot/main.tf"
grep -q "aws_ami_from_instance" registry/mavrickrishi/modules/aws-ami-snapshot/main.tf && echo "   Found aws_ami_from_instance resource"

echo " Test 1.2: Triggered by coder_workspace.me.transition == 'stop'"
echo "  💻 Running: grep 'coder_workspace.me.transition == \"stop\"' main.tf"
grep -q 'coder_workspace.me.transition == "stop"' registry/mavrickrishi/modules/aws-ami-snapshot/main.tf && echo "   Found stop transition trigger"

echo " Test 1.3: Deploy test infrastructure"
echo "  🔧 Initializing Terraform..."
echo "  💻 Running: terraform init"
terraform init
echo ""
echo "  🚀 Applying Terraform configuration..."
echo "  💻 Running: terraform apply -auto-approve"
terraform apply -auto-approve
echo ""
INSTANCE_ID=$(terraform output -raw instance_id)
echo "   Created test instance: $INSTANCE_ID"
echo ""
echo "  📊 Initial module outputs:"
echo "  💻 Running: terraform output"
terraform output

# ===== REQUIREMENT 2: Tag AMIs with workspace metadata =====
echo ""
echo "🔍 REQUIREMENT 2: AMI Tagging with Workspace Metadata"
echo "====================================================="

echo " Test 2.1: Create AMI with proper tags (simulating workspace stop)"
echo "  💻 Running: aws ec2 create-image --instance-id $INSTANCE_ID ..."
AMI_ID=$(aws ec2 create-image \
  --instance-id $INSTANCE_ID \
  --name "$TEST_OWNER-$TEST_WORKSPACE-$(date +%Y-%m-%d-%H%M)" \
  --description "Comprehensive test snapshot" \
  --no-reboot \
  --tag-specifications "ResourceType=image,Tags=[
    {Key=Name,Value=$TEST_OWNER-$TEST_WORKSPACE-snapshot},
    {Key=CoderWorkspace,Value=$TEST_WORKSPACE},
    {Key=CoderOwner,Value=$TEST_OWNER},
    {Key=CoderTemplate,Value=$TEST_TEMPLATE},
    {Key=SnapshotLabel,Value=comprehensive-test},
    {Key=CreatedAt,Value=$(date -Iseconds)},
    {Key=SnapshotType,Value=workspace},
    {Key=WorkspaceId,Value=test-workspace-id}
  ]" \
  --query ImageId --output text)

echo "   Created AMI: $AMI_ID"

echo " Test 2.2: Verify AMI tags include workspace owner"
aws ec2 describe-images --image-ids $AMI_ID --query 'Images[0].Tags[?Key==`CoderOwner`].Value' --output text | grep -q "$TEST_OWNER" && echo "   CoderOwner tag correct"

echo " Test 2.3: Verify AMI tags include workspace name"
aws ec2 describe-images --image-ids $AMI_ID --query 'Images[0].Tags[?Key==`CoderWorkspace`].Value' --output text | grep -q "$TEST_WORKSPACE" && echo "   CoderWorkspace tag correct"

echo " Test 2.4: Verify AMI tags include template name"
aws ec2 describe-images --image-ids $AMI_ID --query 'Images[0].Tags[?Key==`CoderTemplate`].Value' --output text | grep -q "$TEST_TEMPLATE" && echo "   CoderTemplate tag correct"

echo " Test 2.5: Verify AMI tags include creation timestamp"
aws ec2 describe-images --image-ids $AMI_ID --query 'Images[0].Tags[?Key==`CreatedAt`].Value' --output text | grep -q "$(date +%Y-%m-%d)" && echo "   CreatedAt tag correct"

# ===== REQUIREMENT 3: User parameters for snapshot control =====
echo ""
echo "🔍 REQUIREMENT 3: User Parameters for Snapshot Control"
echo "======================================================"

echo " Test 3.1: Enable/disable snapshot functionality parameter"
grep -q 'data "coder_parameter" "enable_snapshots"' registry/mavrickrishi/modules/aws-ami-snapshot/main.tf && echo "   Found enable_snapshots parameter"

echo " Test 3.2: Custom snapshot labels parameter"
grep -q 'data "coder_parameter" "snapshot_label"' registry/mavrickrishi/modules/aws-ami-snapshot/main.tf && echo "   Found snapshot_label parameter"

echo " Test 3.3: Previous snapshots selection parameter"
grep -q 'data "coder_parameter" "use_previous_snapshot"' registry/mavrickrishi/modules/aws-ami-snapshot/main.tf && echo "   Found use_previous_snapshot parameter"

echo " Test 3.4: Parameter has dropdown options"
grep -q 'dynamic "option"' registry/mavrickrishi/modules/aws-ami-snapshot/main.tf && echo "   Found dynamic options for snapshot selection"

# ===== REQUIREMENT 4: Retrieve available snapshots =====
echo ""
echo "🔍 REQUIREMENT 4: Retrieve Available Snapshots"
echo "=============================================="

echo " Test 4.1: aws_ami data source with filters"
grep -q 'data "aws_ami_ids" "workspace_snapshots"' registry/mavrickrishi/modules/aws-ami-snapshot/main.tf && echo "   Found aws_ami_ids data source"

echo " Test 4.2: Filter by Coder-specific tags"
grep -A 10 'data "aws_ami_ids" "workspace_snapshots"' registry/mavrickrishi/modules/aws-ami-snapshot/main.tf | grep -q "CoderWorkspace" && echo "   Found CoderWorkspace filter"
grep -A 10 'data "aws_ami_ids" "workspace_snapshots"' registry/mavrickrishi/modules/aws-ami-snapshot/main.tf | grep -q "CoderOwner" && echo "   Found CoderOwner filter"
grep -A 10 'data "aws_ami_ids" "workspace_snapshots"' registry/mavrickrishi/modules/aws-ami-snapshot/main.tf | grep -q "CoderTemplate" && echo "   Found CoderTemplate filter"

echo " Test 4.3: Wait for AMI to be available"
echo "   Waiting for AMI $AMI_ID to become available (this may take a few minutes)..."
aws ec2 wait image-available --image-ids $AMI_ID
echo "   AMI is now available"

echo " Test 4.4: Test snapshot retrieval functionality"
echo "  🏷️  Updating tags to match Coder provider values..."
aws ec2 create-tags --resources $AMI_ID --tags \
  Key=CoderWorkspace,Value=default \
  Key=CoderOwner,Value=default \
  Key=CoderTemplate,Value=$TEST_TEMPLATE

echo "  🔄 Refreshing Terraform state to detect snapshots..."
echo "  💻 Running: terraform refresh"
terraform refresh
echo ""
echo "  📊 Updated module outputs:"
echo "  💻 Running: terraform output"
terraform output
echo ""
FOUND_SNAPSHOTS=$(terraform output -json available_snapshots | jq -r '.[]' | wc -l)
if [ "$FOUND_SNAPSHOTS" -gt 0 ]; then
  echo "   Module detected $FOUND_SNAPSHOTS snapshot(s)!"
  echo "  📸 Available snapshots:"
  terraform output -json available_snapshots | jq -r '.[]'
else
  echo "   Module did not detect snapshots"
fi

# ===== REQUIREMENT 5: Modify instance creation =====
echo ""
echo "🔍 REQUIREMENT 5: Dynamic AMI Selection"
echo "======================================="

echo " Test 5.1: local.ami_id variable exists"
grep -q 'local.ami_id' registry/mavrickrishi/modules/aws-ami-snapshot/main.tf && echo "   Found local.ami_id variable"

echo " Test 5.2: Dynamic AMI selection logic"
grep -A 5 'locals {' registry/mavrickrishi/modules/aws-ami-snapshot/main.tf | grep -q 'use_snapshot.*=.*' && echo "   Found snapshot selection logic"

echo " Test 5.3: Test AMI ID output"
CURRENT_AMI=$(terraform output -raw ami_id)
echo "   Module returns AMI ID: $CURRENT_AMI"

echo " Test 5.4: Test snapshot usage flag"
IS_USING_SNAPSHOT=$(terraform output -raw is_using_snapshot)
echo "   Using snapshot: $IS_USING_SNAPSHOT"

echo " Test 5.5: Test instance creation from snapshot"
echo "  🚀 Creating new instance from snapshot AMI..."
echo "  💻 Running: aws ec2 run-instances --image-id $AMI_ID ..."
NEW_INSTANCE_ID=$(aws ec2 run-instances \
  --image-id $AMI_ID \
  --instance-type t3.micro \
  --tag-specifications "ResourceType=instance,Tags=[{Key=Name,Value=test-from-snapshot}]" \
  --query 'Instances[0].InstanceId' --output text)
echo "   Waiting for new instance to be running..."
echo "  💻 Running: aws ec2 wait instance-running --instance-ids $NEW_INSTANCE_ID"
aws ec2 wait instance-running --instance-ids $NEW_INSTANCE_ID
echo "   Created instance from snapshot: $NEW_INSTANCE_ID"

# ===== REQUIREMENT 6: Optional cleanup (DLM) =====
echo ""
echo "🔍 REQUIREMENT 6: Optional Cleanup Implementation"
echo "==============================================="

echo " Test 6.1: DLM lifecycle policy resource exists"
grep -q 'aws_dlm_lifecycle_policy' registry/mavrickrishi/modules/aws-ami-snapshot/main.tf && echo "   Found DLM lifecycle policy resource"

echo " Test 6.2: DLM configuration options exist"
grep -q 'variable "enable_dlm_cleanup"' registry/mavrickrishi/modules/aws-ami-snapshot/main.tf && echo "   Found enable_dlm_cleanup variable"
grep -q 'variable "dlm_role_arn"' registry/mavrickrishi/modules/aws-ami-snapshot/main.tf && echo "   Found dlm_role_arn variable"
grep -q 'variable "snapshot_retention_count"' registry/mavrickrishi/modules/aws-ami-snapshot/main.tf && echo "   Found snapshot_retention_count variable"

echo " Test 6.3: DLM targets correct resources"
grep -A 10 'aws_dlm_lifecycle_policy' registry/mavrickrishi/modules/aws-ami-snapshot/main.tf | grep -q 'resource_types.*=.*\["INSTANCE"\]' && echo "   DLM targets instances"

# ===== REQUIREMENT 7: Key Considerations =====
echo ""
echo "🔍 REQUIREMENT 7: Key Considerations"
echo "==================================="

echo " Test 7.1: IAM permissions documented"
grep -q "ec2:CreateImage" registry/mavrickrishi/modules/aws-ami-snapshot/README.md && echo "   Required IAM permissions documented"

echo " Test 7.2: Graceful workspace stop handling"
grep -q "snapshot_without_reboot.*=.*true" registry/mavrickrishi/modules/aws-ami-snapshot/main.tf && echo "   Uses snapshot_without_reboot for graceful handling"

echo " Test 7.3: Cost control through cleanup"
grep -q "deprecation_time" registry/mavrickrishi/modules/aws-ami-snapshot/main.tf && echo "   Sets deprecation_time for cost control"

echo " Test 7.4: Proper tagging for organization"
grep -A 20 'tags = merge' registry/mavrickrishi/modules/aws-ami-snapshot/main.tf | grep -q "SnapshotType" && echo "   Comprehensive tagging implemented"

echo " Test 7.5: Lifecycle ignore_changes prevention"
grep -q "ignore_changes.*=.*\[.*ami.*\]" test-comprehensive.tf && echo "   Terraform conflicts prevented"

# ===== FINAL VALIDATION =====
echo ""
echo "🔍 FINAL VALIDATION: End-to-End Test"
echo "===================================="

echo " Test: Show all created resources"
echo "  Original instance: $INSTANCE_ID (using default AMI)"
echo "  Snapshot AMI: $AMI_ID (with Coder metadata)"  
echo "  New instance: $NEW_INSTANCE_ID (from snapshot)"

echo " Test: Verify snapshot metadata"
echo "  💻 Running: aws ec2 describe-images --image-ids $AMI_ID ..."
aws ec2 describe-images --image-ids $AMI_ID --query 'Images[0].{Name:Name,State:State,Tags:Tags}' --output table

echo ""
echo " Test: Show both instances (original vs from snapshot)"
echo "  💻 Running: aws ec2 describe-instances --instance-ids $INSTANCE_ID $NEW_INSTANCE_ID ..."
aws ec2 describe-instances \
  --instance-ids $INSTANCE_ID $NEW_INSTANCE_ID \
  --query 'Reservations[*].Instances[*].{InstanceId:InstanceId,State:State.Name,ImageId:ImageId,Name:Tags[?Key==`Name`].Value|[0]}' \
  --output table

echo ""
echo " Test: Final module outputs"
echo "  💻 Running: terraform output"
terraform output

echo ""
echo "🎉 COMPREHENSIVE TEST RESULTS"
echo "============================="
echo " ALL REQUIREMENTS FROM ISSUE #26 IMPLEMENTED AND TESTED!"
echo ""
echo "📋 Validated Implementation:"
echo "   AMI snapshots on workspace stop (aws_ami_from_instance)"
echo "   Proper tagging with workspace metadata"
echo "   User parameters (enable, labels, selection)"
echo "   Snapshot retrieval with Coder-specific filters"
echo "   Dynamic AMI selection (local.ami_id)"
echo "   Optional DLM cleanup policies"
echo "   All key considerations addressed"
echo ""
echo "🎯 Module successfully provides persistent workspace state!"

# Cleanup prompt
echo ""
read -p "🧹 Clean up test resources? (y/N): " -n 1 -r
echo
if [[ $REPLY =~ ^[Yy]$ ]]; then
  echo "Cleaning up..."
  echo "  💻 Running: aws ec2 terminate-instances --instance-ids $INSTANCE_ID $NEW_INSTANCE_ID"
  aws ec2 terminate-instances --instance-ids $INSTANCE_ID $NEW_INSTANCE_ID > /dev/null
  echo "  💻 Running: aws ec2 deregister-image --image-id $AMI_ID"
  aws ec2 deregister-image --image-id $AMI_ID > /dev/null
  echo "  💻 Running: terraform destroy -auto-approve"
  terraform destroy -auto-approve > /dev/null
  echo "  💻 Running: rm -f test-comprehensive.tf terraform.tfstate* .terraform.lock.hcl"
  rm -f test-comprehensive.tf terraform.tfstate* .terraform.lock.hcl
  echo "  💻 Running: rm -rf .terraform/"
  rm -rf .terraform/
  echo " Cleanup complete!"
else
  echo "Resources preserved for inspection"
fi
```

</details>

### Test Results Summary

- [x] **Tests pass** (`bun test` - validates module structure)
- [x] **Code formatted** (`bun run fmt` - all files properly formatted)
- [x] **Terraform validation** (`terraform validate` - configuration is
valid)
- [x] **Real AWS testing** (Comprehensive test with actual EC2 instances
and AMIs)
- [x] **All 7 requirements validated** (Every requirement from issue #26
tested)

### Module Structure
```bash
$ tree registry/mavrickrishi/modules/aws-ami-snapshot/
registry/mavrickrishi/modules/aws-ami-snapshot/
├── main.test.ts          # Module tests
├── main.tf               # Terraform configuration
└── README.md             # Documentation
```

### Namespace Structure
```bash
$ tree registry/mavrickrishi/
registry/mavrickrishi/
├── .images/
│   └── avatar.svg        # Namespace avatar
├── README.md             # Namespace documentation
└── modules/
    └── aws-ami-snapshot/ # The module
```

## Key Features Implemented

### 🎯 **Core Functionality:**
- **Automatic AMI creation** on workspace transition to "stop"
- **Workspace-specific snapshot filtering** by owner, workspace, and
template
- **Dynamic AMI selection** - defaults to base AMI, switches to selected
snapshot
- **User-friendly parameters** - enable/disable, custom labels, snapshot
selection

### 🔧 **Technical Implementation:**
- **aws_ami_from_instance** resource with proper lifecycle management
- **Comprehensive tagging** for organization and cost tracking
- **Data Lifecycle Manager** integration for automated cleanup
- **Terraform conflict prevention** with `ignore_changes = [ami]`

### 🎛️ **User Experience:**
- **Enable AMI Snapshots** - Boolean toggle (default: true)
- **Snapshot Label** - Optional custom label for identification
- **Start from Snapshot** - Dropdown with available snapshots and
descriptions

### 💰 **Cost Management:**
- **Deprecation time** set to 7 days for automatic cleanup hints
- **Optional DLM policies** for automated snapshot retention
- **Configurable retention counts** to control storage costs

## Security & IAM

### Required IAM Permissions:
```json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "ec2:CreateImage",
        "ec2:DescribeImages",
        "ec2:DescribeInstances",
        "ec2:CreateTags",
        "ec2:DescribeTags"
      ],
      "Resource": "*"
    },
    {
      "Effect": "Allow",
      "Action": [
        "dlm:CreateLifecyclePolicy",
        "dlm:GetLifecyclePolicy",
        "dlm:UpdateLifecyclePolicy",
        "dlm:DeleteLifecyclePolicy"
      ],
      "Resource": "*",
      "Condition": {
        "StringEquals": {
          "dlm:Target": "INSTANCE"
        }
      }
    }
  ]
}
```

## Usage Example

```hcl
module "ami_snapshot" {
  source = "registry.coder.com/modules/mavrickrishi/aws-ami-snapshot"

  instance_id     = aws_instance.workspace.id
  default_ami_id  = data.aws_ami.ubuntu.id
  template_name   = "my-workspace-template"

  # Optional: Enable automated cleanup
  enable_dlm_cleanup       = true
  dlm_role_arn            = aws_iam_role.dlm_lifecycle_role.arn
  snapshot_retention_count = 5

  tags = {
    Environment = "production"
    Team        = "engineering"
  }
}

resource "aws_instance" "workspace" {
  ami           = module.ami_snapshot.ami_id
  instance_type = "t3.large"

  # Prevent Terraform from recreating instance when AMI changes
  lifecycle {
    ignore_changes = [ami]
  }
}
```

## Related Issues

- **Closes #26** - AWS Snapshot functionality
- **Implements** all 7 requirements from the GitHub issue
- **Provides** persistent workspace state across stop/start cycles

## Video Demonstration




https://github.com/user-attachments/assets/9356e4b5-9a67-4988-a03f-57e950afa5c2


https://github.com/user-attachments/assets/b6af98db-5d01-4aff-853d-055b92911ea5

---------

Co-authored-by: DevCats <christofer@coder.com>
Co-authored-by: DevCats <chris@dualriver.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Atif Ali <atif@coder.com>
2025-08-25 20:21:56 -05:00
35C4n0r 240643d3b0 feat: remove node installation from gemini (#374)
Closes #

## Description
Standardising this across all modules:
- remove default node & nvm installation

<!-- Briefly describe what this PR does and why -->

## Type of Change

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

## Module Information

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

**Path:** `registry/coder-labs/modules/gemini`  
**New version:** `v2.0.0`  
**Breaking change:** [x] Yes [ ] No

## Testing & Validation

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

## Related Issues

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

---------

Co-authored-by: DevCats <christofer@coder.com>
2025-08-25 12:53:48 -05:00
58 changed files with 1287 additions and 619 deletions
+2 -2
View File
@@ -192,8 +192,8 @@ main() {
# Always run formatter to ensure consistent formatting
echo "🔧 Running formatter to ensure consistent formatting..."
if command -v bun >/dev/null 2>&1; then
bun fmt >/dev/null 2>&1 || echo "⚠️ Warning: bun fmt failed, but continuing..."
if command -v bun > /dev/null 2>&1; then
bun fmt > /dev/null 2>&1 || echo "⚠️ Warning: bun fmt failed, but continuing..."
else
echo "⚠️ Warning: bun not found, skipping formatting"
fi
+2
View File
@@ -2,6 +2,8 @@
muc = "muc" # For Munich location code
Hashi = "Hashi"
HashiCorp = "HashiCorp"
mavrickrishi = "mavrickrishi" # Username
mavrick = "mavrick" # Username
[files]
extend-exclude = ["registry/coder/templates/aws-devcontainer/architecture.svg"] #False positive
+1 -1
View File
@@ -21,4 +21,4 @@ jobs:
- name: golangci-lint
uses: golangci/golangci-lint-action@v8
with:
version: v2.1
version: v2.1
+27 -22
View File
@@ -11,33 +11,33 @@ jobs:
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:
@@ -46,15 +46,15 @@ jobs:
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:
@@ -64,24 +64,29 @@ jobs:
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')
if [[ "$PREV_TAG" == release/* ]]; then
FULL_CHANGELOG=$(gh api repos/:owner/:repo/releases/generate-notes \
--field tag_name="$CURRENT_TAG" \
--field previous_tag_name="$PREV_TAG" \
--jq '.body')
else
echo "New module detected, skipping GitHub API"
FULL_CHANGELOG=""
fi
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}
@@ -95,11 +100,11 @@ jobs:
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 }}
@@ -109,4 +114,4 @@ jobs:
run: |
gh release create "$TAG_NAME" \
--title "$RELEASE_TITLE" \
--notes "$CHANGELOG"
--notes "$CHANGELOG"
+2 -2
View File
@@ -163,8 +163,8 @@ linters:
staticcheck:
checks:
- all
- SA4006 # Detects redundant assignments
- SA4009 # Detects redundant variable declarations
- SA4006 # Detects redundant assignments
- SA4009 # Detects redundant variable declarations
- SA1019
exclusions:
generated: lax
+22
View File
@@ -0,0 +1,22 @@
# Ignore symlinks to avoid Prettier errors
CLAUDE.md
.github/copilot-instructions.md
# Ignore node_modules and dependencies
node_modules/
# Ignore Terraform files (formatted by terraform fmt)
*.tf
*.hcl
*.tfvars
# Ignore generated and temporary files
.terraform/
*.tfstate
*.tfstate.backup
*.tfstate.lock.info
# Ignore other files that shouldn't be formatted
bun.lock
go.sum
go.mod
+6 -6
View File
@@ -4,11 +4,11 @@
"": {
"name": "registry",
"devDependencies": {
"@types/bun": "^1.2.18",
"bun-types": "^1.2.18",
"@types/bun": "^1.2.21",
"bun-types": "^1.2.21",
"dedent": "^1.6.0",
"gray-matter": "^4.0.3",
"marked": "^16.0.0",
"marked": "^16.2.0",
"prettier": "^3.6.2",
"prettier-plugin-sh": "^0.18.0",
"prettier-plugin-terraform-formatter": "^1.2.1",
@@ -21,7 +21,7 @@
"packages": {
"@reteps/dockerfmt": ["@reteps/dockerfmt@0.3.6", "", {}, "sha512-Tb5wIMvBf/nLejTQ61krK644/CEMB/cpiaIFXqGApfGqO3GwcR3qnI0DbmkFVCl2OyEp8LnLX3EkucoL0+tbFg=="],
"@types/bun": ["@types/bun@1.2.18", "", { "dependencies": { "bun-types": "1.2.18" } }, "sha512-Xf6RaWVheyemaThV0kUfaAUvCNokFr+bH8Jxp+tTZfx7dAPA8z9ePnP9S9+Vspzuxxx9JRAXhnyccRj3GyCMdQ=="],
"@types/bun": ["@types/bun@1.2.21", "", { "dependencies": { "bun-types": "1.2.21" } }, "sha512-NiDnvEqmbfQ6dmZ3EeUO577s4P5bf4HCTXtI6trMc6f6RzirY5IrF3aIookuSpyslFzrnvv2lmEWv5HyC1X79A=="],
"@types/node": ["@types/node@24.0.14", "", { "dependencies": { "undici-types": "~7.8.0" } }, "sha512-4zXMWD91vBLGRtHK3YbIoFMia+1nqEz72coM42C5ETjnNCa/heoj7NT1G67iAfOqMmcfhuCZ4uNpyz8EjlAejw=="],
@@ -29,7 +29,7 @@
"argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="],
"bun-types": ["bun-types@1.2.18", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-04+Eha5NP7Z0A9YgDAzMk5PHR16ZuLVa83b26kH5+cp1qZW4F6FmAURngE7INf4tKOvCE69vYvDEwoNl1tGiWw=="],
"bun-types": ["bun-types@1.2.21", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-sa2Tj77Ijc/NTLS0/Odjq/qngmEPZfbfnOERi0KRUYhT9R8M4VBioWVmMWE5GrYbKMc+5lVybXygLdibHaqVqw=="],
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
@@ -47,7 +47,7 @@
"kind-of": ["kind-of@6.0.3", "", {}, "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw=="],
"marked": ["marked@16.0.0", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-MUKMXDjsD/eptB7GPzxo4xcnLS6oo7/RHimUMHEDRhUooPwmN9BEpMl7AEOJv3bmso169wHI2wUF9VQgL7zfmA=="],
"marked": ["marked@16.2.0", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-LbbTuye+0dWRz2TS9KJ7wsnD4KAtpj0MVkWc90XvBa6AslXsT0hTBVH5k32pcSyHH1fst9XEFJunXHktVy0zlg=="],
"prettier": ["prettier@3.6.2", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ=="],
+5 -5
View File
@@ -1,18 +1,18 @@
{
"name": "registry",
"scripts": {
"fmt": "bun x prettier --write **/*.sh **/*.ts **/*.md *.md && terraform fmt -recursive -diff",
"fmt:ci": "bun x prettier --check **/*.sh **/*.ts **/*.md *.md && terraform fmt -check -recursive -diff",
"fmt": "bun x prettier --write . && terraform fmt -recursive -diff",
"fmt:ci": "bun x prettier --check . && terraform fmt -check -recursive -diff",
"terraform-validate": "./scripts/terraform_validate.sh",
"test": "./scripts/terraform_test_all.sh",
"update-version": "./update-version.sh"
},
"devDependencies": {
"@types/bun": "^1.2.18",
"bun-types": "^1.2.18",
"@types/bun": "^1.2.21",
"bun-types": "^1.2.21",
"dedent": "^1.6.0",
"gray-matter": "^4.0.3",
"marked": "^16.0.0",
"marked": "^16.2.0",
"prettier": "^3.6.2",
"prettier-plugin-sh": "^0.18.0",
"prettier-plugin-terraform-formatter": "^1.2.1"
+3 -1
View File
@@ -28,7 +28,9 @@ describe("tmux module", async () => {
// check that the script contains expected lines
expect(scriptResource.script).toContain("Installing tmux");
expect(scriptResource.script).toContain("Installing Tmux Plugin Manager (TPM)");
expect(scriptResource.script).toContain(
"Installing Tmux Plugin Manager (TPM)",
);
expect(scriptResource.script).toContain("tmux configuration created at");
expect(scriptResource.script).toContain("✅ tmux setup complete!");
});
+75 -75
View File
@@ -8,75 +8,75 @@ TMUX_CONFIG="${TMUX_CONFIG}"
# Function to install tmux
install_tmux() {
printf "Checking for tmux installation\n"
printf "Checking for tmux installation\n"
if command -v tmux &> /dev/null; then
printf "tmux is already installed \n\n"
return 0
fi
if command -v tmux &> /dev/null; then
printf "tmux is already installed \n\n"
return 0
fi
printf "Installing tmux \n\n"
printf "Installing tmux \n\n"
# Detect package manager and install tmux
if command -v apt-get &> /dev/null; then
sudo apt-get update
sudo apt-get install -y tmux
elif command -v yum &> /dev/null; then
sudo yum install -y tmux
elif command -v dnf &> /dev/null; then
sudo dnf install -y tmux
elif command -v zypper &> /dev/null; then
sudo zypper install -y tmux
elif command -v apk &> /dev/null; then
sudo apk add tmux
elif command -v brew &> /dev/null; then
brew install tmux
else
printf "No supported package manager found. Please install tmux manually. \n"
exit 1
fi
# Detect package manager and install tmux
if command -v apt-get &> /dev/null; then
sudo apt-get update
sudo apt-get install -y tmux
elif command -v yum &> /dev/null; then
sudo yum install -y tmux
elif command -v dnf &> /dev/null; then
sudo dnf install -y tmux
elif command -v zypper &> /dev/null; then
sudo zypper install -y tmux
elif command -v apk &> /dev/null; then
sudo apk add tmux
elif command -v brew &> /dev/null; then
brew install tmux
else
printf "No supported package manager found. Please install tmux manually. \n"
exit 1
fi
printf "tmux installed successfully \n"
printf "tmux installed successfully \n"
}
# Function to install Tmux Plugin Manager (TPM)
install_tpm() {
local tpm_dir="$HOME/.tmux/plugins/tpm"
local tpm_dir="$HOME/.tmux/plugins/tpm"
if [ -d "$tpm_dir" ]; then
printf "TPM is already installed"
return 0
fi
if [ -d "$tpm_dir" ]; then
printf "TPM is already installed"
return 0
fi
printf "Installing Tmux Plugin Manager (TPM) \n"
printf "Installing Tmux Plugin Manager (TPM) \n"
# Create plugins directory
mkdir -p "$HOME/.tmux/plugins"
# Create plugins directory
mkdir -p "$HOME/.tmux/plugins"
# Clone TPM repository
if command -v git &> /dev/null; then
git clone https://github.com/tmux-plugins/tpm "$tpm_dir"
printf "TPM installed successfully"
else
printf "Git is not installed. Please install git to use tmux plugins. \n"
exit 1
fi
# Clone TPM repository
if command -v git &> /dev/null; then
git clone https://github.com/tmux-plugins/tpm "$tpm_dir"
printf "TPM installed successfully"
else
printf "Git is not installed. Please install git to use tmux plugins. \n"
exit 1
fi
}
# Function to create tmux configuration
setup_tmux_config() {
printf "Setting up tmux configuration \n"
printf "Setting up tmux configuration \n"
local config_dir="$HOME/.tmux"
local config_file="$HOME/.tmux.conf"
local config_dir="$HOME/.tmux"
local config_file="$HOME/.tmux.conf"
mkdir -p "$config_dir"
mkdir -p "$config_dir"
if [ -n "$TMUX_CONFIG" ]; then
printf "$TMUX_CONFIG" > "$config_file"
printf "$${BOLD}Custom tmux configuration applied at {$config_file} \n\n"
else
cat > "$config_file" << EOF
if [ -n "$TMUX_CONFIG" ]; then
printf "$TMUX_CONFIG" > "$config_file"
printf "$${BOLD}Custom tmux configuration applied at {$config_file} \n\n"
else
cat > "$config_file" << EOF
# Tmux Configuration File
# =============================================================================
@@ -106,48 +106,48 @@ bind C-r run-shell "~/.tmux/plugins/tmux-resurrect/scripts/restore.sh"
# Initialize TMUX plugin manager (keep this line at the very bottom of tmux.conf)
run '~/.tmux/plugins/tpm/tpm'
EOF
printf "tmux configuration created at {$config_file} \n\n"
fi
printf "tmux configuration created at {$config_file} \n\n"
fi
}
# Function to install tmux plugins
install_plugins() {
printf "Installing tmux plugins"
printf "Installing tmux plugins"
# Check if TPM is installed
if [ ! -d "$HOME/.tmux/plugins/tpm" ]; then
printf "TPM is not installed. Cannot install plugins. \n"
return 1
fi
# Check if TPM is installed
if [ ! -d "$HOME/.tmux/plugins/tpm" ]; then
printf "TPM is not installed. Cannot install plugins. \n"
return 1
fi
# Install plugins using TPM
"$HOME/.tmux/plugins/tpm/bin/install_plugins"
# Install plugins using TPM
"$HOME/.tmux/plugins/tpm/bin/install_plugins"
printf "tmux plugins installed successfully \n"
printf "tmux plugins installed successfully \n"
}
# Main execution
main() {
printf "$${BOLD} 🛠️Setting up tmux with session persistence! \n\n"
printf ""
printf "$${BOLD} 🛠️Setting up tmux with session persistence! \n\n"
printf ""
# Install dependencies
install_tmux
install_tpm
# Install dependencies
install_tmux
install_tpm
# Setup tmux configuration
setup_tmux_config
# Setup tmux configuration
setup_tmux_config
# Install plugins
install_plugins
# Install plugins
install_plugins
printf "$${BOLD}✅ tmux setup complete! \n\n"
printf "$${BOLD}✅ tmux setup complete! \n\n"
printf "$${BOLD} Attempting to restore sessions\n"
tmux new-session -d \; source-file ~/.tmux.conf \; run-shell '~/.tmux/plugins/tmux-resurrect/scripts/restore.sh'
printf "$${BOLD} Sessions restored: -> %s\n" "$(tmux ls)"
printf "$${BOLD} Attempting to restore sessions\n"
tmux new-session -d \; source-file ~/.tmux.conf \; run-shell '~/.tmux/plugins/tmux-resurrect/scripts/restore.sh'
printf "$${BOLD} Sessions restored: -> %s\n" "$(tmux ls)"
}
# Run main function
main
main
@@ -16,7 +16,7 @@ handle_session() {
local session_name="$1"
# Check if the session exists
if tmux has-session -t "$session_name" 2>/dev/null; then
if tmux has-session -t "$session_name" 2> /dev/null; then
echo "Session '$session_name' exists, attaching to it..."
tmux attach-session -t "$session_name"
else
@@ -165,9 +165,9 @@ describe("auggie", async () => {
mcpServers: {
test: {
command: "test-cmd",
type: "stdio"
}
}
type: "stdio",
},
},
});
const { id } = await setup({
moduleVariables: {
@@ -187,13 +187,16 @@ describe("auggie", 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
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");
const rulesFile = await readFileContainer(
id,
"/home/coder/.augment/rules.md",
);
expect(rulesFile).toContain(rules);
});
@@ -309,12 +312,15 @@ describe("auggie", async () => {
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
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");
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");
@@ -25,7 +25,6 @@ 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"
@@ -51,28 +50,27 @@ function install_auggie() {
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=$(
@@ -39,7 +39,6 @@ printf "report_tasks: %s\n" "$ARG_REPORT_TASKS"
echo "--------------------------------"
function validate_auggie_installation() {
if command_exists auggie; then
printf "Auggie is installed\n"
+19 -16
View File
@@ -124,8 +124,8 @@ describe("codex", async () => {
});
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('sandbox_mode = "danger-full-access"');
expect(resp).toContain('preferred_auth_method = "apikey"');
expect(resp).toContain("[custom_section]");
expect(resp).toContain("[mcp_servers.Coder]");
});
@@ -221,7 +221,7 @@ describe("codex", async () => {
debug = true
logging_level = "verbose"
`.trim();
const additionalMCP = dedent`
[mcp_servers.CustomTool]
command = "/usr/local/bin/custom-tool"
@@ -235,7 +235,7 @@ describe("codex", async () => {
type = "stdio"
description = "Database query interface"
`.trim();
const { id } = await setup({
moduleVariables: {
base_config_toml: baseConfig,
@@ -244,14 +244,14 @@ describe("codex", async () => {
});
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('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\"");
expect(resp).toContain('logging_level = "verbose"');
// Check MCP servers
expect(resp).toContain("[mcp_servers.Coder]");
expect(resp).toContain("[mcp_servers.CustomTool]");
@@ -268,17 +268,17 @@ describe("codex", async () => {
});
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_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);
@@ -328,7 +328,10 @@ describe("codex", async () => {
},
});
await execModuleScript(id_2);
const resp_2 = await readFileContainer(id_2, "/home/coder/.codex/AGENTS.md");
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);
@@ -84,8 +84,8 @@ function install_codex() {
}
write_minimal_default_config() {
local config_path="$1"
cat << EOF > "$config_path"
local config_path="$1"
cat << EOF > "$config_path"
# Minimal Default Codex Configuration
sandbox_mode = "workspace-write"
approval_policy = "never"
@@ -98,9 +98,9 @@ EOF
}
append_mcp_servers_section() {
local config_path="$1"
cat << EOF >> "$config_path"
local config_path="$1"
cat << EOF >> "$config_path"
# MCP Servers Configuration
[mcp_servers.Coder]
@@ -112,32 +112,32 @@ type = "stdio"
EOF
if [ -n "$ARG_ADDITIONAL_MCP_SERVERS" ]; then
printf "Adding additional MCP servers\n"
echo "$ARG_ADDITIONAL_MCP_SERVERS" >> "$config_path"
fi
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"
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
@@ -146,7 +146,7 @@ function add_instruction_prompt_if_exists() {
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}" || {
@@ -55,8 +55,6 @@ 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"
@@ -65,7 +63,6 @@ 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
@@ -1,12 +1,22 @@
import { afterEach, beforeAll, describe, expect, setDefaultTimeout, test } from "bun:test";
import {
afterEach,
beforeAll,
describe,
expect,
setDefaultTimeout,
test,
} from "bun:test";
import { execContainer, runTerraformInit, writeFileContainer } from "~test";
import {
execModuleScript,
expectAgentAPIStarted,
loadTestFile,
setup as setupUtil
setup as setupUtil,
} from "../../../coder/modules/agentapi/test-util";
import {
setupContainer,
writeExecutable,
} 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);
@@ -72,11 +82,12 @@ describe("cursor-cli", async () => {
});
test("agentapi-mcp-json", async () => {
const mcpJson = '{"mcpServers": {"test": {"command": "test-cmd", "type": "stdio"}}}';
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);
@@ -99,7 +110,7 @@ describe("cursor-cli", async () => {
const { id } = await setup({
moduleVariables: {
rules_files: JSON.stringify({ "typescript.md": rulesContent }),
}
},
});
const resp = await execModuleScript(id);
expect(resp.exitCode).toBe(0);
@@ -118,7 +129,7 @@ describe("cursor-cli", async () => {
const { id } = await setup({
moduleVariables: {
api_key: apiKey,
}
},
});
const resp = await execModuleScript(id);
expect(resp.exitCode).toBe(0);
@@ -138,7 +149,7 @@ describe("cursor-cli", async () => {
model: model,
force: "true",
ai_prompt: "test prompt",
}
},
});
const resp = await execModuleScript(id);
expect(resp.exitCode).toBe(0);
@@ -158,7 +169,7 @@ describe("cursor-cli", async () => {
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);
@@ -183,7 +194,7 @@ describe("cursor-cli", async () => {
const { id } = await setup({
moduleVariables: {
folder: folder,
}
},
});
const resp = await execModuleScript(id);
expect(resp.exitCode).toBe(0);
@@ -205,8 +216,5 @@ describe("cursor-cli", async () => {
expect(resp.exitCode).toBe(0);
await expectAgentAPIStarted(id);
})
});
});
@@ -58,7 +58,7 @@ 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")
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
@@ -9,6 +9,6 @@ fi
set -e
while true; do
echo "$(date) - cursor-agent-mock"
sleep 15
done
echo "$(date) - cursor-agent-mock"
sleep 15
done
+5 -5
View File
@@ -13,7 +13,7 @@ Run [Gemini CLI](https://github.com/google-gemini/gemini-cli) in your workspace
```tf
module "gemini" {
source = "registry.coder.com/coder-labs/gemini/coder"
version = "1.1.0"
version = "2.0.0"
agent_id = coder_agent.example.id
folder = "/home/coder/project"
}
@@ -30,7 +30,7 @@ module "gemini" {
## Prerequisites
- Node.js and npm will be installed automatically if not present
- **Node.js and npm must be sourced/available before the gemini module installs** - ensure they are installed in your workspace image or via earlier provisioning steps
- The [Coder Login](https://registry.coder.com/modules/coder/coder-login) module is required
## Examples
@@ -46,7 +46,7 @@ variable "gemini_api_key" {
module "gemini" {
source = "registry.coder.com/coder-labs/gemini/coder"
version = "1.1.0"
version = "2.0.0"
agent_id = coder_agent.example.id
gemini_api_key = var.gemini_api_key
folder = "/home/coder/project"
@@ -94,7 +94,7 @@ data "coder_parameter" "ai_prompt" {
module "gemini" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder-labs/gemini/coder"
version = "1.1.0"
version = "2.0.0"
agent_id = coder_agent.example.id
gemini_api_key = var.gemini_api_key
gemini_model = "gemini-2.5-flash"
@@ -118,7 +118,7 @@ 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"
version = "2.0.0"
agent_id = coder_agent.example.id
gemini_api_key = var.gemini_api_key
folder = "/home/coder/project"
+42 -11
View File
@@ -127,7 +127,10 @@ describe("gemini", async () => {
},
});
await execModuleScript(id);
const resp = await readFileContainer(id, "/home/coder/.gemini/settings.json");
const resp = await readFileContainer(
id,
"/home/coder/.gemini/settings.json",
);
expect(resp).toContain("foo");
expect(resp).toContain("bar");
});
@@ -141,7 +144,10 @@ describe("gemini", async () => {
});
await execModuleScript(id);
const resp = await readFileContainer(id, "/home/coder/.gemini-module/agentapi-start.log");
const resp = await readFileContainer(
id,
"/home/coder/.gemini-module/agentapi-start.log",
);
expect(resp).toContain("Using direct Gemini API with API key");
});
@@ -153,8 +159,11 @@ describe("gemini", async () => {
},
});
await execModuleScript(id);
const resp = await readFileContainer(id, "/home/coder/.gemini-module/install.log");
expect(resp).toContain('GOOGLE_GENAI_USE_VERTEXAI=\'true\'');
const resp = await readFileContainer(
id,
"/home/coder/.gemini-module/agentapi-start.log",
);
expect(resp).toContain("GOOGLE_GENAI_USE_VERTEXAI='true'");
});
test("gemini-model", async () => {
@@ -166,7 +175,10 @@ describe("gemini", async () => {
},
});
await execModuleScript(id);
const resp = await readFileContainer(id, "/home/coder/.gemini-module/install.log");
const resp = await readFileContainer(
id,
"/home/coder/.gemini-module/agentapi-start.log",
);
expect(resp).toContain(model);
});
@@ -178,9 +190,15 @@ describe("gemini", async () => {
},
});
await execModuleScript(id);
const preInstallLog = await readFileContainer(id, "/home/coder/.gemini-module/pre_install.log");
const preInstallLog = await readFileContainer(
id,
"/home/coder/.gemini-module/pre_install.log",
);
expect(preInstallLog).toContain("pre-install-script");
const postInstallLog = await readFileContainer(id, "/home/coder/.gemini-module/post_install.log");
const postInstallLog = await readFileContainer(
id,
"/home/coder/.gemini-module/post_install.log",
);
expect(postInstallLog).toContain("post-install-script");
});
@@ -193,7 +211,10 @@ describe("gemini", async () => {
},
});
await execModuleScript(id);
const resp = await readFileContainer(id, "/home/coder/.gemini-module/install.log");
const resp = await readFileContainer(
id,
"/home/coder/.gemini-module/agentapi-start.log",
);
expect(resp).toContain(folder);
});
@@ -205,7 +226,10 @@ describe("gemini", async () => {
},
});
await execModuleScript(id);
const resp = await readFileContainer(id, "/home/coder/.gemini/settings.json");
const resp = await readFileContainer(
id,
"/home/coder/.gemini/settings.json",
);
expect(resp).toContain("custom");
expect(resp).toContain("enabled");
});
@@ -232,14 +256,21 @@ describe("gemini", async () => {
await execModuleScript(id, {
GEMINI_TASK_PROMPT: taskPrompt,
});
const resp = await readFileContainer(id, "/home/coder/.gemini-module/agentapi-start.log");
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);
const prompt = await execContainer(id, ["ls", "-l", "/home/coder/GEMINI.md"]);
const prompt = await execContainer(id, [
"ls",
"-l",
"/home/coder/GEMINI.md",
]);
expect(prompt.exitCode).not.toBe(0);
expect(prompt.stderr).toContain("No such file or directory");
});
@@ -1,9 +1,9 @@
#!/bin/bash
BOLD='\033[0;1m'
source "$HOME"/.bashrc
command_exists() {
command -v "$1" >/dev/null 2>&1
command -v "$1" > /dev/null 2>&1
}
set -o nounset
@@ -21,144 +21,132 @@ 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
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
nvm install --lts
nvm use --lts
nvm alias default node
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 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
printf "Node.js version: %s\n" "$(node --version)"
printf "npm version: %s\n" "$(npm --version)"
}
function install_gemini() {
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
check_dependencies
printf "%s Installing Gemini 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_GEMINI_VERSION" ]; then
npm install -g "@google/gemini-cli@$ARG_GEMINI_VERSION"
else
npm install -g "@google/gemini-cli"
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 Gemini CLI. Version: %s\n" "${BOLD}" "$(gemini --version)"
fi
}
function populate_settings_json() {
if [ "${ARG_GEMINI_CONFIG}" != "" ]; then
SETTINGS_PATH="$HOME/.gemini/settings.json"
mkdir -p "$(dirname "$SETTINGS_PATH")"
printf "Custom gemini_config is provided !\n"
echo "${ARG_GEMINI_CONFIG}" > "$HOME/.gemini/settings.json"
else
printf "No custom gemini_config provided, using default settings.json.\n"
append_extensions_to_settings_json
fi
if [ "${ARG_GEMINI_CONFIG}" != "" ]; then
SETTINGS_PATH="$HOME/.gemini/settings.json"
mkdir -p "$(dirname "$SETTINGS_PATH")"
printf "Custom gemini_config is provided !\n"
echo "${ARG_GEMINI_CONFIG}" > "$HOME/.gemini/settings.json"
else
printf "No custom gemini_config provided, using default settings.json.\n"
append_extensions_to_settings_json
fi
}
function append_extensions_to_settings_json() {
SETTINGS_PATH="$HOME/.gemini/settings.json"
mkdir -p "$(dirname "$SETTINGS_PATH")"
printf "[append_extensions_to_settings_json] Starting extension merge process...\n"
if [ -z "${BASE_EXTENSIONS:-}" ]; then
printf "[append_extensions_to_settings_json] BASE_EXTENSIONS is empty, skipping merge.\n"
return
fi
if [ ! -f "$SETTINGS_PATH" ]; then
printf "%s does not exist. Creating with merged mcpServers structure.\n" "$SETTINGS_PATH"
ADD_EXT_JSON='{}'
if [ -n "${ADDITIONAL_EXTENSIONS:-}" ]; then
ADD_EXT_JSON="$ADDITIONAL_EXTENSIONS"
fi
printf '{"mcpServers":%s}\n' "$(jq -s 'add' <(echo "$BASE_EXTENSIONS") <(echo "$ADD_EXT_JSON"))" > "$SETTINGS_PATH"
fi
TMP_SETTINGS=$(mktemp)
SETTINGS_PATH="$HOME/.gemini/settings.json"
mkdir -p "$(dirname "$SETTINGS_PATH")"
printf "[append_extensions_to_settings_json] Starting extension merge process...\n"
if [ -z "${BASE_EXTENSIONS:-}" ]; then
printf "[append_extensions_to_settings_json] BASE_EXTENSIONS is empty, skipping merge.\n"
return
fi
if [ ! -f "$SETTINGS_PATH" ]; then
printf "%s does not exist. Creating with merged mcpServers structure.\n" "$SETTINGS_PATH"
ADD_EXT_JSON='{}'
if [ -n "${ADDITIONAL_EXTENSIONS:-}" ]; then
printf "[append_extensions_to_settings_json] ADDITIONAL_EXTENSIONS is set.\n"
ADD_EXT_JSON="$ADDITIONAL_EXTENSIONS"
else
printf "[append_extensions_to_settings_json] ADDITIONAL_EXTENSIONS is empty or not set.\n"
fi
printf '{"mcpServers":%s}\n' "$(jq -s 'add' <(echo "$BASE_EXTENSIONS") <(echo "$ADD_EXT_JSON"))" > "$SETTINGS_PATH"
fi
printf "[append_extensions_to_settings_json] Merging BASE_EXTENSIONS and ADDITIONAL_EXTENSIONS into mcpServers...\n"
jq --argjson base "$BASE_EXTENSIONS" --argjson add "$ADD_EXT_JSON" \
'.mcpServers = (.mcpServers // {} + $base + $add)' \
"$SETTINGS_PATH" > "$TMP_SETTINGS" && mv "$TMP_SETTINGS" "$SETTINGS_PATH"
TMP_SETTINGS=$(mktemp)
ADD_EXT_JSON='{}'
if [ -n "${ADDITIONAL_EXTENSIONS:-}" ]; then
printf "[append_extensions_to_settings_json] ADDITIONAL_EXTENSIONS is set.\n"
ADD_EXT_JSON="$ADDITIONAL_EXTENSIONS"
else
printf "[append_extensions_to_settings_json] ADDITIONAL_EXTENSIONS is empty or not set.\n"
fi
jq '.theme = "Default" | .selectedAuthType = "gemini-api-key"' "$SETTINGS_PATH" > "$TMP_SETTINGS" && mv "$TMP_SETTINGS" "$SETTINGS_PATH"
printf "[append_extensions_to_settings_json] Merging BASE_EXTENSIONS and ADDITIONAL_EXTENSIONS into mcpServers...\n"
jq --argjson base "$BASE_EXTENSIONS" --argjson add "$ADD_EXT_JSON" \
'.mcpServers = (.mcpServers // {} + $base + $add)' \
"$SETTINGS_PATH" > "$TMP_SETTINGS" && mv "$TMP_SETTINGS" "$SETTINGS_PATH"
printf "[append_extensions_to_settings_json] Merge complete.\n"
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_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}" || {
printf "Error: Could not change to directory '%s'.\\n" "${GEMINI_START_DIRECTORY}"
exit 1
}
else
printf "Directory '%s' does not exist. Creating and changing to it.\\n" "${GEMINI_START_DIRECTORY}"
mkdir -p "${GEMINI_START_DIRECTORY}" || {
printf "Error: Could not create directory '%s'.\\n" "${GEMINI_START_DIRECTORY}"
exit 1
}
cd "${GEMINI_START_DIRECTORY}" || {
printf "Error: Could not change to directory '%s'.\\n" "${GEMINI_START_DIRECTORY}"
exit 1
}
fi
touch GEMINI.md
printf "Setting GEMINI.md\n"
echo "${GEMINI_SYSTEM_PROMPT}" > GEMINI.md
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}" || {
printf "Error: Could not change to directory '%s'.\\n" "${GEMINI_START_DIRECTORY}"
exit 1
}
else
printf "GEMINI.md is not set.\n"
printf "Directory '%s' does not exist. Creating and changing to it.\\n" "${GEMINI_START_DIRECTORY}"
mkdir -p "${GEMINI_START_DIRECTORY}" || {
printf "Error: Could not create directory '%s'.\\n" "${GEMINI_START_DIRECTORY}"
exit 1
}
cd "${GEMINI_START_DIRECTORY}" || {
printf "Error: Could not change to directory '%s'.\\n" "${GEMINI_START_DIRECTORY}"
exit 1
}
fi
touch GEMINI.md
printf "Setting GEMINI.md\n"
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}"
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
gemini --version
populate_settings_json
add_system_prompt_if_exists
configure_mcp
@@ -5,7 +5,7 @@ set -o pipefail
source "$HOME"/.bashrc
command_exists() {
command -v "$1" >/dev/null 2>&1
command -v "$1" > /dev/null 2>&1
}
if [ -f "$HOME/.nvm/nvm.sh" ]; then
@@ -20,55 +20,55 @@ MODULE_DIR="$HOME/.gemini-module"
mkdir -p "$MODULE_DIR"
if command_exists gemini; then
printf "Gemini is installed\n"
printf "Gemini is installed\n"
else
printf "Error: Gemini is not installed. Please enable install_gemini or install it manually :)\n"
exit 1
printf "Error: Gemini is not installed. Please enable install_gemini or install it manually :)\n"
exit 1
fi
if [ -d "${GEMINI_START_DIRECTORY}" ]; then
printf "Directory '%s' exists. Changing to it.\\n" "${GEMINI_START_DIRECTORY}"
cd "${GEMINI_START_DIRECTORY}" || {
printf "Error: Could not change to directory '%s'.\\n" "${GEMINI_START_DIRECTORY}"
exit 1
}
printf "Directory '%s' exists. Changing to it.\\n" "${GEMINI_START_DIRECTORY}"
cd "${GEMINI_START_DIRECTORY}" || {
printf "Error: Could not change to directory '%s'.\\n" "${GEMINI_START_DIRECTORY}"
exit 1
}
else
printf "Directory '%s' does not exist. Creating and changing to it.\\n" "${GEMINI_START_DIRECTORY}"
mkdir -p "${GEMINI_START_DIRECTORY}" || {
printf "Error: Could not create directory '%s'.\\n" "${GEMINI_START_DIRECTORY}"
exit 1
}
cd "${GEMINI_START_DIRECTORY}" || {
printf "Error: Could not change to directory '%s'.\\n" "${GEMINI_START_DIRECTORY}"
exit 1
}
printf "Directory '%s' does not exist. Creating and changing to it.\\n" "${GEMINI_START_DIRECTORY}"
mkdir -p "${GEMINI_START_DIRECTORY}" || {
printf "Error: Could not create directory '%s'.\\n" "${GEMINI_START_DIRECTORY}"
exit 1
}
cd "${GEMINI_START_DIRECTORY}" || {
printf "Error: Could not change to directory '%s'.\\n" "${GEMINI_START_DIRECTORY}"
exit 1
}
fi
if [ -n "$GEMINI_TASK_PROMPT" ]; then
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")
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 "Starting Gemini CLI in interactive mode.\n"
GEMINI_ARGS=()
printf "Starting Gemini CLI in interactive mode.\n"
GEMINI_ARGS=()
fi
if [ -n "$GEMINI_YOLO_MODE" ] && [ "$GEMINI_YOLO_MODE" = "true" ]; then
printf "YOLO mode enabled - will auto-approve all tool calls\n"
GEMINI_ARGS+=(--yolo)
printf "YOLO mode enabled - will auto-approve all tool calls\n"
GEMINI_ARGS+=(--yolo)
fi
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
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"
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[@]}")"
bash -c "$(printf '%q ' gemini "${GEMINI_ARGS[@]}")"
@@ -1,18 +1,18 @@
FROM ubuntu
RUN apt-get update \
&& apt-get install -y \
curl \
git \
golang \
sudo \
vim \
wget \
&& rm -rf /var/lib/apt/lists/*
&& apt-get install -y \
curl \
git \
golang \
sudo \
vim \
wget \
&& rm -rf /var/lib/apt/lists/*
ARG USER=coder
RUN useradd --groups sudo --no-create-home --shell /bin/bash ${USER} \
&& echo "${USER} ALL=(ALL) NOPASSWD:ALL" >/etc/sudoers.d/${USER} \
&& chmod 0440 /etc/sudoers.d/${USER}
&& echo "${USER} ALL=(ALL) NOPASSWD:ALL" >/etc/sudoers.d/${USER} \
&& chmod 0440 /etc/sudoers.d/${USER}
USER ${USER}
WORKDIR /home/${USER}
+14 -6
View File
@@ -164,7 +164,9 @@ describe("agentapi", async () => {
id,
"/home/coder/test-agentapi-start.log",
);
expect(agentApiStartLog).toContain("Using AGENTAPI_CHAT_BASE_PATH: /@default/default.foo/apps/agentapi-web/chat");
expect(agentApiStartLog).toContain(
"Using AGENTAPI_CHAT_BASE_PATH: /@default/default.foo/apps/agentapi-web/chat",
);
});
test("validate-agentapi-version", async () => {
@@ -186,14 +188,16 @@ describe("agentapi", async () => {
agentapi_version: "v0.0.1",
agentapi_subdomain: "false",
},
shouldThrow: "Running with subdomain = false is only supported by agentapi >= v0.3.3.",
shouldThrow:
"Running with subdomain = false is only supported by agentapi >= v0.3.3.",
},
{
moduleVariables: {
agentapi_version: "v0.3.2",
agentapi_subdomain: "false",
},
shouldThrow: "Running with subdomain = false is only supported by agentapi >= v0.3.3.",
shouldThrow:
"Running with subdomain = false is only supported by agentapi >= v0.3.3.",
},
{
moduleVariables: {
@@ -226,13 +230,17 @@ describe("agentapi", async () => {
agentapi_version: "arbitrary-string-bypasses-validation",
},
shouldThrow: "",
}
},
];
for (const { moduleVariables, shouldThrow } of cases) {
if (shouldThrow) {
expect(setup({ moduleVariables: moduleVariables as Record<string, string> })).rejects.toThrow(shouldThrow);
expect(
setup({ moduleVariables: moduleVariables as Record<string, string> }),
).rejects.toThrow(shouldThrow);
} else {
expect(setup({ moduleVariables: moduleVariables as Record<string, string> })).resolves.toBeDefined();
expect(
setup({ moduleVariables: moduleVariables as Record<string, string> }),
).resolves.toBeDefined();
}
}
});
@@ -11,22 +11,22 @@ agentapi_started=false
echo "Waiting for agentapi server to start on port $port..."
for i in $(seq 1 150); do
for j in $(seq 1 3); do
sleep 0.1
if curl -fs -o /dev/null "http://localhost:$port/status"; then
echo "agentapi response received ($j/3)"
else
echo "agentapi server not responding ($i/15)"
continue 2
fi
done
agentapi_started=true
break
for j in $(seq 1 3); do
sleep 0.1
if curl -fs -o /dev/null "http://localhost:$port/status"; then
echo "agentapi response received ($j/3)"
else
echo "agentapi server not responding ($i/15)"
continue 2
fi
done
agentapi_started=true
break
done
if [ "$agentapi_started" != "true" ]; then
echo "Error: agentapi server did not start on port $port after 15 seconds."
exit 1
echo "Error: agentapi server did not start on port $port after 15 seconds."
exit 1
fi
echo "agentapi server started on port $port."
+47 -47
View File
@@ -17,76 +17,76 @@ AGENTAPI_CHAT_BASE_PATH="${ARG_AGENTAPI_CHAT_BASE_PATH:-}"
set +o nounset
command_exists() {
command -v "$1" >/dev/null 2>&1
command -v "$1" > /dev/null 2>&1
}
module_path="$HOME/${MODULE_DIR_NAME}"
mkdir -p "$module_path/scripts"
if [ ! -d "${WORKDIR}" ]; then
echo "Warning: The specified folder '${WORKDIR}' does not exist."
echo "Creating the folder..."
mkdir -p "${WORKDIR}"
echo "Folder created successfully."
echo "Warning: The specified folder '${WORKDIR}' does not exist."
echo "Creating the folder..."
mkdir -p "${WORKDIR}"
echo "Folder created successfully."
fi
if [ -n "${PRE_INSTALL_SCRIPT}" ]; then
echo "Running pre-install script..."
echo -n "${PRE_INSTALL_SCRIPT}" >"$module_path/pre_install.sh"
chmod +x "$module_path/pre_install.sh"
"$module_path/pre_install.sh" 2>&1 | tee "$module_path/pre_install.log"
echo "Running pre-install script..."
echo -n "${PRE_INSTALL_SCRIPT}" > "$module_path/pre_install.sh"
chmod +x "$module_path/pre_install.sh"
"$module_path/pre_install.sh" 2>&1 | tee "$module_path/pre_install.log"
fi
echo "Running install script..."
echo -n "${INSTALL_SCRIPT}" >"$module_path/install.sh"
echo -n "${INSTALL_SCRIPT}" > "$module_path/install.sh"
chmod +x "$module_path/install.sh"
"$module_path/install.sh" 2>&1 | tee "$module_path/install.log"
# Install AgentAPI if enabled
if [ "${INSTALL_AGENTAPI}" = "true" ]; then
echo "Installing AgentAPI..."
arch=$(uname -m)
if [ "$arch" = "x86_64" ]; then
binary_name="agentapi-linux-amd64"
elif [ "$arch" = "aarch64" ]; then
binary_name="agentapi-linux-arm64"
else
echo "Error: Unsupported architecture: $arch"
exit 1
fi
if [ "${AGENTAPI_VERSION}" = "latest" ]; then
# for the latest release the download URL pattern is different than for tagged releases
# https://docs.github.com/en/repositories/releasing-projects-on-github/linking-to-releases
download_url="https://github.com/coder/agentapi/releases/latest/download/$binary_name"
else
download_url="https://github.com/coder/agentapi/releases/download/${AGENTAPI_VERSION}/$binary_name"
fi
curl \
--retry 5 \
--retry-delay 5 \
--fail \
--retry-all-errors \
-L \
-C - \
-o agentapi \
"$download_url"
chmod +x agentapi
sudo mv agentapi /usr/local/bin/agentapi
echo "Installing AgentAPI..."
arch=$(uname -m)
if [ "$arch" = "x86_64" ]; then
binary_name="agentapi-linux-amd64"
elif [ "$arch" = "aarch64" ]; then
binary_name="agentapi-linux-arm64"
else
echo "Error: Unsupported architecture: $arch"
exit 1
fi
if [ "${AGENTAPI_VERSION}" = "latest" ]; then
# for the latest release the download URL pattern is different than for tagged releases
# https://docs.github.com/en/repositories/releasing-projects-on-github/linking-to-releases
download_url="https://github.com/coder/agentapi/releases/latest/download/$binary_name"
else
download_url="https://github.com/coder/agentapi/releases/download/${AGENTAPI_VERSION}/$binary_name"
fi
curl \
--retry 5 \
--retry-delay 5 \
--fail \
--retry-all-errors \
-L \
-C - \
-o agentapi \
"$download_url"
chmod +x agentapi
sudo mv agentapi /usr/local/bin/agentapi
fi
if ! command_exists agentapi; then
echo "Error: AgentAPI is not installed. Please enable install_agentapi or install it manually."
exit 1
echo "Error: AgentAPI is not installed. Please enable install_agentapi or install it manually."
exit 1
fi
echo -n "${START_SCRIPT}" >"$module_path/scripts/agentapi-start.sh"
echo -n "${WAIT_FOR_START_SCRIPT}" >"$module_path/scripts/agentapi-wait-for-start.sh"
echo -n "${START_SCRIPT}" > "$module_path/scripts/agentapi-start.sh"
echo -n "${WAIT_FOR_START_SCRIPT}" > "$module_path/scripts/agentapi-wait-for-start.sh"
chmod +x "$module_path/scripts/agentapi-start.sh"
chmod +x "$module_path/scripts/agentapi-wait-for-start.sh"
if [ -n "${POST_INSTALL_SCRIPT}" ]; then
echo "Running post-install script..."
echo -n "${POST_INSTALL_SCRIPT}" >"$module_path/post_install.sh"
chmod +x "$module_path/post_install.sh"
"$module_path/post_install.sh" 2>&1 | tee "$module_path/post_install.log"
echo "Running post-install script..."
echo -n "${POST_INSTALL_SCRIPT}" > "$module_path/post_install.sh"
chmod +x "$module_path/post_install.sh"
"$module_path/post_install.sh" 2>&1 | tee "$module_path/post_install.log"
fi
export LANG=en_US.UTF-8
@@ -97,5 +97,5 @@ 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" &
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}"
+9 -3
View File
@@ -25,14 +25,20 @@ export const setupContainer = async ({
const coderScript = findResourceInstance(state, "coder_script");
const id = await runContainer(image ?? "codercom/enterprise-node:latest");
return {
id, coderScript, cleanup: async () => {
if (process.env["DEBUG"] === "true" || process.env["DEBUG"] === "1" || process.env["DEBUG"] === "yes") {
id,
coderScript,
cleanup: async () => {
if (
process.env["DEBUG"] === "true" ||
process.env["DEBUG"] === "1" ||
process.env["DEBUG"] === "yes"
) {
console.log(`Not removing container ${id} in debug mode`);
console.log(`Run "docker rm -f ${id}" to remove it manually.`);
} else {
await removeContainer(id);
}
}
},
};
};
+4 -1
View File
@@ -7,7 +7,10 @@ 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}`);
fs.writeFileSync(
"/home/coder/agentapi-mock.log",
`AGENTAPI_ALLOWED_HOSTS: ${process.env.AGENTAPI_ALLOWED_HOSTS}`,
);
http
.createServer(function (_request, response) {
+6 -6
View File
@@ -8,15 +8,15 @@ port=${2:-3284}
module_path="$HOME/.agentapi-module"
log_file_path="$module_path/agentapi.log"
echo "using prompt: $use_prompt" >>/home/coder/test-agentapi-start.log
echo "using port: $port" >>/home/coder/test-agentapi-start.log
echo "using prompt: $use_prompt" >> /home/coder/test-agentapi-start.log
echo "using port: $port" >> /home/coder/test-agentapi-start.log
AGENTAPI_CHAT_BASE_PATH="${AGENTAPI_CHAT_BASE_PATH:-}"
if [ -n "$AGENTAPI_CHAT_BASE_PATH" ]; then
echo "Using AGENTAPI_CHAT_BASE_PATH: $AGENTAPI_CHAT_BASE_PATH" >>/home/coder/test-agentapi-start.log
export AGENTAPI_CHAT_BASE_PATH
echo "Using AGENTAPI_CHAT_BASE_PATH: $AGENTAPI_CHAT_BASE_PATH" >> /home/coder/test-agentapi-start.log
export AGENTAPI_CHAT_BASE_PATH
fi
agentapi server --port "$port" --term-width 67 --term-height 1190 -- \
bash -c aiagent \
>"$log_file_path" 2>&1
bash -c aiagent \
> "$log_file_path" 2>&1
+3 -3
View File
@@ -13,7 +13,7 @@ Run the [Claude Code](https://docs.anthropic.com/en/docs/agents-and-tools/claude
```tf
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "2.1.0"
version = "2.2.0"
agent_id = coder_agent.example.id
folder = "/home/coder"
install_claude_code = true
@@ -83,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.1.0"
version = "2.2.0"
agent_id = coder_agent.example.id
folder = "/home/coder"
install_claude_code = true
@@ -101,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.1.0"
version = "2.2.0"
agent_id = coder_agent.example.id
folder = "/home/coder"
install_claude_code = true
+21 -3
View File
@@ -100,7 +100,13 @@ variable "install_agentapi" {
variable "agentapi_version" {
type = string
description = "The version of AgentAPI to install."
default = "v0.3.0"
default = "v0.3.3"
}
variable "subdomain" {
type = bool
description = "Whether to use a subdomain for the Claude Code app."
default = true
}
locals {
@@ -113,6 +119,15 @@ locals {
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.sh"))
claude_code_app_slug = "ccw"
// Chat base path is only set if not using a subdomain.
// NOTE:
// - Initial support for --chat-base-path was added in v0.3.1 but configuration
// via environment variable AGENTAPI_CHAT_BASE_PATH was added in v0.3.3.
// - As CODER_WORKSPACE_AGENT_NAME is a recent addition we use agent ID
// for backward compatibility.
agentapi_chat_base_path = var.subdomain ? "" : "/@${data.coder_workspace_owner.me.name}/${data.coder_workspace.me.name}.${var.agent_id}/apps/${local.claude_code_app_slug}/chat"
server_base_path = var.subdomain ? "" : "/@${data.coder_workspace_owner.me.name}/${data.coder_workspace.me.name}.${var.agent_id}/apps/${local.claude_code_app_slug}"
healthcheck_url = "http://localhost:3284${local.server_base_path}/status"
}
# Install and Initialize Claude Code
@@ -229,6 +244,9 @@ resource "coder_script" "claude_code" {
# Disable host header check since AgentAPI is proxied by Coder (which does its own validation)
export AGENTAPI_ALLOWED_HOSTS="*"
# Set chat base path for non-subdomain routing (only set if not using subdomain)
export AGENTAPI_CHAT_BASE_PATH="${local.agentapi_chat_base_path}"
nohup "$module_path/scripts/agentapi-start.sh" use_prompt &> "$module_path/agentapi-start.log" &
"$module_path/scripts/agentapi-wait-for-start.sh"
@@ -245,9 +263,9 @@ resource "coder_app" "claude_code_web" {
icon = var.icon
order = var.order
group = var.group
subdomain = true
subdomain = var.subdomain
healthcheck {
url = "http://localhost:3284/status"
url = local.healthcheck_url
interval = 3
threshold = 20
}
@@ -9,33 +9,33 @@ log_file_path="$module_path/agentapi.log"
# if the first argument is not empty, start claude with the prompt
if [ -n "$1" ]; then
cp "$module_path/prompt.txt" /tmp/claude-code-prompt
cp "$module_path/prompt.txt" /tmp/claude-code-prompt
else
rm -f /tmp/claude-code-prompt
rm -f /tmp/claude-code-prompt
fi
# if the log file already exists, archive it
if [ -f "$log_file_path" ]; then
mv "$log_file_path" "$log_file_path"".$(date +%s)"
mv "$log_file_path" "$log_file_path"".$(date +%s)"
fi
# see the remove-last-session-id.sh script for details
# about why we need it
# avoid exiting if the script fails
bash "$scripts_dir/remove-last-session-id.sh" "$(pwd)" 2>/dev/null || 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
function start_agentapi() {
local continue_flag="$1"
local prompt_subshell='"$(cat /tmp/claude-code-prompt)"'
# use low width to fit in the tasks UI sidebar. height is adjusted so that width x height ~= 80x1000 characters
# visible in the terminal screen by default.
agentapi server --term-width 67 --term-height 1190 -- \
bash -c "claude $continue_flag --dangerously-skip-permissions $prompt_subshell" \
> "$log_file_path" 2>&1
local continue_flag="$1"
local prompt_subshell='"$(cat /tmp/claude-code-prompt)"'
# use low width to fit in the tasks UI sidebar. height is adjusted so that width x height ~= 80x1000 characters
# visible in the terminal screen by default.
agentapi server --term-width 67 --term-height 1190 -- \
bash -c "claude $continue_flag --dangerously-skip-permissions $prompt_subshell" \
> "$log_file_path" 2>&1
}
echo "Starting AgentAPI..."
@@ -47,15 +47,15 @@ exit_code=$?
echo "First AgentAPI exit code: $exit_code"
if [ $exit_code -eq 0 ]; then
exit 0
exit 0
fi
# if there was no conversation to continue, claude exited with an error.
# start claude without the --continue flag.
if grep -q "No conversation found to continue" "$log_file_path"; then
echo "AgentAPI with --continue flag failed, starting claude without it."
start_agentapi
exit_code=$?
echo "AgentAPI with --continue flag failed, starting claude without it."
start_agentapi
exit_code=$?
fi
echo "Second AgentAPI exit code: $exit_code"
@@ -9,22 +9,22 @@ agentapi_started=false
echo "Waiting for agentapi server to start on port 3284..."
for i in $(seq 1 150); do
for j in $(seq 1 3); do
sleep 0.1
if curl -fs -o /dev/null "http://localhost:3284/status"; then
echo "agentapi response received ($j/3)"
else
echo "agentapi server not responding ($i/15)"
continue 2
fi
done
agentapi_started=true
break
for j in $(seq 1 3); do
sleep 0.1
if curl -fs -o /dev/null "http://localhost:3284/status"; then
echo "agentapi response received ($j/3)"
else
echo "agentapi server not responding ($i/15)"
continue 2
fi
done
agentapi_started=true
break
done
if [ "$agentapi_started" != "true" ]; then
echo "Error: agentapi server did not start on port 3284 after 15 seconds."
exit 1
echo "Error: agentapi server did not start on port 3284 after 15 seconds."
exit 1
fi
echo "agentapi server started on port 3284."
@@ -20,7 +20,10 @@ if (
process.exit(1);
}
fs.writeFileSync("/home/coder/agentapi-mock.log", `AGENTAPI_ALLOWED_HOSTS: ${process.env.AGENTAPI_ALLOWED_HOSTS}`);
fs.writeFileSync(
"/home/coder/agentapi-mock.log",
`AGENTAPI_ALLOWED_HOSTS: ${process.env.AGENTAPI_ALLOWED_HOSTS}`,
);
console.log(`starting server on port ${port}`);
+8 -2
View File
@@ -94,12 +94,18 @@ describe("cursor", async () => {
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 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 script = findResourceInstance(
state,
"coder_script",
"cursor_mcp",
).script;
const resp = await execContainer(id, ["sh", "-c", script]);
if (resp.exitCode !== 0) {
console.log(resp.stdout);
+33 -33
View File
@@ -7,55 +7,55 @@
cd "$CODER_SCRIPT_DATA_DIR"
# If @devcontainers/cli is already installed, we can skip
if command -v devcontainer >/dev/null 2>&1; then
echo "🥳 @devcontainers/cli is already installed into $(which devcontainer)!"
exit 0
if command -v devcontainer > /dev/null 2>&1; then
echo "🥳 @devcontainers/cli is already installed into $(which devcontainer)!"
exit 0
fi
# Check if docker is installed
if ! command -v docker >/dev/null 2>&1; then
echo "WARNING: Docker was not found but is required to use @devcontainers/cli, please make sure it is available."
if ! command -v docker > /dev/null 2>&1; then
echo "WARNING: Docker was not found but is required to use @devcontainers/cli, please make sure it is available."
fi
# Determine the package manager to use: npm, pnpm, or yarn
if command -v yarn >/dev/null 2>&1; then
PACKAGE_MANAGER="yarn"
elif command -v npm >/dev/null 2>&1; then
PACKAGE_MANAGER="npm"
elif command -v pnpm >/dev/null 2>&1; then
PACKAGE_MANAGER="pnpm"
if command -v yarn > /dev/null 2>&1; then
PACKAGE_MANAGER="yarn"
elif command -v npm > /dev/null 2>&1; then
PACKAGE_MANAGER="npm"
elif command -v pnpm > /dev/null 2>&1; then
PACKAGE_MANAGER="pnpm"
else
echo "ERROR: No supported package manager (npm, pnpm, yarn) is installed. Please install one first." 1>&2
exit 1
echo "ERROR: No supported package manager (npm, pnpm, yarn) is installed. Please install one first." 1>&2
exit 1
fi
install() {
echo "Installing @devcontainers/cli using $PACKAGE_MANAGER..."
if [ "$PACKAGE_MANAGER" = "npm" ]; then
npm install -g @devcontainers/cli
elif [ "$PACKAGE_MANAGER" = "pnpm" ]; then
# Check if PNPM_HOME is set, if not, set it to the script's bin directory
# pnpm needs this to be set to install binaries
# coder agent ensures this part is part of the PATH
# so that the devcontainer command is available
if [ -z "$PNPM_HOME" ]; then
PNPM_HOME="$CODER_SCRIPT_BIN_DIR"
export PNPM_HOME
fi
pnpm add -g @devcontainers/cli
elif [ "$PACKAGE_MANAGER" = "yarn" ]; then
yarn global add @devcontainers/cli --prefix "$(dirname "$CODER_SCRIPT_BIN_DIR")"
echo "Installing @devcontainers/cli using $PACKAGE_MANAGER..."
if [ "$PACKAGE_MANAGER" = "npm" ]; then
npm install -g @devcontainers/cli
elif [ "$PACKAGE_MANAGER" = "pnpm" ]; then
# Check if PNPM_HOME is set, if not, set it to the script's bin directory
# pnpm needs this to be set to install binaries
# coder agent ensures this part is part of the PATH
# so that the devcontainer command is available
if [ -z "$PNPM_HOME" ]; then
PNPM_HOME="$CODER_SCRIPT_BIN_DIR"
export PNPM_HOME
fi
pnpm add -g @devcontainers/cli
elif [ "$PACKAGE_MANAGER" = "yarn" ]; then
yarn global add @devcontainers/cli --prefix "$(dirname "$CODER_SCRIPT_BIN_DIR")"
fi
}
if ! install; then
echo "Failed to install @devcontainers/cli" >&2
exit 1
echo "Failed to install @devcontainers/cli" >&2
exit 1
fi
if ! command -v devcontainer >/dev/null 2>&1; then
echo "Installation completed but 'devcontainer' command not found in PATH" >&2
exit 1
if ! command -v devcontainer > /dev/null 2>&1; then
echo "Installation completed but 'devcontainer' command not found in PATH" >&2
exit 1
fi
echo "🥳 @devcontainers/cli has been installed into $(which devcontainer)!"
+2 -2
View File
@@ -7,7 +7,7 @@ BOLD='\033[[0;1m'
printf "$${BOLD}Installing filebrowser \n\n"
# Check if filebrowser is installed
if ! command -v filebrowser &>/dev/null; then
if ! command -v filebrowser &> /dev/null; then
curl -fsSL https://raw.githubusercontent.com/filebrowser/get/master/get.sh | bash
fi
@@ -34,6 +34,6 @@ printf "👷 Starting filebrowser in background... \n\n"
printf "📂 Serving $${ROOT_DIR} at http://localhost:${PORT} \n\n"
filebrowser >>${LOG_PATH} 2>&1 &
filebrowser >> ${LOG_PATH} 2>&1 &
printf "📝 Logs at ${LOG_PATH} \n\n"
+3 -1
View File
@@ -267,6 +267,8 @@ describe("goose", async () => {
await execModuleScript(id);
const agentapiMockOutput = await readFileContainer(id, agentapiStartLog);
expect(agentapiMockOutput).toContain("AGENTAPI_CHAT_BASE_PATH=/@default/default.foo/apps/goose/chat");
expect(agentapiMockOutput).toContain(
"AGENTAPI_CHAT_BASE_PATH=/@default/default.foo/apps/goose/chat",
);
});
});
+23 -23
View File
@@ -2,7 +2,7 @@
# Function to check if a command exists
command_exists() {
command -v "$1" >/dev/null 2>&1
command -v "$1" > /dev/null 2>&1
}
set -o nounset
@@ -18,40 +18,40 @@ echo "--------------------------------"
set +o nounset
if [ "${ARG_INSTALL}" = "true" ]; then
echo "Installing Goose..."
parsed_version="${ARG_GOOSE_VERSION}"
if [ "${ARG_GOOSE_VERSION}" = "stable" ]; then
parsed_version=""
fi
curl -fsSL https://github.com/block/goose/releases/download/stable/download_cli.sh | GOOSE_VERSION="${parsed_version}" CONFIGURE=false bash
echo "Goose installed"
echo "Installing Goose..."
parsed_version="${ARG_GOOSE_VERSION}"
if [ "${ARG_GOOSE_VERSION}" = "stable" ]; then
parsed_version=""
fi
curl -fsSL https://github.com/block/goose/releases/download/stable/download_cli.sh | GOOSE_VERSION="${parsed_version}" CONFIGURE=false bash
echo "Goose installed"
else
echo "Skipping Goose installation"
echo "Skipping Goose installation"
fi
if [ "${ARG_GOOSE_CONFIG}" != "" ]; then
echo "Configuring Goose..."
mkdir -p "$HOME/.config/goose"
echo "GOOSE_PROVIDER: $ARG_PROVIDER" >"$HOME/.config/goose/config.yaml"
echo "GOOSE_MODEL: $ARG_MODEL" >>"$HOME/.config/goose/config.yaml"
echo "$ARG_GOOSE_CONFIG" >>"$HOME/.config/goose/config.yaml"
echo "Configuring Goose..."
mkdir -p "$HOME/.config/goose"
echo "GOOSE_PROVIDER: $ARG_PROVIDER" > "$HOME/.config/goose/config.yaml"
echo "GOOSE_MODEL: $ARG_MODEL" >> "$HOME/.config/goose/config.yaml"
echo "$ARG_GOOSE_CONFIG" >> "$HOME/.config/goose/config.yaml"
else
echo "Skipping Goose configuration"
echo "Skipping Goose configuration"
fi
if [ "${GOOSE_SYSTEM_PROMPT}" != "" ]; then
echo "Setting Goose system prompt..."
mkdir -p "$HOME/.config/goose"
echo "$GOOSE_SYSTEM_PROMPT" >"$HOME/.config/goose/.goosehints"
echo "Setting Goose system prompt..."
mkdir -p "$HOME/.config/goose"
echo "$GOOSE_SYSTEM_PROMPT" > "$HOME/.config/goose/.goosehints"
else
echo "Goose system prompt not set. use the GOOSE_SYSTEM_PROMPT environment variable to set it."
echo "Goose system prompt not set. use the GOOSE_SYSTEM_PROMPT environment variable to set it."
fi
if command_exists goose; then
GOOSE_CMD=goose
GOOSE_CMD=goose
elif [ -f "$HOME/.local/bin/goose" ]; then
GOOSE_CMD="$HOME/.local/bin/goose"
GOOSE_CMD="$HOME/.local/bin/goose"
else
echo "Error: Goose is not installed. Please enable install_goose or install it manually."
exit 1
echo "Error: Goose is not installed. Please enable install_goose or install it manually."
exit 1
fi
+13 -13
View File
@@ -4,16 +4,16 @@ set -o errexit
set -o pipefail
command_exists() {
command -v "$1" >/dev/null 2>&1
command -v "$1" > /dev/null 2>&1
}
if command_exists goose; then
GOOSE_CMD=goose
GOOSE_CMD=goose
elif [ -f "$HOME/.local/bin/goose" ]; then
GOOSE_CMD="$HOME/.local/bin/goose"
GOOSE_CMD="$HOME/.local/bin/goose"
else
echo "Error: Goose is not installed. Please enable install_goose or install it manually."
exit 1
echo "Error: Goose is not installed. Please enable install_goose or install it manually."
exit 1
fi
# this must be kept up to date with main.tf
@@ -21,15 +21,15 @@ MODULE_DIR="$HOME/.goose-module"
mkdir -p "$MODULE_DIR"
if [ ! -z "$GOOSE_TASK_PROMPT" ]; then
echo "Starting with a prompt"
PROMPT="Review your goosehints. Every step of the way, report tasks to Coder with proper descriptions and statuses. Your task at hand: $GOOSE_TASK_PROMPT"
PROMPT_FILE="$MODULE_DIR/prompt.txt"
echo -n "$PROMPT" >"$PROMPT_FILE"
GOOSE_ARGS=(run --interactive --instructions "$PROMPT_FILE")
echo "Starting with a prompt"
PROMPT="Review your goosehints. Every step of the way, report tasks to Coder with proper descriptions and statuses. Your task at hand: $GOOSE_TASK_PROMPT"
PROMPT_FILE="$MODULE_DIR/prompt.txt"
echo -n "$PROMPT" > "$PROMPT_FILE"
GOOSE_ARGS=(run --interactive --instructions "$PROMPT_FILE")
else
echo "Starting without a prompt"
GOOSE_ARGS=()
echo "Starting without a prompt"
GOOSE_ARGS=()
fi
agentapi server --term-width 67 --term-height 1190 -- \
bash -c "$(printf '%q ' "$GOOSE_CMD" "${GOOSE_ARGS[@]}")"
bash -c "$(printf '%q ' "$GOOSE_CMD" "${GOOSE_ARGS[@]}")"
@@ -3,7 +3,9 @@
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"]}`);
console.log(
`AGENTAPI_CHAT_BASE_PATH=${process.env["AGENTAPI_CHAT_BASE_PATH"]}`,
);
const port = 3284;
console.log(`starting server on port ${port}`);
+2 -2
View File
@@ -3,6 +3,6 @@
set -e
while true; do
echo "$(date) - goose-mock"
sleep 15
echo "$(date) - goose-mock"
sleep 15
done
@@ -97,4 +97,4 @@ describe("jetbrains-fleet", async () => {
expect(coder_app?.instances.length).toBe(1);
expect(coder_app?.instances[0].attributes.group).toBe("JetBrains IDEs");
});
});
});
+13 -6
View File
@@ -115,22 +115,29 @@ describe("jupyterlab", async () => {
port: 8888,
token: "test-token",
password: "",
allow_origin: "*"
}
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 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");
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);
@@ -145,7 +152,7 @@ describe("jupyterlab", async () => {
config: "{}",
});
const configScripts = state.resources.filter(
(res) => res.type === "coder_script" && res.name === "jupyterlab_config"
(res) => res.type === "coder_script" && res.name === "jupyterlab_config",
);
expect(configScripts.length).toBe(1);
});
@@ -155,7 +162,7 @@ describe("jupyterlab", async () => {
agent_id: "foo",
});
const configScripts = state.resources.filter(
(res) => res.type === "coder_script" && res.name === "jupyterlab_config"
(res) => res.type === "coder_script" && res.name === "jupyterlab_config",
);
expect(configScripts.length).toBe(1);
});
+14 -14
View File
@@ -3,13 +3,13 @@ INSTALLER=""
check_available_installer() {
# check if pipx is installed
echo "Checking for a supported installer"
if command -v pipx >/dev/null 2>&1; then
if command -v pipx > /dev/null 2>&1; then
echo "pipx is installed"
INSTALLER="pipx"
return
fi
# check if uv is installed
if command -v uv >/dev/null 2>&1; then
if command -v uv > /dev/null 2>&1; then
echo "uv is installed"
INSTALLER="uv"
return
@@ -26,21 +26,21 @@ fi
BOLD='\033[0;1m'
# check if jupyterlab is installed
if ! command -v jupyter-lab >/dev/null 2>&1; then
if ! command -v jupyter-lab > /dev/null 2>&1; then
# install jupyterlab
check_available_installer
printf "$${BOLD}Installing jupyterlab!\n"
case $INSTALLER in
uv)
uv pip install -q jupyterlab &&
printf "%s\n" "🥳 jupyterlab has been installed"
JUPYTER="$HOME/.venv/bin/jupyter-lab"
;;
pipx)
pipx install jupyterlab &&
printf "%s\n" "🥳 jupyterlab has been installed"
JUPYTER="$HOME/.local/bin/jupyter-lab"
;;
uv)
uv pip install -q jupyterlab \
&& printf "%s\n" "🥳 jupyterlab has been installed"
JUPYTER="$HOME/.venv/bin/jupyter-lab"
;;
pipx)
pipx install jupyterlab \
&& printf "%s\n" "🥳 jupyterlab has been installed"
JUPYTER="$HOME/.local/bin/jupyter-lab"
;;
esac
else
printf "%s\n\n" "🥳 jupyterlab is already installed"
@@ -55,4 +55,4 @@ $JUPYTER --no-browser \
--ServerApp.port="${PORT}" \
--ServerApp.token='' \
--ServerApp.password='' \
>"${LOG_PATH}" 2>&1 &
> "${LOG_PATH}" 2>&1 &
+54 -24
View File
@@ -1,4 +1,4 @@
<!DOCTYPE html>
<!doctype html>
<html>
<head>
<title>Path-Sharing Bounce Page</title>
@@ -6,40 +6,64 @@
:root {
color-scheme: light dark;
--dark: #121212;
--header-bg: rgba(127,127,127,0.2);
--header-bg: rgba(127, 127, 127, 0.2);
--light: white;
--rule-color: light-dark(rgba(0,0,0,0.8), rgba(255,255,255,0.8));
--rule-color: light-dark(rgba(0, 0, 0, 0.8), rgba(255, 255, 255, 0.8));
background-color: light-dark(var(--light), var(--dark));
color: light-dark(var(--dark), var(--light));
}
body, h1, p {
body,
h1,
p {
box-sizing: border-box;
margin:0; padding:0;
margin: 0;
padding: 0;
}
body{
font-family:Inter, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
body {
font-family:
Inter,
system-ui,
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
Roboto,
Oxygen,
Ubuntu,
Cantarell,
"Open Sans",
"Helvetica Neue",
sans-serif;
}
h1{
h1 {
width: 100%;
padding: 1rem;
letter-spacing: -1.5pt;
padding-bottom:10px;
padding-bottom: 10px;
border-bottom: 1px solid var(--rule-color);
background-color: var(--header-bg);
}
p {
padding: 1rem; letter-spacing: -0.5pt;}
a.indent { display:inline-block; padding-top:0.5rem; padding-left: 2rem; font-size:0.8rem }
</style>
padding: 1rem;
letter-spacing: -0.5pt;
}
a.indent {
display: inline-block;
padding-top: 0.5rem;
padding-left: 2rem;
font-size: 0.8rem;
}
</style>
<meta charset="UTF-8" />
</head>
<body>
<h1>Path-Sharing Bounce Page</h1>
<p>
This application is being served via path sharing.
If you are not redirected, <span id="help">check the
Javascript console in your browser's developer tools
for more information.</span>
This application is being served via path sharing. If you are not
redirected,
<span id="help"
>check the Javascript console in your browser's developer tools for more
information.</span
>
</p>
</body>
<script language="javascript">
@@ -58,24 +82,30 @@
// This apparently doesn't tolerate a leading `/` so we use a
// function to tidy that up.
function trimFirstCharIf(str, char) {
return str.charAt(0) === char ? str.slice(1) : str;
return str.charAt(0) === char ? str.slice(1) : str;
}
function trimLastCharIf(str, char) {
return str.endsWith("/") ? str.slice(0,str.length-1) : str;
return str.endsWith("/") ? str.slice(0, str.length - 1) : str;
}
const newloc = new URL(window.location);
const h = document.getElementById("help")
const h = document.getElementById("help");
// Building the websockify path must happen before we append the filename to newloc.pathname
newloc.searchParams.append("path",
trimLastCharIf(trimFirstCharIf(newloc.pathname,"/"),"/")+"/websockify");
newloc.searchParams.append("encrypted", newloc.protocol==="https:"? true : false);
newloc.searchParams.append(
"path",
trimLastCharIf(trimFirstCharIf(newloc.pathname, "/"), "/") +
"/websockify",
);
newloc.searchParams.append(
"encrypted",
newloc.protocol === "https:" ? true : false,
);
newloc.pathname += "vnc.html"
newloc.pathname += "vnc.html";
console.log(newloc);
h.innerHTML = `click <a id="link" href="${newloc.toString()}">here</a> to go to the application.
<br/><br/>The rewritten URL is:<br/><a id="link" class="indent" href="${newloc.toString()}">${newloc.toString()}</a>`
<br/><br/>The rewritten URL is:<br/><a id="link" class="indent" href="${newloc.toString()}">${newloc.toString()}</a>`;
window.location = newloc.href;
</script>
</html>
+24 -21
View File
@@ -3,7 +3,10 @@
# Exit on error, undefined variables, and pipe failures
set -euo pipefail
error() { printf "💀 ERROR: %s\n" "$@"; exit 1; }
error() {
printf "💀 ERROR: %s\n" "$@"
exit 1
}
# Function to check if vncserver is already installed
check_installed() {
@@ -248,30 +251,30 @@ get_http_dir() {
echo $httpd_directory
}
fix_server_index_file(){
local fname=$${FUNCNAME[0]} # gets current function name
if [[ $# -ne 1 ]]; then
error "$fname requires exactly 1 parameter:\n\tpath to KasmVNC httpd_directory"
fi
local httpdir="$1"
if [[ ! -d "$httpdir" ]]; then
error "$fname: $httpdir is not a directory"
fi
pushd "$httpdir" > /dev/null
fix_server_index_file() {
local fname=$${FUNCNAME[0]} # gets current function name
if [[ $# -ne 1 ]]; then
error "$fname requires exactly 1 parameter:\n\tpath to KasmVNC httpd_directory"
fi
local httpdir="$1"
if [[ ! -d "$httpdir" ]]; then
error "$fname: $httpdir is not a directory"
fi
pushd "$httpdir" > /dev/null
cat <<'EOH' > /tmp/path_vnc.html
cat << 'EOH' > /tmp/path_vnc.html
${PATH_VNC_HTML}
EOH
$SUDO mv /tmp/path_vnc.html .
# check for the switcheroo
if [[ -f "index.html" && -L "vnc.html" ]]; then
$SUDO mv $httpdir/index.html $httpdir/vnc.html
fi
$SUDO ln -s -f path_vnc.html index.html
popd > /dev/null
$SUDO mv /tmp/path_vnc.html .
# check for the switcheroo
if [[ -f "index.html" && -L "vnc.html" ]]; then
$SUDO mv $httpdir/index.html $httpdir/vnc.html
fi
$SUDO ln -s -f path_vnc.html index.html
popd > /dev/null
}
patch_kasm_http_files(){
patch_kasm_http_files() {
homedir=$(get_http_dir)
fix_server_index_file "$homedir"
}
@@ -292,7 +295,7 @@ set -e
if [[ $RETVAL -ne 0 ]]; then
echo "ERROR: Failed to start KasmVNC server. Return code: $RETVAL"
if [[ -f "$VNC_LOG" ]]; then
if [[ -f "$VNC_LOG" ]]; then
echo "Full logs:"
cat "$VNC_LOG"
else
+3 -3
View File
@@ -56,7 +56,7 @@ describe("kiro", async () => {
slug: "kiro-ai",
display_name: "Kiro AI IDE",
});
const coder_app = state.resources.find(
(res) => res.type === "coder_app" && res.name === "kiro",
);
@@ -70,7 +70,7 @@ describe("kiro", async () => {
agent_id: "foo",
order: "5",
});
const coder_app = state.resources.find(
(res) => res.type === "coder_app" && res.name === "kiro",
);
@@ -83,7 +83,7 @@ describe("kiro", async () => {
agent_id: "foo",
group: "AI IDEs",
});
const coder_app = state.resources.find(
(res) => res.type === "coder_app" && res.name === "kiro",
);
+7 -7
View File
@@ -9,11 +9,11 @@ CODER_OIDC_ACCESS_TOKEN=${CODER_OIDC_ACCESS_TOKEN}
fetch() {
dest="$1"
url="$2"
if command -v curl >/dev/null 2>&1; then
if command -v curl > /dev/null 2>&1; then
curl -sSL --fail "$${url}" -o "$${dest}"
elif command -v wget >/dev/null 2>&1; then
elif command -v wget > /dev/null 2>&1; then
wget -O "$${dest}" "$${url}"
elif command -v busybox >/dev/null 2>&1; then
elif command -v busybox > /dev/null 2>&1; then
busybox wget -O "$${dest}" "$${url}"
else
printf "curl, wget, or busybox is not installed. Please install curl or wget in your image.\n"
@@ -22,9 +22,9 @@ fetch() {
}
unzip_safe() {
if command -v unzip >/dev/null 2>&1; then
if command -v unzip > /dev/null 2>&1; then
command unzip "$@"
elif command -v busybox >/dev/null 2>&1; then
elif command -v busybox > /dev/null 2>&1; then
busybox unzip "$@"
else
printf "unzip or busybox is not installed. Please install unzip in your image.\n"
@@ -56,7 +56,7 @@ install() {
# Check if the vault CLI is installed and has the correct version
installation_needed=1
if command -v vault >/dev/null 2>&1; then
if command -v vault > /dev/null 2>&1; then
CURRENT_VERSION=$(vault version | grep -oE '[0-9]+\.[0-9]+\.[0-9]+')
if [ "$${CURRENT_VERSION}" = "$${VAULT_CLI_VERSION}" ]; then
printf "Vault version %s is already installed and up-to-date.\n\n" "$${CURRENT_VERSION}"
@@ -81,7 +81,7 @@ install() {
return 1
fi
rm vault.zip
if sudo mv vault /usr/local/bin/vault 2>/dev/null; then
if sudo mv vault /usr/local/bin/vault 2> /dev/null; then
printf "Vault installed successfully!\n\n"
else
mkdir -p ~/.local/bin
@@ -14,7 +14,7 @@ const defaultVariables = {
coder_app_slug: "vscode",
coder_app_display_name: "VS Code Desktop",
protocol: "vscode",
}
};
describe("vscode-desktop-core", async () => {
await runTerraformInit(import.meta.dir);
@@ -40,7 +40,7 @@ describe("vscode-desktop-core", async () => {
const state = await runTerraformApply(import.meta.dir, {
folder: "/foo/bar",
...defaultVariables
...defaultVariables,
});
expect(state.outputs.ide_uri.value).toBe(
@@ -86,7 +86,7 @@ describe("vscode-desktop-core", async () => {
it("expect order to be set", async () => {
const state = await runTerraformApply(import.meta.dir, {
coder_app_order: "22",
...defaultVariables
...defaultVariables,
});
const coder_app = state.resources.find(
+1 -1
View File
@@ -68,7 +68,7 @@ esac
# Detect the platform
if [ -n "${PLATFORM}" ]; then
DETECTED_PLATFORM="${PLATFORM}"
elif [ -f /etc/alpine-release ] || grep -qi 'ID=alpine' /etc/os-release 2>/dev/null || command -v apk > /dev/null 2>&1; then
elif [ -f /etc/alpine-release ] || grep -qi 'ID=alpine' /etc/os-release 2> /dev/null || command -v apk > /dev/null 2>&1; then
DETECTED_PLATFORM="alpine"
elif [ "$(uname -s)" = "Darwin" ]; then
DETECTED_PLATFORM="darwin"
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

+21
View File
@@ -0,0 +1,21 @@
---
display_name: "Rishi Mondal"
bio: "Breaking code, fixing bugs, and occasionally making it work! Always caffeinated, always committing"
avatar: "./.images/avatar.jpeg"
github: "MAVRICK-1"
linkedin: "https://www.linkedin.com/in/rishi-mondal-5238b2282/" # Optional
website: "https://mavrick-portfolio.vercel.app/" # Optional
support_email: "mavrickrishi@gmail.com" # Optional
status: "community"
---
# Rishi Mondal
I'm Rishi Mondal, a passionate developer from Chinsurah Hooghly, West Bengal, India.
I'm a maintainer at CNCF KubeStellar, GSoC contributor at UCSC OSPO, and a Docker Captain.
When I'm not breaking code and fixing bugs, you'll find me contributing to open-source projects,
participating in LFX CNCF programs, and helping the developer community grow.
## Modules
- **aws-ami-snapshot**: Create and manage AMI snapshots for Coder workspaces with restore capabilities
@@ -0,0 +1,173 @@
---
display_name: AWS AMI Snapshot
description: Create and manage AMI snapshots for Coder workspaces with restore capabilities
icon: ../../../../.icons/aws.svg
verified: false
tags: [aws, snapshot, ami, backup, persistence]
---
# AWS AMI Snapshot Module
This module provides AMI-based snapshot functionality for Coder workspaces running on AWS EC2 instances. It enables users to create snapshots when workspaces are stopped and restore from previous snapshots when starting workspaces.
```tf
module "ami_snapshot" {
source = "registry.coder.com/mavrickrishi/aws-ami-snapshot/coder"
version = "1.0.0"
instance_id = aws_instance.workspace.id
default_ami_id = data.aws_ami.ubuntu.id
template_name = "aws-linux"
}
```
## Features
- **Automatic Snapshots**: Create AMI snapshots when workspaces are stopped
- **User Control**: Enable/disable snapshot functionality per workspace
- **Custom Labels**: Add custom labels to snapshots for easy identification
- **Snapshot Selection**: Choose from available snapshots when starting workspaces
- **Automatic Cleanup**: Optional Data Lifecycle Manager integration for automated cleanup
- **Workspace Isolation**: Snapshots are tagged and filtered by workspace and owner
## Parameters
The module exposes the following parameters to workspace users:
- `enable_snapshots`: Enable/disable AMI snapshot creation (default: true)
- `snapshot_label`: Custom label for the snapshot (optional)
- `use_previous_snapshot`: Select a previous snapshot to restore from (default: none)
## Usage
### Basic Usage
```hcl
module "ami_snapshot" {
source = "registry.coder.com/modules/aws-ami-snapshot"
instance_id = aws_instance.workspace.id
default_ami_id = data.aws_ami.ubuntu.id
template_name = "aws-linux"
}
resource "aws_instance" "workspace" {
ami = module.ami_snapshot.ami_id
instance_type = "t3.micro"
# Prevent Terraform from recreating instance when AMI changes
lifecycle {
ignore_changes = [ami]
}
}
```
### With Optional Cleanup
```hcl
module "ami_snapshot" {
source = "registry.coder.com/modules/aws-ami-snapshot"
instance_id = aws_instance.workspace.id
default_ami_id = data.aws_ami.ubuntu.id
template_name = "aws-linux"
enable_dlm_cleanup = true
dlm_role_arn = aws_iam_role.dlm_lifecycle_role.arn
snapshot_retention_count = 5
tags = {
Environment = "development"
Project = "my-project"
}
}
```
### IAM Role for DLM (Optional)
If using automatic cleanup, create an IAM role for Data Lifecycle Manager:
```hcl
resource "aws_iam_role" "dlm_lifecycle_role" {
name = "dlm-lifecycle-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = {
Service = "dlm.amazonaws.com"
}
}
]
})
}
resource "aws_iam_role_policy_attachment" "dlm_lifecycle" {
role = aws_iam_role.dlm_lifecycle_role.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSDataLifecycleManagerServiceRole"
}
```
## Required IAM Permissions
Users need the following IAM permissions for full functionality:
```json
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"ec2:CreateImage",
"ec2:DescribeImages",
"ec2:DescribeInstances",
"ec2:CreateTags",
"ec2:DescribeTags"
],
"Resource": "*"
},
{
"Effect": "Allow",
"Action": [
"dlm:CreateLifecyclePolicy",
"dlm:GetLifecyclePolicy",
"dlm:UpdateLifecyclePolicy",
"dlm:DeleteLifecyclePolicy"
],
"Resource": "*",
"Condition": {
"StringEquals": {
"dlm:Target": "INSTANCE"
}
}
}
]
}
```
## How It Works
1. **Snapshot Creation**: When a workspace transitions to "stop", an AMI snapshot is automatically created (if enabled)
2. **Tagging**: Snapshots are tagged with workspace name, owner, template, and custom labels
3. **Snapshot Retrieval**: Available snapshots are retrieved and presented as options for workspace start
4. **AMI Selection**: The module outputs the appropriate AMI ID (default or selected snapshot)
5. **Cleanup**: Optional DLM policies can automatically clean up old snapshots
## Considerations
- **Cost**: AMI snapshots incur storage costs. Use cleanup policies to manage costs
- **Time**: AMI creation takes time; workspace stop operations may take longer
- **Permissions**: Ensure proper IAM permissions for AMI creation and management
- **Region**: Snapshots are region-specific and cannot be used across regions
- **Lifecycle**: Use `ignore_changes = [ami]` on EC2 instances to prevent conflicts
## Examples
See the updated AWS templates that use this module:
- [`coder/templates/aws-linux`](https://registry.coder.com/templates/aws-linux)
- [`coder/templates/aws-windows`](https://registry.coder.com/templates/aws-windows)
- [`coder/templates/aws-devcontainer`](https://registry.coder.com/templates/aws-devcontainer)
@@ -0,0 +1,65 @@
import { describe, expect, it } from "bun:test";
import {
runTerraformApply,
runTerraformInit,
testRequiredVariables,
} from "~test";
describe("aws-ami-snapshot", async () => {
await runTerraformInit(import.meta.dir);
it("required variables with test mode", async () => {
await runTerraformApply(import.meta.dir, {
instance_id: "i-1234567890abcdef0",
default_ami_id: "ami-12345678",
template_name: "test-template",
test_mode: true,
});
});
it("missing variable: instance_id", async () => {
await expect(
runTerraformApply(import.meta.dir, {
default_ami_id: "ami-12345678",
template_name: "test-template",
test_mode: true,
}),
).rejects.toThrow();
});
it("missing variable: default_ami_id", async () => {
await expect(
runTerraformApply(import.meta.dir, {
instance_id: "i-1234567890abcdef0",
template_name: "test-template",
test_mode: true,
}),
).rejects.toThrow();
});
it("missing variable: template_name", async () => {
await expect(
runTerraformApply(import.meta.dir, {
instance_id: "i-1234567890abcdef0",
default_ami_id: "ami-12345678",
test_mode: true,
}),
).rejects.toThrow();
});
it("supports optional variables", async () => {
await runTerraformApply(import.meta.dir, {
instance_id: "i-1234567890abcdef0",
default_ami_id: "ami-12345678",
template_name: "test-template",
test_mode: true,
enable_dlm_cleanup: true,
dlm_role_arn: "arn:aws:iam::123456789012:role/dlm-lifecycle-role",
snapshot_retention_count: 5,
tags: JSON.stringify({
Environment: "test",
Project: "coder",
}),
});
});
});
@@ -0,0 +1,260 @@
terraform {
required_version = ">= 1.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
coder = {
source = "coder/coder"
version = ">= 0.17"
}
}
}
# Provider configuration for testing only
# In production, the provider will be inherited from the calling module
provider "aws" {
region = "us-east-1"
skip_credentials_validation = true
skip_requesting_account_id = true
skip_region_validation = true
# Mock credentials for testing
access_key = "test"
secret_key = "test"
}
# Variables
variable "test_mode" {
description = "Set to true when running tests to skip AWS API calls"
type = bool
default = false
}
variable "instance_id" {
description = "The EC2 instance ID to create snapshots from"
type = string
}
variable "default_ami_id" {
description = "The default AMI ID to use when not restoring from a snapshot"
type = string
}
variable "template_name" {
description = "The name of the Coder template using this module"
type = string
}
variable "tags" {
description = "Additional tags to apply to snapshots"
type = map(string)
default = {}
}
variable "enable_dlm_cleanup" {
description = "Enable Data Lifecycle Manager for automated snapshot cleanup"
type = bool
default = false
}
variable "dlm_role_arn" {
description = "ARN of the IAM role for DLM (required if enable_dlm_cleanup is true)"
type = string
default = ""
}
variable "snapshot_retention_count" {
description = "Number of snapshots to retain when using DLM cleanup"
type = number
default = 7
}
# Parameters for snapshot control
data "coder_parameter" "enable_snapshots" {
name = "enable_snapshots"
display_name = "Enable AMI Snapshots"
description = "Create AMI snapshots when workspace is stopped"
type = "bool"
default = "true"
mutable = true
}
data "coder_parameter" "snapshot_label" {
name = "snapshot_label"
display_name = "Snapshot Label"
description = "Custom label for this snapshot (optional)"
type = "string"
default = ""
mutable = true
}
data "coder_parameter" "use_previous_snapshot" {
name = "use_previous_snapshot"
display_name = "Start from Snapshot"
description = "Select a previous snapshot to restore from"
type = "string"
default = "none"
mutable = true
option {
name = "Use default AMI"
value = "none"
description = "Start with a fresh instance"
}
dynamic "option" {
for_each = local.workspace_snapshot_ids
content {
name = var.test_mode ? "Test Snapshot" : "${local.snapshot_info[option.value].name} (${formatdate("YYYY-MM-DD hh:mm", timeadd(local.snapshot_info[option.value].creation_date, "0s"))})"
value = option.value
description = var.test_mode ? "Test Description" : local.snapshot_info[option.value].description
}
}
}
# Get workspace information
data "coder_workspace" "me" {}
data "coder_workspace_owner" "me" {}
# Local values to handle test mode
locals {
workspace_snapshot_ids = var.test_mode ? [] : data.aws_ami_ids.workspace_snapshots[0].ids
snapshot_info = var.test_mode ? {} : {
for ami_id in local.workspace_snapshot_ids : ami_id => data.aws_ami.snapshot_info[ami_id]
}
}
# Retrieve existing snapshots for this workspace
data "aws_ami_ids" "workspace_snapshots" {
count = var.test_mode ? 0 : 1
owners = ["self"]
filter {
name = "tag:CoderWorkspace"
values = [data.coder_workspace.me.name]
}
filter {
name = "tag:CoderOwner"
values = [data.coder_workspace_owner.me.name]
}
filter {
name = "tag:CoderTemplate"
values = [var.template_name]
}
filter {
name = "state"
values = ["available"]
}
}
# Get detailed information about each snapshot
data "aws_ami" "snapshot_info" {
for_each = toset(local.workspace_snapshot_ids)
owners = ["self"]
filter {
name = "image-id"
values = [each.value]
}
}
# Determine which AMI to use
locals {
use_snapshot = data.coder_parameter.use_previous_snapshot.value != "none"
ami_id = local.use_snapshot ? data.coder_parameter.use_previous_snapshot.value : var.default_ami_id
}
# Create AMI snapshot when workspace is stopped
resource "aws_ami_from_instance" "workspace_snapshot" {
count = data.coder_parameter.enable_snapshots.value && data.coder_workspace.me.transition == "stop" ? 1 : 0
name = "${data.coder_workspace_owner.me.name}-${data.coder_workspace.me.name}-${formatdate("YYYY-MM-DD-hhmm", timestamp())}"
source_instance_id = var.instance_id
snapshot_without_reboot = true
deprecation_time = timeadd(timestamp(), "168h") # 7 days
tags = merge(var.tags, {
Name = "${data.coder_workspace_owner.me.name}-${data.coder_workspace.me.name}-snapshot"
CoderWorkspace = data.coder_workspace.me.name
CoderOwner = data.coder_workspace_owner.me.name
CoderTemplate = var.template_name
SnapshotLabel = data.coder_parameter.snapshot_label.value
CreatedAt = timestamp()
SnapshotType = "workspace"
WorkspaceId = data.coder_workspace.me.id
})
lifecycle {
ignore_changes = [
deprecation_time
]
}
}
# Optional: Data Lifecycle Manager policy for automated cleanup
resource "aws_dlm_lifecycle_policy" "workspace_snapshots" {
count = var.enable_dlm_cleanup && !var.test_mode ? 1 : 0
description = "Lifecycle policy for Coder workspace AMI snapshots"
execution_role_arn = var.dlm_role_arn
state = "ENABLED"
policy_details {
resource_types = ["INSTANCE"]
target_tags = {
CoderTemplate = var.template_name
SnapshotType = "workspace"
}
schedule {
name = "Coder workspace snapshot cleanup"
create_rule {
interval = 24
interval_unit = "HOURS"
times = ["03:00"]
}
retain_rule {
count = var.snapshot_retention_count
}
copy_tags = true
}
}
}
# Outputs
output "ami_id" {
description = "The AMI ID to use for the workspace instance (either default or selected snapshot)"
value = local.ami_id
}
output "is_using_snapshot" {
description = "Whether the workspace is using a snapshot AMI"
value = local.use_snapshot
}
output "snapshot_ami_id" {
description = "The AMI ID of the created snapshot (if any)"
value = data.coder_parameter.enable_snapshots.value && data.coder_workspace.me.transition == "stop" ? aws_ami_from_instance.workspace_snapshot[0].id : null
}
output "available_snapshots" {
description = "List of available snapshot AMI IDs for this workspace"
value = local.workspace_snapshot_ids
}
output "snapshot_info" {
description = "Detailed information about available snapshots"
value = var.test_mode ? {} : {
for ami_id in local.workspace_snapshot_ids : ami_id => {
name = local.snapshot_info[ami_id].name
description = local.snapshot_info[ami_id].description
created_date = local.snapshot_info[ami_id].creation_date
tags = local.snapshot_info[ami_id].tags
}
}
}