mirror of
https://github.com/coder/registry.git
synced 2026-06-03 04:58:15 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2b4f485d72 |
@@ -11,7 +11,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Run check.sh
|
||||
run: |
|
||||
|
||||
@@ -12,7 +12,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up Terraform
|
||||
uses: coder/coder/.github/actions/setup-tf@main
|
||||
- name: Set up Bun
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v4
|
||||
- name: Install Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
- name: Validate formatting
|
||||
run: bun fmt:ci
|
||||
- name: Check for typos
|
||||
uses: crate-ci/typos@v1.35.4
|
||||
uses: crate-ci/typos@v1.34.0
|
||||
with:
|
||||
config: .github/typos.toml
|
||||
validate-readme-files:
|
||||
@@ -59,7 +59,7 @@ jobs:
|
||||
needs: validate-style
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
|
||||
@@ -14,7 +14,6 @@ on:
|
||||
paths:
|
||||
- ".github/workflows/deploy-registry.yaml"
|
||||
- "registry/**/templates/**"
|
||||
- "registry/**/README.md"
|
||||
- ".icons/**"
|
||||
|
||||
jobs:
|
||||
@@ -28,14 +27,14 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v4
|
||||
- name: Authenticate with Google Cloud
|
||||
uses: google-github-actions/auth@b7593ed2efd1c1617e1b0254da33b86225adb2a5
|
||||
with:
|
||||
workload_identity_provider: projects/309789351055/locations/global/workloadIdentityPools/github-actions/providers/github
|
||||
service_account: registry-v2-github@coder-registry-1.iam.gserviceaccount.com
|
||||
- name: Set up Google Cloud SDK
|
||||
uses: google-github-actions/setup-gcloud@cb1e50a9932213ecece00a606661ae9ca44f3397
|
||||
uses: google-github-actions/setup-gcloud@6a7c903a70c8625ed6700fa299f5ddb4ca6022e9
|
||||
- name: Deploy to dev.registry.coder.com
|
||||
run: gcloud builds triggers run 29818181-126d-4f8a-a937-f228b27d3d34 --branch main
|
||||
- name: Deploy to registry.coder.com
|
||||
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
name: lint
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: stable
|
||||
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
issues: write
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
<svg width="721" height="721" viewBox="0 0 721 721" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_1637_2935)">
|
||||
<g clip-path="url(#clip1_1637_2935)">
|
||||
<path d="M304.246 295.411V249.828C304.246 245.989 305.687 243.109 309.044 241.191L400.692 188.412C413.167 181.215 428.042 177.858 443.394 177.858C500.971 177.858 537.44 222.482 537.44 269.982C537.44 273.34 537.44 277.179 536.959 281.018L441.954 225.358C436.197 222 430.437 222 424.68 225.358L304.246 295.411ZM518.245 472.945V364.024C518.245 357.304 515.364 352.507 509.608 349.149L389.174 279.096L428.519 256.543C431.877 254.626 434.757 254.626 438.115 256.543L529.762 309.323C556.154 324.679 573.905 357.304 573.905 388.971C573.905 425.436 552.315 459.024 518.245 472.941V472.945ZM275.937 376.982L236.592 353.952C233.235 352.034 231.794 349.154 231.794 345.315V239.756C231.794 188.416 271.139 149.548 324.4 149.548C344.555 149.548 363.264 156.268 379.102 168.262L284.578 222.964C278.822 226.321 275.942 231.119 275.942 237.838V376.986L275.937 376.982ZM360.626 425.922L304.246 394.255V327.083L360.626 295.416L417.002 327.083V394.255L360.626 425.922ZM396.852 571.789C376.698 571.789 357.989 565.07 342.151 553.075L436.674 498.374C442.431 495.017 445.311 490.219 445.311 483.499V344.352L485.138 367.382C488.495 369.299 489.936 372.179 489.936 376.018V481.577C489.936 532.917 450.109 571.785 396.852 571.785V571.789ZM283.134 464.79L191.486 412.01C165.094 396.654 147.343 364.029 147.343 332.362C147.343 295.416 169.415 262.309 203.48 248.393V357.791C203.48 364.51 206.361 369.308 212.117 372.665L332.074 442.237L292.729 464.79C289.372 466.707 286.491 466.707 283.134 464.79ZM277.859 543.48C223.639 543.48 183.813 502.695 183.813 452.314C183.813 448.475 184.294 444.636 184.771 440.797L279.295 495.498C285.051 498.856 290.812 498.856 296.568 495.498L417.002 425.927V471.509C417.002 475.349 415.562 478.229 412.204 480.146L320.557 532.926C308.081 540.122 293.206 543.48 277.854 543.48H277.859ZM396.852 600.576C454.911 600.576 503.37 559.313 514.41 504.612C568.149 490.696 602.696 440.315 602.696 388.976C602.696 355.387 588.303 322.762 562.392 299.25C564.791 289.173 566.231 279.096 566.231 269.024C566.231 200.411 510.571 149.067 446.274 149.067C433.322 149.067 420.846 150.984 408.37 155.305C386.775 134.192 357.026 120.758 324.4 120.758C266.342 120.758 217.883 162.02 206.843 216.721C153.104 230.637 118.557 281.018 118.557 332.357C118.557 365.946 132.95 398.571 158.861 422.083C156.462 432.16 155.022 442.237 155.022 452.309C155.022 520.922 210.682 572.266 274.978 572.266C287.931 572.266 300.407 570.349 312.883 566.028C334.473 587.141 364.222 600.576 396.852 600.576Z" fill="white"/>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_1637_2935">
|
||||
<rect width="720" height="720" fill="white" transform="translate(0.606934 0.899902)"/>
|
||||
</clipPath>
|
||||
<clipPath id="clip1_1637_2935">
|
||||
<rect width="484.139" height="479.818" fill="white" transform="translate(118.557 120.758)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.9 KiB |
@@ -1,137 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
width="800"
|
||||
height="800"
|
||||
viewBox="0 0 211.66666 211.66666"
|
||||
version="1.1"
|
||||
id="svg924"
|
||||
inkscape:export-filename="/home/daniela/Documents/proxmox/Proxmox/Marketing/Logo/proxmox-logo/Screen/Full Lockup/stacked/proxmox-logo-color-stacked-bgblack.png"
|
||||
inkscape:export-xdpi="360"
|
||||
inkscape:export-ydpi="360"
|
||||
inkscape:version="1.0.2 (e86c870879, 2021-01-15)"
|
||||
sodipodi:docname="proxmox-logo-stacked-inverted-color-bgtrans.svg">
|
||||
<defs
|
||||
id="defs918" />
|
||||
<sodipodi:namedview
|
||||
id="base"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1.0"
|
||||
inkscape:pageopacity="0"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:zoom="0.38489655"
|
||||
inkscape:cx="541.41545"
|
||||
inkscape:cy="189.70435"
|
||||
inkscape:document-units="mm"
|
||||
inkscape:current-layer="layer1"
|
||||
inkscape:document-rotation="0"
|
||||
showgrid="false"
|
||||
inkscape:pagecheckerboard="true"
|
||||
fit-margin-top="0"
|
||||
fit-margin-left="0"
|
||||
fit-margin-right="0"
|
||||
fit-margin-bottom="0"
|
||||
inkscape:window-width="1720"
|
||||
inkscape:window-height="1343"
|
||||
inkscape:window-x="1720"
|
||||
inkscape:window-y="27"
|
||||
inkscape:window-maximized="0"
|
||||
units="px" />
|
||||
<metadata
|
||||
id="metadata921">
|
||||
<rdf:RDF>
|
||||
<cc:Work
|
||||
rdf:about="">
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||
<dc:title></dc:title>
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<g
|
||||
inkscape:label="Layer 1"
|
||||
inkscape:groupmode="layer"
|
||||
id="layer1"
|
||||
transform="translate(-11.346916,-31.368461)">
|
||||
<g
|
||||
id="g209">
|
||||
<g
|
||||
transform="matrix(0.84666672,0,0,0.84666672,544.05161,-814.30036)"
|
||||
id="g1288"
|
||||
style="fill:#ffffff">
|
||||
<g
|
||||
id="g1286"
|
||||
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:25.8336px;line-height:125%;font-family:Helion;-inkscape-font-specification:Helion;text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#ffffff;fill-opacity:1;stroke:none"
|
||||
transform="matrix(1.2435137,0,0,1.2435137,-791.06481,553.75862)">
|
||||
<path
|
||||
inkscape:connector-curvature="0"
|
||||
id="path1272"
|
||||
d="m 168.85142,500.9595 h -11.85747 c -0.46554,0.0129 -0.85842,0.18085 -1.17864,0.50374 -0.32023,0.32294 -0.48707,0.72335 -0.50052,1.20125 v 16.3783 c 1.27229,-0.0318 2.33145,-0.472 3.17749,-1.32073 0.84604,-0.84873 1.2852,-1.91543 1.3175,-3.2001 h 9.04164 c 1.28573,-0.0317 2.35673,-0.47199 3.21302,-1.32072 0.85624,-0.84873 1.30079,-1.91543 1.33364,-3.2001 v -4.49499 c -0.0328,-1.28573 -0.4774,-2.35673 -1.33364,-3.21301 -0.85629,-0.85625 -1.92729,-1.3008 -3.21302,-1.33364 z m -9.04164,9.60997 v -5.06332 h 7.93081 c 0.0463,-0.0231 0.23141,0.0232 0.55542,0.13885 0.32398,0.11573 0.50912,0.43972 0.55541,0.97198 v 2.81583 c 0.0231,0.0474 -0.0231,0.23681 -0.13885,0.56833 -0.11573,0.33154 -0.43972,0.52098 -0.97198,0.56833 z"
|
||||
style="fill:#ffffff;fill-opacity:1" />
|
||||
<path
|
||||
inkscape:connector-curvature="0"
|
||||
id="path1274"
|
||||
d="m 194.05931,508.89031 v -3.38416 c -0.0318,-1.28573 -0.47201,-2.35673 -1.32072,-3.21301 -0.84875,-0.85625 -1.91545,-1.3008 -3.2001,-1.33364 h -11.85747 c -0.47684,0.0129 -0.87295,0.18085 -1.18833,0.50374 -0.31538,0.32294 -0.47899,0.72335 -0.49083,1.20125 v 16.3783 c 1.27336,-0.0318 2.33683,-0.472 3.1904,-1.32073 0.85357,-0.84873 1.29705,-1.91543 1.33042,-3.2001 v -1.13666 h 5.14082 l 2.60916,3.71999 c 0.4187,0.60063 0.94398,1.07208 1.57583,1.41437 0.63182,0.34229 1.33793,0.51667 2.11833,0.52313 0.37618,-5.4e-4 0.74107,-0.0447 1.09468,-0.1324 0.35358,-0.0877 0.68618,-0.21582 0.99781,-0.38427 l -3.64249,-5.19249 c 1.05592,-0.22872 1.92133,-0.74647 2.59625,-1.55322 0.67487,-0.80675 1.02362,-1.77011 1.04624,-2.8901 z m -13.53663,0.5425 v -3.92666 h 7.87915 c 0.0474,-0.0231 0.23679,0.0232 0.56833,0.13885 0.33151,0.11573 0.52096,0.43972 0.56833,0.97198 v 1.705 c 0.0237,0.0463 -0.0237,0.23143 -0.14208,0.55541 -0.11842,0.324 -0.44995,0.50914 -0.99458,0.55542 z"
|
||||
style="fill:#ffffff;fill-opacity:1" />
|
||||
<path
|
||||
inkscape:connector-curvature="0"
|
||||
id="path1276"
|
||||
d="m 210.1751,500.9595 h -9.01581 c -1.28467,0.0328 -2.35137,0.47739 -3.2001,1.33364 -0.84873,0.85628 -1.28897,1.92728 -1.32072,3.21301 v 9.01581 c 0.0317,1.28467 0.47199,2.35137 1.32072,3.2001 0.84873,0.84873 1.91543,1.28897 3.2001,1.32073 h 9.01581 c 1.28465,-0.0318 2.35135,-0.472 3.2001,-1.32073 0.84871,-0.84873 1.28895,-1.91543 1.32072,-3.2001 v -9.01581 c -0.0318,-1.28573 -0.47201,-2.35673 -1.32072,-3.21301 -0.84875,-0.85625 -1.91545,-1.3008 -3.2001,-1.33364 z m 0,12.4258 c 0.0237,0.0474 -0.0237,0.23681 -0.14208,0.56833 -0.11842,0.33153 -0.44995,0.52098 -0.99458,0.56833 h -6.74249 c -0.0474,0.0237 -0.23681,-0.0237 -0.56833,-0.14208 -0.33153,-0.1184 -0.52098,-0.44993 -0.56833,-0.99458 v -6.76832 c -0.0237,-0.0463 0.0237,-0.23141 0.14208,-0.55541 0.1184,-0.32398 0.44993,-0.50912 0.99458,-0.55542 h 6.74249 c 0.0473,-0.0231 0.23679,0.0232 0.56833,0.13885 0.33151,0.11573 0.52096,0.43972 0.56833,0.97198 z"
|
||||
style="fill:#ffffff;fill-opacity:1" />
|
||||
<path
|
||||
inkscape:connector-curvature="0"
|
||||
id="path1278"
|
||||
d="m 237.4767,502.25116 c -0.39183,-0.39179 -0.84822,-0.69964 -1.36917,-0.92354 -0.52099,-0.22387 -1.08071,-0.33797 -1.67916,-0.34229 -0.6367,0.005 -1.22333,0.1308 -1.75989,0.37781 -0.53659,0.24705 -1.00052,0.58611 -1.39177,1.01719 l -3.90082,4.28832 -3.92666,-4.28832 c -0.4015,-0.44238 -0.86434,-0.78467 -1.38854,-1.02688 -0.5242,-0.24217 -1.1033,-0.36487 -1.73729,-0.36812 -0.59847,0.004 -1.15819,0.11842 -1.67916,0.34229 -0.52097,0.2239 -0.97736,0.53175 -1.36916,0.92354 l 7.05248,7.74998 -7.05248,7.74998 c 0.3918,0.40419 0.84819,0.71957 1.36916,0.94615 0.52097,0.22657 1.08069,0.34175 1.67916,0.34552 0.62538,-0.005 1.20878,-0.13079 1.75021,-0.37782 0.54142,-0.24703 1.00857,-0.58609 1.40145,-1.01718 l 3.90083,-4.28832 3.90082,4.28832 c 0.39125,0.43109 0.85518,0.77015 1.39177,1.01718 0.53656,0.24703 1.12319,0.37297 1.75989,0.37782 0.59845,-0.004 1.15817,-0.11895 1.67916,-0.34552 0.52096,-0.22658 0.97734,-0.54196 1.36917,-0.94615 l -7.05249,-7.74998 z"
|
||||
style="fill:#ffffff;fill-opacity:1" />
|
||||
<path
|
||||
inkscape:connector-curvature="0"
|
||||
id="path1280"
|
||||
d="m 260.98042,500.9595 h -2.84166 c -0.92947,0.0129 -1.75721,0.2648 -2.48322,0.75562 -0.72604,0.49085 -1.27607,1.14314 -1.6501,1.95687 l 0.0258,-0.0517 -2.66082,5.83832 -2.635,-5.83832 v 0.0517 c -0.36275,-0.81373 -0.90955,-1.46602 -1.64041,-1.95687 -0.73087,-0.49082 -1.56184,-0.74269 -2.49291,-0.75562 h -2.81583 c -0.48922,0.0129 -0.89286,0.18085 -1.21093,0.50374 -0.31808,0.32294 -0.48276,0.72335 -0.49406,1.20125 v 16.3783 c 1.27336,-0.0318 2.33683,-0.472 3.19041,-1.32073 0.85357,-0.84873 1.29704,-1.91543 1.33041,-3.2001 v -8.65414 c 0.002,-0.11785 0.0371,-0.2115 0.10656,-0.28094 0.0694,-0.0694 0.16307,-0.10493 0.28094,-0.10656 0.0673,0.002 0.13293,0.0237 0.19698,0.0646 0.064,0.0409 0.11032,0.0883 0.13885,0.14208 l 5.01166,11.05664 c 0.0947,0.19752 0.23464,0.3579 0.41979,0.48115 0.18512,0.12325 0.38964,0.18675 0.61354,0.19052 0.22226,-0.003 0.42354,-0.0619 0.60385,-0.1776 0.18028,-0.11571 0.32344,-0.27179 0.42948,-0.46823 L 257.4154,505.687 c 0.0393,-0.0538 0.0899,-0.10116 0.15177,-0.14208 0.0619,-0.0409 0.13184,-0.0624 0.2099,-0.0646 0.10547,0.002 0.19158,0.0371 0.25833,0.10656 0.0667,0.0694 0.10116,0.16309 0.10333,0.28094 v 8.65414 c 0.0333,1.28467 0.47682,2.35137 1.33042,3.2001 0.85355,0.84873 1.91702,1.28897 3.19041,1.32073 v -16.3783 c -0.0119,-0.4779 -0.17548,-0.87831 -0.49084,-1.20125 -0.3154,-0.32289 -0.71151,-0.49081 -1.18833,-0.50374 z"
|
||||
style="fill:#ffffff;fill-opacity:1" />
|
||||
<path
|
||||
inkscape:connector-curvature="0"
|
||||
id="path1282"
|
||||
d="m 278.79561,500.9595 h -9.01581 c -1.28467,0.0328 -2.35137,0.47739 -3.20009,1.33364 -0.84874,0.85628 -1.28898,1.92728 -1.32073,3.21301 v 9.01581 c 0.0317,1.28467 0.47199,2.35137 1.32073,3.2001 0.84872,0.84873 1.91542,1.28897 3.20009,1.32073 h 9.01581 c 1.28466,-0.0318 2.35135,-0.472 3.2001,-1.32073 0.84871,-0.84873 1.28896,-1.91543 1.32073,-3.2001 v -9.01581 c -0.0318,-1.28573 -0.47202,-2.35673 -1.32073,-3.21301 -0.84875,-0.85625 -1.91544,-1.3008 -3.2001,-1.33364 z m 0,12.4258 c 0.0237,0.0474 -0.0237,0.23681 -0.14208,0.56833 -0.11842,0.33153 -0.44994,0.52098 -0.99458,0.56833 h -6.74248 c -0.0474,0.0237 -0.23681,-0.0237 -0.56834,-0.14208 -0.33153,-0.1184 -0.52097,-0.44993 -0.56833,-0.99458 v -6.76832 c -0.0237,-0.0463 0.0237,-0.23141 0.14209,-0.55541 0.11839,-0.32398 0.44992,-0.50912 0.99458,-0.55542 h 6.74248 c 0.0473,-0.0231 0.23679,0.0232 0.56833,0.13885 0.33152,0.11573 0.52096,0.43972 0.56833,0.97198 z"
|
||||
style="fill:#ffffff;fill-opacity:1" />
|
||||
<path
|
||||
inkscape:connector-curvature="0"
|
||||
id="path1284"
|
||||
d="m 306.0972,502.25116 c -0.39182,-0.39179 -0.84821,-0.69964 -1.36916,-0.92354 -0.52099,-0.22387 -1.08071,-0.33797 -1.67916,-0.34229 -0.6367,0.005 -1.22333,0.1308 -1.75989,0.37781 -0.5366,0.24705 -1.00052,0.58611 -1.39177,1.01719 l -3.90083,4.28832 -3.92665,-4.28832 c -0.4015,-0.44238 -0.86435,-0.78467 -1.38854,-1.02688 -0.52421,-0.24217 -1.1033,-0.36487 -1.73729,-0.36812 -0.59847,0.004 -1.15819,0.11842 -1.67916,0.34229 -0.52097,0.2239 -0.97736,0.53175 -1.36917,0.92354 l 7.05249,7.74998 -7.05249,7.74998 c 0.39181,0.40419 0.8482,0.71957 1.36917,0.94615 0.52097,0.22657 1.08069,0.34175 1.67916,0.34552 0.62538,-0.005 1.20878,-0.13079 1.7502,-0.37782 0.54142,-0.24703 1.00857,-0.58609 1.40146,-1.01718 l 3.90082,-4.28832 3.90083,4.28832 c 0.39125,0.43109 0.85517,0.77015 1.39177,1.01718 0.53656,0.24703 1.12319,0.37297 1.75989,0.37782 0.59845,-0.004 1.15817,-0.11895 1.67916,-0.34552 0.52095,-0.22658 0.97734,-0.54196 1.36916,-0.94615 l -7.05248,-7.74998 z"
|
||||
style="fill:#ffffff;fill-opacity:1" />
|
||||
</g>
|
||||
</g>
|
||||
<path
|
||||
inkscape:connector-curvature="0"
|
||||
id="path1290"
|
||||
style="fill:#e57000;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.730891"
|
||||
d="m 141.89758,116.88641 25.41237,27.92599 c -2.7927,2.88547 -6.70224,4.65495 -10.98527,4.65495 -4.56076,0 -8.56315,-1.95514 -11.35592,-5.02707 l -14.05575,-15.45172 -10.9846,-12.10215 10.9846,-12.00854 14.05575,-15.452532 c 2.79277,-3.071175 6.79516,-5.027074 11.35592,-5.027074 4.28303,0 8.19257,1.768759 10.98527,4.561525 z"
|
||||
sodipodi:nodetypes="ccscccccscc" />
|
||||
<path
|
||||
inkscape:connector-curvature="0"
|
||||
id="path1292"
|
||||
style="fill:#e57000;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.730891"
|
||||
d="m 92.334245,116.8861 -25.41238,27.92603 c 2.7927,2.88547 6.702237,4.65495 10.985287,4.65495 4.560744,0 8.563138,-1.95514 11.355905,-5.02707 L 103.3188,128.98825 114.30338,116.8861 103.3188,104.87756 89.263057,89.425028 c -2.792767,-3.071215 -6.795161,-5.027074 -11.355905,-5.027074 -4.28305,0 -8.192587,1.768759 -10.985287,4.561526 z"
|
||||
sodipodi:nodetypes="ccscccccscc" />
|
||||
<path
|
||||
inkscape:connector-curvature="0"
|
||||
id="path1294"
|
||||
style="fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.66712"
|
||||
d="m 127.12922,130.73289 -10.02585,-11.04587 -10.0263,11.04587 -23.195348,25.48982 c 2.548811,2.54902 6.118169,4.16327 10.025148,4.16327 4.16359,0 7.64778,-1.69975 10.28111,-4.58817 l 12.91539,-14.10405 12.82882,14.10405 c 2.5493,2.80293 6.20218,4.58817 10.36513,4.58817 3.9091,0 7.47768,-1.61425 10.02652,-4.16327 z"
|
||||
sodipodi:nodetypes="ccccscccscc" />
|
||||
<path
|
||||
inkscape:connector-curvature="0"
|
||||
id="path1296"
|
||||
style="fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.66712"
|
||||
d="M 127.1286,103.03813 117.10275,114.084 107.07645,103.03813 83.881116,77.548321 c 2.548838,-2.548932 6.118156,-4.163226 10.025162,-4.163226 4.163603,0 7.647762,1.699732 10.281082,4.588143 l 12.91539,14.104094 12.82882,-14.104094 c 2.54934,-2.802999 6.20221,-4.588143 10.36513,-4.588143 3.90911,0 7.47772,1.614294 10.02653,4.163226 z"
|
||||
sodipodi:nodetypes="ccccscccscc" />
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 12 KiB |
@@ -1,5 +0,0 @@
|
||||
<svg width="400" height="400" viewBox="0 0 19 19" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3.41508 17.2983L7.88484 12.7653L9.51146 18.9412L11.8745 18.2949L9.52018 9.32758L0.69527 6.93747L0.066864 9.35199L6.13926 11.0015L1.68806 15.5279L3.41508 17.2983Z" fill="#F34E3F"/>
|
||||
<path d="M16.3044 12.0436L18.6675 11.3973L16.3132 2.43003L7.48824 0.0399246L6.85984 2.45444L14.312 4.47881L16.3044 12.0436Z" fill="#F34E3F"/>
|
||||
<path d="M12.9126 15.4902L15.2756 14.8439L12.9213 5.87659L4.09639 3.48648L3.46799 5.901L10.9201 7.92537L12.9126 15.4902Z" fill="#F34E3F"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 576 B |
+2
-2
@@ -89,7 +89,7 @@ Create `registry/[your-username]/README.md`:
|
||||
---
|
||||
display_name: "Your Name"
|
||||
bio: "Brief description of who you are and what you do"
|
||||
avatar: "./.images/avatar.png"
|
||||
avatar_url: "./.images/avatar.png"
|
||||
github: "your-username"
|
||||
linkedin: "https://www.linkedin.com/in/your-username" # Optional
|
||||
website: "https://yourwebsite.com" # Optional
|
||||
@@ -102,7 +102,7 @@ status: "community"
|
||||
Brief description of who you are and what you do.
|
||||
```
|
||||
|
||||
> **Note**: The `avatar` must point to `./.images/avatar.png` or `./.images/avatar.svg`.
|
||||
> **Note**: The `avatar_url` must point to `./.images/avatar.png` or `./.images/avatar.svg`.
|
||||
|
||||
### 2. Generate Module Files
|
||||
|
||||
|
||||
+1
-1
@@ -127,7 +127,7 @@ tags: ["tag1", "tag2"]
|
||||
```yaml
|
||||
display_name: "Your Name"
|
||||
bio: "Brief description of who you are and what you do"
|
||||
avatar: "./.images/avatar.png"
|
||||
avatar_url: "./.images/avatar.png"
|
||||
github: "username"
|
||||
linkedin: "https://www.linkedin.com/in/username" # Optional
|
||||
website: "https://yourwebsite.com" # Optional
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
)
|
||||
|
||||
func validateCoderModuleReadmeBody(body string) []error {
|
||||
var errs []error
|
||||
|
||||
trimmed := strings.TrimSpace(body)
|
||||
if baseErrs := validateReadmeBody(trimmed); len(baseErrs) != 0 {
|
||||
errs = append(errs, baseErrs...)
|
||||
}
|
||||
|
||||
foundParagraph := false
|
||||
terraformCodeBlockCount := 0
|
||||
foundTerraformVersionRef := false
|
||||
|
||||
lineNum := 0
|
||||
isInsideCodeBlock := false
|
||||
isInsideTerraform := false
|
||||
|
||||
lineScanner := bufio.NewScanner(strings.NewReader(trimmed))
|
||||
for lineScanner.Scan() {
|
||||
lineNum++
|
||||
nextLine := lineScanner.Text()
|
||||
|
||||
// Code assumes that invalid headers would've already been handled by the base validation function, so we don't
|
||||
// need to check deeper if the first line isn't an h1.
|
||||
if lineNum == 1 {
|
||||
if !strings.HasPrefix(nextLine, "# ") {
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.HasPrefix(nextLine, "```") {
|
||||
isInsideCodeBlock = !isInsideCodeBlock
|
||||
isInsideTerraform = isInsideCodeBlock && strings.HasPrefix(nextLine, "```tf")
|
||||
if isInsideTerraform {
|
||||
terraformCodeBlockCount++
|
||||
}
|
||||
if strings.HasPrefix(nextLine, "```hcl") {
|
||||
errs = append(errs, xerrors.New("all hcl code blocks must be converted to tf"))
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if isInsideCodeBlock {
|
||||
if isInsideTerraform {
|
||||
foundTerraformVersionRef = foundTerraformVersionRef || terraformVersionRe.MatchString(nextLine)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Code assumes that we can treat this case as the end of the "h1 section" and don't need to process any further lines.
|
||||
if lineNum > 1 && strings.HasPrefix(nextLine, "#") {
|
||||
break
|
||||
}
|
||||
|
||||
// Code assumes that if we've reached this point, the only other options are:
|
||||
// (1) empty spaces, (2) paragraphs, (3) HTML, and (4) asset references made via [] syntax.
|
||||
trimmedLine := strings.TrimSpace(nextLine)
|
||||
isParagraph := trimmedLine != "" && !strings.HasPrefix(trimmedLine, "![") && !strings.HasPrefix(trimmedLine, "<")
|
||||
foundParagraph = foundParagraph || isParagraph
|
||||
}
|
||||
|
||||
if terraformCodeBlockCount == 0 {
|
||||
errs = append(errs, xerrors.New("did not find Terraform code block within h1 section"))
|
||||
} else {
|
||||
if terraformCodeBlockCount > 1 {
|
||||
errs = append(errs, xerrors.New("cannot have more than one Terraform code block in h1 section"))
|
||||
}
|
||||
if !foundTerraformVersionRef {
|
||||
errs = append(errs, xerrors.New("did not find Terraform code block that specifies 'version' field"))
|
||||
}
|
||||
}
|
||||
if !foundParagraph {
|
||||
errs = append(errs, xerrors.New("did not find paragraph within h1 section"))
|
||||
}
|
||||
if isInsideCodeBlock {
|
||||
errs = append(errs, xerrors.New("code blocks inside h1 section do not all terminate before end of file"))
|
||||
}
|
||||
|
||||
return errs
|
||||
}
|
||||
|
||||
func validateCoderModuleReadme(rm coderResourceReadme) []error {
|
||||
var errs []error
|
||||
for _, err := range validateCoderModuleReadmeBody(rm.body) {
|
||||
errs = append(errs, addFilePathToError(rm.filePath, err))
|
||||
}
|
||||
if fmErrs := validateCoderResourceFrontmatter("modules", rm.filePath, rm.frontmatter); len(fmErrs) != 0 {
|
||||
errs = append(errs, fmErrs...)
|
||||
}
|
||||
return errs
|
||||
}
|
||||
|
||||
func validateAllCoderModuleReadmes(resources []coderResourceReadme) error {
|
||||
var yamlValidationErrors []error
|
||||
for _, readme := range resources {
|
||||
errs := validateCoderModuleReadme(readme)
|
||||
if len(errs) > 0 {
|
||||
yamlValidationErrors = append(yamlValidationErrors, errs...)
|
||||
}
|
||||
}
|
||||
if len(yamlValidationErrors) != 0 {
|
||||
return validationPhaseError{
|
||||
phase: validationPhaseReadme,
|
||||
errors: yamlValidationErrors,
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateAllCoderModules() error {
|
||||
const resourceType = "modules"
|
||||
allReadmeFiles, err := aggregateCoderResourceReadmeFiles(resourceType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
logger.Info(context.Background(), "processing template README files", "resource_type", resourceType, "num_files", len(allReadmeFiles))
|
||||
resources, err := parseCoderResourceReadmeFiles(resourceType, allReadmeFiles)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = validateAllCoderModuleReadmes(resources)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
logger.Info(context.Background(), "processed README files as valid Coder resources", "resource_type", resourceType, "num_files", len(resources))
|
||||
|
||||
if err := validateCoderResourceRelativeURLs(resources); err != nil {
|
||||
return err
|
||||
}
|
||||
logger.Info(context.Background(), "all relative URLs for READMEs are valid", "resource_type", resourceType)
|
||||
return nil
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"errors"
|
||||
"net/url"
|
||||
"os"
|
||||
@@ -15,7 +17,6 @@ import (
|
||||
|
||||
var (
|
||||
supportedResourceTypes = []string{"modules", "templates"}
|
||||
operatingSystems = []string{"windows", "macos", "linux"}
|
||||
|
||||
// TODO: This is a holdover from the validation logic used by the Coder Modules repo. It gives us some assurance, but
|
||||
// realistically, we probably want to parse any Terraform code snippets, and make some deeper guarantees about how it's
|
||||
@@ -24,21 +25,11 @@ var (
|
||||
)
|
||||
|
||||
type coderResourceFrontmatter struct {
|
||||
Description string `yaml:"description"`
|
||||
IconURL string `yaml:"icon"`
|
||||
DisplayName *string `yaml:"display_name"`
|
||||
Verified *bool `yaml:"verified"`
|
||||
Tags []string `yaml:"tags"`
|
||||
OperatingSystems []string `yaml:"supported_os"`
|
||||
}
|
||||
|
||||
// A slice version of the struct tags from coderResourceFrontmatter. Might be worth using reflection to generate this
|
||||
// list at runtime in the future, but this should be okay for now
|
||||
var supportedCoderResourceStructKeys = []string{
|
||||
"description", "icon", "display_name", "verified", "tags", "supported_os",
|
||||
// TODO: This is an old, officially deprecated key from the archived coder/modules repo. We can remove this once we
|
||||
// make sure that the Registry Server is no longer checking this field.
|
||||
"maintainer_github",
|
||||
Description string `yaml:"description"`
|
||||
IconURL string `yaml:"icon"`
|
||||
DisplayName *string `yaml:"display_name"`
|
||||
Verified *bool `yaml:"verified"`
|
||||
Tags []string `yaml:"tags"`
|
||||
}
|
||||
|
||||
// coderResourceReadme represents a README describing a Terraform resource used
|
||||
@@ -51,17 +42,6 @@ type coderResourceReadme struct {
|
||||
frontmatter coderResourceFrontmatter
|
||||
}
|
||||
|
||||
func validateSupportedOperatingSystems(systems []string) []error {
|
||||
var errs []error
|
||||
for _, s := range systems {
|
||||
if slices.Contains(operatingSystems, s) {
|
||||
continue
|
||||
}
|
||||
errs = append(errs, xerrors.Errorf("detected unknown operating system %q", s))
|
||||
}
|
||||
return errs
|
||||
}
|
||||
|
||||
func validateCoderResourceDisplayName(displayName *string) error {
|
||||
if displayName != nil && *displayName == "" {
|
||||
return xerrors.New("if defined, display_name must not be empty string")
|
||||
@@ -87,7 +67,7 @@ func validateCoderResourceIconURL(iconURL string) []error {
|
||||
return []error{xerrors.New("icon URL cannot be empty")}
|
||||
}
|
||||
|
||||
var errs []error
|
||||
errs := []error{}
|
||||
|
||||
// If the URL does not have a relative path.
|
||||
if !strings.HasPrefix(iconURL, ".") && !strings.HasPrefix(iconURL, "/") {
|
||||
@@ -118,7 +98,7 @@ func validateCoderResourceTags(tags []string) error {
|
||||
|
||||
// All of these tags are used for the module/template filter controls in the Registry site. Need to make sure they
|
||||
// can all be placed in the browser URL without issue.
|
||||
var invalidTags []string
|
||||
invalidTags := []string{}
|
||||
for _, t := range tags {
|
||||
if t != url.QueryEscape(t) {
|
||||
invalidTags = append(invalidTags, t)
|
||||
@@ -131,50 +111,119 @@ func validateCoderResourceTags(tags []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateCoderResourceFrontmatter(resourceType string, filePath string, fm coderResourceFrontmatter) []error {
|
||||
if !slices.Contains(supportedResourceTypes, resourceType) {
|
||||
return []error{xerrors.Errorf("cannot process unknown resource type %q", resourceType)}
|
||||
}
|
||||
|
||||
func validateCoderResourceReadmeBody(body string) []error {
|
||||
var errs []error
|
||||
if err := validateCoderResourceDisplayName(fm.DisplayName); err != nil {
|
||||
errs = append(errs, addFilePathToError(filePath, err))
|
||||
}
|
||||
if err := validateCoderResourceDescription(fm.Description); err != nil {
|
||||
errs = append(errs, addFilePathToError(filePath, err))
|
||||
}
|
||||
if err := validateCoderResourceTags(fm.Tags); err != nil {
|
||||
errs = append(errs, addFilePathToError(filePath, err))
|
||||
|
||||
trimmed := strings.TrimSpace(body)
|
||||
// TODO: this may cause unexpected behavior since the errors slice may have a 0 length. Add a test.
|
||||
errs = append(errs, validateReadmeBody(trimmed)...)
|
||||
|
||||
foundParagraph := false
|
||||
terraformCodeBlockCount := 0
|
||||
foundTerraformVersionRef := false
|
||||
|
||||
lineNum := 0
|
||||
isInsideCodeBlock := false
|
||||
isInsideTerraform := false
|
||||
|
||||
lineScanner := bufio.NewScanner(strings.NewReader(trimmed))
|
||||
for lineScanner.Scan() {
|
||||
lineNum++
|
||||
nextLine := lineScanner.Text()
|
||||
|
||||
// Code assumes that invalid headers would've already been handled by the base validation function, so we don't
|
||||
// need to check deeper if the first line isn't an h1.
|
||||
if lineNum == 1 {
|
||||
if !strings.HasPrefix(nextLine, "# ") {
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.HasPrefix(nextLine, "```") {
|
||||
isInsideCodeBlock = !isInsideCodeBlock
|
||||
isInsideTerraform = isInsideCodeBlock && strings.HasPrefix(nextLine, "```tf")
|
||||
if isInsideTerraform {
|
||||
terraformCodeBlockCount++
|
||||
}
|
||||
if strings.HasPrefix(nextLine, "```hcl") {
|
||||
errs = append(errs, xerrors.New("all .hcl language references must be converted to .tf"))
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if isInsideCodeBlock {
|
||||
if isInsideTerraform {
|
||||
foundTerraformVersionRef = foundTerraformVersionRef || terraformVersionRe.MatchString(nextLine)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Code assumes that we can treat this case as the end of the "h1 section" and don't need to process any further lines.
|
||||
if lineNum > 1 && strings.HasPrefix(nextLine, "#") {
|
||||
break
|
||||
}
|
||||
|
||||
// Code assumes that if we've reached this point, the only other options are:
|
||||
// (1) empty spaces, (2) paragraphs, (3) HTML, and (4) asset references made via [] syntax.
|
||||
trimmedLine := strings.TrimSpace(nextLine)
|
||||
isParagraph := trimmedLine != "" && !strings.HasPrefix(trimmedLine, "![") && !strings.HasPrefix(trimmedLine, "<")
|
||||
foundParagraph = foundParagraph || isParagraph
|
||||
}
|
||||
|
||||
for _, err := range validateCoderResourceIconURL(fm.IconURL) {
|
||||
errs = append(errs, addFilePathToError(filePath, err))
|
||||
if terraformCodeBlockCount == 0 {
|
||||
errs = append(errs, xerrors.New("did not find Terraform code block within h1 section"))
|
||||
} else {
|
||||
if terraformCodeBlockCount > 1 {
|
||||
errs = append(errs, xerrors.New("cannot have more than one Terraform code block in h1 section"))
|
||||
}
|
||||
if !foundTerraformVersionRef {
|
||||
errs = append(errs, xerrors.New("did not find Terraform code block that specifies 'version' field"))
|
||||
}
|
||||
}
|
||||
for _, err := range validateSupportedOperatingSystems(fm.OperatingSystems) {
|
||||
errs = append(errs, addFilePathToError(filePath, err))
|
||||
if !foundParagraph {
|
||||
errs = append(errs, xerrors.New("did not find paragraph within h1 section"))
|
||||
}
|
||||
if isInsideCodeBlock {
|
||||
errs = append(errs, xerrors.New("code blocks inside h1 section do not all terminate before end of file"))
|
||||
}
|
||||
|
||||
return errs
|
||||
}
|
||||
|
||||
func parseCoderResourceReadme(resourceType string, rm readme) (coderResourceReadme, []error) {
|
||||
fm, body, err := separateFrontmatter(rm.rawText)
|
||||
if err != nil {
|
||||
return coderResourceReadme{}, []error{xerrors.Errorf("%q: failed to parse frontmatter: %v", rm.filePath, err)}
|
||||
func validateCoderResourceReadme(rm coderResourceReadme) []error {
|
||||
var errs []error
|
||||
|
||||
for _, err := range validateCoderResourceReadmeBody(rm.body) {
|
||||
errs = append(errs, addFilePathToError(rm.filePath, err))
|
||||
}
|
||||
|
||||
keyErrs := validateFrontmatterYamlKeys(fm, supportedCoderResourceStructKeys)
|
||||
if len(keyErrs) != 0 {
|
||||
var remapped []error
|
||||
for _, e := range keyErrs {
|
||||
remapped = append(remapped, addFilePathToError(rm.filePath, e))
|
||||
}
|
||||
return coderResourceReadme{}, remapped
|
||||
if err := validateCoderResourceDisplayName(rm.frontmatter.DisplayName); err != nil {
|
||||
errs = append(errs, addFilePathToError(rm.filePath, err))
|
||||
}
|
||||
if err := validateCoderResourceDescription(rm.frontmatter.Description); err != nil {
|
||||
errs = append(errs, addFilePathToError(rm.filePath, err))
|
||||
}
|
||||
if err := validateCoderResourceTags(rm.frontmatter.Tags); err != nil {
|
||||
errs = append(errs, addFilePathToError(rm.filePath, err))
|
||||
}
|
||||
|
||||
for _, err := range validateCoderResourceIconURL(rm.frontmatter.IconURL) {
|
||||
errs = append(errs, addFilePathToError(rm.filePath, err))
|
||||
}
|
||||
|
||||
return errs
|
||||
}
|
||||
|
||||
func parseCoderResourceReadme(resourceType string, rm readme) (coderResourceReadme, error) {
|
||||
fm, body, err := separateFrontmatter(rm.rawText)
|
||||
if err != nil {
|
||||
return coderResourceReadme{}, xerrors.Errorf("%q: failed to parse frontmatter: %v", rm.filePath, err)
|
||||
}
|
||||
|
||||
yml := coderResourceFrontmatter{}
|
||||
if err := yaml.Unmarshal([]byte(fm), &yml); err != nil {
|
||||
return coderResourceReadme{}, []error{xerrors.Errorf("%q: failed to parse: %v", rm.filePath, err)}
|
||||
return coderResourceReadme{}, xerrors.Errorf("%q: failed to parse: %v", rm.filePath, err)
|
||||
}
|
||||
|
||||
return coderResourceReadme{
|
||||
@@ -185,17 +234,13 @@ func parseCoderResourceReadme(resourceType string, rm readme) (coderResourceRead
|
||||
}, nil
|
||||
}
|
||||
|
||||
func parseCoderResourceReadmeFiles(resourceType string, rms []readme) ([]coderResourceReadme, error) {
|
||||
if !slices.Contains(supportedResourceTypes, resourceType) {
|
||||
return nil, xerrors.Errorf("cannot process unknown resource type %q", resourceType)
|
||||
}
|
||||
|
||||
func parseCoderResourceReadmeFiles(resourceType string, rms []readme) (map[string]coderResourceReadme, error) {
|
||||
resources := map[string]coderResourceReadme{}
|
||||
var yamlParsingErrs []error
|
||||
for _, rm := range rms {
|
||||
p, errs := parseCoderResourceReadme(resourceType, rm)
|
||||
if len(errs) != 0 {
|
||||
yamlParsingErrs = append(yamlParsingErrs, errs...)
|
||||
p, err := parseCoderResourceReadme(resourceType, rm)
|
||||
if err != nil {
|
||||
yamlParsingErrs = append(yamlParsingErrs, err)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -208,27 +253,30 @@ func parseCoderResourceReadmeFiles(resourceType string, rms []readme) ([]coderRe
|
||||
}
|
||||
}
|
||||
|
||||
var serialized []coderResourceReadme
|
||||
for _, r := range resources {
|
||||
serialized = append(serialized, r)
|
||||
yamlValidationErrors := []error{}
|
||||
for _, readme := range resources {
|
||||
errs := validateCoderResourceReadme(readme)
|
||||
if len(errs) > 0 {
|
||||
yamlValidationErrors = append(yamlValidationErrors, errs...)
|
||||
}
|
||||
}
|
||||
slices.SortFunc(serialized, func(r1 coderResourceReadme, r2 coderResourceReadme) int {
|
||||
return strings.Compare(r1.filePath, r2.filePath)
|
||||
})
|
||||
return serialized, nil
|
||||
if len(yamlValidationErrors) != 0 {
|
||||
return nil, validationPhaseError{
|
||||
phase: validationPhaseReadme,
|
||||
errors: yamlValidationErrors,
|
||||
}
|
||||
}
|
||||
|
||||
return resources, nil
|
||||
}
|
||||
|
||||
// Todo: Need to beef up this function by grabbing each image/video URL from
|
||||
// the body's AST.
|
||||
func validateCoderResourceRelativeURLs(_ []coderResourceReadme) error {
|
||||
func validateCoderResourceRelativeURLs(_ map[string]coderResourceReadme) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func aggregateCoderResourceReadmeFiles(resourceType string) ([]readme, error) {
|
||||
if !slices.Contains(supportedResourceTypes, resourceType) {
|
||||
return nil, xerrors.Errorf("cannot process unknown resource type %q", resourceType)
|
||||
}
|
||||
|
||||
registryFiles, err := os.ReadDir(rootRegistryPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -277,3 +325,27 @@ func aggregateCoderResourceReadmeFiles(resourceType string) ([]readme, error) {
|
||||
}
|
||||
return allReadmeFiles, nil
|
||||
}
|
||||
|
||||
func validateAllCoderResourceFilesOfType(resourceType string) error {
|
||||
if !slices.Contains(supportedResourceTypes, resourceType) {
|
||||
return xerrors.Errorf("resource type %q is not part of supported list [%s]", resourceType, strings.Join(supportedResourceTypes, ", "))
|
||||
}
|
||||
|
||||
allReadmeFiles, err := aggregateCoderResourceReadmeFiles(resourceType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
logger.Info(context.Background(), "processing README files", "num_files", len(allReadmeFiles))
|
||||
resources, err := parseCoderResourceReadmeFiles(resourceType, allReadmeFiles)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
logger.Info(context.Background(), "processed README files as valid Coder resources", "num_files", len(resources), "type", resourceType)
|
||||
|
||||
if err := validateCoderResourceRelativeURLs(resources); err != nil {
|
||||
return err
|
||||
}
|
||||
logger.Info(context.Background(), "all relative URLs for READMEs are valid", "type", resourceType)
|
||||
return nil
|
||||
}
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ func TestValidateCoderResourceReadmeBody(t *testing.T) {
|
||||
t.Run("Parses a valid README body with zero issues", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
errs := validateCoderModuleReadmeBody(testBody)
|
||||
errs := validateCoderResourceReadmeBody(testBody)
|
||||
for _, e := range errs {
|
||||
t.Error(e)
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
)
|
||||
|
||||
func validateCoderTemplateReadmeBody(body string) []error {
|
||||
var errs []error
|
||||
|
||||
trimmed := strings.TrimSpace(body)
|
||||
if baseErrs := validateReadmeBody(trimmed); len(baseErrs) != 0 {
|
||||
errs = append(errs, baseErrs...)
|
||||
}
|
||||
|
||||
var nextLine string
|
||||
foundParagraph := false
|
||||
isInsideCodeBlock := false
|
||||
lineNum := 0
|
||||
|
||||
lineScanner := bufio.NewScanner(strings.NewReader(trimmed))
|
||||
for lineScanner.Scan() {
|
||||
lineNum++
|
||||
nextLine = lineScanner.Text()
|
||||
|
||||
// Code assumes that invalid headers would've already been handled by the base validation function, so we don't
|
||||
// need to check deeper if the first line isn't an h1.
|
||||
if lineNum == 1 {
|
||||
if !strings.HasPrefix(nextLine, "# ") {
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.HasPrefix(nextLine, "```") {
|
||||
isInsideCodeBlock = !isInsideCodeBlock
|
||||
if strings.HasPrefix(nextLine, "```hcl") {
|
||||
errs = append(errs, xerrors.New("all .hcl language references must be converted to .tf"))
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Code assumes that we can treat this case as the end of the "h1 section" and don't need to process any further lines.
|
||||
if lineNum > 1 && strings.HasPrefix(nextLine, "#") {
|
||||
break
|
||||
}
|
||||
|
||||
// Code assumes that if we've reached this point, the only other options are:
|
||||
// (1) empty spaces, (2) paragraphs, (3) HTML, and (4) asset references made via [] syntax.
|
||||
trimmedLine := strings.TrimSpace(nextLine)
|
||||
isParagraph := trimmedLine != "" && !strings.HasPrefix(trimmedLine, "![") && !strings.HasPrefix(trimmedLine, "<")
|
||||
foundParagraph = foundParagraph || isParagraph
|
||||
}
|
||||
|
||||
if !foundParagraph {
|
||||
errs = append(errs, xerrors.New("did not find paragraph within h1 section"))
|
||||
}
|
||||
if isInsideCodeBlock {
|
||||
errs = append(errs, xerrors.New("code blocks inside h1 section do not all terminate before end of file"))
|
||||
}
|
||||
|
||||
return errs
|
||||
}
|
||||
|
||||
func validateCoderTemplateReadme(rm coderResourceReadme) []error {
|
||||
var errs []error
|
||||
for _, err := range validateCoderTemplateReadmeBody(rm.body) {
|
||||
errs = append(errs, addFilePathToError(rm.filePath, err))
|
||||
}
|
||||
if fmErrs := validateCoderResourceFrontmatter("templates", rm.filePath, rm.frontmatter); len(fmErrs) != 0 {
|
||||
errs = append(errs, fmErrs...)
|
||||
}
|
||||
return errs
|
||||
}
|
||||
|
||||
func validateAllCoderTemplateReadmes(resources []coderResourceReadme) error {
|
||||
var yamlValidationErrors []error
|
||||
for _, readme := range resources {
|
||||
errs := validateCoderTemplateReadme(readme)
|
||||
if len(errs) > 0 {
|
||||
yamlValidationErrors = append(yamlValidationErrors, errs...)
|
||||
}
|
||||
}
|
||||
if len(yamlValidationErrors) != 0 {
|
||||
return validationPhaseError{
|
||||
phase: validationPhaseReadme,
|
||||
errors: yamlValidationErrors,
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateAllCoderTemplates() error {
|
||||
const resourceType = "templates"
|
||||
allReadmeFiles, err := aggregateCoderResourceReadmeFiles(resourceType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
logger.Info(context.Background(), "processing template README files", "resource_type", resourceType, "num_files", len(allReadmeFiles))
|
||||
resources, err := parseCoderResourceReadmeFiles(resourceType, allReadmeFiles)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = validateAllCoderTemplateReadmes(resources)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
logger.Info(context.Background(), "processed README files as valid Coder resources", "resource_type", resourceType, "num_files", len(resources))
|
||||
|
||||
if err := validateCoderResourceRelativeURLs(resources); err != nil {
|
||||
return err
|
||||
}
|
||||
logger.Info(context.Background(), "all relative URLs for READMEs are valid", "resource_type", resourceType)
|
||||
return nil
|
||||
}
|
||||
@@ -19,16 +19,11 @@ type contributorProfileFrontmatter struct {
|
||||
Bio string `yaml:"bio"`
|
||||
ContributorStatus string `yaml:"status"`
|
||||
AvatarURL *string `yaml:"avatar"`
|
||||
GithubUsername *string `yaml:"github"`
|
||||
LinkedinURL *string `yaml:"linkedin"`
|
||||
WebsiteURL *string `yaml:"website"`
|
||||
SupportEmail *string `yaml:"support_email"`
|
||||
}
|
||||
|
||||
// A slice version of the struct tags from contributorProfileFrontmatter. Might be worth using reflection to generate
|
||||
// this list at runtime in the future, but this should be okay for now
|
||||
var supportedContributorProfileStructKeys = []string{"display_name", "bio", "status", "avatar", "linkedin", "github", "website", "support_email"}
|
||||
|
||||
type contributorProfileReadme struct {
|
||||
frontmatter contributorProfileFrontmatter
|
||||
namespace string
|
||||
@@ -55,22 +50,6 @@ func validateContributorLinkedinURL(linkedinURL *string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateGithubUsername(username *string) error {
|
||||
if username == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
name := *username
|
||||
trimmed := strings.TrimSpace(name)
|
||||
if trimmed == "" {
|
||||
return xerrors.New("username must have non-whitespace characters")
|
||||
}
|
||||
if name != trimmed {
|
||||
return xerrors.Errorf("username %q has extra whitespace", trimmed)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateContributorSupportEmail does best effort validation of a contributors email address. We can't 100% validate
|
||||
// that this is correct without actually sending an email, especially because some contributors are individual developers
|
||||
// and we don't want to do that on every single run of the CI pipeline. The best we can do is verify the general structure.
|
||||
@@ -79,7 +58,7 @@ func validateContributorSupportEmail(email *string) []error {
|
||||
return nil
|
||||
}
|
||||
|
||||
var errs []error
|
||||
errs := []error{}
|
||||
|
||||
username, server, ok := strings.Cut(*email, "@")
|
||||
if !ok {
|
||||
@@ -140,7 +119,7 @@ func validateContributorAvatarURL(avatarURL *string) []error {
|
||||
return []error{xerrors.New("avatar URL must be omitted or non-empty string")}
|
||||
}
|
||||
|
||||
var errs []error
|
||||
errs := []error{}
|
||||
// Have to use .Parse instead of .ParseRequestURI because this is the one field that's allowed to be a relative URL.
|
||||
if _, err := url.Parse(*avatarURL); err != nil {
|
||||
errs = append(errs, xerrors.Errorf("URL %q is not a valid relative or absolute URL", *avatarURL))
|
||||
@@ -166,7 +145,7 @@ func validateContributorAvatarURL(avatarURL *string) []error {
|
||||
}
|
||||
|
||||
func validateContributorReadme(rm contributorProfileReadme) []error {
|
||||
var allErrs []error
|
||||
allErrs := []error{}
|
||||
|
||||
if err := validateContributorDisplayName(rm.frontmatter.DisplayName); err != nil {
|
||||
allErrs = append(allErrs, addFilePathToError(rm.filePath, err))
|
||||
@@ -174,9 +153,6 @@ func validateContributorReadme(rm contributorProfileReadme) []error {
|
||||
if err := validateContributorLinkedinURL(rm.frontmatter.LinkedinURL); err != nil {
|
||||
allErrs = append(allErrs, addFilePathToError(rm.filePath, err))
|
||||
}
|
||||
if err := validateGithubUsername(rm.frontmatter.GithubUsername); err != nil {
|
||||
allErrs = append(allErrs, addFilePathToError(rm.filePath, err))
|
||||
}
|
||||
if err := validateContributorWebsite(rm.frontmatter.WebsiteURL); err != nil {
|
||||
allErrs = append(allErrs, addFilePathToError(rm.filePath, err))
|
||||
}
|
||||
@@ -194,24 +170,15 @@ func validateContributorReadme(rm contributorProfileReadme) []error {
|
||||
return allErrs
|
||||
}
|
||||
|
||||
func parseContributorProfile(rm readme) (contributorProfileReadme, []error) {
|
||||
func parseContributorProfile(rm readme) (contributorProfileReadme, error) {
|
||||
fm, _, err := separateFrontmatter(rm.rawText)
|
||||
if err != nil {
|
||||
return contributorProfileReadme{}, []error{xerrors.Errorf("%q: failed to parse frontmatter: %v", rm.filePath, err)}
|
||||
}
|
||||
|
||||
keyErrs := validateFrontmatterYamlKeys(fm, supportedContributorProfileStructKeys)
|
||||
if len(keyErrs) != 0 {
|
||||
var remapped []error
|
||||
for _, e := range keyErrs {
|
||||
remapped = append(remapped, addFilePathToError(rm.filePath, e))
|
||||
}
|
||||
return contributorProfileReadme{}, remapped
|
||||
return contributorProfileReadme{}, xerrors.Errorf("%q: failed to parse frontmatter: %v", rm.filePath, err)
|
||||
}
|
||||
|
||||
yml := contributorProfileFrontmatter{}
|
||||
if err := yaml.Unmarshal([]byte(fm), &yml); err != nil {
|
||||
return contributorProfileReadme{}, []error{xerrors.Errorf("%q: failed to parse: %v", rm.filePath, err)}
|
||||
return contributorProfileReadme{}, xerrors.Errorf("%q: failed to parse: %v", rm.filePath, err)
|
||||
}
|
||||
|
||||
return contributorProfileReadme{
|
||||
@@ -223,11 +190,11 @@ func parseContributorProfile(rm readme) (contributorProfileReadme, []error) {
|
||||
|
||||
func parseContributorFiles(readmeEntries []readme) (map[string]contributorProfileReadme, error) {
|
||||
profilesByNamespace := map[string]contributorProfileReadme{}
|
||||
var yamlParsingErrors []error
|
||||
yamlParsingErrors := []error{}
|
||||
for _, rm := range readmeEntries {
|
||||
p, errs := parseContributorProfile(rm)
|
||||
if len(errs) != 0 {
|
||||
yamlParsingErrors = append(yamlParsingErrors, errs...)
|
||||
p, err := parseContributorProfile(rm)
|
||||
if err != nil {
|
||||
yamlParsingErrors = append(yamlParsingErrors, err)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -244,7 +211,7 @@ func parseContributorFiles(readmeEntries []readme) (map[string]contributorProfil
|
||||
}
|
||||
}
|
||||
|
||||
var yamlValidationErrors []error
|
||||
yamlValidationErrors := []error{}
|
||||
for _, p := range profilesByNamespace {
|
||||
if errors := validateContributorReadme(p); len(errors) > 0 {
|
||||
yamlValidationErrors = append(yamlValidationErrors, errors...)
|
||||
@@ -267,8 +234,8 @@ func aggregateContributorReadmeFiles() ([]readme, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var allReadmeFiles []readme
|
||||
var errs []error
|
||||
allReadmeFiles := []readme{}
|
||||
errs := []error{}
|
||||
dirPath := ""
|
||||
for _, e := range dirEntries {
|
||||
if !e.IsDir() {
|
||||
|
||||
@@ -31,11 +31,7 @@ func main() {
|
||||
if err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
err = validateAllCoderModules()
|
||||
if err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
err = validateAllCoderTemplates()
|
||||
err = validateAllCoderResourceFilesOfType("modules")
|
||||
if err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
@@ -40,9 +39,7 @@ const (
|
||||
|
||||
var (
|
||||
supportedAvatarFileFormats = []string{".png", ".jpeg", ".jpg", ".gif", ".svg"}
|
||||
// Matches markdown headers placed at the beginning of a line (e.g., "# " or "### "). To make the logic for
|
||||
// validateReadmeBody easier, this pattern deliberately matches on invalid headers (header levels must be in the
|
||||
// range 1–6 to be valid). The function has checks to see if the level is correct.
|
||||
// Matches markdown headers, must be at the beginning of a line, such as "# " or "### ".
|
||||
readmeHeaderRe = regexp.MustCompile(`^(#+)(\s*)`)
|
||||
)
|
||||
|
||||
@@ -171,25 +168,3 @@ func validateReadmeBody(body string) []error {
|
||||
|
||||
return errs
|
||||
}
|
||||
|
||||
func validateFrontmatterYamlKeys(frontmatter string, allowedKeys []string) []error {
|
||||
if len(allowedKeys) == 0 {
|
||||
return []error{xerrors.New("Set of allowed keys is empty")}
|
||||
}
|
||||
|
||||
var key string
|
||||
var cutOk bool
|
||||
var line string
|
||||
|
||||
var errs []error
|
||||
lineScanner := bufio.NewScanner(strings.NewReader(frontmatter))
|
||||
for lineScanner.Scan() {
|
||||
line = lineScanner.Text()
|
||||
key, _, cutOk = strings.Cut(line, ":")
|
||||
if !cutOk || slices.Contains(allowedKeys, key) {
|
||||
continue
|
||||
}
|
||||
errs = append(errs, xerrors.Errorf("detected unknown key %q", key))
|
||||
}
|
||||
return errs
|
||||
}
|
||||
|
||||
@@ -10,21 +10,18 @@ import (
|
||||
"golang.org/x/xerrors"
|
||||
)
|
||||
|
||||
var supportedUserNameSpaceDirectories = append(supportedResourceTypes, ".images")
|
||||
var supportedUserNameSpaceDirectories = append(supportedResourceTypes, ".icons", ".images")
|
||||
|
||||
// validateCoderResourceSubdirectory validates that the structure of a module or template within a namespace follows all
|
||||
// expected file conventions
|
||||
func validateCoderResourceSubdirectory(dirPath string) []error {
|
||||
resourceDir, err := os.Stat(dirPath)
|
||||
subDir, err := os.Stat(dirPath)
|
||||
if err != nil {
|
||||
// It's valid for a specific resource directory not to exist. It's just that if it does exist, it must follow
|
||||
// specific rules.
|
||||
// It's valid for a specific resource directory not to exist. It's just that if it does exist, it must follow specific rules.
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
return []error{addFilePathToError(dirPath, err)}
|
||||
}
|
||||
}
|
||||
|
||||
if !resourceDir.IsDir() {
|
||||
if !subDir.IsDir() {
|
||||
return []error{xerrors.Errorf("%q: path is not a directory", dirPath)}
|
||||
}
|
||||
|
||||
@@ -33,11 +30,10 @@ func validateCoderResourceSubdirectory(dirPath string) []error {
|
||||
return []error{addFilePathToError(dirPath, err)}
|
||||
}
|
||||
|
||||
var errs []error
|
||||
errs := []error{}
|
||||
for _, f := range files {
|
||||
// The .coder subdirectories are sometimes generated as part of our Bun tests. These subdirectories will never
|
||||
// be committed to the repo, but in the off chance that they don't get cleaned up properly, we want to skip over
|
||||
// them.
|
||||
// The .coder subdirectories are sometimes generated as part of Bun tests. These subdirectories will never be
|
||||
// committed to the repo, but in the off chance that they don't get cleaned up properly, we want to skip over them.
|
||||
if !f.IsDir() || f.Name() == ".coder" {
|
||||
continue
|
||||
}
|
||||
@@ -63,53 +59,49 @@ func validateCoderResourceSubdirectory(dirPath string) []error {
|
||||
return errs
|
||||
}
|
||||
|
||||
// validateRegistryDirectory validates that the contents of `/registry` follow all expected file conventions. This
|
||||
// includes the top-level structure of the individual namespace directories.
|
||||
func validateRegistryDirectory() []error {
|
||||
namespaceDirs, err := os.ReadDir(rootRegistryPath)
|
||||
userDirs, err := os.ReadDir(rootRegistryPath)
|
||||
if err != nil {
|
||||
return []error{err}
|
||||
}
|
||||
|
||||
var allErrs []error
|
||||
for _, nDir := range namespaceDirs {
|
||||
namespacePath := path.Join(rootRegistryPath, nDir.Name())
|
||||
if !nDir.IsDir() {
|
||||
allErrs = append(allErrs, xerrors.Errorf("detected non-directory file %q at base of main Registry directory", namespacePath))
|
||||
allErrs := []error{}
|
||||
for _, d := range userDirs {
|
||||
dirPath := path.Join(rootRegistryPath, d.Name())
|
||||
if !d.IsDir() {
|
||||
allErrs = append(allErrs, xerrors.Errorf("detected non-directory file %q at base of main Registry directory", dirPath))
|
||||
continue
|
||||
}
|
||||
|
||||
contributorReadmePath := path.Join(namespacePath, "README.md")
|
||||
contributorReadmePath := path.Join(dirPath, "README.md")
|
||||
if _, err := os.Stat(contributorReadmePath); err != nil {
|
||||
allErrs = append(allErrs, err)
|
||||
}
|
||||
|
||||
files, err := os.ReadDir(namespacePath)
|
||||
files, err := os.ReadDir(dirPath)
|
||||
if err != nil {
|
||||
allErrs = append(allErrs, err)
|
||||
continue
|
||||
}
|
||||
|
||||
for _, f := range files {
|
||||
// TODO: Decide if there's anything more formal that we want to ensure about non-directories at the top
|
||||
// level of each user namespace.
|
||||
// TODO: Decide if there's anything more formal that we want to ensure about non-directories scoped to user namespaces.
|
||||
if !f.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
segment := f.Name()
|
||||
filePath := path.Join(namespacePath, segment)
|
||||
filePath := path.Join(dirPath, segment)
|
||||
|
||||
if !slices.Contains(supportedUserNameSpaceDirectories, segment) {
|
||||
allErrs = append(allErrs, xerrors.Errorf("%q: only these sub-directories are allowed at top of user namespace: [%s]", filePath, strings.Join(supportedUserNameSpaceDirectories, ", ")))
|
||||
continue
|
||||
}
|
||||
if !slices.Contains(supportedResourceTypes, segment) {
|
||||
continue
|
||||
}
|
||||
|
||||
if errs := validateCoderResourceSubdirectory(filePath); len(errs) != 0 {
|
||||
allErrs = append(allErrs, errs...)
|
||||
if slices.Contains(supportedResourceTypes, segment) {
|
||||
if errs := validateCoderResourceSubdirectory(filePath); len(errs) != 0 {
|
||||
allErrs = append(allErrs, errs...)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -117,9 +109,6 @@ func validateRegistryDirectory() []error {
|
||||
return allErrs
|
||||
}
|
||||
|
||||
// validateRepoStructure validates that the structure of the repo is "correct enough" to do all necessary validation
|
||||
// checks. It is NOT an exhaustive validation of the entire repo structure – it only checks the parts of the repo that
|
||||
// are relevant for the main validation steps
|
||||
func validateRepoStructure() error {
|
||||
var errs []error
|
||||
if vrdErrs := validateRegistryDirectory(); len(vrdErrs) != 0 {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
display_name: "Jay Kumar"
|
||||
bio: "I'm a Software Engineer :)"
|
||||
avatar: "./.images/avatar.jpeg"
|
||||
avatar_url: "./.images/avatar.png"
|
||||
github: "35C4n0r"
|
||||
linkedin: "https://www.linkedin.com/in/jaykum4r"
|
||||
support_email: "work.jaykumar@gmail.com"
|
||||
|
||||
@@ -1,155 +0,0 @@
|
||||
---
|
||||
display_name: Codex CLI
|
||||
icon: ../../../../.icons/openai.svg
|
||||
description: Run Codex CLI in your workspace with AgentAPI integration
|
||||
verified: true
|
||||
tags: [agent, codex, ai, openai, tasks]
|
||||
---
|
||||
|
||||
# Codex CLI
|
||||
|
||||
Run Codex CLI in your workspace to access OpenAI's models through the Codex interface, with custom pre/post install scripts. This module integrates with [AgentAPI](https://github.com/coder/agentapi) for Coder Tasks compatibility.
|
||||
|
||||
```tf
|
||||
module "codex" {
|
||||
source = "registry.coder.com/coder-labs/codex/coder"
|
||||
version = "1.0.1"
|
||||
agent_id = coder_agent.example.id
|
||||
openai_api_key = var.openai_api_key
|
||||
folder = "/home/coder/project"
|
||||
}
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- You must add the [Coder Login](https://registry.coder.com/modules/coder/coder-login) module to your template
|
||||
- OpenAI API key for Codex access
|
||||
|
||||
## Usage Example
|
||||
|
||||
- Simple usage Example:
|
||||
|
||||
```tf
|
||||
module "codex" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder-labs/codex/coder"
|
||||
version = "1.0.1"
|
||||
agent_id = coder_agent.example.id
|
||||
openai_api_key = "..."
|
||||
codex_model = "o4-mini"
|
||||
install_codex = true
|
||||
codex_version = "latest"
|
||||
folder = "/home/coder/project"
|
||||
codex_system_prompt = "You are a helpful coding assistant. Start every response with `Codex says:`"
|
||||
}
|
||||
```
|
||||
|
||||
- Example usage with Tasks:
|
||||
|
||||
```tf
|
||||
# This
|
||||
data "coder_parameter" "ai_prompt" {
|
||||
type = "string"
|
||||
name = "AI Prompt"
|
||||
default = ""
|
||||
description = "Initial prompt for the Codex CLI"
|
||||
mutable = true
|
||||
}
|
||||
|
||||
module "coder-login" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/coder-login/coder"
|
||||
version = "1.0.31"
|
||||
agent_id = coder_agent.example.id
|
||||
}
|
||||
|
||||
module "codex" {
|
||||
source = "registry.coder.com/coder-labs/codex/coder"
|
||||
agent_id = coder_agent.example.id
|
||||
openai_api_key = "..."
|
||||
ai_prompt = data.coder_parameter.ai_prompt.value
|
||||
folder = "/home/coder/project"
|
||||
approval_policy = "never" # Full auto mode
|
||||
}
|
||||
```
|
||||
|
||||
> [!WARNING]
|
||||
> **Security Notice**: This module configures Codex with a `workspace-write` sandbox that allows AI tasks to read/write files in the specified folder. While the sandbox provides security boundaries, Codex can still modify files within the workspace. Use this module in trusted environments and be aware of the security implications.
|
||||
|
||||
## How it Works
|
||||
|
||||
- **Install**: The module installs Codex CLI and sets up the environment
|
||||
- **System Prompt**: If `codex_system_prompt` and `folder` are set, creates the directory (if needed) and writes the prompt to `AGENTS.md`
|
||||
- **Start**: Launches Codex CLI in the specified directory, wrapped by AgentAPI
|
||||
- **Configuration**: Sets `OPENAI_API_KEY` environment variable and passes `--model` flag to Codex CLI (if variables provided)
|
||||
|
||||
## Sandbox Configuration
|
||||
|
||||
The module automatically configures Codex with a secure sandbox that allows AI tasks to work effectively:
|
||||
|
||||
- **Sandbox Mode**: `workspace-write` - Allows Codex to read/write files in the specified `folder`
|
||||
- **Approval Policy**: `on-request` - Codex asks for permission before performing potentially risky operations
|
||||
- **Network Access**: Enabled within the workspace for package installation and API calls
|
||||
|
||||
### Customizing Sandbox Behavior
|
||||
|
||||
You can customize the sandbox behavior using dedicated variables:
|
||||
|
||||
#### **Using Dedicated Variables (Recommended)**
|
||||
|
||||
For most use cases, use the dedicated sandbox variables:
|
||||
|
||||
```tf
|
||||
module "codex" {
|
||||
source = "registry.coder.com/coder-labs/codex/coder"
|
||||
# ... other variables ...
|
||||
|
||||
# Containerized environments (fixes Landlock errors)
|
||||
sandbox_mode = "danger-full-access"
|
||||
|
||||
# Or for read-only mode
|
||||
# sandbox_mode = "read-only"
|
||||
|
||||
# Or for full auto mode
|
||||
# approval_policy = "never"
|
||||
|
||||
# Or disable network access
|
||||
# network_access = false
|
||||
}
|
||||
```
|
||||
|
||||
#### **Using extra_codex_settings_toml (Advanced)**
|
||||
|
||||
For advanced configuration or when you need to override multiple settings:
|
||||
|
||||
```tf
|
||||
module "codex" {
|
||||
source = "registry.coder.com/coder-labs/codex/coder"
|
||||
# ... other variables ...
|
||||
|
||||
extra_codex_settings_toml = <<-EOT
|
||||
# Any custom Codex configuration
|
||||
model = "gpt-4"
|
||||
disable_response_storage = true
|
||||
EOT
|
||||
}
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> The dedicated variables (`sandbox_mode`, `approval_policy`, `network_access`) are the recommended way to configure sandbox behavior. Use `extra_codex_settings_toml` only for advanced configuration that isn't covered by the dedicated variables.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- Check installation and startup logs in `~/.codex-module/`
|
||||
- Ensure your OpenAI API key has access to the specified model
|
||||
|
||||
> [!IMPORTANT]
|
||||
> To use tasks with Codex CLI, ensure you have the `openai_api_key` variable set, and **you create a `coder_parameter` named `"AI Prompt"` and pass its value to the codex module's `ai_prompt` variable**. [Tasks Template Example](https://registry.coder.com/templates/coder-labs/tasks-docker).
|
||||
> The module automatically configures Codex with your API key and model preferences.
|
||||
> folder is a required variable for the module to function correctly.
|
||||
|
||||
## References
|
||||
|
||||
- [OpenAI API Documentation](https://platform.openai.com/docs)
|
||||
- [AgentAPI Documentation](https://github.com/coder/agentapi)
|
||||
- [Coder AI Agents Guide](https://coder.com/docs/tutorials/ai-agents)
|
||||
@@ -1,281 +0,0 @@
|
||||
import {
|
||||
test,
|
||||
afterEach,
|
||||
describe,
|
||||
setDefaultTimeout,
|
||||
beforeAll,
|
||||
expect,
|
||||
} from "bun:test";
|
||||
import { execContainer, readFileContainer, runTerraformInit } from "~test";
|
||||
import {
|
||||
loadTestFile,
|
||||
writeExecutable,
|
||||
setup as setupUtil,
|
||||
execModuleScript,
|
||||
expectAgentAPIStarted,
|
||||
} from "../../../coder/modules/agentapi/test-util";
|
||||
import dedent from "dedent";
|
||||
|
||||
let cleanupFunctions: (() => Promise<void>)[] = [];
|
||||
const registerCleanup = (cleanup: () => Promise<void>) => {
|
||||
cleanupFunctions.push(cleanup);
|
||||
};
|
||||
afterEach(async () => {
|
||||
const cleanupFnsCopy = cleanupFunctions.slice().reverse();
|
||||
cleanupFunctions = [];
|
||||
for (const cleanup of cleanupFnsCopy) {
|
||||
try {
|
||||
await cleanup();
|
||||
} catch (error) {
|
||||
console.error("Error during cleanup:", error);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
interface SetupProps {
|
||||
skipAgentAPIMock?: boolean;
|
||||
skipCodexMock?: boolean;
|
||||
moduleVariables?: Record<string, string>;
|
||||
agentapiMockScript?: string;
|
||||
}
|
||||
|
||||
const setup = async (props?: SetupProps): Promise<{ id: string }> => {
|
||||
const projectDir = "/home/coder/project";
|
||||
const { id } = await setupUtil({
|
||||
moduleDir: import.meta.dir,
|
||||
moduleVariables: {
|
||||
install_codex: props?.skipCodexMock ? "true" : "false",
|
||||
install_agentapi: props?.skipAgentAPIMock ? "true" : "false",
|
||||
codex_model: "gpt-4-turbo",
|
||||
folder: "/home/coder",
|
||||
...props?.moduleVariables,
|
||||
},
|
||||
registerCleanup,
|
||||
projectDir,
|
||||
skipAgentAPIMock: props?.skipAgentAPIMock,
|
||||
agentapiMockScript: props?.agentapiMockScript,
|
||||
});
|
||||
if (!props?.skipCodexMock) {
|
||||
await writeExecutable({
|
||||
containerId: id,
|
||||
filePath: "/usr/bin/codex",
|
||||
content: await loadTestFile(import.meta.dir, "codex-mock.sh"),
|
||||
});
|
||||
}
|
||||
return { id };
|
||||
};
|
||||
|
||||
setDefaultTimeout(60 * 1000);
|
||||
|
||||
describe("codex", async () => {
|
||||
beforeAll(async () => {
|
||||
await runTerraformInit(import.meta.dir);
|
||||
});
|
||||
|
||||
test("happy-path", async () => {
|
||||
const { id } = await setup();
|
||||
await execModuleScript(id);
|
||||
await expectAgentAPIStarted(id);
|
||||
});
|
||||
|
||||
test("install-codex-version", async () => {
|
||||
const version_to_install = "0.10.0";
|
||||
const { id } = await setup({
|
||||
skipCodexMock: true,
|
||||
moduleVariables: {
|
||||
install_codex: "true",
|
||||
codex_version: version_to_install,
|
||||
},
|
||||
});
|
||||
await execModuleScript(id);
|
||||
const resp = await execContainer(id, [
|
||||
"bash",
|
||||
"-c",
|
||||
`cat /home/coder/.codex-module/install.log`,
|
||||
]);
|
||||
expect(resp.stdout).toContain(version_to_install);
|
||||
});
|
||||
|
||||
test("check-latest-codex-version-works", async () => {
|
||||
const { id } = await setup({
|
||||
skipCodexMock: true,
|
||||
skipAgentAPIMock: true,
|
||||
moduleVariables: {
|
||||
install_codex: "true",
|
||||
},
|
||||
});
|
||||
await execModuleScript(id);
|
||||
await expectAgentAPIStarted(id);
|
||||
});
|
||||
|
||||
test("codex-config-toml", async () => {
|
||||
const settings = dedent`
|
||||
[mcp_servers.CustomMCP]
|
||||
command = "/Users/jkmr/Documents/work/coder/coder_darwin_arm64"
|
||||
args = ["exp", "mcp", "server", "app-status-slug=codex"]
|
||||
env = { "CODER_MCP_APP_STATUS_SLUG" = "codex", "CODER_MCP_AI_AGENTAPI_URL"= "http://localhost:3284" }
|
||||
description = "Report ALL tasks and statuses (in progress, done, failed) you are working on."
|
||||
enabled = true
|
||||
type = "stdio"
|
||||
`.trim();
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
extra_codex_settings_toml: settings,
|
||||
},
|
||||
});
|
||||
await execModuleScript(id);
|
||||
const resp = await readFileContainer(id, "/home/coder/.codex/config.toml");
|
||||
expect(resp).toContain("[mcp_servers.CustomMCP]");
|
||||
expect(resp).toContain("[mcp_servers.Coder]");
|
||||
});
|
||||
|
||||
test("codex-api-key", async () => {
|
||||
const apiKey = "test-api-key-123";
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
openai_api_key: apiKey,
|
||||
},
|
||||
});
|
||||
await execModuleScript(id);
|
||||
|
||||
const resp = await readFileContainer(
|
||||
id,
|
||||
"/home/coder/.codex-module/agentapi-start.log",
|
||||
);
|
||||
expect(resp).toContain("openai_api_key provided !");
|
||||
});
|
||||
|
||||
test("pre-post-install-scripts", async () => {
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
pre_install_script: "#!/bin/bash\necho 'pre-install-script'",
|
||||
post_install_script: "#!/bin/bash\necho 'post-install-script'",
|
||||
},
|
||||
});
|
||||
await execModuleScript(id);
|
||||
const preInstallLog = await readFileContainer(
|
||||
id,
|
||||
"/home/coder/.codex-module/pre_install.log",
|
||||
);
|
||||
expect(preInstallLog).toContain("pre-install-script");
|
||||
const postInstallLog = await readFileContainer(
|
||||
id,
|
||||
"/home/coder/.codex-module/post_install.log",
|
||||
);
|
||||
expect(postInstallLog).toContain("post-install-script");
|
||||
});
|
||||
|
||||
test("folder-variable", async () => {
|
||||
const folder = "/tmp/codex-test-folder";
|
||||
const { id } = await setup({
|
||||
skipCodexMock: false,
|
||||
moduleVariables: {
|
||||
folder,
|
||||
},
|
||||
});
|
||||
await execModuleScript(id);
|
||||
const resp = await readFileContainer(
|
||||
id,
|
||||
"/home/coder/.codex-module/install.log",
|
||||
);
|
||||
expect(resp).toContain(folder);
|
||||
});
|
||||
|
||||
test("additional-extensions", async () => {
|
||||
const additional = dedent`
|
||||
[mcp_servers.CustomMCP]
|
||||
command = "/Users/jkmr/Documents/work/coder/coder_darwin_arm64"
|
||||
args = ["exp", "mcp", "server", "app-status-slug=codex"]
|
||||
env = { "CODER_MCP_APP_STATUS_SLUG" = "codex", "CODER_MCP_AI_AGENTAPI_URL"= "http://localhost:3284" }
|
||||
description = "Report ALL tasks and statuses (in progress, done, failed) you are working on."
|
||||
enabled = true
|
||||
type = "stdio"
|
||||
`.trim();
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
additional_extensions: additional,
|
||||
},
|
||||
});
|
||||
await execModuleScript(id);
|
||||
const resp = await readFileContainer(id, "/home/coder/.codex/config.toml");
|
||||
expect(resp).toContain("[mcp_servers.CustomMCP]");
|
||||
expect(resp).toContain("[mcp_servers.Coder]");
|
||||
});
|
||||
|
||||
test("codex-system-prompt", async () => {
|
||||
const prompt = "This is a system prompt for Codex.";
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
codex_system_prompt: prompt,
|
||||
},
|
||||
});
|
||||
await execModuleScript(id);
|
||||
const resp = await readFileContainer(id, "/home/coder/AGENTS.md");
|
||||
expect(resp).toContain(prompt);
|
||||
});
|
||||
|
||||
test("codex-system-prompt-skip-append-if-exists", async () => {
|
||||
const prompt_1 = "This is a system prompt for Codex.";
|
||||
const prompt_2 = "This is a system prompt for Goose.";
|
||||
const prompt_3 = dedent`
|
||||
This is a system prompt for Codex.
|
||||
This is a system prompt for Gemini.
|
||||
`.trim();
|
||||
const pre_install_script = dedent`
|
||||
#!/bin/bash
|
||||
echo -e "${prompt_3}" >> /home/coder/AGENTS.md
|
||||
`.trim();
|
||||
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
pre_install_script,
|
||||
codex_system_prompt: prompt_2,
|
||||
},
|
||||
});
|
||||
await execModuleScript(id);
|
||||
const resp = await readFileContainer(id, "/home/coder/AGENTS.md");
|
||||
expect(resp).toContain(prompt_1);
|
||||
expect(resp).toContain(prompt_2);
|
||||
|
||||
// Re-run with a prompt that already exists, it should not append again
|
||||
const { id: id_2 } = await setup({
|
||||
moduleVariables: {
|
||||
pre_install_script,
|
||||
codex_system_prompt: prompt_1,
|
||||
},
|
||||
});
|
||||
await execModuleScript(id_2);
|
||||
const resp_2 = await readFileContainer(id_2, "/home/coder/AGENTS.md");
|
||||
expect(resp_2).toContain(prompt_1);
|
||||
const count = (resp_2.match(new RegExp(prompt_1, "g")) || []).length;
|
||||
expect(count).toBe(1);
|
||||
});
|
||||
|
||||
test("codex-ai-task-prompt", async () => {
|
||||
const prompt = "This is a system prompt for Codex.";
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
ai_prompt: prompt,
|
||||
},
|
||||
});
|
||||
await execModuleScript(id);
|
||||
const resp = await execContainer(id, [
|
||||
"bash",
|
||||
"-c",
|
||||
`cat /home/coder/.codex-module/agentapi-start.log`,
|
||||
]);
|
||||
expect(resp.stdout).toContain(prompt);
|
||||
});
|
||||
|
||||
test("start-without-prompt", async () => {
|
||||
const { id } = await setup();
|
||||
await execModuleScript(id);
|
||||
const prompt = await execContainer(id, [
|
||||
"ls",
|
||||
"-l",
|
||||
"/home/coder/AGENTS.md",
|
||||
]);
|
||||
expect(prompt.exitCode).not.toBe(0);
|
||||
expect(prompt.stderr).toContain("No such file or directory");
|
||||
});
|
||||
});
|
||||
@@ -1,207 +0,0 @@
|
||||
terraform {
|
||||
required_version = ">= 1.0"
|
||||
|
||||
required_providers {
|
||||
coder = {
|
||||
source = "coder/coder"
|
||||
version = ">= 2.7"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
variable "agent_id" {
|
||||
type = string
|
||||
description = "The ID of a Coder agent."
|
||||
}
|
||||
|
||||
data "coder_workspace" "me" {}
|
||||
|
||||
data "coder_workspace_owner" "me" {}
|
||||
|
||||
variable "order" {
|
||||
type = number
|
||||
description = "The order determines the position of app in the UI presentation. The lowest order is shown first and apps with equal order are sorted by name (ascending order)."
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "group" {
|
||||
type = string
|
||||
description = "The name of a group that this app belongs to."
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "icon" {
|
||||
type = string
|
||||
description = "The icon to use for the app."
|
||||
default = "/icon/openai.svg"
|
||||
}
|
||||
|
||||
variable "folder" {
|
||||
type = string
|
||||
description = "The folder to run Codex in."
|
||||
}
|
||||
|
||||
variable "install_codex" {
|
||||
type = bool
|
||||
description = "Whether to install Codex."
|
||||
default = true
|
||||
}
|
||||
|
||||
variable "codex_version" {
|
||||
type = string
|
||||
description = "The version of Codex to install."
|
||||
default = "" # empty string means the latest available version
|
||||
}
|
||||
|
||||
variable "extra_codex_settings_toml" {
|
||||
type = string
|
||||
description = "Settings to append to ~/.codex/config.toml."
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "sandbox_mode" {
|
||||
type = string
|
||||
description = "The sandbox mode for Codex. Options: workspace-write, read-only, danger-full-access."
|
||||
default = "workspace-write"
|
||||
validation {
|
||||
condition = contains(["workspace-write", "read-only", "danger-full-access"], var.sandbox_mode)
|
||||
error_message = "sandbox_mode must be one of: workspace-write, read-only, danger-full-access."
|
||||
}
|
||||
}
|
||||
|
||||
variable "approval_policy" {
|
||||
type = string
|
||||
description = "The approval policy for Codex. Options: on-request, never, untrusted."
|
||||
default = "on-request"
|
||||
validation {
|
||||
condition = contains(["on-request", "never", "untrusted"], var.approval_policy)
|
||||
error_message = "approval_policy must be one of: on-request, never, untrusted."
|
||||
}
|
||||
}
|
||||
|
||||
variable "network_access" {
|
||||
type = bool
|
||||
description = "Whether to allow network access in workspace-write mode."
|
||||
default = true
|
||||
}
|
||||
|
||||
variable "openai_api_key" {
|
||||
type = string
|
||||
description = "Codex API Key"
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "install_agentapi" {
|
||||
type = bool
|
||||
description = "Whether to install AgentAPI."
|
||||
default = true
|
||||
}
|
||||
|
||||
variable "agentapi_version" {
|
||||
type = string
|
||||
description = "The version of AgentAPI to install."
|
||||
default = "v0.5.0"
|
||||
}
|
||||
|
||||
variable "codex_model" {
|
||||
type = string
|
||||
description = "The model for Codex to use (e.g., o4-mini)."
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "pre_install_script" {
|
||||
type = string
|
||||
description = "Custom script to run before installing Codex."
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "post_install_script" {
|
||||
type = string
|
||||
description = "Custom script to run after installing Codex."
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "ai_prompt" {
|
||||
type = string
|
||||
description = "Task prompt for the Codex CLI"
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "additional_extensions" {
|
||||
type = string
|
||||
description = "Additional extensions configuration in json format to append to the config."
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "codex_system_prompt" {
|
||||
type = string
|
||||
description = "System prompt for Codex. It will be added to AGENTS.md in the specified folder."
|
||||
default = ""
|
||||
}
|
||||
|
||||
|
||||
|
||||
resource "coder_env" "openai_api_key" {
|
||||
agent_id = var.agent_id
|
||||
name = "OPENAI_API_KEY"
|
||||
value = var.openai_api_key
|
||||
}
|
||||
|
||||
locals {
|
||||
app_slug = "codex"
|
||||
install_script = file("${path.module}/scripts/install.sh")
|
||||
start_script = file("${path.module}/scripts/start.sh")
|
||||
module_dir_name = ".codex-module"
|
||||
}
|
||||
|
||||
module "agentapi" {
|
||||
source = "registry.coder.com/coder/agentapi/coder"
|
||||
version = "1.1.1"
|
||||
|
||||
agent_id = var.agent_id
|
||||
web_app_slug = local.app_slug
|
||||
web_app_order = var.order
|
||||
web_app_group = var.group
|
||||
web_app_icon = var.icon
|
||||
web_app_display_name = "Codex"
|
||||
cli_app_slug = "${local.app_slug}-cli"
|
||||
cli_app_display_name = "Codex CLI"
|
||||
module_dir_name = local.module_dir_name
|
||||
install_agentapi = var.install_agentapi
|
||||
agentapi_version = var.agentapi_version
|
||||
pre_install_script = var.pre_install_script
|
||||
post_install_script = var.post_install_script
|
||||
start_script = <<-EOT
|
||||
#!/bin/bash
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
|
||||
echo -n '${base64encode(local.start_script)}' | base64 -d > /tmp/start.sh
|
||||
chmod +x /tmp/start.sh
|
||||
ARG_OPENAI_API_KEY='${var.openai_api_key}' \
|
||||
ARG_CODEX_MODEL='${var.codex_model}' \
|
||||
ARG_CODEX_START_DIRECTORY='${var.folder}' \
|
||||
ARG_CODEX_TASK_PROMPT='${base64encode(var.ai_prompt)}' \
|
||||
/tmp/start.sh
|
||||
EOT
|
||||
|
||||
install_script = <<-EOT
|
||||
#!/bin/bash
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
|
||||
echo -n '${base64encode(local.install_script)}' | base64 -d > /tmp/install.sh
|
||||
chmod +x /tmp/install.sh
|
||||
ARG_INSTALL='${var.install_codex}' \
|
||||
ARG_CODEX_VERSION='${var.codex_version}' \
|
||||
ARG_EXTRA_CODEX_CONFIG='${base64encode(var.extra_codex_settings_toml)}' \
|
||||
ARG_CODER_MCP_APP_STATUS_SLUG='${local.app_slug}' \
|
||||
ARG_ADDITIONAL_EXTENSIONS='${base64encode(var.additional_extensions)}' \
|
||||
ARG_CODEX_START_DIRECTORY='${var.folder}' \
|
||||
ARG_CODEX_INSTRUCTION_PROMPT='${base64encode(var.codex_system_prompt)}' \
|
||||
ARG_SANDBOX_MODE='${var.sandbox_mode}' \
|
||||
ARG_APPROVAL_POLICY='${var.approval_policy}' \
|
||||
ARG_NETWORK_ACCESS='${var.network_access}' \
|
||||
/tmp/install.sh
|
||||
EOT
|
||||
}
|
||||
@@ -1,171 +0,0 @@
|
||||
#!/bin/bash
|
||||
source "$HOME"/.bashrc
|
||||
|
||||
BOLD='\033[0;1m'
|
||||
|
||||
# Function to check if a command exists
|
||||
command_exists() {
|
||||
command -v "$1" > /dev/null 2>&1
|
||||
}
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
set -o nounset
|
||||
|
||||
ARG_EXTRA_CODEX_CONFIG=$(echo -n "$ARG_EXTRA_CODEX_CONFIG" | base64 -d)
|
||||
ARG_ADDITIONAL_EXTENSIONS=$(echo -n "$ARG_ADDITIONAL_EXTENSIONS" | base64 -d)
|
||||
ARG_CODEX_INSTRUCTION_PROMPT=$(echo -n "$ARG_CODEX_INSTRUCTION_PROMPT" | base64 -d)
|
||||
|
||||
echo "--------------------------------"
|
||||
printf "install: %s\n" "$ARG_INSTALL"
|
||||
printf "codex_version: %s\n" "$ARG_CODEX_VERSION"
|
||||
printf "codex_config: %s\n" "$ARG_EXTRA_CODEX_CONFIG"
|
||||
printf "app_slug: %s\n" "$ARG_CODER_MCP_APP_STATUS_SLUG"
|
||||
printf "additional_extensions: %s\n" "$ARG_ADDITIONAL_EXTENSIONS"
|
||||
printf "start_directory: %s\n" "$ARG_CODEX_START_DIRECTORY"
|
||||
printf "instruction_prompt: %s\n" "$ARG_CODEX_INSTRUCTION_PROMPT"
|
||||
|
||||
echo "--------------------------------"
|
||||
|
||||
set +o nounset
|
||||
|
||||
function install_node() {
|
||||
# borrowed from claude-code module
|
||||
if ! command_exists npm; then
|
||||
printf "npm not found, checking for Node.js installation...\n"
|
||||
if ! command_exists node; then
|
||||
printf "Node.js not found, installing Node.js via NVM...\n"
|
||||
export NVM_DIR="$HOME/.nvm"
|
||||
if [ ! -d "$NVM_DIR" ]; then
|
||||
mkdir -p "$NVM_DIR"
|
||||
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
|
||||
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
|
||||
else
|
||||
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
|
||||
fi
|
||||
|
||||
nvm install --lts
|
||||
nvm use --lts
|
||||
nvm alias default node
|
||||
|
||||
printf "Node.js installed: %s\n" "$(node --version)"
|
||||
printf "npm installed: %s\n" "$(npm --version)"
|
||||
else
|
||||
printf "Node.js is installed but npm is not available. Please install npm manually.\n"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
function install_codex() {
|
||||
if [ "${ARG_INSTALL}" = "true" ]; then
|
||||
# we need node to install and run codex-cli
|
||||
install_node
|
||||
|
||||
# If nvm does not exist, we will create a global npm directory (this os to prevent the possibility of EACCESS issues on npm -g)
|
||||
if ! command_exists nvm; then
|
||||
printf "which node: %s\n" "$(which node)"
|
||||
printf "which npm: %s\n" "$(which npm)"
|
||||
|
||||
# Create a directory for global packages
|
||||
mkdir -p "$HOME"/.npm-global
|
||||
|
||||
# Configure npm to use it
|
||||
npm config set prefix "$HOME/.npm-global"
|
||||
|
||||
# Add to PATH for current session
|
||||
export PATH="$HOME/.npm-global/bin:$PATH"
|
||||
|
||||
# Add to shell profile for future sessions
|
||||
if ! grep -q "export PATH=$HOME/.npm-global/bin:\$PATH" ~/.bashrc; then
|
||||
echo "export PATH=$HOME/.npm-global/bin:\$PATH" >> ~/.bashrc
|
||||
fi
|
||||
fi
|
||||
|
||||
printf "%s Installing Codex CLI\n" "${BOLD}"
|
||||
|
||||
if [ -n "$ARG_CODEX_VERSION" ]; then
|
||||
npm install -g "@openai/codex@$ARG_CODEX_VERSION"
|
||||
else
|
||||
npm install -g "@openai/codex"
|
||||
fi
|
||||
printf "%s Successfully installed Codex CLI. Version: %s\n" "${BOLD}" "$(codex --version)"
|
||||
fi
|
||||
}
|
||||
|
||||
function populate_config_toml() {
|
||||
CONFIG_PATH="$HOME/.codex/config.toml"
|
||||
mkdir -p "$(dirname "$CONFIG_PATH")"
|
||||
printf "Custom codex_config is provided !\n"
|
||||
BASE_SANDBOX_CONFIG=$(
|
||||
cat << EOF
|
||||
# Base sandbox configuration for Codex workspace access
|
||||
# This ensures Codex can read/write files in the specified folder for AI tasks
|
||||
sandbox_mode = "${ARG_SANDBOX_MODE}"
|
||||
approval_policy = "${ARG_APPROVAL_POLICY}"
|
||||
|
||||
# Allow network access in workspace-write mode for package installation, API calls, etc.
|
||||
[sandbox_workspace_write]
|
||||
network_access = ${ARG_NETWORK_ACCESS}
|
||||
EOF
|
||||
)
|
||||
|
||||
BASE_EXTENSIONS=$(
|
||||
cat << EOF
|
||||
[mcp_servers.Coder]
|
||||
command = "coder"
|
||||
args = ["exp", "mcp", "server"]
|
||||
env = { "CODER_MCP_APP_STATUS_SLUG" = "${ARG_CODER_MCP_APP_STATUS_SLUG}", "CODER_MCP_AI_AGENTAPI_URL"= "http://localhost:3284", "CODER_AGENT_URL" = "${CODER_AGENT_URL}", "CODER_AGENT_TOKEN" = "${CODER_AGENT_TOKEN}" }
|
||||
description = "Report ALL tasks and statuses (in progress, done, failed) you are working on."
|
||||
type = "stdio"
|
||||
EOF
|
||||
)
|
||||
|
||||
echo "
|
||||
${BASE_SANDBOX_CONFIG}
|
||||
|
||||
${ARG_EXTRA_CODEX_CONFIG}
|
||||
|
||||
${BASE_EXTENSIONS}
|
||||
|
||||
${ARG_ADDITIONAL_EXTENSIONS}
|
||||
" > "$HOME/.codex/config.toml"
|
||||
|
||||
}
|
||||
|
||||
function add_instruction_prompt_if_exists() {
|
||||
if [ -n "${ARG_CODEX_INSTRUCTION_PROMPT:-}" ]; then
|
||||
if [ -d "${ARG_CODEX_START_DIRECTORY}" ]; then
|
||||
printf "Directory '%s' exists. Changing to it.\\n" "${ARG_CODEX_START_DIRECTORY}"
|
||||
cd "${ARG_CODEX_START_DIRECTORY}" || {
|
||||
printf "Error: Could not change to directory '%s'.\\n" "${ARG_CODEX_START_DIRECTORY}"
|
||||
exit 1
|
||||
}
|
||||
else
|
||||
printf "Directory '%s' does not exist. Creating and changing to it.\\n" "${ARG_CODEX_START_DIRECTORY}"
|
||||
mkdir -p "${ARG_CODEX_START_DIRECTORY}" || {
|
||||
printf "Error: Could not create directory '%s'.\\n" "${ARG_CODEX_START_DIRECTORY}"
|
||||
exit 1
|
||||
}
|
||||
cd "${ARG_CODEX_START_DIRECTORY}" || {
|
||||
printf "Error: Could not change to directory '%s'.\\n" "${ARG_CODEX_START_DIRECTORY}"
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
|
||||
# Check if AGENTS.md contains the instruction prompt already
|
||||
if [ -f AGENTS.md ] && grep -Fxq "${ARG_CODEX_INSTRUCTION_PROMPT}" AGENTS.md; then
|
||||
printf "AGENTS.md already contains the instruction prompt. Skipping append.\n"
|
||||
else
|
||||
printf "Appending instruction prompt to AGENTS.md\n"
|
||||
echo -e "\n${ARG_CODEX_INSTRUCTION_PROMPT}" >> AGENTS.md
|
||||
fi
|
||||
else
|
||||
printf "AGENTS.md is not set.\n"
|
||||
fi
|
||||
}
|
||||
|
||||
# Install Codex
|
||||
install_codex
|
||||
codex --version
|
||||
populate_config_toml
|
||||
add_instruction_prompt_if_exists
|
||||
@@ -1,78 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Load shell environment
|
||||
source "$HOME"/.bashrc
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
command_exists() {
|
||||
command -v "$1" > /dev/null 2>&1
|
||||
}
|
||||
|
||||
if [ -f "$HOME/.nvm/nvm.sh" ]; then
|
||||
source "$HOME"/.nvm/nvm.sh
|
||||
else
|
||||
export PATH="$HOME/.npm-global/bin:$PATH"
|
||||
fi
|
||||
|
||||
printf "Version: %s\n" "$(codex --version)"
|
||||
set -o nounset
|
||||
ARG_CODEX_TASK_PROMPT=$(echo -n "$ARG_CODEX_TASK_PROMPT" | base64 -d)
|
||||
|
||||
echo "--------------------------------"
|
||||
printf "openai_api_key: %s\n" "$ARG_OPENAI_API_KEY"
|
||||
printf "codex_model: %s\n" "$ARG_CODEX_MODEL"
|
||||
printf "start_directory: %s\n" "$ARG_CODEX_START_DIRECTORY"
|
||||
printf "task_prompt: %s\n" "$ARG_CODEX_TASK_PROMPT"
|
||||
echo "--------------------------------"
|
||||
set +o nounset
|
||||
CODEX_ARGS=()
|
||||
|
||||
if command_exists codex; then
|
||||
printf "Codex is installed\n"
|
||||
else
|
||||
printf "Error: Codex is not installed. Please enable install_codex or install it manually\n"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -d "${ARG_CODEX_START_DIRECTORY}" ]; then
|
||||
printf "Directory '%s' exists. Changing to it.\\n" "${ARG_CODEX_START_DIRECTORY}"
|
||||
cd "${ARG_CODEX_START_DIRECTORY}" || {
|
||||
printf "Error: Could not change to directory '%s'.\\n" "${ARG_CODEX_START_DIRECTORY}"
|
||||
exit 1
|
||||
}
|
||||
else
|
||||
printf "Directory '%s' does not exist. Creating and changing to it.\\n" "${ARG_CODEX_START_DIRECTORY}"
|
||||
mkdir -p "${ARG_CODEX_START_DIRECTORY}" || {
|
||||
printf "Error: Could not create directory '%s'.\\n" "${ARG_CODEX_START_DIRECTORY}"
|
||||
exit 1
|
||||
}
|
||||
cd "${ARG_CODEX_START_DIRECTORY}" || {
|
||||
printf "Error: Could not change to directory '%s'.\\n" "${ARG_CODEX_START_DIRECTORY}"
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
|
||||
if [ -n "$ARG_CODEX_MODEL" ]; then
|
||||
CODEX_ARGS+=("--model" "$ARG_CODEX_MODEL")
|
||||
fi
|
||||
|
||||
|
||||
|
||||
if [ -n "$ARG_CODEX_TASK_PROMPT" ]; then
|
||||
printf "Running the task prompt %s\n" "$ARG_CODEX_TASK_PROMPT"
|
||||
PROMPT="Complete the task at hand in one go. Every step of the way, report your progress using coder_report_task tool with proper summary and statuses. Your task at hand: $ARG_CODEX_TASK_PROMPT"
|
||||
CODEX_ARGS+=("$PROMPT")
|
||||
else
|
||||
printf "No task prompt given.\n"
|
||||
fi
|
||||
|
||||
if [ -n "$ARG_OPENAI_API_KEY" ]; then
|
||||
printf "openai_api_key provided !\n"
|
||||
else
|
||||
printf "openai_api_key not provided\n"
|
||||
fi
|
||||
|
||||
# use low width to fit in the tasks UI sidebar
|
||||
# we adjust the height to 930 due to a bug in codex, see: https://github.com/openai/codex/issues/1608
|
||||
printf "Starting codex with %s\n" "${CODEX_ARGS[@]}"
|
||||
agentapi server --term-width 67 --term-height 1190 -- codex "${CODEX_ARGS[@]}"
|
||||
@@ -1,14 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
if [[ "$1" == "--version" ]]; then
|
||||
echo "HELLO: $(bash -c env)"
|
||||
echo "codex version v1.0.0"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
set -e
|
||||
|
||||
while true; do
|
||||
echo "$(date) - codex-mock"
|
||||
sleep 15
|
||||
done
|
||||
@@ -1,135 +0,0 @@
|
||||
---
|
||||
display_name: Cursor CLI
|
||||
icon: ../../../../.icons/cursor.svg
|
||||
description: Run Cursor Agent CLI in your workspace for AI pair programming
|
||||
verified: true
|
||||
tags: [agent, cursor, ai, tasks]
|
||||
---
|
||||
|
||||
# Cursor CLI
|
||||
|
||||
Run the Cursor Agent CLI in your workspace for interactive coding assistance and automated task execution.
|
||||
|
||||
```tf
|
||||
module "cursor_cli" {
|
||||
source = "registry.coder.com/coder-labs/cursor-cli/coder"
|
||||
version = "0.1.1"
|
||||
agent_id = coder_agent.example.id
|
||||
folder = "/home/coder/project"
|
||||
}
|
||||
```
|
||||
|
||||
## Basic setup
|
||||
|
||||
A full example with MCP, rules, and pre/post install scripts:
|
||||
|
||||
```tf
|
||||
|
||||
data "coder_parameter" "ai_prompt" {
|
||||
type = "string"
|
||||
name = "AI Prompt"
|
||||
default = ""
|
||||
description = "Build a Minesweeper in Python."
|
||||
mutable = true
|
||||
}
|
||||
|
||||
module "coder-login" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/coder-login/coder"
|
||||
version = "1.0.31"
|
||||
agent_id = coder_agent.main.id
|
||||
}
|
||||
|
||||
module "cursor_cli" {
|
||||
source = "registry.coder.com/coder-labs/cursor-cli/coder"
|
||||
version = "0.1.1"
|
||||
agent_id = coder_agent.example.id
|
||||
folder = "/home/coder/project"
|
||||
|
||||
# Optional
|
||||
install_cursor_cli = true
|
||||
force = true
|
||||
model = "gpt-5"
|
||||
ai_prompt = data.coder_parameter.ai_prompt.value
|
||||
api_key = "xxxx-xxxx-xxxx" # Required while using tasks, see note below
|
||||
|
||||
# Minimal MCP server (writes `folder/.cursor/mcp.json`):
|
||||
mcp = jsonencode({
|
||||
mcpServers = {
|
||||
playwright = {
|
||||
command = "npx"
|
||||
args = ["-y", "@playwright/mcp@latest", "--headless", "--isolated", "--no-sandbox"]
|
||||
}
|
||||
desktop-commander = {
|
||||
command = "npx"
|
||||
args = ["-y", "@wonderwhy-er/desktop-commander"]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
# Use a pre_install_script to install the CLI
|
||||
pre_install_script = <<-EOT
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
|
||||
apt-get install -y nodejs
|
||||
EOT
|
||||
|
||||
# Use post_install_script to wait for the repo to be ready
|
||||
post_install_script = <<-EOT
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
TARGET="$${FOLDER}/.git/config"
|
||||
echo "[cursor-cli] waiting for $${TARGET}..."
|
||||
for i in $(seq 1 600); do
|
||||
[ -f "$TARGET" ] && { echo "ready"; exit 0; }
|
||||
sleep 1
|
||||
done
|
||||
echo "timeout waiting for $${TARGET}" >&2
|
||||
EOT
|
||||
|
||||
# Provide a map of file name to content; files are written to `folder/.cursor/rules/<name>`.
|
||||
rules_files = {
|
||||
"python.mdc" = <<-EOT
|
||||
---
|
||||
description: RPC Service boilerplate
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
- Use our internal RPC pattern when defining services
|
||||
- Always use snake_case for service names.
|
||||
|
||||
@service-template.ts
|
||||
EOT
|
||||
|
||||
"frontend.mdc" = <<-EOT
|
||||
---
|
||||
description: RPC Service boilerplate
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
- Use our internal RPC pattern when defining services
|
||||
- Always use snake_case for service names.
|
||||
|
||||
@service-template.ts
|
||||
EOT
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> A `.cursor` directory will be created in the specified `folder`, containing the MCP configuration, rules.
|
||||
> To use this module with tasks, please pass the API Key obtained from Cursor to the `api_key` variable. To obtain the api key follow the instructions [here](https://docs.cursor.com/en/cli/reference/authentication#step-1%3A-generate-an-api-key)
|
||||
|
||||
## References
|
||||
|
||||
- See Cursor CLI docs: `https://docs.cursor.com/en/cli/overview`
|
||||
- For MCP project config, see `https://docs.cursor.com/en/context/mcp#using-mcp-json`. This module writes your `mcp_json` into `folder/.cursor/mcp.json`.
|
||||
- For Rules, see `https://docs.cursor.com/en/context/rules#project-rules`. Provide `rules_files` (map of file name to content) to populate `folder/.cursor/rules/`.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- Ensure the CLI is installed (enable `install_cursor_cli = true` or preinstall it in your image)
|
||||
- Logs are written to `~/.cursor-cli-module/`
|
||||
@@ -1,152 +0,0 @@
|
||||
run "test_cursor_cli_basic" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent-123"
|
||||
folder = "/home/coder/projects"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = coder_env.status_slug.name == "CODER_MCP_APP_STATUS_SLUG"
|
||||
error_message = "Status slug environment variable should be set correctly"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = coder_env.status_slug.value == "cursorcli"
|
||||
error_message = "Status slug value should be 'cursorcli'"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.folder == "/home/coder/projects"
|
||||
error_message = "Folder variable should be set correctly"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.agent_id == "test-agent-123"
|
||||
error_message = "Agent ID variable should be set correctly"
|
||||
}
|
||||
}
|
||||
|
||||
run "test_cursor_cli_with_api_key" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent-456"
|
||||
folder = "/home/coder/workspace"
|
||||
api_key = "test-api-key-123"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = coder_env.cursor_api_key[0].name == "CURSOR_API_KEY"
|
||||
error_message = "Cursor API key environment variable should be set correctly"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = coder_env.cursor_api_key[0].value == "test-api-key-123"
|
||||
error_message = "Cursor API key value should match the input"
|
||||
}
|
||||
}
|
||||
|
||||
run "test_cursor_cli_with_custom_options" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent-789"
|
||||
folder = "/home/coder/custom"
|
||||
order = 5
|
||||
group = "development"
|
||||
icon = "/icon/custom.svg"
|
||||
model = "sonnet-4"
|
||||
ai_prompt = "Help me write better code"
|
||||
force = false
|
||||
install_cursor_cli = false
|
||||
install_agentapi = false
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.order == 5
|
||||
error_message = "Order variable should be set to 5"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.group == "development"
|
||||
error_message = "Group variable should be set to 'development'"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.icon == "/icon/custom.svg"
|
||||
error_message = "Icon variable should be set to custom icon"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.model == "sonnet-4"
|
||||
error_message = "Model variable should be set to 'sonnet-4'"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.ai_prompt == "Help me write better code"
|
||||
error_message = "AI prompt variable should be set correctly"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.force == false
|
||||
error_message = "Force variable should be set to false"
|
||||
}
|
||||
}
|
||||
|
||||
run "test_cursor_cli_with_mcp_and_rules" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent-mcp"
|
||||
folder = "/home/coder/mcp-test"
|
||||
mcp = jsonencode({
|
||||
mcpServers = {
|
||||
test = {
|
||||
command = "test-server"
|
||||
args = ["--config", "test.json"]
|
||||
}
|
||||
}
|
||||
})
|
||||
rules_files = {
|
||||
"general.md" = "# General coding rules\n- Write clean code\n- Add comments"
|
||||
"security.md" = "# Security rules\n- Never commit secrets\n- Validate inputs"
|
||||
}
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.mcp != null
|
||||
error_message = "MCP configuration should be provided"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.rules_files != null
|
||||
error_message = "Rules files should be provided"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = length(var.rules_files) == 2
|
||||
error_message = "Should have 2 rules files"
|
||||
}
|
||||
}
|
||||
|
||||
run "test_cursor_cli_with_scripts" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent-scripts"
|
||||
folder = "/home/coder/scripts"
|
||||
pre_install_script = "echo 'Pre-install script'"
|
||||
post_install_script = "echo 'Post-install script'"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.pre_install_script == "echo 'Pre-install script'"
|
||||
error_message = "Pre-install script should be set correctly"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.post_install_script == "echo 'Post-install script'"
|
||||
error_message = "Post-install script should be set correctly"
|
||||
}
|
||||
}
|
||||
@@ -1,212 +0,0 @@
|
||||
import { afterEach, beforeAll, describe, expect, setDefaultTimeout, test } from "bun:test";
|
||||
import { execContainer, runTerraformInit, writeFileContainer } from "~test";
|
||||
import {
|
||||
execModuleScript,
|
||||
expectAgentAPIStarted,
|
||||
loadTestFile,
|
||||
setup as setupUtil
|
||||
} from "../../../coder/modules/agentapi/test-util";
|
||||
import { setupContainer, writeExecutable } from "../../../coder/modules/agentapi/test-util";
|
||||
|
||||
let cleanupFns: (() => Promise<void>)[] = [];
|
||||
const registerCleanup = (fn: () => Promise<void>) => cleanupFns.push(fn);
|
||||
|
||||
afterEach(async () => {
|
||||
const fns = cleanupFns.slice().reverse();
|
||||
cleanupFns = [];
|
||||
for (const fn of fns) {
|
||||
try {
|
||||
await fn();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
interface SetupProps {
|
||||
skipAgentAPIMock?: boolean;
|
||||
skipCursorCliMock?: boolean;
|
||||
moduleVariables?: Record<string, string>;
|
||||
agentapiMockScript?: string;
|
||||
}
|
||||
|
||||
const setup = async (props?: SetupProps): Promise<{ id: string }> => {
|
||||
const projectDir = "/home/coder/project";
|
||||
const { id } = await setupUtil({
|
||||
moduleDir: import.meta.dir,
|
||||
moduleVariables: {
|
||||
enable_agentapi: "true",
|
||||
install_cursor_cli: props?.skipCursorCliMock ? "true" : "false",
|
||||
install_agentapi: props?.skipAgentAPIMock ? "true" : "false",
|
||||
folder: projectDir,
|
||||
...props?.moduleVariables,
|
||||
},
|
||||
registerCleanup,
|
||||
projectDir,
|
||||
skipAgentAPIMock: props?.skipAgentAPIMock,
|
||||
agentapiMockScript: props?.agentapiMockScript,
|
||||
});
|
||||
if (!props?.skipCursorCliMock) {
|
||||
await writeExecutable({
|
||||
containerId: id,
|
||||
filePath: "/usr/bin/cursor-agent",
|
||||
content: await loadTestFile(import.meta.dir, "cursor-cli-mock.sh"),
|
||||
});
|
||||
}
|
||||
return { id };
|
||||
};
|
||||
|
||||
setDefaultTimeout(180 * 1000);
|
||||
|
||||
describe("cursor-cli", async () => {
|
||||
beforeAll(async () => {
|
||||
await runTerraformInit(import.meta.dir);
|
||||
});
|
||||
|
||||
test("agentapi-happy-path", async () => {
|
||||
const { id } = await setup({});
|
||||
const resp = await execModuleScript(id);
|
||||
expect(resp.exitCode).toBe(0);
|
||||
|
||||
await expectAgentAPIStarted(id);
|
||||
});
|
||||
|
||||
test("agentapi-mcp-json", async () => {
|
||||
const mcpJson = '{"mcpServers": {"test": {"command": "test-cmd", "type": "stdio"}}}';
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
mcp: mcpJson,
|
||||
}
|
||||
});
|
||||
const resp = await execModuleScript(id);
|
||||
expect(resp.exitCode).toBe(0);
|
||||
|
||||
const mcpContent = await execContainer(id, [
|
||||
"bash",
|
||||
"-c",
|
||||
`cat '/home/coder/project/.cursor/mcp.json'`,
|
||||
]);
|
||||
expect(mcpContent.exitCode).toBe(0);
|
||||
expect(mcpContent.stdout).toContain("mcpServers");
|
||||
expect(mcpContent.stdout).toContain("test");
|
||||
expect(mcpContent.stdout).toContain("test-cmd");
|
||||
expect(mcpContent.stdout).toContain("/tmp/mcp-hack.sh");
|
||||
expect(mcpContent.stdout).toContain("coder");
|
||||
});
|
||||
|
||||
test("agentapi-rules-files", async () => {
|
||||
const rulesContent = "Always use TypeScript";
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
rules_files: JSON.stringify({ "typescript.md": rulesContent }),
|
||||
}
|
||||
});
|
||||
const resp = await execModuleScript(id);
|
||||
expect(resp.exitCode).toBe(0);
|
||||
|
||||
const rulesFile = await execContainer(id, [
|
||||
"bash",
|
||||
"-c",
|
||||
`cat '/home/coder/project/.cursor/rules/typescript.md'`,
|
||||
]);
|
||||
expect(rulesFile.exitCode).toBe(0);
|
||||
expect(rulesFile.stdout).toContain(rulesContent);
|
||||
});
|
||||
|
||||
test("agentapi-api-key", async () => {
|
||||
const apiKey = "test-cursor-api-key-123";
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
api_key: apiKey,
|
||||
}
|
||||
});
|
||||
const resp = await execModuleScript(id);
|
||||
expect(resp.exitCode).toBe(0);
|
||||
|
||||
const envCheck = await execContainer(id, [
|
||||
"bash",
|
||||
"-c",
|
||||
`env | grep CURSOR_API_KEY || echo "CURSOR_API_KEY not found"`,
|
||||
]);
|
||||
expect(envCheck.stdout).toContain("CURSOR_API_KEY");
|
||||
});
|
||||
|
||||
test("agentapi-model-and-force-flags", async () => {
|
||||
const model = "sonnet-4";
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
model: model,
|
||||
force: "true",
|
||||
ai_prompt: "test prompt",
|
||||
}
|
||||
});
|
||||
const resp = await execModuleScript(id);
|
||||
expect(resp.exitCode).toBe(0);
|
||||
|
||||
const startLog = await execContainer(id, [
|
||||
"bash",
|
||||
"-c",
|
||||
"cat /home/coder/.cursor-cli-module/agentapi-start.log || cat /home/coder/.cursor-cli-module/start.log || true",
|
||||
]);
|
||||
expect(startLog.stdout).toContain(`-m ${model}`);
|
||||
expect(startLog.stdout).toContain("-f");
|
||||
expect(startLog.stdout).toContain("test prompt");
|
||||
});
|
||||
|
||||
test("agentapi-pre-post-install-scripts", async () => {
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
pre_install_script: "#!/bin/bash\necho 'cursor-pre-install-script'",
|
||||
post_install_script: "#!/bin/bash\necho 'cursor-post-install-script'",
|
||||
}
|
||||
});
|
||||
const resp = await execModuleScript(id);
|
||||
expect(resp.exitCode).toBe(0);
|
||||
|
||||
const preInstallLog = await execContainer(id, [
|
||||
"bash",
|
||||
"-c",
|
||||
"cat /home/coder/.cursor-cli-module/pre_install.log || true",
|
||||
]);
|
||||
expect(preInstallLog.stdout).toContain("cursor-pre-install-script");
|
||||
|
||||
const postInstallLog = await execContainer(id, [
|
||||
"bash",
|
||||
"-c",
|
||||
"cat /home/coder/.cursor-cli-module/post_install.log || true",
|
||||
]);
|
||||
expect(postInstallLog.stdout).toContain("cursor-post-install-script");
|
||||
});
|
||||
|
||||
test("agentapi-folder-variable", async () => {
|
||||
const folder = "/tmp/cursor-test-folder";
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
folder: folder,
|
||||
}
|
||||
});
|
||||
const resp = await execModuleScript(id);
|
||||
expect(resp.exitCode).toBe(0);
|
||||
|
||||
const installLog = await execContainer(id, [
|
||||
"bash",
|
||||
"-c",
|
||||
"cat /home/coder/.cursor-cli-module/install.log || true",
|
||||
]);
|
||||
expect(installLog.stdout).toContain(folder);
|
||||
});
|
||||
|
||||
test("install-test-cursor-cli-latest", async () => {
|
||||
const { id } = await setup({
|
||||
skipCursorCliMock: true,
|
||||
skipAgentAPIMock: true,
|
||||
});
|
||||
const resp = await execModuleScript(id);
|
||||
expect(resp.exitCode).toBe(0);
|
||||
|
||||
await expectAgentAPIStarted(id);
|
||||
})
|
||||
|
||||
});
|
||||
|
||||
|
||||
@@ -1,179 +0,0 @@
|
||||
terraform {
|
||||
required_version = ">= 1.0"
|
||||
|
||||
required_providers {
|
||||
coder = {
|
||||
source = "coder/coder"
|
||||
version = ">= 2.7"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
variable "agent_id" {
|
||||
type = string
|
||||
description = "The ID of a Coder agent."
|
||||
}
|
||||
|
||||
data "coder_workspace" "me" {}
|
||||
|
||||
data "coder_workspace_owner" "me" {}
|
||||
|
||||
variable "order" {
|
||||
type = number
|
||||
description = "The order determines the position of app in the UI presentation. The lowest order is shown first and apps with equal order are sorted by name (ascending order)."
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "group" {
|
||||
type = string
|
||||
description = "The name of a group that this app belongs to."
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "icon" {
|
||||
type = string
|
||||
description = "The icon to use for the app."
|
||||
default = "/icon/cursor.svg"
|
||||
}
|
||||
|
||||
variable "folder" {
|
||||
type = string
|
||||
description = "The folder to run Cursor CLI in."
|
||||
}
|
||||
|
||||
variable "install_cursor_cli" {
|
||||
type = bool
|
||||
description = "Whether to install Cursor CLI."
|
||||
default = true
|
||||
}
|
||||
|
||||
variable "install_agentapi" {
|
||||
type = bool
|
||||
description = "Whether to install AgentAPI."
|
||||
default = true
|
||||
}
|
||||
|
||||
variable "agentapi_version" {
|
||||
type = string
|
||||
description = "The version of AgentAPI to install."
|
||||
default = "v0.5.0"
|
||||
}
|
||||
|
||||
variable "force" {
|
||||
type = bool
|
||||
description = "Force allow commands unless explicitly denied"
|
||||
default = true
|
||||
}
|
||||
|
||||
variable "model" {
|
||||
type = string
|
||||
description = "Model to use (e.g., sonnet-4, sonnet-4-thinking, gpt-5)"
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "ai_prompt" {
|
||||
type = string
|
||||
description = "AI prompt/task passed to cursor-agent."
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "api_key" {
|
||||
type = string
|
||||
description = "API key for Cursor CLI."
|
||||
default = ""
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
variable "mcp" {
|
||||
type = string
|
||||
description = "Workspace-specific MCP JSON to write to folder/.cursor/mcp.json. See https://docs.cursor.com/en/context/mcp#using-mcp-json"
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "rules_files" {
|
||||
type = map(string)
|
||||
description = "Optional map of rule file name to content. Files will be written to folder/.cursor/rules/<name>. See https://docs.cursor.com/en/context/rules#project-rules"
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "pre_install_script" {
|
||||
type = string
|
||||
description = "Optional script to run before installing Cursor CLI."
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "post_install_script" {
|
||||
type = string
|
||||
description = "Optional script to run after installing Cursor CLI."
|
||||
default = null
|
||||
}
|
||||
|
||||
locals {
|
||||
app_slug = "cursorcli"
|
||||
install_script = file("${path.module}/scripts/install.sh")
|
||||
start_script = file("${path.module}/scripts/start.sh")
|
||||
module_dir_name = ".cursor-cli-module"
|
||||
}
|
||||
|
||||
# Expose status slug and API key to the agent environment
|
||||
resource "coder_env" "status_slug" {
|
||||
agent_id = var.agent_id
|
||||
name = "CODER_MCP_APP_STATUS_SLUG"
|
||||
value = local.app_slug
|
||||
}
|
||||
|
||||
resource "coder_env" "cursor_api_key" {
|
||||
count = var.api_key != "" ? 1 : 0
|
||||
agent_id = var.agent_id
|
||||
name = "CURSOR_API_KEY"
|
||||
value = var.api_key
|
||||
}
|
||||
|
||||
module "agentapi" {
|
||||
source = "registry.coder.com/coder/agentapi/coder"
|
||||
version = "1.1.1"
|
||||
|
||||
agent_id = var.agent_id
|
||||
web_app_slug = local.app_slug
|
||||
web_app_order = var.order
|
||||
web_app_group = var.group
|
||||
web_app_icon = var.icon
|
||||
web_app_display_name = "Cursor CLI"
|
||||
cli_app_slug = local.app_slug
|
||||
cli_app_display_name = "Cursor CLI"
|
||||
module_dir_name = local.module_dir_name
|
||||
install_agentapi = var.install_agentapi
|
||||
agentapi_version = var.agentapi_version
|
||||
pre_install_script = var.pre_install_script
|
||||
post_install_script = var.post_install_script
|
||||
start_script = <<-EOT
|
||||
#!/bin/bash
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
|
||||
echo -n '${base64encode(local.start_script)}' | base64 -d > /tmp/start.sh
|
||||
chmod +x /tmp/start.sh
|
||||
ARG_FORCE='${var.force}' \
|
||||
ARG_MODEL='${var.model}' \
|
||||
ARG_AI_PROMPT='${base64encode(var.ai_prompt)}' \
|
||||
ARG_MODULE_DIR_NAME='${local.module_dir_name}' \
|
||||
ARG_FOLDER='${var.folder}' \
|
||||
/tmp/start.sh
|
||||
EOT
|
||||
|
||||
install_script = <<-EOT
|
||||
#!/bin/bash
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
|
||||
echo -n '${base64encode(local.install_script)}' | base64 -d > /tmp/install.sh
|
||||
chmod +x /tmp/install.sh
|
||||
ARG_INSTALL='${var.install_cursor_cli}' \
|
||||
ARG_WORKSPACE_MCP_JSON='${var.mcp != null ? base64encode(replace(var.mcp, "'", "'\\''")) : ""}' \
|
||||
ARG_WORKSPACE_RULES_JSON='${var.rules_files != null ? base64encode(jsonencode(var.rules_files)) : ""}' \
|
||||
ARG_MODULE_DIR_NAME='${local.module_dir_name}' \
|
||||
ARG_FOLDER='${var.folder}' \
|
||||
ARG_CODER_MCP_APP_STATUS_SLUG='${local.app_slug}' \
|
||||
/tmp/install.sh
|
||||
EOT
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
|
||||
command_exists() {
|
||||
command -v "$1" > /dev/null 2>&1
|
||||
}
|
||||
|
||||
# Inputs
|
||||
ARG_INSTALL=${ARG_INSTALL:-true}
|
||||
ARG_MODULE_DIR_NAME=${ARG_MODULE_DIR_NAME:-.cursor-cli-module}
|
||||
ARG_FOLDER=${ARG_FOLDER:-$HOME}
|
||||
ARG_CODER_MCP_APP_STATUS_SLUG=${ARG_CODER_MCP_APP_STATUS_SLUG:-}
|
||||
|
||||
mkdir -p "$HOME/$ARG_MODULE_DIR_NAME"
|
||||
|
||||
ARG_WORKSPACE_MCP_JSON=$(echo -n "$ARG_WORKSPACE_MCP_JSON" | base64 -d)
|
||||
ARG_WORKSPACE_RULES_JSON=$(echo -n "$ARG_WORKSPACE_RULES_JSON" | base64 -d)
|
||||
|
||||
echo "--------------------------------"
|
||||
echo "install: $ARG_INSTALL"
|
||||
echo "folder: $ARG_FOLDER"
|
||||
echo "coder_mcp_app_status_slug: $ARG_CODER_MCP_APP_STATUS_SLUG"
|
||||
echo "module_dir_name: $ARG_MODULE_DIR_NAME"
|
||||
echo "--------------------------------"
|
||||
|
||||
# Install Cursor via official installer if requested
|
||||
function install_cursor_cli() {
|
||||
if [ "$ARG_INSTALL" = "true" ]; then
|
||||
echo "Installing Cursor via official installer..."
|
||||
set +e
|
||||
curl https://cursor.com/install -fsS | bash 2>&1
|
||||
CURL_EXIT=${PIPESTATUS[0]}
|
||||
set -e
|
||||
if [ $CURL_EXIT -ne 0 ]; then
|
||||
echo "Cursor installer failed with exit code $CURL_EXIT"
|
||||
fi
|
||||
|
||||
# Ensure binaries are discoverable; create stable symlink to cursor-agent
|
||||
CANDIDATES=(
|
||||
"$(command -v cursor-agent || true)"
|
||||
"$HOME/.cursor/bin/cursor-agent"
|
||||
)
|
||||
FOUND_BIN=""
|
||||
for c in "${CANDIDATES[@]}"; do
|
||||
if [ -n "$c" ] && [ -x "$c" ]; then
|
||||
FOUND_BIN="$c"
|
||||
break
|
||||
fi
|
||||
done
|
||||
mkdir -p "$HOME/.local/bin"
|
||||
if [ -n "$FOUND_BIN" ]; then
|
||||
ln -sf "$FOUND_BIN" "$HOME/.local/bin/cursor-agent"
|
||||
fi
|
||||
echo "Installed cursor-agent at: $(command -v cursor-agent || true) (resolved: $FOUND_BIN)"
|
||||
fi
|
||||
}
|
||||
|
||||
# Write MCP config to user's home if provided (ARG_FOLDER/.cursor/mcp.json)
|
||||
function write_mcp_config() {
|
||||
TARGET_DIR="$ARG_FOLDER/.cursor"
|
||||
TARGET_FILE="$TARGET_DIR/mcp.json"
|
||||
mkdir -p "$TARGET_DIR"
|
||||
|
||||
CURSOR_MCP_HACK_SCRIPT=$(
|
||||
cat << EOF
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
|
||||
# --- Set environment variables ---
|
||||
export CODER_MCP_APP_STATUS_SLUG="${ARG_CODER_MCP_APP_STATUS_SLUG}"
|
||||
export CODER_MCP_AI_AGENTAPI_URL="http://localhost:3284"
|
||||
export CODER_AGENT_URL="${CODER_AGENT_URL}"
|
||||
export CODER_AGENT_TOKEN="${CODER_AGENT_TOKEN}"
|
||||
|
||||
# --- Launch the MCP server ---
|
||||
exec coder exp mcp server
|
||||
EOF
|
||||
)
|
||||
echo "$CURSOR_MCP_HACK_SCRIPT" > "/tmp/mcp-hack.sh"
|
||||
chmod +x /tmp/mcp-hack.sh
|
||||
|
||||
CODER_MCP=$(
|
||||
cat << EOF
|
||||
{
|
||||
"coder": {
|
||||
"args": [],
|
||||
"command": "/tmp/mcp-hack.sh",
|
||||
"description": "Report ALL tasks and statuses (in progress, done, failed) you are working on.",
|
||||
"name": "Coder",
|
||||
"timeout": 3000,
|
||||
"type": "stdio",
|
||||
"trust": true
|
||||
}
|
||||
}
|
||||
EOF
|
||||
)
|
||||
|
||||
echo "${ARG_WORKSPACE_MCP_JSON:-{}}" | jq --argjson base "$CODER_MCP" \
|
||||
'.mcpServers = ((.mcpServers // {}) + $base)' > "$TARGET_FILE"
|
||||
echo "Wrote workspace MCP to $TARGET_FILE"
|
||||
}
|
||||
|
||||
# Write rules files to user's home (FOLDER/.cursor/rules)
|
||||
function write_rules_file() {
|
||||
if [ -n "$ARG_WORKSPACE_RULES_JSON" ]; then
|
||||
RULES_DIR="$ARG_FOLDER/.cursor/rules"
|
||||
mkdir -p "$RULES_DIR"
|
||||
echo "$ARG_WORKSPACE_RULES_JSON" | jq -r 'to_entries[] | @base64' | while read -r entry; do
|
||||
_jq() { echo "${entry}" | base64 -d | jq -r ${1}; }
|
||||
NAME=$(_jq '.key')
|
||||
CONTENT=$(_jq '.value')
|
||||
echo "$CONTENT" > "$RULES_DIR/$NAME"
|
||||
echo "Wrote rule: $RULES_DIR/$NAME"
|
||||
done
|
||||
fi
|
||||
}
|
||||
|
||||
install_cursor_cli
|
||||
write_mcp_config
|
||||
write_rules_file
|
||||
@@ -1,67 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
|
||||
command_exists() {
|
||||
command -v "$1" > /dev/null 2>&1
|
||||
}
|
||||
|
||||
ARG_AI_PROMPT=$(echo -n "${ARG_AI_PROMPT:-}" | base64 -d)
|
||||
ARG_FORCE=${ARG_FORCE:-false}
|
||||
ARG_MODEL=${ARG_MODEL:-}
|
||||
ARG_OUTPUT_FORMAT=${ARG_OUTPUT_FORMAT:-json}
|
||||
ARG_MODULE_DIR_NAME=${ARG_MODULE_DIR_NAME:-.cursor-cli-module}
|
||||
ARG_FOLDER=${ARG_FOLDER:-$HOME}
|
||||
|
||||
echo "--------------------------------"
|
||||
echo "install: $ARG_INSTALL"
|
||||
echo "version: $ARG_VERSION"
|
||||
echo "folder: $ARG_FOLDER"
|
||||
echo "ai_prompt: $ARG_AI_PROMPT"
|
||||
echo "force: $ARG_FORCE"
|
||||
echo "model: $ARG_MODEL"
|
||||
echo "output_format: $ARG_OUTPUT_FORMAT"
|
||||
echo "module_dir_name: $ARG_MODULE_DIR_NAME"
|
||||
echo "folder: $ARG_FOLDER"
|
||||
echo "--------------------------------"
|
||||
|
||||
mkdir -p "$HOME/$ARG_MODULE_DIR_NAME"
|
||||
|
||||
# Find cursor agent cli
|
||||
if command_exists cursor-agent; then
|
||||
CURSOR_CMD=cursor-agent
|
||||
elif [ -x "$HOME/.local/bin/cursor-agent" ]; then
|
||||
CURSOR_CMD="$HOME/.local/bin/cursor-agent"
|
||||
else
|
||||
echo "Error: cursor-agent not found. Install it or set install_cursor_cli=true."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Ensure working directory exists
|
||||
if [ -d "$ARG_FOLDER" ]; then
|
||||
cd "$ARG_FOLDER"
|
||||
else
|
||||
mkdir -p "$ARG_FOLDER"
|
||||
cd "$ARG_FOLDER"
|
||||
fi
|
||||
|
||||
ARGS=()
|
||||
|
||||
# global flags
|
||||
if [ -n "$ARG_MODEL" ]; then
|
||||
ARGS+=("-m" "$ARG_MODEL")
|
||||
fi
|
||||
if [ "$ARG_FORCE" = "true" ]; then
|
||||
ARGS+=("-f")
|
||||
fi
|
||||
|
||||
if [ -n "$ARG_AI_PROMPT" ]; then
|
||||
printf "AI prompt provided\n"
|
||||
ARGS+=("Complete the task at hand in one go. Every step of the way, report your progress using coder_report_task tool with proper summary and statuses. Your task at hand: $ARG_AI_PROMPT")
|
||||
fi
|
||||
|
||||
# Log and run in background, redirecting all output to the log file
|
||||
printf "Running: %q %s\n" "$CURSOR_CMD" "$(printf '%q ' "${ARGS[@]}")"
|
||||
|
||||
agentapi server --type cursor --term-width 67 --term-height 1190 -- "$CURSOR_CMD" "${ARGS[@]}"
|
||||
@@ -1,41 +1,36 @@
|
||||
---
|
||||
display_name: Gemini CLI
|
||||
description: Run Gemini CLI in your workspace for AI pair programming
|
||||
icon: ../../../../.icons/gemini.svg
|
||||
description: Run Gemini CLI in your workspace with AgentAPI integration
|
||||
verified: true
|
||||
tags: [agent, gemini, ai, google, tasks]
|
||||
---
|
||||
|
||||
# Gemini CLI
|
||||
|
||||
Run [Gemini CLI](https://github.com/google-gemini/gemini-cli) in your workspace to access Google's Gemini AI models for interactive coding assistance and automated task execution.
|
||||
Run [Gemini CLI](https://ai.google.dev/gemini-api/docs/cli) in your workspace to access Google's Gemini AI models, and custom pre/post install scripts. This module integrates with [AgentAPI](https://github.com/coder/agentapi) for Coder Tasks compatibility.
|
||||
|
||||
```tf
|
||||
module "gemini" {
|
||||
source = "registry.coder.com/coder-labs/gemini/coder"
|
||||
version = "1.1.0"
|
||||
agent_id = coder_agent.example.id
|
||||
folder = "/home/coder/project"
|
||||
source = "registry.coder.com/coder-labs/gemini/coder"
|
||||
version = "1.0.0"
|
||||
agent_id = coder_agent.example.id
|
||||
gemini_api_key = var.gemini_api_key
|
||||
gemini_model = "gemini-2.5-pro"
|
||||
install_gemini = true
|
||||
gemini_version = "latest"
|
||||
agentapi_version = "latest"
|
||||
}
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- **Interactive AI Assistance**: Run Gemini CLI directly in your terminal for coding help
|
||||
- **Automated Task Execution**: Execute coding tasks automatically via AgentAPI integration
|
||||
- **Multiple AI Models**: Support for Gemini 2.5 Pro, Flash, and other Google AI models
|
||||
- **API Key Integration**: Seamless authentication with Gemini API
|
||||
- **MCP Server Integration**: Built-in Coder MCP server for task reporting
|
||||
- **Persistent Sessions**: Maintain context across workspace sessions
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- You must add the [Coder Login](https://registry.coder.com/modules/coder-login/coder) module to your template
|
||||
- Node.js and npm will be installed automatically if not present
|
||||
- The [Coder Login](https://registry.coder.com/modules/coder/coder-login) module is required
|
||||
|
||||
## Examples
|
||||
## Usage Example
|
||||
|
||||
### Basic setup
|
||||
- Example 1:
|
||||
|
||||
```tf
|
||||
variable "gemini_api_key" {
|
||||
@@ -45,97 +40,39 @@ variable "gemini_api_key" {
|
||||
}
|
||||
|
||||
module "gemini" {
|
||||
source = "registry.coder.com/coder-labs/gemini/coder"
|
||||
version = "1.1.0"
|
||||
agent_id = coder_agent.example.id
|
||||
gemini_api_key = var.gemini_api_key
|
||||
folder = "/home/coder/project"
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder-labs/gemini/coder"
|
||||
version = "1.0.0"
|
||||
agent_id = coder_agent.example.id
|
||||
gemini_api_key = var.gemini_api_key # we recommend providing this parameter inorder to have a smoother experience (i.e. no google sign-in)
|
||||
gemini_model = "gemini-2.5-flash"
|
||||
install_gemini = true
|
||||
gemini_version = "latest"
|
||||
gemini_instruction_prompt = "Start every response with `Gemini says:`"
|
||||
}
|
||||
```
|
||||
|
||||
This basic setup will:
|
||||
## How it Works
|
||||
|
||||
- Install Gemini CLI in the workspace
|
||||
- Configure authentication with your API key
|
||||
- Set Gemini to run in `/home/coder/project` directory
|
||||
- Enable interactive use from the terminal
|
||||
- Set up MCP server integration for task reporting
|
||||
|
||||
### Automated task execution (Experimental)
|
||||
|
||||
> This functionality is in early access and is still evolving.
|
||||
> For now, we recommend testing it in a demo or staging environment,
|
||||
> rather than deploying to production
|
||||
>
|
||||
> Learn more in [the Coder documentation](https://coder.com/docs/ai-coder)
|
||||
|
||||
```tf
|
||||
variable "gemini_api_key" {
|
||||
type = string
|
||||
description = "Gemini API key"
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
module "coder-login" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/coder-login/coder"
|
||||
version = "~> 1.0"
|
||||
agent_id = coder_agent.example.id
|
||||
}
|
||||
|
||||
data "coder_parameter" "ai_prompt" {
|
||||
type = "string"
|
||||
name = "AI Prompt"
|
||||
default = ""
|
||||
description = "Task prompt for automated Gemini execution"
|
||||
mutable = true
|
||||
}
|
||||
|
||||
module "gemini" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder-labs/gemini/coder"
|
||||
version = "1.1.0"
|
||||
agent_id = coder_agent.example.id
|
||||
gemini_api_key = var.gemini_api_key
|
||||
gemini_model = "gemini-2.5-flash"
|
||||
folder = "/home/coder/project"
|
||||
task_prompt = data.coder_parameter.ai_prompt.value
|
||||
enable_yolo_mode = true # Auto-approve all tool calls for automation
|
||||
gemini_system_prompt = <<-EOT
|
||||
You are a helpful coding assistant. Always explain your code changes clearly.
|
||||
YOU MUST REPORT ALL TASKS TO CODER.
|
||||
EOT
|
||||
}
|
||||
```
|
||||
|
||||
> [!WARNING]
|
||||
> YOLO mode automatically approves all tool calls without user confirmation. The agent has access to your machine's file system and terminal. Only enable in trusted, isolated environments.
|
||||
|
||||
### Using Vertex AI (Enterprise)
|
||||
|
||||
For enterprise users who prefer Google's Vertex AI platform:
|
||||
|
||||
```tf
|
||||
module "gemini" {
|
||||
source = "registry.coder.com/coder-labs/gemini/coder"
|
||||
version = "1.1.0"
|
||||
agent_id = coder_agent.example.id
|
||||
gemini_api_key = var.gemini_api_key
|
||||
folder = "/home/coder/project"
|
||||
use_vertexai = true
|
||||
}
|
||||
```
|
||||
- **Install**: The module installs Gemini CLI using npm (installs Node.js via NVM if needed)
|
||||
- **Instruction Prompt**: If `GEMINI_INSTRUCTION_PROMPT` and `GEMINI_START_DIRECTORY` are set, creates the directory (if needed) and writes the prompt to `GEMINI.md`
|
||||
- **Start**: Launches Gemini CLI in the specified directory, wrapped by AgentAPI
|
||||
- **Environment**: Sets `GEMINI_API_KEY`, `GOOGLE_GENAI_USE_VERTEXAI`, `GEMINI_MODEL` for the CLI (if variables provided)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- If Gemini CLI is not found, ensure your API key is valid (`install_gemini` defaults to `true`)
|
||||
- Check logs in `~/.gemini-module/` for install/start output
|
||||
- Use the `gemini_api_key` variable to avoid requiring Google sign-in
|
||||
- If Gemini CLI is not found, ensure `install_gemini = true` and your API key is valid
|
||||
- Node.js and npm are installed automatically if missing (using NVM)
|
||||
- Check logs in `/home/coder/.gemini-module/` for install/start output
|
||||
- We highly recommend using the `gemini_api_key` variable, this also ensures smooth tasks running without needing to sign in to Google.
|
||||
|
||||
The module creates log files in the workspace's `~/.gemini-module` directory for debugging purposes.
|
||||
> [!IMPORTANT]
|
||||
> To use tasks with Gemini CLI, ensure you have the `gemini_api_key` variable set, and **you pass the `AI Prompt` Parameter**.
|
||||
> By default we inject the "theme": "Default" and "selectedAuthType": "gemini-api-key" to your ~/.gemini/settings.json along with the coder mcp server.
|
||||
> In `gemini_instruction_prompt` and `AI Prompt` text we recommend using (\`\`) backticks instead of quotes to avoid escaping issues. Eg: gemini_instruction_prompt = "Start every response with \`Gemini says:\` "
|
||||
|
||||
## References
|
||||
|
||||
- [Gemini CLI Documentation](https://github.com/google-gemini/gemini-cli/blob/main/docs/index.md)
|
||||
- [Gemini CLI Documentation](https://ai.google.dev/gemini-api/docs/cli)
|
||||
- [AgentAPI Documentation](https://github.com/coder/agentapi)
|
||||
- [Coder AI Agents Guide](https://coder.com/docs/ai-coder)
|
||||
- [Coder AI Agents Guide](https://coder.com/docs/tutorials/ai-agents)
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from "bun:test";
|
||||
import { execContainer, readFileContainer, runTerraformInit } from "~test";
|
||||
import {
|
||||
loadTestFile,
|
||||
writeExecutable,
|
||||
setup as setupUtil,
|
||||
execModuleScript,
|
||||
@@ -53,24 +54,10 @@ const setup = async (props?: SetupProps): Promise<{ id: string }> => {
|
||||
agentapiMockScript: props?.agentapiMockScript,
|
||||
});
|
||||
if (!props?.skipGeminiMock) {
|
||||
const geminiMockContent = `#!/bin/bash
|
||||
|
||||
if [[ "$1" == "--version" ]]; then
|
||||
echo "HELLO: $(bash -c env)"
|
||||
echo "gemini version v2.5.0"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
set -e
|
||||
|
||||
while true; do
|
||||
echo "$(date) - gemini-mock"
|
||||
sleep 15
|
||||
done`;
|
||||
await writeExecutable({
|
||||
containerId: id,
|
||||
filePath: "/usr/bin/gemini",
|
||||
content: geminiMockContent,
|
||||
content: await loadTestFile(import.meta.dir, "gemini-mock.sh"),
|
||||
});
|
||||
}
|
||||
return { id };
|
||||
@@ -83,7 +70,7 @@ describe("gemini", async () => {
|
||||
await runTerraformInit(import.meta.dir);
|
||||
});
|
||||
|
||||
test("agent-api", async () => {
|
||||
test("happy-path", async () => {
|
||||
const { id } = await setup();
|
||||
await execModuleScript(id);
|
||||
await expectAgentAPIStarted(id);
|
||||
@@ -130,7 +117,7 @@ describe("gemini", async () => {
|
||||
await execModuleScript(id);
|
||||
|
||||
const resp = await readFileContainer(id, "/home/coder/.gemini-module/agentapi-start.log");
|
||||
expect(resp).toContain("Using direct Gemini API with API key");
|
||||
expect(resp).toContain("gemini_api_key provided !");
|
||||
});
|
||||
|
||||
test("use-vertexai", async () => {
|
||||
@@ -210,20 +197,6 @@ describe("gemini", async () => {
|
||||
expect(resp).toContain(prompt);
|
||||
});
|
||||
|
||||
test("task-prompt", async () => {
|
||||
const taskPrompt = "Create a simple Hello World function";
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
task_prompt: taskPrompt,
|
||||
},
|
||||
});
|
||||
await execModuleScript(id, {
|
||||
GEMINI_TASK_PROMPT: taskPrompt,
|
||||
});
|
||||
const resp = await readFileContainer(id, "/home/coder/.gemini-module/agentapi-start.log");
|
||||
expect(resp).toContain("Running automated task:");
|
||||
});
|
||||
|
||||
test("start-without-prompt", async () => {
|
||||
const { id } = await setup();
|
||||
await execModuleScript(id);
|
||||
|
||||
@@ -74,14 +74,14 @@ variable "use_vertexai" {
|
||||
|
||||
variable "install_agentapi" {
|
||||
type = bool
|
||||
description = "Whether to install AgentAPI for web UI and task automation."
|
||||
description = "Whether to install AgentAPI."
|
||||
default = true
|
||||
}
|
||||
|
||||
variable "agentapi_version" {
|
||||
type = string
|
||||
description = "The version of AgentAPI to install."
|
||||
default = "v0.2.3"
|
||||
default = "v0.3.0"
|
||||
}
|
||||
|
||||
variable "gemini_model" {
|
||||
@@ -102,10 +102,12 @@ variable "post_install_script" {
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "task_prompt" {
|
||||
type = string
|
||||
description = "Task prompt for automated Gemini execution"
|
||||
data "coder_parameter" "ai_prompt" {
|
||||
type = "string"
|
||||
name = "AI Prompt"
|
||||
default = ""
|
||||
description = "Initial prompt for the Gemini CLI"
|
||||
mutable = true
|
||||
}
|
||||
|
||||
variable "additional_extensions" {
|
||||
@@ -120,24 +122,12 @@ variable "gemini_system_prompt" {
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "enable_yolo_mode" {
|
||||
type = bool
|
||||
description = "Enable YOLO mode to automatically approve all tool calls without user confirmation. Use with caution."
|
||||
default = false
|
||||
}
|
||||
|
||||
resource "coder_env" "gemini_api_key" {
|
||||
agent_id = var.agent_id
|
||||
name = "GEMINI_API_KEY"
|
||||
value = var.gemini_api_key
|
||||
}
|
||||
|
||||
resource "coder_env" "google_api_key" {
|
||||
agent_id = var.agent_id
|
||||
name = "GOOGLE_API_KEY"
|
||||
value = var.gemini_api_key
|
||||
}
|
||||
|
||||
resource "coder_env" "gemini_use_vertex_ai" {
|
||||
agent_id = var.agent_id
|
||||
name = "GOOGLE_GENAI_USE_VERTEXAI"
|
||||
@@ -176,7 +166,7 @@ EOT
|
||||
|
||||
module "agentapi" {
|
||||
source = "registry.coder.com/coder/agentapi/coder"
|
||||
version = "1.1.1"
|
||||
version = "1.0.0"
|
||||
|
||||
agent_id = var.agent_id
|
||||
web_app_slug = local.app_slug
|
||||
@@ -191,7 +181,22 @@ module "agentapi" {
|
||||
agentapi_version = var.agentapi_version
|
||||
pre_install_script = var.pre_install_script
|
||||
post_install_script = var.post_install_script
|
||||
install_script = <<-EOT
|
||||
start_script = <<-EOT
|
||||
#!/bin/bash
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
|
||||
echo -n '${base64encode(local.start_script)}' | base64 -d > /tmp/start.sh
|
||||
chmod +x /tmp/start.sh
|
||||
GEMINI_API_KEY='${var.gemini_api_key}' \
|
||||
GOOGLE_GENAI_USE_VERTEXAI='${var.use_vertexai}' \
|
||||
GEMINI_MODEL='${var.gemini_model}' \
|
||||
GEMINI_START_DIRECTORY='${var.folder}' \
|
||||
GEMINI_TASK_PROMPT='${base64encode(data.coder_parameter.ai_prompt.value)}' \
|
||||
/tmp/start.sh
|
||||
EOT
|
||||
|
||||
install_script = <<-EOT
|
||||
#!/bin/bash
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
@@ -204,23 +209,7 @@ module "agentapi" {
|
||||
BASE_EXTENSIONS='${base64encode(replace(local.base_extensions, "'", "'\\''"))}' \
|
||||
ADDITIONAL_EXTENSIONS='${base64encode(replace(var.additional_extensions != null ? var.additional_extensions : "", "'", "'\\''"))}' \
|
||||
GEMINI_START_DIRECTORY='${var.folder}' \
|
||||
GEMINI_SYSTEM_PROMPT='${base64encode(var.gemini_system_prompt)}' \
|
||||
GEMINI_INSTRUCTION_PROMPT='${base64encode(var.gemini_system_prompt)}' \
|
||||
/tmp/install.sh
|
||||
EOT
|
||||
start_script = <<-EOT
|
||||
#!/bin/bash
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
|
||||
echo -n '${base64encode(local.start_script)}' | base64 -d > /tmp/start.sh
|
||||
chmod +x /tmp/start.sh
|
||||
GEMINI_API_KEY='${var.gemini_api_key}' \
|
||||
GOOGLE_API_KEY='${var.gemini_api_key}' \
|
||||
GOOGLE_GENAI_USE_VERTEXAI='${var.use_vertexai}' \
|
||||
GEMINI_YOLO_MODE='${var.enable_yolo_mode}' \
|
||||
GEMINI_MODEL='${var.gemini_model}' \
|
||||
GEMINI_START_DIRECTORY='${var.folder}' \
|
||||
GEMINI_TASK_PROMPT='${var.task_prompt}' \
|
||||
/tmp/start.sh
|
||||
EOT
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
BOLD='\033[0;1m'
|
||||
|
||||
# Function to check if a command exists
|
||||
command_exists() {
|
||||
command -v "$1" >/dev/null 2>&1
|
||||
}
|
||||
@@ -11,7 +12,7 @@ set -o nounset
|
||||
ARG_GEMINI_CONFIG=$(echo -n "$ARG_GEMINI_CONFIG" | base64 -d)
|
||||
BASE_EXTENSIONS=$(echo -n "$BASE_EXTENSIONS" | base64 -d)
|
||||
ADDITIONAL_EXTENSIONS=$(echo -n "$ADDITIONAL_EXTENSIONS" | base64 -d)
|
||||
GEMINI_SYSTEM_PROMPT=$(echo -n "$GEMINI_SYSTEM_PROMPT" | base64 -d)
|
||||
GEMINI_INSTRUCTION_PROMPT=$(echo -n "$GEMINI_INSTRUCTION_PROMPT" | base64 -d)
|
||||
|
||||
echo "--------------------------------"
|
||||
printf "gemini_config: %s\n" "$ARG_GEMINI_CONFIG"
|
||||
@@ -22,6 +23,7 @@ echo "--------------------------------"
|
||||
set +o nounset
|
||||
|
||||
function install_node() {
|
||||
# borrowed from claude-code module
|
||||
if ! command_exists npm; then
|
||||
printf "npm not found, checking for Node.js installation...\n"
|
||||
if ! command_exists node; then
|
||||
@@ -50,15 +52,24 @@ function install_node() {
|
||||
|
||||
function install_gemini() {
|
||||
if [ "${ARG_INSTALL}" = "true" ]; then
|
||||
# we need node to install and run gemini-cli
|
||||
install_node
|
||||
|
||||
# If nvm does not exist, we will create a global npm directory (this os to prevent the possibility of EACCESS issues on npm -g)
|
||||
if ! command_exists nvm; then
|
||||
printf "which node: %s\n" "$(which node)"
|
||||
printf "which npm: %s\n" "$(which npm)"
|
||||
|
||||
# Create a directory for global packages
|
||||
mkdir -p "$HOME"/.npm-global
|
||||
|
||||
# Configure npm to use it
|
||||
npm config set prefix "$HOME/.npm-global"
|
||||
|
||||
# Add to PATH for current session
|
||||
export PATH="$HOME/.npm-global/bin:$PATH"
|
||||
|
||||
# Add to shell profile for future sessions
|
||||
if ! grep -q "export PATH=$HOME/.npm-global/bin:\$PATH" ~/.bashrc; then
|
||||
echo "export PATH=$HOME/.npm-global/bin:\$PATH" >> ~/.bashrc
|
||||
fi
|
||||
@@ -97,6 +108,7 @@ function append_extensions_to_settings_json() {
|
||||
fi
|
||||
if [ ! -f "$SETTINGS_PATH" ]; then
|
||||
printf "%s does not exist. Creating with merged mcpServers structure.\n" "$SETTINGS_PATH"
|
||||
# If ADDITIONAL_EXTENSIONS is not set or empty, use '{}'
|
||||
ADD_EXT_JSON='{}'
|
||||
if [ -n "${ADDITIONAL_EXTENSIONS:-}" ]; then
|
||||
ADD_EXT_JSON="$ADDITIONAL_EXTENSIONS"
|
||||
@@ -104,7 +116,10 @@ function append_extensions_to_settings_json() {
|
||||
printf '{"mcpServers":%s}\n' "$(jq -s 'add' <(echo "$BASE_EXTENSIONS") <(echo "$ADD_EXT_JSON"))" > "$SETTINGS_PATH"
|
||||
fi
|
||||
|
||||
# Prepare temp files
|
||||
TMP_SETTINGS=$(mktemp)
|
||||
|
||||
# If ADDITIONAL_EXTENSIONS is not set or empty, use '{}'
|
||||
ADD_EXT_JSON='{}'
|
||||
if [ -n "${ADDITIONAL_EXTENSIONS:-}" ]; then
|
||||
printf "[append_extensions_to_settings_json] ADDITIONAL_EXTENSIONS is set.\n"
|
||||
@@ -118,13 +133,14 @@ function append_extensions_to_settings_json() {
|
||||
'.mcpServers = (.mcpServers // {} + $base + $add)' \
|
||||
"$SETTINGS_PATH" > "$TMP_SETTINGS" && mv "$TMP_SETTINGS" "$SETTINGS_PATH"
|
||||
|
||||
# Add theme and selectedAuthType fields
|
||||
jq '.theme = "Default" | .selectedAuthType = "gemini-api-key"' "$SETTINGS_PATH" > "$TMP_SETTINGS" && mv "$TMP_SETTINGS" "$SETTINGS_PATH"
|
||||
|
||||
printf "[append_extensions_to_settings_json] Merge complete.\n"
|
||||
}
|
||||
|
||||
function add_system_prompt_if_exists() {
|
||||
if [ -n "${GEMINI_SYSTEM_PROMPT:-}" ]; then
|
||||
function add_instruction_prompt_if_exists() {
|
||||
if [ -n "${GEMINI_INSTRUCTION_PROMPT:-}" ]; then
|
||||
if [ -d "${GEMINI_START_DIRECTORY}" ]; then
|
||||
printf "Directory '%s' exists. Changing to it.\\n" "${GEMINI_START_DIRECTORY}"
|
||||
cd "${GEMINI_START_DIRECTORY}" || {
|
||||
@@ -144,21 +160,16 @@ function add_system_prompt_if_exists() {
|
||||
fi
|
||||
touch GEMINI.md
|
||||
printf "Setting GEMINI.md\n"
|
||||
echo "${GEMINI_SYSTEM_PROMPT}" > GEMINI.md
|
||||
echo "${GEMINI_INSTRUCTION_PROMPT}" > GEMINI.md
|
||||
else
|
||||
printf "GEMINI.md is not set.\n"
|
||||
fi
|
||||
}
|
||||
|
||||
function configure_mcp() {
|
||||
export CODER_MCP_APP_STATUS_SLUG="gemini"
|
||||
export CODER_MCP_AI_AGENTAPI_URL="http://localhost:3284"
|
||||
coder exp mcp configure gemini "${GEMINI_START_DIRECTORY}"
|
||||
}
|
||||
|
||||
# Install Gemini
|
||||
install_gemini
|
||||
gemini --version
|
||||
populate_settings_json
|
||||
add_system_prompt_if_exists
|
||||
configure_mcp
|
||||
add_instruction_prompt_if_exists
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
#!/bin/bash
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
|
||||
# Load shell environment
|
||||
source "$HOME"/.bashrc
|
||||
|
||||
command_exists() {
|
||||
@@ -16,8 +15,7 @@ fi
|
||||
|
||||
printf "Version: %s\n" "$(gemini --version)"
|
||||
|
||||
MODULE_DIR="$HOME/.gemini-module"
|
||||
mkdir -p "$MODULE_DIR"
|
||||
GEMINI_TASK_PROMPT=$(echo -n "$GEMINI_TASK_PROMPT" | base64 -d)
|
||||
|
||||
if command_exists gemini; then
|
||||
printf "Gemini is installed\n"
|
||||
@@ -45,30 +43,20 @@ else
|
||||
fi
|
||||
|
||||
if [ -n "$GEMINI_TASK_PROMPT" ]; then
|
||||
printf "Running automated task: %s\n" "$GEMINI_TASK_PROMPT"
|
||||
printf "Running the task prompt %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"
|
||||
printf "No task prompt given.\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)
|
||||
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 "$GEMINI_API_KEY" ]; then
|
||||
printf "gemini_api_key provided !\n"
|
||||
else
|
||||
printf "No API key provided (neither GEMINI_API_KEY nor GOOGLE_API_KEY)\n"
|
||||
printf "gemini_api_key not provided\n"
|
||||
fi
|
||||
|
||||
agentapi server --term-width 67 --term-height 1190 -- \
|
||||
bash -c "$(printf '%q ' gemini "${GEMINI_ARGS[@]}")"
|
||||
# use low width to fit in the tasks UI sidebar. height is adjusted so that width x height ~= 80x1000 characters
|
||||
# are visible in the terminal screen by default.
|
||||
agentapi server --term-width 67 --term-height 1190 -- gemini "${GEMINI_ARGS[@]}"
|
||||
+2
-2
@@ -2,13 +2,13 @@
|
||||
|
||||
if [[ "$1" == "--version" ]]; then
|
||||
echo "HELLO: $(bash -c env)"
|
||||
echo "cursor-agent version v2.5.0"
|
||||
echo "gemini version v2.5.0"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
set -e
|
||||
|
||||
while true; do
|
||||
echo "$(date) - cursor-agent-mock"
|
||||
echo "$(date) - gemini-mock"
|
||||
sleep 15
|
||||
done
|
||||
@@ -1,90 +0,0 @@
|
||||
---
|
||||
display_name: Sourcegraph AMP
|
||||
icon: ../../../../.icons/sourcegraph-amp.svg
|
||||
description: Run Sourcegraph AMP CLI in your workspace with AgentAPI integration
|
||||
verified: false
|
||||
tags: [agent, sourcegraph, amp, ai, tasks]
|
||||
---
|
||||
|
||||
# Sourcegraph AMP CLI
|
||||
|
||||
Run [Sourcegraph AMP CLI](https://sourcegraph.com/amp) in your workspace to access Sourcegraph's AI-powered code search and analysis tools, with AgentAPI integration for seamless Coder Tasks support.
|
||||
|
||||
```tf
|
||||
module "sourcegraph_amp" {
|
||||
source = "registry.coder.com/coder-labs/sourcegraph_amp/coder"
|
||||
version = "1.0.0"
|
||||
agent_id = coder_agent.example.id
|
||||
sourcegraph_amp_api_key = var.sourcegraph_amp_api_key
|
||||
install_sourcegraph_amp = true
|
||||
agentapi_version = "latest"
|
||||
}
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Include the [Coder Login](https://registry.coder.com/modules/coder-login/coder) module in your template
|
||||
- Node.js and npm are automatically installed (via NVM) if not already available
|
||||
|
||||
## Usage Example
|
||||
|
||||
```tf
|
||||
data "coder_parameter" "ai_prompt" {
|
||||
name = "AI Prompt"
|
||||
description = "Write an initial prompt for AMP to work on."
|
||||
type = "string"
|
||||
default = ""
|
||||
mutable = true
|
||||
|
||||
}
|
||||
|
||||
# Set system prompt for Sourcegraph Amp via environment variables
|
||||
resource "coder_agent" "main" {
|
||||
# ...
|
||||
env = {
|
||||
SOURCEGRAPH_AMP_SYSTEM_PROMPT = <<-EOT
|
||||
You are an AMP assistant that helps developers debug and write code efficiently.
|
||||
|
||||
Always log task status to Coder.
|
||||
EOT
|
||||
SOURCEGRAPH_AMP_TASK_PROMPT = data.coder_parameter.ai_prompt.value
|
||||
}
|
||||
}
|
||||
|
||||
variable "sourcegraph_amp_api_key" {
|
||||
type = string
|
||||
description = "Sourcegraph AMP API key"
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
module "sourcegraph_amp" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder-labs/sourcegraph_amp/coder"
|
||||
version = "1.0.0"
|
||||
agent_id = coder_agent.example.id
|
||||
sourcegraph_amp_api_key = var.sourcegraph_amp_api_key # recommended for authenticated usage
|
||||
install_sourcegraph_amp = true
|
||||
}
|
||||
```
|
||||
|
||||
## How it Works
|
||||
|
||||
- **Install**: Installs Sourcegraph AMP CLI using npm (installs Node.js via NVM if required)
|
||||
- **Start**: Launches AMP CLI in the specified directory, wrapped with AgentAPI to enable tasks and AI interactions
|
||||
- **Environment Variables**: Sets `SOURCEGRAPH_AMP_API_KEY` and `SOURCEGRAPH_AMP_START_DIRECTORY` for the CLI execution
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- If `amp` is not found, ensure `install_sourcegraph_amp = true` and your API key is valid
|
||||
- Logs are written under `/home/coder/.sourcegraph-amp-module/` (`install.log`, `agentapi-start.log`) for debugging
|
||||
- If AgentAPI fails to start, verify that your container has network access and executable permissions for the scripts
|
||||
|
||||
> [!IMPORTANT]
|
||||
> For using **Coder Tasks** with Sourcegraph AMP, make sure to pass the `AI Prompt` parameter and set `sourcegraph_amp_api_key`.
|
||||
> This ensures task reporting and status updates work seamlessly.
|
||||
|
||||
## References
|
||||
|
||||
- [Sourcegraph AMP Documentation](https://ampcode.com/manual)
|
||||
- [AgentAPI Documentation](https://github.com/coder/agentapi)
|
||||
- [Coder AI Agents Guide](https://coder.com/docs/tutorials/ai-agents)
|
||||
@@ -1,157 +0,0 @@
|
||||
import {
|
||||
test,
|
||||
afterEach,
|
||||
describe,
|
||||
setDefaultTimeout,
|
||||
beforeAll,
|
||||
expect,
|
||||
} from "bun:test";
|
||||
import { execContainer, readFileContainer, runTerraformInit } from "~test";
|
||||
import {
|
||||
loadTestFile,
|
||||
writeExecutable,
|
||||
setup as setupUtil,
|
||||
execModuleScript,
|
||||
expectAgentAPIStarted,
|
||||
} from "../../../coder/modules/agentapi/test-util";
|
||||
|
||||
let cleanupFunctions: (() => Promise<void>)[] = [];
|
||||
const registerCleanup = (cleanup: () => Promise<void>) => {
|
||||
cleanupFunctions.push(cleanup);
|
||||
};
|
||||
afterEach(async () => {
|
||||
const cleanupFnsCopy = cleanupFunctions.slice().reverse();
|
||||
cleanupFunctions = [];
|
||||
for (const cleanup of cleanupFnsCopy) {
|
||||
try {
|
||||
await cleanup();
|
||||
} catch (error) {
|
||||
console.error("Error during cleanup:", error);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
interface SetupProps {
|
||||
skipAgentAPIMock?: boolean;
|
||||
skipAmpMock?: boolean;
|
||||
moduleVariables?: Record<string, string>;
|
||||
agentapiMockScript?: string;
|
||||
}
|
||||
|
||||
const setup = async (props?: SetupProps): Promise<{ id: string }> => {
|
||||
const projectDir = "/home/coder/project";
|
||||
const { id } = await setupUtil({
|
||||
moduleDir: import.meta.dir,
|
||||
moduleVariables: {
|
||||
install_sourcegraph_amp: props?.skipAmpMock ? "true" : "false",
|
||||
install_agentapi: props?.skipAgentAPIMock ? "true" : "false",
|
||||
sourcegraph_amp_model: "test-model",
|
||||
...props?.moduleVariables,
|
||||
},
|
||||
registerCleanup,
|
||||
projectDir,
|
||||
skipAgentAPIMock: props?.skipAgentAPIMock,
|
||||
agentapiMockScript: props?.agentapiMockScript,
|
||||
});
|
||||
|
||||
// Place the AMP mock CLI binary inside the container
|
||||
if (!props?.skipAmpMock) {
|
||||
await writeExecutable({
|
||||
containerId: id,
|
||||
filePath: "/usr/bin/amp",
|
||||
content: await loadTestFile(`${import.meta.dir}`, "amp-mock.sh"),
|
||||
});
|
||||
}
|
||||
|
||||
return { id };
|
||||
};
|
||||
|
||||
setDefaultTimeout(60 * 1000);
|
||||
|
||||
describe("sourcegraph-amp", async () => {
|
||||
beforeAll(async () => {
|
||||
await runTerraformInit(import.meta.dir);
|
||||
});
|
||||
|
||||
test("happy-path", async () => {
|
||||
const { id } = await setup();
|
||||
await execModuleScript(id);
|
||||
await expectAgentAPIStarted(id);
|
||||
});
|
||||
|
||||
test("api-key", async () => {
|
||||
const apiKey = "test-api-key-123";
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
sourcegraph_amp_api_key: apiKey,
|
||||
},
|
||||
});
|
||||
await execModuleScript(id);
|
||||
const resp = await readFileContainer(
|
||||
id,
|
||||
"/home/coder/.sourcegraph-amp-module/agentapi-start.log",
|
||||
);
|
||||
expect(resp).toContain("sourcegraph_amp_api_key provided !");
|
||||
});
|
||||
|
||||
test("custom-folder", async () => {
|
||||
const folder = "/tmp/sourcegraph-amp-test";
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
folder,
|
||||
},
|
||||
});
|
||||
await execModuleScript(id);
|
||||
const resp = await readFileContainer(
|
||||
id,
|
||||
"/home/coder/.sourcegraph-amp-module/install.log",
|
||||
);
|
||||
expect(resp).toContain(folder);
|
||||
});
|
||||
|
||||
test("pre-post-install-scripts", async () => {
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
pre_install_script: "#!/bin/bash\necho 'pre-install-script'",
|
||||
post_install_script: "#!/bin/bash\necho 'post-install-script'",
|
||||
},
|
||||
});
|
||||
await execModuleScript(id);
|
||||
const preLog = await readFileContainer(
|
||||
id,
|
||||
"/home/coder/.sourcegraph-amp-module/pre_install.log",
|
||||
);
|
||||
expect(preLog).toContain("pre-install-script");
|
||||
const postLog = await readFileContainer(
|
||||
id,
|
||||
"/home/coder/.sourcegraph-amp-module/post_install.log",
|
||||
);
|
||||
expect(postLog).toContain("post-install-script");
|
||||
});
|
||||
|
||||
test("system-prompt", async () => {
|
||||
const prompt = "this is a system prompt for AMP";
|
||||
const { id } = await setup();
|
||||
await execModuleScript(id, {
|
||||
SOURCEGRAPH_AMP_SYSTEM_PROMPT: prompt,
|
||||
});
|
||||
const resp = await readFileContainer(
|
||||
id,
|
||||
"/home/coder/.sourcegraph-amp-module/SYSTEM_PROMPT.md",
|
||||
);
|
||||
expect(resp).toContain(prompt);
|
||||
});
|
||||
|
||||
test("task-prompt", async () => {
|
||||
const prompt = "this is a task prompt for AMP";
|
||||
const { id } = await setup();
|
||||
await execModuleScript(id, {
|
||||
SOURCEGRAPH_AMP_TASK_PROMPT: prompt,
|
||||
});
|
||||
const resp = await readFileContainer(
|
||||
id,
|
||||
"/home/coder/.sourcegraph-amp-module/agentapi-start.log",
|
||||
);
|
||||
expect(resp).toContain(`sourcegraph amp task prompt provided : ${prompt}`);
|
||||
});
|
||||
});
|
||||
@@ -1,195 +0,0 @@
|
||||
terraform {
|
||||
required_version = ">= 1.0"
|
||||
|
||||
required_providers {
|
||||
coder = {
|
||||
source = "coder/coder"
|
||||
version = ">= 2.7"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
variable "agent_id" {
|
||||
type = string
|
||||
description = "The ID of a Coder agent."
|
||||
}
|
||||
|
||||
data "coder_workspace" "me" {}
|
||||
|
||||
data "coder_workspace_owner" "me" {}
|
||||
|
||||
variable "order" {
|
||||
type = number
|
||||
description = "The order determines the position of app in the UI presentation. The lowest order is shown first and apps with equal order are sorted by name (ascending order)."
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "group" {
|
||||
type = string
|
||||
description = "The name of a group that this app belongs to."
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "icon" {
|
||||
type = string
|
||||
description = "The icon to use for the app."
|
||||
default = "/icon/sourcegraph-amp.svg"
|
||||
}
|
||||
|
||||
variable "folder" {
|
||||
type = string
|
||||
description = "The folder to run sourcegraph_amp in."
|
||||
default = "/home/coder"
|
||||
}
|
||||
|
||||
variable "install_sourcegraph_amp" {
|
||||
type = bool
|
||||
description = "Whether to install sourcegraph-amp."
|
||||
default = true
|
||||
}
|
||||
|
||||
variable "sourcegraph_amp_api_key" {
|
||||
type = string
|
||||
description = "sourcegraph-amp API Key"
|
||||
default = ""
|
||||
}
|
||||
|
||||
resource "coder_env" "sourcegraph_amp_api_key" {
|
||||
agent_id = var.agent_id
|
||||
name = "SOURCEGRAPH_AMP_API_KEY"
|
||||
value = var.sourcegraph_amp_api_key
|
||||
}
|
||||
|
||||
variable "install_agentapi" {
|
||||
type = bool
|
||||
description = "Whether to install AgentAPI."
|
||||
default = true
|
||||
}
|
||||
|
||||
variable "agentapi_version" {
|
||||
type = string
|
||||
description = "The version of AgentAPI to install."
|
||||
default = "v0.3.0"
|
||||
}
|
||||
|
||||
variable "pre_install_script" {
|
||||
type = string
|
||||
description = "Custom script to run before installing sourcegraph_amp"
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "post_install_script" {
|
||||
type = string
|
||||
description = "Custom script to run after installing sourcegraph_amp."
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "base_amp_config" {
|
||||
type = string
|
||||
description = <<-EOT
|
||||
Base AMP configuration in JSON format. Can be overridden to customize AMP settings.
|
||||
|
||||
If empty, defaults enable thinking and todos for autonomous operation. Additional options include:
|
||||
- "amp.permissions": [] (tool permissions)
|
||||
- "amp.tools.stopTimeout": 600 (extend timeout for long operations)
|
||||
- "amp.terminal.commands.nodeSpawn.loadProfile": "daily" (environment loading)
|
||||
- "amp.tools.disable": ["builtin:open"] (disable tools for containers)
|
||||
- "amp.git.commit.ampThread.enabled": true (link commits to threads)
|
||||
- "amp.git.commit.coauthor.enabled": true (add Amp as co-author)
|
||||
|
||||
Reference: https://ampcode.com/manual
|
||||
EOT
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "additional_mcp_servers" {
|
||||
type = string
|
||||
description = "Additional MCP servers configuration in JSON format to append to amp.mcpServers."
|
||||
default = null
|
||||
}
|
||||
|
||||
locals {
|
||||
app_slug = "amp"
|
||||
|
||||
default_base_config = {
|
||||
"amp.anthropic.thinking.enabled" = true
|
||||
"amp.todos.enabled" = true
|
||||
}
|
||||
|
||||
# Use provided config or default, then extract base settings (excluding mcpServers)
|
||||
user_config = var.base_amp_config != "" ? jsondecode(var.base_amp_config) : local.default_base_config
|
||||
base_amp_settings = { for k, v in local.user_config : k => v if k != "amp.mcpServers" }
|
||||
|
||||
coder_mcp = {
|
||||
"coder" = {
|
||||
"command" = "coder"
|
||||
"args" = ["exp", "mcp", "server"]
|
||||
"env" = {
|
||||
"CODER_MCP_APP_STATUS_SLUG" = local.app_slug
|
||||
"CODER_MCP_AI_AGENTAPI_URL" = "http://localhost:3284"
|
||||
}
|
||||
"type" = "stdio"
|
||||
}
|
||||
}
|
||||
|
||||
additional_mcp = var.additional_mcp_servers != null ? jsondecode(var.additional_mcp_servers) : {}
|
||||
|
||||
merged_mcp_servers = merge(
|
||||
lookup(local.user_config, "amp.mcpServers", {}),
|
||||
local.coder_mcp,
|
||||
local.additional_mcp
|
||||
)
|
||||
|
||||
final_config = merge(local.base_amp_settings, {
|
||||
"amp.mcpServers" = local.merged_mcp_servers
|
||||
})
|
||||
|
||||
install_script = file("${path.module}/scripts/install.sh")
|
||||
start_script = file("${path.module}/scripts/start.sh")
|
||||
module_dir_name = ".sourcegraph-amp-module"
|
||||
}
|
||||
|
||||
module "agentapi" {
|
||||
source = "registry.coder.com/coder/agentapi/coder"
|
||||
version = "1.0.1"
|
||||
|
||||
agent_id = var.agent_id
|
||||
web_app_slug = local.app_slug
|
||||
web_app_order = var.order
|
||||
web_app_group = var.group
|
||||
web_app_icon = var.icon
|
||||
web_app_display_name = "Sourcegraph Amp"
|
||||
cli_app_slug = "${local.app_slug}-cli"
|
||||
cli_app_display_name = "Sourcegraph Amp CLI"
|
||||
module_dir_name = local.module_dir_name
|
||||
install_agentapi = var.install_agentapi
|
||||
agentapi_version = var.agentapi_version
|
||||
pre_install_script = var.pre_install_script
|
||||
post_install_script = var.post_install_script
|
||||
start_script = <<-EOT
|
||||
#!/bin/bash
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
|
||||
echo -n '${base64encode(local.start_script)}' | base64 -d > /tmp/start.sh
|
||||
chmod +x /tmp/start.sh
|
||||
SOURCEGRAPH_AMP_API_KEY='${var.sourcegraph_amp_api_key}' \
|
||||
SOURCEGRAPH_AMP_START_DIRECTORY='${var.folder}' \
|
||||
/tmp/start.sh
|
||||
EOT
|
||||
|
||||
install_script = <<-EOT
|
||||
#!/bin/bash
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
|
||||
echo -n '${base64encode(local.install_script)}' | base64 -d > /tmp/install.sh
|
||||
chmod +x /tmp/install.sh
|
||||
ARG_INSTALL_SOURCEGRAPH_AMP='${var.install_sourcegraph_amp}' \
|
||||
SOURCEGRAPH_AMP_START_DIRECTORY='${var.folder}' \
|
||||
ARG_AMP_CONFIG="$(echo -n '${base64encode(jsonencode(local.final_config))}' | base64 -d)" \
|
||||
/tmp/install.sh
|
||||
EOT
|
||||
}
|
||||
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
# ANSI colors
|
||||
BOLD='\033[1m'
|
||||
|
||||
echo "--------------------------------"
|
||||
echo "Install flag: $ARG_INSTALL_SOURCEGRAPH_AMP"
|
||||
echo "Workspace: $SOURCEGRAPH_AMP_START_DIRECTORY"
|
||||
echo "--------------------------------"
|
||||
|
||||
# Helper function to check if a command exists
|
||||
command_exists() {
|
||||
command -v "$1" > /dev/null 2>&1
|
||||
}
|
||||
|
||||
function install_node() {
|
||||
if ! command_exists npm; then
|
||||
printf "npm not found, checking for Node.js installation...\n"
|
||||
if ! command_exists node; then
|
||||
printf "Node.js not found, installing Node.js via NVM...\n"
|
||||
export NVM_DIR="$HOME/.nvm"
|
||||
if [ ! -d "$NVM_DIR" ]; then
|
||||
mkdir -p "$NVM_DIR"
|
||||
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
|
||||
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
|
||||
else
|
||||
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
|
||||
fi
|
||||
|
||||
# Temporarily disable nounset (-u) for nvm to avoid PROVIDED_VERSION error
|
||||
set +u
|
||||
nvm install --lts
|
||||
nvm use --lts
|
||||
nvm alias default node
|
||||
set -u
|
||||
|
||||
printf "Node.js installed: %s\n" "$(node --version)"
|
||||
printf "npm installed: %s\n" "$(npm --version)"
|
||||
else
|
||||
printf "Node.js is installed but npm is not available. Please install npm manually.\n"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
function install_sourcegraph_amp() {
|
||||
if [ "${ARG_INSTALL_SOURCEGRAPH_AMP}" = "true" ]; then
|
||||
install_node
|
||||
|
||||
# If nvm is not used, set up user npm global directory
|
||||
if ! command_exists nvm; then
|
||||
mkdir -p "$HOME/.npm-global"
|
||||
npm config set prefix "$HOME/.npm-global"
|
||||
export PATH="$HOME/.npm-global/bin:$PATH"
|
||||
if ! grep -q "export PATH=$HOME/.npm-global/bin:\$PATH" ~/.bashrc; then
|
||||
echo "export PATH=$HOME/.npm-global/bin:\$PATH" >> ~/.bashrc
|
||||
fi
|
||||
fi
|
||||
|
||||
printf "%s Installing Sourcegraph AMP CLI...\n" "${BOLD}"
|
||||
npm install -g @sourcegraph/amp@0.0.1754179307-gba1f97
|
||||
printf "%s Successfully installed Sourcegraph AMP CLI. Version: %s\n" "${BOLD}" "$(amp --version)"
|
||||
fi
|
||||
}
|
||||
|
||||
function setup_system_prompt() {
|
||||
if [ -n "${SOURCEGRAPH_AMP_SYSTEM_PROMPT:-}" ]; then
|
||||
echo "Setting Sourcegraph AMP system prompt..."
|
||||
mkdir -p "$HOME/.sourcegraph-amp-module"
|
||||
echo "$SOURCEGRAPH_AMP_SYSTEM_PROMPT" > "$HOME/.sourcegraph-amp-module/SYSTEM_PROMPT.md"
|
||||
echo "System prompt saved to $HOME/.sourcegraph-amp-module/SYSTEM_PROMPT.md"
|
||||
else
|
||||
echo "No system prompt provided for Sourcegraph AMP."
|
||||
fi
|
||||
}
|
||||
|
||||
function configure_amp_settings() {
|
||||
echo "Configuring AMP settings..."
|
||||
SETTINGS_PATH="$HOME/.config/amp/settings.json"
|
||||
mkdir -p "$(dirname "$SETTINGS_PATH")"
|
||||
|
||||
if [ -z "${ARG_AMP_CONFIG:-}" ]; then
|
||||
echo "No AMP config provided, skipping configuration"
|
||||
return
|
||||
fi
|
||||
|
||||
echo "Writing AMP configuration to $SETTINGS_PATH"
|
||||
printf '%s\n' "$ARG_AMP_CONFIG" > "$SETTINGS_PATH"
|
||||
|
||||
echo "AMP configuration complete"
|
||||
}
|
||||
|
||||
install_sourcegraph_amp
|
||||
setup_system_prompt
|
||||
configure_amp_settings
|
||||
@@ -1,49 +0,0 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
# Load user environment
|
||||
# shellcheck source=/dev/null
|
||||
source "$HOME/.bashrc"
|
||||
# shellcheck source=/dev/null
|
||||
if [ -f "$HOME/.nvm/nvm.sh" ]; then
|
||||
source "$HOME"/.nvm/nvm.sh
|
||||
else
|
||||
export PATH="$HOME/.npm-global/bin:$PATH"
|
||||
fi
|
||||
|
||||
function ensure_command() {
|
||||
command -v "$1" &> /dev/null || {
|
||||
echo "Error: '$1' not found." >&2
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
ensure_command amp
|
||||
echo "AMP version: $(amp --version)"
|
||||
|
||||
dir="$SOURCEGRAPH_AMP_START_DIRECTORY"
|
||||
if [[ -d "$dir" ]]; then
|
||||
echo "Using existing directory: $dir"
|
||||
else
|
||||
echo "Creating directory: $dir"
|
||||
mkdir -p "$dir"
|
||||
fi
|
||||
cd "$dir"
|
||||
|
||||
if [ -n "$SOURCEGRAPH_AMP_API_KEY" ]; then
|
||||
printf "sourcegraph_amp_api_key provided !\n"
|
||||
export AMP_API_KEY=$SOURCEGRAPH_AMP_API_KEY
|
||||
else
|
||||
printf "sourcegraph_amp_api_key not provided\n"
|
||||
fi
|
||||
|
||||
if [ -n "${SOURCEGRAPH_AMP_TASK_PROMPT:-}" ]; then
|
||||
printf "sourcegraph amp task prompt provided : $SOURCEGRAPH_AMP_TASK_PROMPT"
|
||||
PROMPT="Every step of the way, report tasks to Coder with proper descriptions and statuses. Your task at hand: $SOURCEGRAPH_AMP_TASK_PROMPT"
|
||||
|
||||
# Pipe the prompt into amp, which will be run inside agentapi
|
||||
agentapi server --term-width=67 --term-height=1190 -- bash -c "echo \"$PROMPT\" | amp"
|
||||
else
|
||||
printf "No task prompt given.\n"
|
||||
agentapi server --term-width=67 --term-height=1190 -- amp
|
||||
fi
|
||||
@@ -1,14 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Mock behavior of the AMP CLI
|
||||
if [[ "$1" == "--version" ]]; then
|
||||
echo "AMP CLI mock version v1.0.0"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Simulate AMP running in a loop for AgentAPI to connect
|
||||
set -e
|
||||
while true; do
|
||||
echo "$(date) - AMP mock is running..."
|
||||
sleep 15
|
||||
done
|
||||
@@ -8,10 +8,6 @@ tags: [docker, container, dockerfile]
|
||||
|
||||
# Remote Development on Docker Containers (Build from Dockerfile)
|
||||
|
||||
> [!NOTE]
|
||||
> This template is designed to be a starting point for testing purposes.
|
||||
> In a production environment, you would want to move away from storing the Dockerfile in-template and move towards using a centralized image registry.
|
||||
|
||||
Build and provision Docker containers from a Dockerfile as [Coder workspaces](https://coder.com/docs/workspaces) with this example template.
|
||||
|
||||
This template builds a custom Docker image from the included Dockerfile, allowing you to customize the development environment by modifying the Dockerfile rather than using a pre-built image.
|
||||
@@ -22,22 +18,7 @@ This template builds a custom Docker image from the included Dockerfile, allowin
|
||||
|
||||
### Infrastructure
|
||||
|
||||
#### Running Coder inside Docker
|
||||
|
||||
If you installed Coder as a container within Docker, you will have to do the following things:
|
||||
|
||||
- Make the the Docker socket available to the container
|
||||
- **(recommended) Mount `/var/run/docker.sock` via `--mount`/`volume`**
|
||||
- _(advanced) Restrict the Docker socket via https://github.com/Tecnativa/docker-socket-proxy_
|
||||
- Set `--group-add`/`group_add` to the GID of the Docker group on the **host** machine
|
||||
- You can get the GID by running `getent group docker` on the **host** machine
|
||||
|
||||
If you are using `docker-compose`, here is an example on how to do those things (don't forget to edit `group_add`!):
|
||||
https://github.com/coder/coder/blob/0bfe0d63aec83ae438bdcb77e306effd100dba3d/docker-compose.yaml#L16-L23
|
||||
|
||||
#### Running Coder outside of Docker
|
||||
|
||||
If you installed Coder as a system package, the VM you run Coder on must have a running Docker socket and the `coder` user must be added to the Docker group:
|
||||
The VM you run Coder on must have a running Docker socket and the `coder` user must be added to the Docker group:
|
||||
|
||||
```sh
|
||||
# Add coder user to Docker group
|
||||
|
||||
@@ -63,7 +63,7 @@ Visit this URL for your Coder deployment:
|
||||
https://coder.example.com/templates/new?exampleId=scratch
|
||||
```
|
||||
|
||||
After creating the template, paste the contents from [main.tf](https://github.com/coder/registry/blob/main/registry/coder-labs/templates/tasks-docker/main.tf) into the template editor and save.
|
||||
After creating the template, paste the contents from [main.tf](./main.tf) into the template editor and save.
|
||||
|
||||
Alternatively, you can use the Coder CLI to [push the template](https://coder.com/docs/reference/cli/templates_push)
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ The AgentAPI module is a building block for modules that need to run an AgentAPI
|
||||
```tf
|
||||
module "agentapi" {
|
||||
source = "registry.coder.com/coder/agentapi/coder"
|
||||
version = "1.1.1"
|
||||
version = "1.1.0"
|
||||
|
||||
agent_id = var.agent_id
|
||||
web_app_slug = local.app_slug
|
||||
|
||||
@@ -236,17 +236,4 @@ describe("agentapi", async () => {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("agentapi-allowed-hosts", async () => {
|
||||
// verify that the agentapi binary has access to the AGENTAPI_ALLOWED_HOSTS environment variable
|
||||
// set in main.sh
|
||||
const { id } = await setup();
|
||||
await execModuleScript(id);
|
||||
await expectAgentAPIStarted(id);
|
||||
const agentApiStartLog = await readFileContainer(
|
||||
id,
|
||||
"/home/coder/agentapi-mock.log",
|
||||
);
|
||||
expect(agentApiStartLog).toContain("AGENTAPI_ALLOWED_HOSTS: *");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -95,7 +95,5 @@ export LC_ALL=en_US.UTF-8
|
||||
cd "${WORKDIR}"
|
||||
|
||||
export AGENTAPI_CHAT_BASE_PATH="${AGENTAPI_CHAT_BASE_PATH:-}"
|
||||
# Disable host header check since AgentAPI is proxied by Coder (which does its own validation)
|
||||
export AGENTAPI_ALLOWED_HOSTS="*"
|
||||
nohup "$module_path/scripts/agentapi-start.sh" true "${AGENTAPI_PORT}" &>"$module_path/agentapi-start.log" &
|
||||
"$module_path/scripts/agentapi-wait-for-start.sh" "${AGENTAPI_PORT}"
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const http = require("http");
|
||||
const fs = require("fs");
|
||||
const args = process.argv.slice(2);
|
||||
const portIdx = args.findIndex((arg) => arg === "--port") + 1;
|
||||
const port = portIdx ? args[portIdx] : 3284;
|
||||
|
||||
console.log(`starting server on port ${port}`);
|
||||
fs.writeFileSync("/home/coder/agentapi-mock.log", `AGENTAPI_ALLOWED_HOSTS: ${process.env.AGENTAPI_ALLOWED_HOSTS}`);
|
||||
|
||||
http
|
||||
.createServer(function (_request, response) {
|
||||
|
||||
@@ -13,7 +13,7 @@ Run the [Claude Code](https://docs.anthropic.com/en/docs/agents-and-tools/claude
|
||||
```tf
|
||||
module "claude-code" {
|
||||
source = "registry.coder.com/coder/claude-code/coder"
|
||||
version = "2.1.0"
|
||||
version = "2.0.6"
|
||||
agent_id = coder_agent.example.id
|
||||
folder = "/home/coder"
|
||||
install_claude_code = true
|
||||
@@ -28,6 +28,7 @@ module "claude-code" {
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js and npm must be installed in your workspace to install Claude Code
|
||||
- You must add the [Coder Login](https://registry.coder.com/modules/coder-login) module to your template
|
||||
|
||||
The `codercom/oss-dogfood:latest` container image can be used for testing on container-based workspaces.
|
||||
@@ -83,7 +84,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.0.6"
|
||||
agent_id = coder_agent.example.id
|
||||
folder = "/home/coder"
|
||||
install_claude_code = true
|
||||
@@ -101,7 +102,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.0.6"
|
||||
agent_id = coder_agent.example.id
|
||||
folder = "/home/coder"
|
||||
install_claude_code = true
|
||||
|
||||
@@ -10,7 +10,6 @@ import path from "path";
|
||||
import {
|
||||
execContainer,
|
||||
findResourceInstance,
|
||||
readFileContainer,
|
||||
removeContainer,
|
||||
runContainer,
|
||||
runTerraformApply,
|
||||
@@ -320,21 +319,4 @@ describe("claude-code", async () => {
|
||||
agentApiUrl: "http://localhost:3284",
|
||||
});
|
||||
});
|
||||
|
||||
// verify that the agentapi binary has access to the AGENTAPI_ALLOWED_HOSTS environment variable
|
||||
// set in main.tf
|
||||
test("agentapi-allowed-hosts", async () => {
|
||||
const { id } = await setup();
|
||||
|
||||
const respModuleScript = await execModuleScript(id);
|
||||
expect(respModuleScript.exitCode).toBe(0);
|
||||
|
||||
await expectAgentAPIStarted(id);
|
||||
|
||||
const agentApiStartLog = await readFileContainer(
|
||||
id,
|
||||
"/home/coder/agentapi-mock.log",
|
||||
);
|
||||
expect(agentApiStartLog).toContain("AGENTAPI_ALLOWED_HOSTS: *");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -111,7 +111,7 @@ locals {
|
||||
encoded_post_install_script = var.experiment_post_install_script != null ? base64encode(var.experiment_post_install_script) : ""
|
||||
agentapi_start_script_b64 = base64encode(file("${path.module}/scripts/agentapi-start.sh"))
|
||||
agentapi_wait_for_start_script_b64 = base64encode(file("${path.module}/scripts/agentapi-wait-for-start.sh"))
|
||||
remove_last_session_id_script_b64 = base64encode(file("${path.module}/scripts/remove-last-session-id.sh"))
|
||||
remove_last_session_id_script_b64 = base64encode(file("${path.module}/scripts/remove-last-session-id.js"))
|
||||
claude_code_app_slug = "ccw"
|
||||
}
|
||||
|
||||
@@ -129,21 +129,6 @@ resource "coder_script" "claude_code" {
|
||||
command -v "$1" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
function install_claude_code_cli() {
|
||||
echo "Installing Claude Code via official installer"
|
||||
set +e
|
||||
curl -fsSL claude.ai/install.sh | bash -s -- "${var.claude_code_version}" 2>&1
|
||||
CURL_EXIT=$${PIPESTATUS[0]}
|
||||
set -e
|
||||
if [ $CURL_EXIT -ne 0 ]; then
|
||||
echo "Claude Code installer failed with exit code $$CURL_EXIT"
|
||||
fi
|
||||
|
||||
# Ensure binaries are discoverable.
|
||||
export PATH="~/.local/bin:$PATH"
|
||||
echo "Installed Claude Code successfully. Version: $(claude --version || echo 'unknown')"
|
||||
}
|
||||
|
||||
if [ ! -d "${local.workdir}" ]; then
|
||||
echo "Warning: The specified folder '${local.workdir}' does not exist."
|
||||
echo "Creating the folder..."
|
||||
@@ -158,7 +143,37 @@ resource "coder_script" "claude_code" {
|
||||
fi
|
||||
|
||||
if [ "${var.install_claude_code}" = "true" ]; then
|
||||
install_claude_code_cli
|
||||
if ! command_exists npm; then
|
||||
echo "npm not found, checking for Node.js installation..."
|
||||
if ! command_exists node; then
|
||||
echo "Node.js not found, installing Node.js via NVM..."
|
||||
export NVM_DIR="$HOME/.nvm"
|
||||
if [ ! -d "$NVM_DIR" ]; then
|
||||
mkdir -p "$NVM_DIR"
|
||||
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
|
||||
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
|
||||
else
|
||||
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
|
||||
fi
|
||||
|
||||
nvm install --lts
|
||||
nvm use --lts
|
||||
nvm alias default node
|
||||
|
||||
echo "Node.js installed: $(node --version)"
|
||||
echo "npm installed: $(npm --version)"
|
||||
else
|
||||
echo "Node.js is installed but npm is not available. Please install npm manually."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
echo "Installing Claude Code..."
|
||||
npm install -g @anthropic-ai/claude-code@${var.claude_code_version}
|
||||
fi
|
||||
|
||||
if ! command_exists node; then
|
||||
echo "Error: Node.js is not installed. Please install Node.js manually."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Install AgentAPI if enabled
|
||||
@@ -199,7 +214,7 @@ resource "coder_script" "claude_code" {
|
||||
|
||||
echo -n "${local.agentapi_start_script_b64}" | base64 -d > "$module_path/scripts/agentapi-start.sh"
|
||||
echo -n "${local.agentapi_wait_for_start_script_b64}" | base64 -d > "$module_path/scripts/agentapi-wait-for-start.sh"
|
||||
echo -n "${local.remove_last_session_id_script_b64}" | base64 -d > "$module_path/scripts/remove-last-session-id.sh"
|
||||
echo -n "${local.remove_last_session_id_script_b64}" | base64 -d > "$module_path/scripts/remove-last-session-id.js"
|
||||
chmod +x "$module_path/scripts/agentapi-start.sh"
|
||||
chmod +x "$module_path/scripts/agentapi-wait-for-start.sh"
|
||||
|
||||
@@ -226,10 +241,6 @@ resource "coder_script" "claude_code" {
|
||||
export LC_ALL=en_US.UTF-8
|
||||
|
||||
cd "${local.workdir}"
|
||||
|
||||
# Disable host header check since AgentAPI is proxied by Coder (which does its own validation)
|
||||
export AGENTAPI_ALLOWED_HOSTS="*"
|
||||
|
||||
nohup "$module_path/scripts/agentapi-start.sh" use_prompt &> "$module_path/agentapi-start.log" &
|
||||
"$module_path/scripts/agentapi-wait-for-start.sh"
|
||||
EOT
|
||||
@@ -277,4 +288,4 @@ resource "coder_ai_task" "claude_code" {
|
||||
sidebar_app {
|
||||
id = coder_app.claude_code_web.id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,10 +19,10 @@ if [ -f "$log_file_path" ]; then
|
||||
mv "$log_file_path" "$log_file_path"".$(date +%s)"
|
||||
fi
|
||||
|
||||
# see the remove-last-session-id.sh script for details
|
||||
# see the remove-last-session-id.js 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
|
||||
node "$scripts_dir/remove-last-session-id.js" "$(pwd)" || true
|
||||
|
||||
# we'll be manually handling errors from this point on
|
||||
set +o errexit
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
// If lastSessionId is present in .claude.json, claude --continue will start a
|
||||
// conversation starting from that session. The problem is that lastSessionId
|
||||
// doesn't always point to the last session. The field is updated by claude only
|
||||
// at the point of normal CLI exit. If Claude exits with an error, or if the user
|
||||
// restarts the Coder workspace, lastSessionId will be stale, and claude --continue
|
||||
// will start from an old session.
|
||||
//
|
||||
// If lastSessionId is missing, claude seems to accurately figure out where to
|
||||
// start using the conversation history - even if the CLI previously exited with
|
||||
// an error.
|
||||
//
|
||||
// This script removes the lastSessionId field from .claude.json.
|
||||
const path = require("path")
|
||||
const fs = require("fs")
|
||||
|
||||
const workingDirArg = process.argv[2]
|
||||
if (!workingDirArg) {
|
||||
console.log("No working directory provided - it must be the first argument")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const workingDir = path.resolve(workingDirArg)
|
||||
console.log("workingDir", workingDir)
|
||||
|
||||
|
||||
const claudeJsonPath = path.join(process.env.HOME, ".claude.json")
|
||||
console.log(".claude.json path", claudeJsonPath)
|
||||
if (!fs.existsSync(claudeJsonPath)) {
|
||||
console.log("No .claude.json file found")
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const claudeJson = JSON.parse(fs.readFileSync(claudeJsonPath, "utf8"))
|
||||
if ("projects" in claudeJson && workingDir in claudeJson.projects && "lastSessionId" in claudeJson.projects[workingDir]) {
|
||||
delete claudeJson.projects[workingDir].lastSessionId
|
||||
fs.writeFileSync(claudeJsonPath, JSON.stringify(claudeJson, null, 2))
|
||||
console.log("Removed lastSessionId from .claude.json")
|
||||
} else {
|
||||
console.log("No lastSessionId found in .claude.json - nothing to do")
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
# If lastSessionId is present in .claude.json, claude --continue will start a
|
||||
# conversation starting from that session. The problem is that lastSessionId
|
||||
# doesn't always point to the last session. The field is updated by claude only
|
||||
# at the point of normal CLI exit. If Claude exits with an error, or if the user
|
||||
# restarts the Coder workspace, lastSessionId will be stale, and claude --continue
|
||||
# will start from an old session.
|
||||
#
|
||||
# If lastSessionId is missing, claude seems to accurately figure out where to
|
||||
# start using the conversation history - even if the CLI previously exited with
|
||||
# an error.
|
||||
#
|
||||
# This script removes the lastSessionId field from .claude.json.
|
||||
if [ $# -eq 0 ]; then
|
||||
echo "No working directory provided - it must be the first argument"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Get absolute path of working directory
|
||||
working_dir=$(realpath "$1")
|
||||
echo "workingDir $working_dir"
|
||||
|
||||
# Path to .claude.json
|
||||
claude_json_path="$HOME/.claude.json"
|
||||
echo ".claude.json path $claude_json_path"
|
||||
|
||||
# Check if .claude.json exists
|
||||
if [ ! -f "$claude_json_path" ]; then
|
||||
echo "No .claude.json file found"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Use jq to check if lastSessionId exists for the working directory and remove it
|
||||
|
||||
if jq -e ".projects[\"$working_dir\"].lastSessionId" "$claude_json_path" > /dev/null 2>&1; then
|
||||
# Remove lastSessionId and update the file
|
||||
jq "del(.projects[\"$working_dir\"].lastSessionId)" "$claude_json_path" > "${claude_json_path}.tmp" && mv "${claude_json_path}.tmp" "$claude_json_path"
|
||||
echo "Removed lastSessionId from .claude.json"
|
||||
else
|
||||
echo "No lastSessionId found in .claude.json - nothing to do"
|
||||
fi
|
||||
@@ -20,8 +20,6 @@ if (
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
fs.writeFileSync("/home/coder/agentapi-mock.log", `AGENTAPI_ALLOWED_HOSTS: ${process.env.AGENTAPI_ALLOWED_HOSTS}`);
|
||||
|
||||
console.log(`starting server on port ${port}`);
|
||||
|
||||
http
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
run "required_vars" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "foo"
|
||||
}
|
||||
}
|
||||
|
||||
run "offline_and_use_cached_conflict" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "foo"
|
||||
use_cached = true
|
||||
offline = true
|
||||
}
|
||||
|
||||
expect_failures = [
|
||||
resource.coder_script.code-server
|
||||
]
|
||||
}
|
||||
|
||||
run "offline_disallows_extensions" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "foo"
|
||||
offline = true
|
||||
extensions = ["ms-python.python", "golang.go"]
|
||||
}
|
||||
|
||||
expect_failures = [
|
||||
resource.coder_script.code-server
|
||||
]
|
||||
}
|
||||
|
||||
run "url_with_folder_query" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "foo"
|
||||
folder = "/home/coder/project"
|
||||
port = 13337
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = resource.coder_app.code-server.url == "http://localhost:13337/?folder=%2Fhome%2Fcoder%2Fproject"
|
||||
error_message = "coder_app URL must include encoded folder query param"
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@ Uses the [Coder Remote VS Code Extension](https://github.com/coder/vscode-coder)
|
||||
module "cursor" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/cursor/coder"
|
||||
version = "1.3.1"
|
||||
version = "1.2.1"
|
||||
agent_id = coder_agent.example.id
|
||||
}
|
||||
```
|
||||
@@ -29,39 +29,8 @@ module "cursor" {
|
||||
module "cursor" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/cursor/coder"
|
||||
version = "1.3.1"
|
||||
version = "1.2.1"
|
||||
agent_id = coder_agent.example.id
|
||||
folder = "/home/coder/project"
|
||||
}
|
||||
```
|
||||
|
||||
### Configure MCP servers for Cursor
|
||||
|
||||
Provide a JSON-encoded string via the `mcp` input. When set, the module writes the value to `~/.cursor/mcp.json` using a `coder_script` on workspace start.
|
||||
|
||||
The following example configures Cursor to use the GitHub MCP server with authentication facilitated by the [`coder_external_auth`](https://coder.com/docs/admin/external-auth#configure-a-github-oauth-app) resource.
|
||||
|
||||
```tf
|
||||
module "cursor" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/cursor/coder"
|
||||
version = "1.3.1"
|
||||
agent_id = coder_agent.example.id
|
||||
folder = "/home/coder/project"
|
||||
mcp = jsonencode({
|
||||
mcpServers = {
|
||||
"github" : {
|
||||
"url" : "https://api.githubcopilot.com/mcp/",
|
||||
"headers" : {
|
||||
"Authorization" : "Bearer ${data.coder_external_auth.github.access_token}",
|
||||
},
|
||||
"type" : "http"
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
data "coder_external_auth" "github" {
|
||||
id = "github"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
import { describe, it, expect } from "bun:test";
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import {
|
||||
runTerraformApply,
|
||||
runTerraformInit,
|
||||
testRequiredVariables,
|
||||
runContainer,
|
||||
execContainer,
|
||||
removeContainer,
|
||||
findResourceInstance,
|
||||
readFileContainer,
|
||||
} from "~test";
|
||||
|
||||
describe("cursor", async () => {
|
||||
@@ -90,26 +85,4 @@ describe("cursor", async () => {
|
||||
expect(coder_app?.instances.length).toBe(1);
|
||||
expect(coder_app?.instances[0].attributes.order).toBe(22);
|
||||
});
|
||||
|
||||
it("writes ~/.cursor/mcp.json when mcp provided", async () => {
|
||||
const id = await runContainer("alpine");
|
||||
try {
|
||||
const mcp = JSON.stringify({ servers: { demo: { url: "http://localhost:1234" } } });
|
||||
const state = await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "foo",
|
||||
mcp,
|
||||
});
|
||||
const script = findResourceInstance(state, "coder_script", "cursor_mcp").script;
|
||||
const resp = await execContainer(id, ["sh", "-c", script]);
|
||||
if (resp.exitCode !== 0) {
|
||||
console.log(resp.stdout);
|
||||
console.log(resp.stderr);
|
||||
}
|
||||
expect(resp.exitCode).toBe(0);
|
||||
const content = await readFileContainer(id, "/root/.cursor/mcp.json");
|
||||
expect(content).toBe(mcp);
|
||||
} finally {
|
||||
await removeContainer(id);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -50,20 +50,9 @@ variable "display_name" {
|
||||
default = "Cursor Desktop"
|
||||
}
|
||||
|
||||
variable "mcp" {
|
||||
type = string
|
||||
description = "JSON-encoded string to configure MCP servers for Cursor. When set, writes ~/.cursor/mcp.json."
|
||||
default = ""
|
||||
}
|
||||
|
||||
data "coder_workspace" "me" {}
|
||||
|
||||
data "coder_workspace_owner" "me" {}
|
||||
|
||||
locals {
|
||||
mcp_b64 = var.mcp != "" ? base64encode(var.mcp) : ""
|
||||
}
|
||||
|
||||
resource "coder_app" "cursor" {
|
||||
agent_id = var.agent_id
|
||||
external = true
|
||||
@@ -86,21 +75,6 @@ resource "coder_app" "cursor" {
|
||||
])
|
||||
}
|
||||
|
||||
resource "coder_script" "cursor_mcp" {
|
||||
count = var.mcp != "" ? 1 : 0
|
||||
agent_id = var.agent_id
|
||||
display_name = "Cursor MCP"
|
||||
icon = "/icon/cursor.svg"
|
||||
run_on_start = true
|
||||
start_blocks_login = false
|
||||
script = <<-EOT
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
mkdir -p "$HOME/.cursor"
|
||||
echo -n "${local.mcp_b64}" | base64 -d > "$HOME/.cursor/mcp.json"
|
||||
EOT
|
||||
}
|
||||
|
||||
output "cursor_url" {
|
||||
value = coder_app.cursor.url
|
||||
description = "Cursor IDE Desktop URL."
|
||||
|
||||
@@ -13,7 +13,7 @@ Run the [Goose](https://block.github.io/goose/) agent in your workspace to gener
|
||||
```tf
|
||||
module "goose" {
|
||||
source = "registry.coder.com/coder/goose/coder"
|
||||
version = "2.1.1"
|
||||
version = "2.1.0"
|
||||
agent_id = coder_agent.example.id
|
||||
folder = "/home/coder"
|
||||
install_goose = true
|
||||
@@ -79,7 +79,7 @@ resource "coder_agent" "main" {
|
||||
module "goose" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/goose/coder"
|
||||
version = "2.1.1"
|
||||
version = "2.1.0"
|
||||
agent_id = coder_agent.example.id
|
||||
folder = "/home/coder"
|
||||
install_goose = true
|
||||
|
||||
@@ -139,7 +139,7 @@ EOT
|
||||
|
||||
module "agentapi" {
|
||||
source = "registry.coder.com/coder/agentapi/coder"
|
||||
version = "1.1.1"
|
||||
version = "1.1.0"
|
||||
|
||||
agent_id = var.agent_id
|
||||
web_app_slug = local.app_slug
|
||||
|
||||
@@ -14,7 +14,7 @@ This module adds JetBrains IDE buttons to launch IDEs directly from the dashboar
|
||||
module "jetbrains" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/jetbrains/coder"
|
||||
version = "1.0.3"
|
||||
version = "1.0.2"
|
||||
agent_id = coder_agent.example.id
|
||||
folder = "/home/coder/project"
|
||||
}
|
||||
@@ -39,7 +39,7 @@ When `default` contains IDE codes, those IDEs are created directly without user
|
||||
module "jetbrains" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/jetbrains/coder"
|
||||
version = "1.0.3"
|
||||
version = "1.0.2"
|
||||
agent_id = coder_agent.example.id
|
||||
folder = "/home/coder/project"
|
||||
default = ["PY", "IU"] # Pre-configure GoLand and IntelliJ IDEA
|
||||
@@ -52,7 +52,7 @@ module "jetbrains" {
|
||||
module "jetbrains" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/jetbrains/coder"
|
||||
version = "1.0.3"
|
||||
version = "1.0.2"
|
||||
agent_id = coder_agent.example.id
|
||||
folder = "/home/coder/project"
|
||||
# Show parameter with limited options
|
||||
@@ -66,7 +66,7 @@ module "jetbrains" {
|
||||
module "jetbrains" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/jetbrains/coder"
|
||||
version = "1.0.3"
|
||||
version = "1.0.2"
|
||||
agent_id = coder_agent.example.id
|
||||
folder = "/home/coder/project"
|
||||
default = ["IU", "PY"]
|
||||
@@ -81,7 +81,7 @@ module "jetbrains" {
|
||||
module "jetbrains" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/jetbrains/coder"
|
||||
version = "1.0.3"
|
||||
version = "1.0.2"
|
||||
agent_id = coder_agent.example.id
|
||||
folder = "/workspace/project"
|
||||
|
||||
@@ -107,7 +107,7 @@ module "jetbrains" {
|
||||
module "jetbrains_pycharm" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/jetbrains/coder"
|
||||
version = "1.0.3"
|
||||
version = "1.0.2"
|
||||
agent_id = coder_agent.example.id
|
||||
folder = "/workspace/project"
|
||||
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
run "requires_agent_and_folder" {
|
||||
command = plan
|
||||
|
||||
# Setting both required vars should plan
|
||||
variables {
|
||||
agent_id = "foo"
|
||||
folder = "/home/coder"
|
||||
}
|
||||
}
|
||||
|
||||
run "creates_parameter_when_default_empty_latest" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "foo"
|
||||
folder = "/home/coder"
|
||||
major_version = "latest"
|
||||
}
|
||||
|
||||
# When default is empty, a coder_parameter should be created
|
||||
assert {
|
||||
condition = can(data.coder_parameter.jetbrains_ides[0].type)
|
||||
error_message = "Expected data.coder_parameter.jetbrains_ides to exist when default is empty"
|
||||
}
|
||||
}
|
||||
|
||||
run "no_apps_when_default_empty" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "foo"
|
||||
folder = "/home/coder"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = length(resource.coder_app.jetbrains) == 0
|
||||
error_message = "Expected no coder_app resources when default is empty"
|
||||
}
|
||||
}
|
||||
|
||||
run "single_app_when_default_GO" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "foo"
|
||||
folder = "/home/coder"
|
||||
default = ["GO"]
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = length(resource.coder_app.jetbrains) == 1
|
||||
error_message = "Expected exactly one coder_app when default contains GO"
|
||||
}
|
||||
}
|
||||
|
||||
run "url_contains_required_params" {
|
||||
command = apply
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent-123"
|
||||
folder = "/custom/project/path"
|
||||
default = ["GO"]
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = anytrue([for app in values(resource.coder_app.jetbrains) : length(regexall("jetbrains://gateway/coder", app.url)) > 0])
|
||||
error_message = "URL must contain jetbrains scheme"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = anytrue([for app in values(resource.coder_app.jetbrains) : length(regexall("&folder=/custom/project/path", app.url)) > 0])
|
||||
error_message = "URL must include folder path"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = anytrue([for app in values(resource.coder_app.jetbrains) : length(regexall("ide_product_code=GO", app.url)) > 0])
|
||||
error_message = "URL must include product code"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = anytrue([for app in values(resource.coder_app.jetbrains) : length(regexall("ide_build_number=", app.url)) > 0])
|
||||
error_message = "URL must include build number"
|
||||
}
|
||||
}
|
||||
|
||||
run "includes_agent_name_when_set" {
|
||||
command = apply
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent-123"
|
||||
agent_name = "main-agent"
|
||||
folder = "/custom/project/path"
|
||||
default = ["GO"]
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = anytrue([for app in values(resource.coder_app.jetbrains) : length(regexall("&agent_name=main-agent", app.url)) > 0])
|
||||
error_message = "URL must include agent_name when provided"
|
||||
}
|
||||
}
|
||||
|
||||
run "parameter_order_when_default_empty" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "foo"
|
||||
folder = "/home/coder"
|
||||
coder_parameter_order = 5
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = data.coder_parameter.jetbrains_ides[0].order == 5
|
||||
error_message = "Expected coder_parameter order to be set to 5"
|
||||
}
|
||||
}
|
||||
|
||||
run "app_order_when_default_not_empty" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "foo"
|
||||
folder = "/home/coder"
|
||||
default = ["GO"]
|
||||
coder_app_order = 10
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = anytrue([for app in values(resource.coder_app.jetbrains) : app.order == 10])
|
||||
error_message = "Expected coder_app order to be set to 10"
|
||||
}
|
||||
}
|
||||
@@ -231,7 +231,6 @@ resource "coder_app" "jetbrains" {
|
||||
icon = local.options_metadata[each.key].icon
|
||||
external = true
|
||||
order = var.coder_app_order
|
||||
group = var.group
|
||||
url = join("", [
|
||||
"jetbrains://gateway/coder?&workspace=", # requires 2.6.3+ version of Toolbox
|
||||
data.coder_workspace.me.name,
|
||||
|
||||
@@ -14,7 +14,7 @@ Automatically install [KasmVNC](https://kasmweb.com/kasmvnc) in a workspace, and
|
||||
module "kasmvnc" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/kasmvnc/coder"
|
||||
version = "1.2.2"
|
||||
version = "1.2.1"
|
||||
agent_id = coder_agent.example.id
|
||||
desktop_environment = "xfce"
|
||||
subdomain = true
|
||||
|
||||
@@ -240,7 +240,7 @@ get_http_dir() {
|
||||
|
||||
# Check the home directory for overriding values
|
||||
if [[ -e "$HOME/.vnc/kasmvnc.yaml" ]]; then
|
||||
d=($(grep -E "^\s*httpd_directory:.*$" "$HOME/.vnc/kasmvnc.yaml"))
|
||||
d=($(grep -E "^\s*httpd_directory:.*$" /etc/kasmvnc/kasmvnc.yaml))
|
||||
if [[ $${#d[@]} -eq 2 && -d "$${d[1]}" ]]; then
|
||||
httpd_directory="$${d[1]}"
|
||||
fi
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
---
|
||||
display_name: VSCode Desktop Core
|
||||
description: Building block for modules that need to link to an external VSCode-based IDE
|
||||
icon: ../../../../.icons/coder.svg
|
||||
verified: true
|
||||
tags: [internal, library]
|
||||
---
|
||||
|
||||
# VS Code Desktop Core
|
||||
|
||||
> [!CAUTION]
|
||||
> We do not recommend using this module directly. Instead, please consider using one of our [Desktop IDE modules](https://registry.coder.com/modules?search=tag%3Aide).
|
||||
|
||||
The VSCode Desktop Core module is a building block for modules that need to expose access to VSCode-based IDEs. It is intended primarily to be used as a library to create modules for VSCode-based IDEs.
|
||||
|
||||
```tf
|
||||
module "vscode-desktop-core" {
|
||||
source = "registry.coder.com/coder/vscode-desktop-core/coder"
|
||||
version = "1.0.0"
|
||||
|
||||
agent_id = var.agent_id
|
||||
|
||||
coder_app_icon = "/icon/code.svg"
|
||||
coder_app_slug = "vscode"
|
||||
coder_app_display_name = "VS Code Desktop"
|
||||
coder_app_order = var.order
|
||||
coder_app_group = var.group
|
||||
|
||||
folder = var.folder
|
||||
open_recent = var.open_recent
|
||||
protocol = "vscode"
|
||||
}
|
||||
```
|
||||
@@ -1,100 +0,0 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import {
|
||||
runTerraformApply,
|
||||
runTerraformInit,
|
||||
testRequiredVariables,
|
||||
} from "~test";
|
||||
|
||||
// hardcoded coder_app name in main.tf
|
||||
const appName = "vscode-desktop";
|
||||
|
||||
const defaultVariables = {
|
||||
agent_id: "foo",
|
||||
coder_app_icon: "/icon/code.svg",
|
||||
coder_app_slug: "vscode",
|
||||
coder_app_display_name: "VS Code Desktop",
|
||||
protocol: "vscode",
|
||||
}
|
||||
|
||||
describe("vscode-desktop-core", async () => {
|
||||
await runTerraformInit(import.meta.dir);
|
||||
|
||||
testRequiredVariables(import.meta.dir, defaultVariables);
|
||||
|
||||
it("default output", async () => {
|
||||
const state = await runTerraformApply(import.meta.dir, defaultVariables);
|
||||
expect(state.outputs.ide_uri.value).toBe(
|
||||
`${defaultVariables.protocol}://coder.coder-remote/open?owner=default&workspace=default&url=https://mydeployment.coder.com&token=$SESSION_TOKEN`,
|
||||
);
|
||||
|
||||
const coder_app = state.resources.find(
|
||||
(res) => res.type === "coder_app" && res.name === appName,
|
||||
);
|
||||
|
||||
expect(coder_app).not.toBeNull();
|
||||
expect(coder_app?.instances.length).toBe(1);
|
||||
expect(coder_app?.instances[0].attributes.order).toBeNull();
|
||||
});
|
||||
|
||||
it("adds folder", async () => {
|
||||
const state = await runTerraformApply(import.meta.dir, {
|
||||
folder: "/foo/bar",
|
||||
|
||||
...defaultVariables
|
||||
});
|
||||
|
||||
expect(state.outputs.ide_uri.value).toBe(
|
||||
`${defaultVariables.protocol}://coder.coder-remote/open?owner=default&workspace=default&folder=/foo/bar&url=https://mydeployment.coder.com&token=$SESSION_TOKEN`,
|
||||
);
|
||||
});
|
||||
|
||||
it("adds folder and open_recent", async () => {
|
||||
const state = await runTerraformApply(import.meta.dir, {
|
||||
folder: "/foo/bar",
|
||||
open_recent: "true",
|
||||
|
||||
...defaultVariables,
|
||||
});
|
||||
expect(state.outputs.ide_uri.value).toBe(
|
||||
`${defaultVariables.protocol}://coder.coder-remote/open?owner=default&workspace=default&folder=/foo/bar&openRecent&url=https://mydeployment.coder.com&token=$SESSION_TOKEN`,
|
||||
);
|
||||
});
|
||||
|
||||
it("adds folder but not open_recent", async () => {
|
||||
const state = await runTerraformApply(import.meta.dir, {
|
||||
folder: "/foo/bar",
|
||||
openRecent: "false",
|
||||
|
||||
...defaultVariables,
|
||||
});
|
||||
expect(state.outputs.ide_uri.value).toBe(
|
||||
`${defaultVariables.protocol}://coder.coder-remote/open?owner=default&workspace=default&folder=/foo/bar&url=https://mydeployment.coder.com&token=$SESSION_TOKEN`,
|
||||
);
|
||||
});
|
||||
|
||||
it("adds open_recent", async () => {
|
||||
const state = await runTerraformApply(import.meta.dir, {
|
||||
open_recent: "true",
|
||||
|
||||
...defaultVariables,
|
||||
});
|
||||
expect(state.outputs.ide_uri.value).toBe(
|
||||
`${defaultVariables.protocol}://coder.coder-remote/open?owner=default&workspace=default&openRecent&url=https://mydeployment.coder.com&token=$SESSION_TOKEN`,
|
||||
);
|
||||
});
|
||||
|
||||
it("expect order to be set", async () => {
|
||||
const state = await runTerraformApply(import.meta.dir, {
|
||||
coder_app_order: "22",
|
||||
...defaultVariables
|
||||
});
|
||||
|
||||
const coder_app = state.resources.find(
|
||||
(res) => res.type === "coder_app" && res.name === appName,
|
||||
);
|
||||
|
||||
expect(coder_app).not.toBeNull();
|
||||
expect(coder_app?.instances.length).toBe(1);
|
||||
expect(coder_app?.instances[0].attributes.order).toBe(22);
|
||||
});
|
||||
});
|
||||
@@ -1,92 +0,0 @@
|
||||
terraform {
|
||||
required_version = ">= 1.0"
|
||||
|
||||
required_providers {
|
||||
coder = {
|
||||
source = "coder/coder"
|
||||
version = ">= 2.5"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
variable "agent_id" {
|
||||
type = string
|
||||
description = "The ID of a Coder agent."
|
||||
}
|
||||
|
||||
variable "folder" {
|
||||
type = string
|
||||
description = "The folder to open in the IDE."
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "open_recent" {
|
||||
type = bool
|
||||
description = "Open the most recent workspace or folder. Falls back to the folder if there is no recent workspace or folder to open."
|
||||
default = false
|
||||
}
|
||||
|
||||
variable "protocol" {
|
||||
type = string
|
||||
description = "The URI protocol for the IDE."
|
||||
}
|
||||
|
||||
variable "coder_app_icon" {
|
||||
type = string
|
||||
description = "The icon of the coder_app."
|
||||
}
|
||||
|
||||
variable "coder_app_slug" {
|
||||
type = string
|
||||
description = "The slug of the coder_app."
|
||||
}
|
||||
|
||||
variable "coder_app_display_name" {
|
||||
type = string
|
||||
description = "The display name of the coder_app."
|
||||
}
|
||||
|
||||
variable "coder_app_order" {
|
||||
type = number
|
||||
description = "The order of the coder_app."
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "coder_app_group" {
|
||||
type = string
|
||||
description = "The group of the coder_app."
|
||||
default = null
|
||||
}
|
||||
|
||||
data "coder_workspace" "me" {}
|
||||
data "coder_workspace_owner" "me" {}
|
||||
|
||||
resource "coder_app" "vscode-desktop" {
|
||||
agent_id = var.agent_id
|
||||
external = true
|
||||
|
||||
icon = var.coder_app_icon
|
||||
slug = var.coder_app_slug
|
||||
display_name = var.coder_app_display_name
|
||||
|
||||
order = var.coder_app_order
|
||||
group = var.coder_app_group
|
||||
|
||||
# While the call to "join" is not strictly necessary, it makes the URL more readable.
|
||||
url = join("", [
|
||||
"${var.protocol}://coder.coder-remote/open",
|
||||
"?owner=${data.coder_workspace_owner.me.name}",
|
||||
"&workspace=${data.coder_workspace.me.name}",
|
||||
var.folder != "" ? join("", ["&folder=", var.folder]) : "",
|
||||
var.open_recent ? "&openRecent" : "",
|
||||
"&url=${data.coder_workspace.me.access_url}",
|
||||
# NOTE: There is a protocol whitelist for the token replacement, so this will only work with the protocols hardcoded in the front-end.
|
||||
# (https://github.com/coder/coder/blob/6ba4b5bbc95e2e528d7f5b1e31fffa200ae1a6db/site/src/modules/apps/apps.ts#L18)
|
||||
"&token=$SESSION_TOKEN",
|
||||
])
|
||||
}
|
||||
|
||||
output "ide_uri" {
|
||||
value = coder_app.vscode-desktop.url
|
||||
description = "IDE URI."
|
||||
}
|
||||
@@ -19,7 +19,7 @@ Zed is a high-performance, multiplayer code editor from the creators of Atom and
|
||||
module "zed" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/zed/coder"
|
||||
version = "1.1.0"
|
||||
version = "1.0.1"
|
||||
agent_id = coder_agent.example.id
|
||||
}
|
||||
```
|
||||
@@ -32,7 +32,7 @@ module "zed" {
|
||||
module "zed" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/zed/coder"
|
||||
version = "1.1.0"
|
||||
version = "1.0.1"
|
||||
agent_id = coder_agent.example.id
|
||||
folder = "/home/coder/project"
|
||||
}
|
||||
@@ -44,7 +44,7 @@ module "zed" {
|
||||
module "zed" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/zed/coder"
|
||||
version = "1.1.0"
|
||||
version = "1.0.1"
|
||||
agent_id = coder_agent.example.id
|
||||
display_name = "Zed Editor"
|
||||
order = 1
|
||||
@@ -57,36 +57,8 @@ module "zed" {
|
||||
module "zed" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/zed/coder"
|
||||
version = "1.1.0"
|
||||
version = "1.0.1"
|
||||
agent_id = coder_agent.example.id
|
||||
agent_name = coder_agent.example.name
|
||||
}
|
||||
```
|
||||
|
||||
### Configure Zed settings including MCP servers
|
||||
|
||||
Zed stores settings at `~/.config/zed/settings.json` by default. If `XDG_CONFIG_HOME` is set on Linux, settings will be at `$XDG_CONFIG_HOME/zed/settings.json`.
|
||||
|
||||
You can declaratively set/merge settings with the `settings` input. Provide a JSON string (e.g., via `jsonencode(...)`). For example, to configure MCP servers:
|
||||
|
||||
```tf
|
||||
module "zed" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/zed/coder"
|
||||
version = "1.1.0"
|
||||
agent_id = coder_agent.example.id
|
||||
|
||||
settings = jsonencode({
|
||||
context_servers = {
|
||||
your-mcp-server = {
|
||||
source = "custom"
|
||||
command = "some-command"
|
||||
args = ["arg-1", "arg-2"]
|
||||
env = {}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
See Zed’s settings files documentation: https://zed.dev/docs/configuring-zed#settings-files
|
||||
|
||||
@@ -50,14 +50,7 @@ variable "display_name" {
|
||||
default = "Zed"
|
||||
}
|
||||
|
||||
variable "settings" {
|
||||
type = string
|
||||
description = "JSON encoded settings.json"
|
||||
default = ""
|
||||
}
|
||||
|
||||
data "coder_workspace" "me" {}
|
||||
|
||||
data "coder_workspace_owner" "me" {}
|
||||
|
||||
locals {
|
||||
@@ -67,30 +60,6 @@ locals {
|
||||
hostname = var.agent_name != "" ? "${local.agent_name}.${local.workspace_name}.${local.owner_name}.coder" : "${local.workspace_name}.coder"
|
||||
}
|
||||
|
||||
resource "coder_script" "zed_settings" {
|
||||
agent_id = var.agent_id
|
||||
display_name = "Configure Zed settings"
|
||||
icon = "/icon/zed.svg"
|
||||
run_on_start = true
|
||||
script = <<-EOT
|
||||
set -eu
|
||||
SETTINGS_JSON='${replace(var.settings, "\"", "\\\"")}'
|
||||
if [ -z "$${SETTINGS_JSON}" ] || [ "$${SETTINGS_JSON}" = "{}" ]; then
|
||||
exit 0
|
||||
fi
|
||||
CONFIG_HOME="$${XDG_CONFIG_HOME:-$HOME/.config}"
|
||||
ZED_DIR="$${CONFIG_HOME}/zed"
|
||||
mkdir -p "$${ZED_DIR}"
|
||||
SETTINGS_FILE="$${ZED_DIR}/settings.json"
|
||||
if command -v jq >/dev/null 2>&1 && [ -s "$${SETTINGS_FILE}" ]; then
|
||||
tmpfile="$(mktemp)"
|
||||
jq -s '.[0] * .[1]' "$${SETTINGS_FILE}" <(printf '%s\n' "$${SETTINGS_JSON}") > "$${tmpfile}" && mv "$${tmpfile}" "$${SETTINGS_FILE}"
|
||||
else
|
||||
printf '%s\n' "$${SETTINGS_JSON}" > "$${SETTINGS_FILE}"
|
||||
fi
|
||||
EOT
|
||||
}
|
||||
|
||||
resource "coder_app" "zed" {
|
||||
agent_id = var.agent_id
|
||||
display_name = var.display_name
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
run "default_output" {
|
||||
command = apply
|
||||
|
||||
variables {
|
||||
agent_id = "foo"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = output.zed_url == "zed://ssh/default.coder"
|
||||
error_message = "zed_url did not match expected default URL"
|
||||
}
|
||||
}
|
||||
|
||||
run "adds_folder" {
|
||||
command = apply
|
||||
|
||||
variables {
|
||||
agent_id = "foo"
|
||||
folder = "/foo/bar"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = output.zed_url == "zed://ssh/default.coder/foo/bar"
|
||||
error_message = "zed_url did not include provided folder path"
|
||||
}
|
||||
}
|
||||
|
||||
run "adds_agent_name" {
|
||||
command = apply
|
||||
|
||||
variables {
|
||||
agent_id = "foo"
|
||||
agent_name = "myagent"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = output.zed_url == "zed://ssh/myagent.default.default.coder"
|
||||
error_message = "zed_url did not include agent_name in hostname"
|
||||
}
|
||||
}
|
||||
@@ -14,26 +14,11 @@ Provision Devcontainers as [Coder workspaces](https://coder.com/docs/workspaces)
|
||||
|
||||
### Infrastructure
|
||||
|
||||
#### Running Coder inside Docker
|
||||
Coder must have access to a running Docker socket, and the `coder` user must be a member of the `docker` group:
|
||||
|
||||
If you installed Coder as a container within Docker, you will have to do the following things:
|
||||
|
||||
- Make the the Docker socket available to the container
|
||||
- **(recommended) Mount `/var/run/docker.sock` via `--mount`/`volume`**
|
||||
- _(advanced) Restrict the Docker socket via https://github.com/Tecnativa/docker-socket-proxy_
|
||||
- Set `--group-add`/`group_add` to the GID of the Docker group on the **host** machine
|
||||
- You can get the GID by running `getent group docker` on the **host** machine
|
||||
|
||||
If you are using `docker-compose`, here is an example on how to do those things (don't forget to edit `group_add`!):
|
||||
https://github.com/coder/coder/blob/0bfe0d63aec83ae438bdcb77e306effd100dba3d/docker-compose.yaml#L16-L23
|
||||
|
||||
#### Running Coder outside of Docker
|
||||
|
||||
If you installed Coder as a system package, the VM you run Coder on must have a running Docker socket and the `coder` user must be added to the Docker group:
|
||||
|
||||
```sh
|
||||
```shell
|
||||
# Add coder user to Docker group
|
||||
sudo adduser coder docker
|
||||
sudo usermod -aG docker coder
|
||||
|
||||
# Restart Coder server
|
||||
sudo systemctl restart coder
|
||||
|
||||
@@ -16,22 +16,7 @@ Provision Docker containers as [Coder workspaces](https://coder.com/docs/workspa
|
||||
|
||||
### Infrastructure
|
||||
|
||||
#### Running Coder inside Docker
|
||||
|
||||
If you installed Coder as a container within Docker, you will have to do the following things:
|
||||
|
||||
- Make the the Docker socket available to the container
|
||||
- **(recommended) Mount `/var/run/docker.sock` via `--mount`/`volume`**
|
||||
- _(advanced) Restrict the Docker socket via https://github.com/Tecnativa/docker-socket-proxy_
|
||||
- Set `--group-add`/`group_add` to the GID of the Docker group on the **host** machine
|
||||
- You can get the GID by running `getent group docker` on the **host** machine
|
||||
|
||||
If you are using `docker-compose`, here is an example on how to do those things (don't forget to edit `group_add`!):
|
||||
https://github.com/coder/coder/blob/0bfe0d63aec83ae438bdcb77e306effd100dba3d/docker-compose.yaml#L16-L23
|
||||
|
||||
#### Running Coder outside of Docker
|
||||
|
||||
If you installed Coder as a system package, the VM you run Coder on must have a running Docker socket and the `coder` user must be added to the Docker group:
|
||||
The VM you run Coder on must have a running Docker socket and the `coder` user must be added to the Docker group:
|
||||
|
||||
```sh
|
||||
# Add coder user to Docker group
|
||||
|
||||
@@ -8,8 +8,6 @@ tags: [vm, linux, gcp, devcontainer]
|
||||
|
||||
# Remote Development in a Devcontainer on Google Compute Engine
|
||||
|
||||
Provision a Devcontainer on Google Compute Engine instances as Coder workspaces
|
||||
|
||||

|
||||
|
||||
## Prerequisites
|
||||
|
||||
@@ -8,8 +8,6 @@ tags: [vm, linux, gcp]
|
||||
|
||||
# Remote Development on Google Compute Engine (Linux)
|
||||
|
||||
Provision Google Compute Engine instances as Coder workspaces
|
||||
|
||||
## Prerequisites
|
||||
|
||||
### Authentication
|
||||
|
||||
@@ -8,8 +8,6 @@ tags: [vm-container, linux, gcp]
|
||||
|
||||
# Remote Development on Google Compute Engine (VM Container)
|
||||
|
||||
Provision Google Compute Engine instances as Coder workspaces.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
### Authentication
|
||||
|
||||
@@ -8,8 +8,6 @@ tags: [vm, windows, gcp]
|
||||
|
||||
# Remote Development on Google Compute Engine (Windows)
|
||||
|
||||
Provision Google Compute Engine instances as Coder workspaces
|
||||
|
||||
## Prerequisites
|
||||
|
||||
### Authentication
|
||||
|
||||
@@ -8,8 +8,6 @@ tags: [kubernetes, containers, docker-in-docker]
|
||||
|
||||
# envbox
|
||||
|
||||
Provision envbox pods as Coder workspaces
|
||||
|
||||
## Introduction
|
||||
|
||||
`envbox` is an image that enables creating non-privileged containers capable of running system-level software (e.g. `dockerd`, `systemd`, etc) in Kubernetes.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
display_name: "Eric Paulsen"
|
||||
bio: "Field CTO, EMEA @ Coder"
|
||||
avatar: "./.images/avatar.png"
|
||||
avatar_url: "./.images/avatar.png"
|
||||
github: "ericpaulsen"
|
||||
linkedin: "https://www.linkedin.com/in/ericpaulsen17" # Optional
|
||||
website: "https://ericpaulsen.io" # Optional
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 28 KiB |
@@ -1,32 +0,0 @@
|
||||
---
|
||||
display_name: "Mark Milligan"
|
||||
bio: "VP of Revenue at https://nuon.co. Former VP of Sales at Coder. Love building startup revenue teams and tinkering with technology."
|
||||
avatar: "./.images/avatar.png"
|
||||
github: "sharkymark"
|
||||
linkedin: "https://www.linkedin.com/in/marktmilligan" # Optional
|
||||
website: "https://markmilligan.io" # Optional
|
||||
support_email: "mtm20176@gmail.com" # Optional
|
||||
status: "community"
|
||||
---
|
||||
|
||||
# Mark Milligan
|
||||
|
||||
Former VP of Sales at Coder for 4 years, and now VP of Revenue at Nuon. I love building startup revenue teams and tinkering with technology.
|
||||
|
||||
## About Me
|
||||
|
||||
Visit my [website](https://markmilligan.io) to learn more about my work and interests.
|
||||
|
||||
## Links
|
||||
|
||||
[My presentation about Great White Sharks](https://docs.google.com/presentation/d/13I3Af7l-ZSVCh-ovEvOKIM30ABIvNKhkRC3CnYZN450/edit?slide=id.p#slide=id.p) - given twice in 2020 and 2021 to the Coder team.
|
||||
|
||||
[NOAA Radar](https://radar.weather.gov/)
|
||||
|
||||
[Flight Radar](https://www.flightradar24.com/airport/aus)
|
||||
|
||||
### Webcams
|
||||
|
||||
[Austin - facing south](https://cctv.austinmobility.io/image/51.jpg)
|
||||
|
||||
[Austin - facing north](https://cctv.austinmobility.io/image/52.jpg)
|
||||
@@ -1,36 +0,0 @@
|
||||
---
|
||||
display_name: "Claude Code AI Agent Template"
|
||||
description: An experimental AI agent integration with Claude CodeAI agent
|
||||
icon: "../../../../.icons/claude.svg"
|
||||
verified: false
|
||||
tags: ["ai", "docker", "container", "claude", "agent", "tasks"]
|
||||
---
|
||||
|
||||
# AI agent template for a workspace in a container on a Docker host
|
||||
|
||||
An experimental AI agent integration with Claude CodeAI agent
|
||||
|
||||
## Docker image
|
||||
|
||||
1. Based on Coder-managed image `codercom/example-universal:ubuntu`
|
||||
|
||||
[Image on DockerHub](https://hub.docker.com/r/codercom/example-universal)
|
||||
|
||||
## Apps included
|
||||
|
||||
1. A web-based terminal
|
||||
1. code-server Web IDE
|
||||
1. A [sample app](https://github.com/gothinkster/realworld) to test the environment
|
||||
1. [Claude Code AI agent](https://www.anthropic.com/claude-code) to assist with development tasks
|
||||
|
||||
## Resources
|
||||
|
||||
[Coder docs on AI agents and tasks](https://coder.com/docs/ai-coder/tasks)
|
||||
|
||||
[main.tf for Coder example](https://github.com/coder/registry/blob/main/registry/coder-labs/templates/tasks-docker/main.tf)
|
||||
|
||||
[Claude Code Coder Terraform module](https://registry.coder.com/modules/coder/claude-code)
|
||||
|
||||
[Docker Terraform provider](https://registry.terraform.io/providers/kreuzwerker/docker/latest/docs)
|
||||
|
||||
[Coder Terraform provider](https://registry.terraform.io/providers/coder/coder/latest/docs)
|
||||
@@ -1,363 +0,0 @@
|
||||
terraform {
|
||||
required_providers {
|
||||
coder = {
|
||||
source = "coder/coder"
|
||||
}
|
||||
docker = {
|
||||
source = "kreuzwerker/docker"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
provider "docker" {
|
||||
host = var.socket
|
||||
}
|
||||
|
||||
provider "coder" {
|
||||
}
|
||||
|
||||
data "coder_workspace" "me" {
|
||||
}
|
||||
|
||||
data "coder_workspace_owner" "me" {
|
||||
}
|
||||
|
||||
data "coder_provisioner" "me" {
|
||||
}
|
||||
|
||||
variable "socket" {
|
||||
type = string
|
||||
description = <<-EOF
|
||||
The Unix socket that the Docker daemon listens on and how containers
|
||||
communicate with the Docker daemon.
|
||||
|
||||
Either Unix or TCP
|
||||
e.g., unix:///var/run/docker.sock
|
||||
|
||||
EOF
|
||||
default = "unix:///var/run/docker.sock"
|
||||
}
|
||||
|
||||
variable "anthropic_api_key" {
|
||||
type = string
|
||||
description = "Generate one at: https://console.anthropic.com/settings/keys"
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
resource "coder_env" "anthropic_api_key" {
|
||||
agent_id = coder_agent.dev.id
|
||||
name = "CODER_MCP_CLAUDE_API_KEY"
|
||||
value = var.anthropic_api_key
|
||||
}
|
||||
|
||||
# The Claude Code module does the automatic task reporting
|
||||
# Other agent modules: https://registry.coder.com/modules?search=agent
|
||||
# Or use a custom agent:
|
||||
module "claude-code" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/claude-code/coder"
|
||||
version = "2.0.0"
|
||||
agent_id = coder_agent.dev.id
|
||||
folder = "/home/coder/projects"
|
||||
install_claude_code = true
|
||||
claude_code_version = "latest"
|
||||
order = 999
|
||||
|
||||
experiment_post_install_script = data.coder_parameter.setup_script.value
|
||||
|
||||
# This enables Coder Tasks
|
||||
experiment_report_tasks = true
|
||||
}
|
||||
|
||||
# We are using presets to set the prompts, image, and set up instructions
|
||||
# See https://coder.com/docs/admin/templates/extending-templates/parameters#workspace-presets
|
||||
data "coder_workspace_preset" "default" {
|
||||
name = "Real World App: Angular + Django"
|
||||
default = true
|
||||
parameters = {
|
||||
"system_prompt" = <<-EOT
|
||||
-- Framing --
|
||||
You are a helpful assistant that can help with code. You are running inside a Coder Workspace and provide status updates to the user via Coder MCP. Stay on track, feel free to debug, but when the original plan fails, do not choose a different route/architecture without checking the user first.
|
||||
|
||||
-- Tool Selection --
|
||||
- playwright: previewing your changes after you made them
|
||||
to confirm it worked as expected
|
||||
- desktop-commander - use only for commands that keep running
|
||||
(servers, dev watchers, GUI apps).
|
||||
- Built-in tools - use for everything else:
|
||||
(file operations, git commands, builds & installs, one-off shell commands)
|
||||
|
||||
Remember this decision rule:
|
||||
- Stays running? → desktop-commander
|
||||
- Finishes immediately? → built-in tools
|
||||
|
||||
-- Context --
|
||||
There is an existing app and tmux dev server running on port 8000. Be sure to read it's CLAUDE.md (./realworld-django-rest-framework-angular/CLAUDE.md) to learn more about it.
|
||||
|
||||
Since this app is for demo purposes and the user is previewing the homepage and subsequent pages, aim to make the first visual change/prototype very quickly so the user can preview it, then focus on backend or logic which can be a more involved, long-running architecture plan.
|
||||
|
||||
EOT
|
||||
|
||||
"setup_script" = <<-EOT
|
||||
# Set up projects dir
|
||||
mkdir -p /home/coder/projects
|
||||
cd $HOME/projects
|
||||
|
||||
# Packages: Install additional packages
|
||||
sudo apt-get update && sudo apt-get install -y tmux
|
||||
if ! command -v google-chrome >/dev/null 2>&1; then
|
||||
yes | npx playwright install chrome
|
||||
fi
|
||||
|
||||
# MCP: Install and configure MCP Servers
|
||||
npm install -g @wonderwhy-er/desktop-commander
|
||||
claude mcp add playwright npx -- @playwright/mcp@latest --headless --isolated --no-sandbox
|
||||
claude mcp add desktop-commander desktop-commander
|
||||
|
||||
# Repo: Clone and pull changes from the git repository
|
||||
if [ ! -d "realworld-django-rest-framework-angular" ]; then
|
||||
git clone https://github.com/coder-contrib/realworld-django-rest-framework-angular.git
|
||||
else
|
||||
cd realworld-django-rest-framework-angular
|
||||
git fetch
|
||||
# Check for uncommitted changes
|
||||
if git diff-index --quiet HEAD -- && \
|
||||
[ -z "$(git status --porcelain --untracked-files=no)" ] && \
|
||||
[ -z "$(git log --branches --not --remotes)" ]; then
|
||||
echo "Repo is clean. Pulling latest changes..."
|
||||
git pull
|
||||
else
|
||||
echo "Repo has uncommitted or unpushed changes. Skipping pull."
|
||||
fi
|
||||
|
||||
cd ..
|
||||
fi
|
||||
|
||||
# Initialize: Start the development server
|
||||
cd realworld-django-rest-framework-angular && ./start-dev.sh
|
||||
EOT
|
||||
"preview_port" = "4200"
|
||||
"container_image" = "codercom/example-universal:ubuntu"
|
||||
}
|
||||
}
|
||||
|
||||
# Advanced parameters (these are all set via preset)
|
||||
data "coder_parameter" "system_prompt" {
|
||||
name = "system_prompt"
|
||||
display_name = "System Prompt"
|
||||
type = "string"
|
||||
form_type = "textarea"
|
||||
description = "System prompt for the agent with generalized instructions"
|
||||
mutable = false
|
||||
}
|
||||
data "coder_parameter" "ai_prompt" {
|
||||
type = "string"
|
||||
name = "AI Prompt"
|
||||
default = ""
|
||||
description = "Write a prompt for Claude Code"
|
||||
mutable = true
|
||||
}
|
||||
data "coder_parameter" "setup_script" {
|
||||
name = "setup_script"
|
||||
display_name = "Setup Script"
|
||||
type = "string"
|
||||
form_type = "textarea"
|
||||
description = "Script to run before running the agent"
|
||||
mutable = false
|
||||
}
|
||||
data "coder_parameter" "container_image" {
|
||||
name = "container_image"
|
||||
display_name = "Container Image"
|
||||
type = "string"
|
||||
default = "codercom/example-universal:ubuntu"
|
||||
mutable = false
|
||||
}
|
||||
data "coder_parameter" "preview_port" {
|
||||
name = "preview_port"
|
||||
display_name = "Preview Port"
|
||||
description = "The port the web app is running to preview in Tasks"
|
||||
type = "number"
|
||||
default = "3000"
|
||||
mutable = false
|
||||
}
|
||||
|
||||
# Other variables for Claude Code
|
||||
resource "coder_env" "claude_task_prompt" {
|
||||
agent_id = coder_agent.dev.id
|
||||
name = "CODER_MCP_CLAUDE_TASK_PROMPT"
|
||||
value = data.coder_parameter.ai_prompt.value
|
||||
}
|
||||
resource "coder_env" "app_status_slug" {
|
||||
agent_id = coder_agent.dev.id
|
||||
name = "CODER_MCP_APP_STATUS_SLUG"
|
||||
value = "claude-code"
|
||||
}
|
||||
resource "coder_env" "claude_system_prompt" {
|
||||
agent_id = coder_agent.dev.id
|
||||
name = "CODER_MCP_CLAUDE_SYSTEM_PROMPT"
|
||||
value = data.coder_parameter.system_prompt.value
|
||||
}
|
||||
|
||||
module "coder-login" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/modules/coder-login/coder"
|
||||
agent_id = coder_agent.dev.id
|
||||
}
|
||||
|
||||
module "dotfiles" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/modules/dotfiles/coder"
|
||||
agent_id = coder_agent.dev.id
|
||||
}
|
||||
|
||||
module "code-server" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/modules/code-server/coder"
|
||||
agent_id = coder_agent.dev.id
|
||||
folder = "/home/coder/projects"
|
||||
}
|
||||
|
||||
module "git-config" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/modules/git-config/coder"
|
||||
agent_id = coder_agent.dev.id
|
||||
}
|
||||
|
||||
resource "coder_agent" "dev" {
|
||||
arch = data.coder_provisioner.me.arch
|
||||
os = "linux"
|
||||
|
||||
# The following metadata blocks are optional. They are used to display
|
||||
# information about your workspace in the dashboard. You can remove them
|
||||
# if you don't want to display any information.
|
||||
# For basic resources, you can use the `coder stat` command.
|
||||
# If you need more control, you can write your own script.
|
||||
|
||||
metadata {
|
||||
display_name = "CPU Usage"
|
||||
key = "0_cpu_usage"
|
||||
script = "coder stat cpu"
|
||||
interval = 10
|
||||
timeout = 1
|
||||
}
|
||||
|
||||
metadata {
|
||||
display_name = "RAM Usage"
|
||||
key = "1_ram_usage"
|
||||
script = "coder stat mem"
|
||||
interval = 10
|
||||
timeout = 1
|
||||
}
|
||||
|
||||
metadata {
|
||||
display_name = "Home Disk"
|
||||
key = "3_home_disk"
|
||||
script = "coder stat disk --path $${HOME}"
|
||||
interval = 60
|
||||
timeout = 1
|
||||
}
|
||||
|
||||
metadata {
|
||||
display_name = "CPU Usage (Host)"
|
||||
key = "4_cpu_usage_host"
|
||||
script = "coder stat cpu --host"
|
||||
interval = 10
|
||||
timeout = 1
|
||||
}
|
||||
|
||||
metadata {
|
||||
display_name = "Memory Usage (Host)"
|
||||
key = "5_mem_usage_host"
|
||||
script = "coder stat mem --host"
|
||||
interval = 10
|
||||
timeout = 1
|
||||
}
|
||||
|
||||
display_apps {
|
||||
vscode = true
|
||||
vscode_insiders = false
|
||||
ssh_helper = false
|
||||
port_forwarding_helper = true
|
||||
web_terminal = true
|
||||
}
|
||||
|
||||
startup_script_behavior = "non-blocking"
|
||||
connection_timeout = 300
|
||||
|
||||
env = {
|
||||
|
||||
GIT_AUTHOR_NAME = coalesce(data.coder_workspace_owner.me.full_name, data.coder_workspace_owner.me.name)
|
||||
GIT_AUTHOR_EMAIL = "${data.coder_workspace_owner.me.email}"
|
||||
GIT_COMMITTER_NAME = coalesce(data.coder_workspace_owner.me.full_name, data.coder_workspace_owner.me.name)
|
||||
GIT_COMMITTER_EMAIL = "${data.coder_workspace_owner.me.email}"
|
||||
}
|
||||
|
||||
startup_script = <<EOT
|
||||
#!/bin/sh
|
||||
|
||||
EOT
|
||||
|
||||
}
|
||||
|
||||
resource "coder_app" "preview" {
|
||||
agent_id = coder_agent.dev.id
|
||||
slug = "preview"
|
||||
display_name = "Preview your app"
|
||||
icon = "${data.coder_workspace.me.access_url}/emojis/1f50e.png"
|
||||
url = "http://localhost:${data.coder_parameter.preview_port.value}"
|
||||
share = "authenticated"
|
||||
subdomain = true
|
||||
open_in = "tab"
|
||||
order = 0
|
||||
healthcheck {
|
||||
url = "http://localhost:${data.coder_parameter.preview_port.value}/"
|
||||
interval = 5
|
||||
threshold = 15
|
||||
}
|
||||
}
|
||||
|
||||
resource "docker_container" "workspace" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
image = data.coder_parameter.container_image.value
|
||||
# Uses lower() to avoid Docker restriction on container names.
|
||||
name = "coder-${data.coder_workspace_owner.me.name}-${lower(data.coder_workspace.me.name)}"
|
||||
hostname = lower(data.coder_workspace.me.name)
|
||||
dns = ["1.1.1.1"]
|
||||
|
||||
# Use the docker gateway if the access URL is 127.0.0.1
|
||||
#entrypoint = ["sh", "-c", replace(coder_agent.dev.init_script, "127.0.0.1", "host.docker.internal")]
|
||||
|
||||
# Use the docker gateway if the access URL is 127.0.0.1
|
||||
command = [
|
||||
"sh", "-c",
|
||||
<<EOT
|
||||
trap '[ $? -ne 0 ] && echo === Agent script exited with non-zero code. Sleeping infinitely to preserve logs... && sleep infinity' EXIT
|
||||
${replace(coder_agent.dev.init_script, "/localhost|127\\.0\\.0\\.1/", "host.docker.internal")}
|
||||
EOT
|
||||
]
|
||||
|
||||
|
||||
env = ["CODER_AGENT_TOKEN=${coder_agent.dev.token}"]
|
||||
volumes {
|
||||
container_path = "/home/coder/"
|
||||
volume_name = docker_volume.coder_volume.name
|
||||
read_only = false
|
||||
}
|
||||
host {
|
||||
host = "host.docker.internal"
|
||||
ip = "host-gateway"
|
||||
}
|
||||
}
|
||||
|
||||
resource "docker_volume" "coder_volume" {
|
||||
name = "coder-${data.coder_workspace_owner.me.name}-${data.coder_workspace.me.name}"
|
||||
}
|
||||
|
||||
resource "coder_metadata" "workspace_info" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
resource_id = docker_container.workspace[0].id
|
||||
item {
|
||||
key = "image"
|
||||
value = data.coder_parameter.container_image.value
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 632 KiB |
@@ -1,13 +0,0 @@
|
||||
---
|
||||
display_name: "Muhammad Uamir Ali"
|
||||
bio: "Cloud Engineer | Infrastructure as code, Kubernetes | SRE"
|
||||
github: "m4rrypro"
|
||||
avatar: "./.images/avatar.jpeg"
|
||||
linkedin: "https://www.linkedin.com/in/m4rry"
|
||||
support_email: "m.umair.ali200@gmail.com"
|
||||
status: "community"
|
||||
---
|
||||
|
||||
# Muhammad Umair Ali
|
||||
|
||||
Cloud Engineer | Infrastructure as code, Kubernetes | SRE
|
||||
@@ -1,161 +0,0 @@
|
||||
---
|
||||
display_name: Proxmox VM
|
||||
description: Provision VMs on Proxmox VE as Coder workspaces
|
||||
icon: ../../../../.icons/proxmox.svg
|
||||
verified: false
|
||||
tags: [proxmox, vm, cloud-init, qemu]
|
||||
---
|
||||
|
||||
# Proxmox VM Template for Coder
|
||||
|
||||
Provision Linux VMs on Proxmox as [Coder workspaces](https://coder.com/docs/workspaces). The template clones a cloud‑init base image, injects user‑data via Snippets, and runs the Coder agent under the workspace owner's Linux user.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Proxmox VE 8/9
|
||||
- Proxmox API token with access to nodes and storages
|
||||
- SSH access from Coder provisioner to Proxmox VE
|
||||
- Storage with "Snippets" content enabled
|
||||
- Ubuntu cloud‑init image/template on Proxmox
|
||||
- Latest images: https://cloud-images.ubuntu.com/ ([source](https://cloud-images.ubuntu.com/))
|
||||
|
||||
## Prepare a Proxmox Cloud‑Init Template (once)
|
||||
|
||||
Run on the Proxmox node. This uses a RELEASE variable so you always pull a current image.
|
||||
|
||||
```bash
|
||||
# Choose a release (e.g., jammy or noble)
|
||||
RELEASE=jammy
|
||||
IMG_URL="https://cloud-images.ubuntu.com/${RELEASE}/current/${RELEASE}-server-cloudimg-amd64.img"
|
||||
IMG_PATH="/var/lib/vz/template/iso/${RELEASE}-server-cloudimg-amd64.img"
|
||||
|
||||
# Download cloud image
|
||||
wget "$IMG_URL" -O "$IMG_PATH"
|
||||
|
||||
# Create base VM (example ID 999), enable QGA, correct boot order
|
||||
NAME="ubuntu-${RELEASE}-cloudinit"
|
||||
qm create 999 --name "$NAME" --memory 4096 --cores 2 \
|
||||
--net0 virtio,bridge=vmbr0 --agent enabled=1
|
||||
qm set 999 --scsihw virtio-scsi-pci
|
||||
qm importdisk 999 "$IMG_PATH" local-lvm
|
||||
qm set 999 --scsi0 local-lvm:vm-999-disk-0
|
||||
qm set 999 --ide2 local-lvm:cloudinit
|
||||
qm set 999 --serial0 socket --vga serial0
|
||||
qm set 999 --boot 'order=scsi0;ide2;net0'
|
||||
|
||||
# Enable Snippets on storage 'local' (one‑time)
|
||||
pvesm set local --content snippets,vztmpl,backup,iso
|
||||
|
||||
# Convert to template
|
||||
qm template 999
|
||||
```
|
||||
|
||||
Verify:
|
||||
|
||||
```bash
|
||||
qm config 999 | grep -E 'template:|agent:|boot:|ide2:|scsi0:'
|
||||
```
|
||||
|
||||
### Enable Snippets via GUI
|
||||
|
||||
- Datacenter → Storage → select `local` → Edit → Content → check "Snippets" → OK
|
||||
- Ensure `/var/lib/vz/snippets/` exists on the node for snippet files
|
||||
- Template page → Cloud‑Init → Snippet Storage: `local` → File: your yml → Apply
|
||||
|
||||
## Configure this template
|
||||
|
||||
Edit `terraform.tfvars` with your environment:
|
||||
|
||||
```hcl
|
||||
# Proxmox API
|
||||
proxmox_api_url = "https://<PVE_HOST>:8006/api2/json"
|
||||
proxmox_api_token_id = "<USER@REALM>!<TOKEN>"
|
||||
proxmox_api_token_secret = "<SECRET>"
|
||||
|
||||
# SSH to the node (for snippet upload)
|
||||
proxmox_host = "<PVE_HOST>"
|
||||
proxmox_password = "<NODE_ROOT_OR_SUDO_PASSWORD>"
|
||||
proxmox_ssh_user = "root"
|
||||
|
||||
# Infra defaults
|
||||
proxmox_node = "pve"
|
||||
disk_storage = "local-lvm"
|
||||
snippet_storage = "local"
|
||||
bridge = "vmbr0"
|
||||
vlan = 0
|
||||
clone_template_vmid = 999
|
||||
```
|
||||
|
||||
### Variables (terraform.tfvars)
|
||||
|
||||
- These values are standard Terraform variables that the template reads at apply time.
|
||||
- Place secrets (e.g., `proxmox_api_token_secret`, `proxmox_password`) in `terraform.tfvars` or inject with environment variables using `TF_VAR_*` (e.g., `TF_VAR_proxmox_api_token_secret`).
|
||||
- You can also override with `-var`/`-var-file` if you run Terraform directly. With Coder, the repo's `terraform.tfvars` is bundled when pushing the template.
|
||||
|
||||
Variables expected:
|
||||
|
||||
- `proxmox_api_url`, `proxmox_api_token_id`, `proxmox_api_token_secret` (sensitive)
|
||||
- `proxmox_host`, `proxmox_password` (sensitive), `proxmox_ssh_user`
|
||||
- `proxmox_node`, `disk_storage`, `snippet_storage`, `bridge`, `vlan`, `clone_template_vmid`
|
||||
- Coder parameters: `cpu_cores`, `memory_mb`, `disk_size_gb`
|
||||
|
||||
## Proxmox API Token (GUI/CLI)
|
||||
|
||||
Docs: https://pve.proxmox.com/wiki/User_Management#pveum_tokens
|
||||
|
||||
GUI:
|
||||
|
||||
1. (Optional) Create automation user: Datacenter → Permissions → Users → Add (e.g., `terraform@pve`)
|
||||
2. Permissions: Datacenter → Permissions → Add → User Permission
|
||||
- Path: `/` (or narrower covering your nodes/storages)
|
||||
- Role: `PVEVMAdmin` + `PVEStorageAdmin` (or `PVEAdmin` for simplicity)
|
||||
3. Token: Datacenter → Permissions → API Tokens → Add → copy Token ID and Secret
|
||||
4. Test:
|
||||
|
||||
```bash
|
||||
curl -k -H "Authorization: PVEAPIToken=<USER@REALM>!<TOKEN>=<SECRET>" \
|
||||
https:// < PVE_HOST > :8006/api2/json/version
|
||||
```
|
||||
|
||||
CLI:
|
||||
|
||||
```bash
|
||||
pveum user add terraform@pve --comment 'Terraform automation user'
|
||||
pveum aclmod / -user terraform@pve -role PVEAdmin
|
||||
pveum user token add terraform@pve terraform --privsep 0
|
||||
```
|
||||
|
||||
## Use
|
||||
|
||||
```bash
|
||||
# From this directory
|
||||
coder templates push --yes proxmox-cloudinit --directory . | cat
|
||||
```
|
||||
|
||||
Create a workspace from the template in the Coder UI. First boot usually takes 60–120s while cloud‑init runs.
|
||||
|
||||
## How it works
|
||||
|
||||
- Uploads rendered cloud‑init user‑data to `<storage>:snippets/<vm>.yml` via the provider's `proxmox_virtual_environment_file`
|
||||
- VM config: `virtio-scsi-pci`, boot order `scsi0, ide2, net0`, QGA enabled
|
||||
- Linux user equals Coder workspace owner (sanitized). To avoid collisions, reserved names (`admin`, `root`, etc.) get a suffix (e.g., `admin1`). User is created with `primary_group: adm`, `groups: [sudo]`, `no_user_group: true`
|
||||
- systemd service runs as that user:
|
||||
- `coder-agent.service`
|
||||
|
||||
## Troubleshooting quick hits
|
||||
|
||||
- iPXE boot loop: ensure template has bootable root disk and boot order `scsi0,ide2,net0`
|
||||
- QGA not responding: install/enable QGA in template; allow 60–120s on first boot
|
||||
- Snippet upload errors: storage must include `Snippets`; token needs Datastore permissions; path format `<storage>:snippets/<file>` handled by provider
|
||||
- Permissions errors: ensure the token's role covers the target node(s) and storages
|
||||
- Verify snippet/QGA: `qm config <vmid> | egrep 'cicustom|ide2|ciuser'`
|
||||
|
||||
## References
|
||||
|
||||
- Ubuntu Cloud Images (latest): https://cloud-images.ubuntu.com/ ([source](https://cloud-images.ubuntu.com/))
|
||||
- Proxmox qm(1) manual: https://pve.proxmox.com/pve-docs/qm.1.html
|
||||
- Proxmox Cloud‑Init Support: https://pve.proxmox.com/wiki/Cloud-Init_Support
|
||||
- Terraform Proxmox provider (bpg): `bpg/proxmox` on the Terraform Registry
|
||||
- Coder – Best practices & templates:
|
||||
- https://coder.com/docs/tutorials/best-practices/speed-up-templates
|
||||
- https://coder.com/docs/tutorials/template-from-scratch
|
||||
@@ -1,53 +0,0 @@
|
||||
#cloud-config
|
||||
hostname: ${hostname}
|
||||
|
||||
users:
|
||||
- name: ${linux_user}
|
||||
groups: [sudo]
|
||||
shell: /bin/bash
|
||||
sudo: ["ALL=(ALL) NOPASSWD:ALL"]
|
||||
|
||||
package_update: false
|
||||
package_upgrade: false
|
||||
packages:
|
||||
- curl
|
||||
- ca-certificates
|
||||
- git
|
||||
- jq
|
||||
|
||||
write_files:
|
||||
- path: /opt/coder/init.sh
|
||||
permissions: "0755"
|
||||
owner: root:root
|
||||
encoding: b64
|
||||
content: |
|
||||
${coder_init_script_b64}
|
||||
|
||||
- path: /etc/systemd/system/coder-agent.service
|
||||
permissions: "0644"
|
||||
owner: root:root
|
||||
content: |
|
||||
[Unit]
|
||||
Description=Coder Agent
|
||||
Wants=network-online.target
|
||||
After=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=${linux_user}
|
||||
WorkingDirectory=/home/${linux_user}
|
||||
Environment=HOME=/home/${linux_user}
|
||||
Environment=CODER_AGENT_TOKEN=${coder_token}
|
||||
ExecStart=/opt/coder/init.sh
|
||||
OOMScoreAdjust=-1000
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
||||
runcmd:
|
||||
- systemctl daemon-reload
|
||||
- systemctl enable --now coder-agent.service
|
||||
|
||||
final_message: "Cloud-init complete on ${hostname}"
|
||||
@@ -1,283 +0,0 @@
|
||||
terraform {
|
||||
required_providers {
|
||||
coder = {
|
||||
source = "coder/coder"
|
||||
}
|
||||
proxmox = {
|
||||
source = "bpg/proxmox"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
provider "coder" {}
|
||||
|
||||
provider "proxmox" {
|
||||
endpoint = var.proxmox_api_url
|
||||
api_token = "${var.proxmox_api_token_id}=${var.proxmox_api_token_secret}"
|
||||
insecure = true
|
||||
|
||||
# SSH is needed for file uploads to Proxmox
|
||||
ssh {
|
||||
username = var.proxmox_ssh_user
|
||||
password = var.proxmox_password
|
||||
|
||||
node {
|
||||
name = var.proxmox_node
|
||||
address = var.proxmox_host
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
variable "proxmox_api_url" {
|
||||
type = string
|
||||
}
|
||||
|
||||
variable "proxmox_api_token_id" {
|
||||
type = string
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
variable "proxmox_api_token_secret" {
|
||||
type = string
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
|
||||
variable "proxmox_host" {
|
||||
description = "Proxmox node IP or DNS for SSH"
|
||||
type = string
|
||||
}
|
||||
|
||||
variable "proxmox_password" {
|
||||
description = "Proxmox password (used for SSH)"
|
||||
type = string
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
variable "proxmox_ssh_user" {
|
||||
description = "SSH username on Proxmox node"
|
||||
type = string
|
||||
default = "root"
|
||||
}
|
||||
|
||||
variable "proxmox_node" {
|
||||
description = "Target Proxmox node"
|
||||
type = string
|
||||
default = "pve"
|
||||
}
|
||||
variable "disk_storage" {
|
||||
description = "Disk storage (e.g., local-lvm)"
|
||||
type = string
|
||||
default = "local-lvm"
|
||||
}
|
||||
|
||||
variable "snippet_storage" {
|
||||
description = "Storage with Snippets content"
|
||||
type = string
|
||||
default = "local"
|
||||
}
|
||||
|
||||
variable "bridge" {
|
||||
description = "Bridge (e.g., vmbr0)"
|
||||
type = string
|
||||
default = "vmbr0"
|
||||
}
|
||||
|
||||
variable "vlan" {
|
||||
description = "VLAN tag (0 none)"
|
||||
type = number
|
||||
default = 0
|
||||
}
|
||||
|
||||
variable "clone_template_vmid" {
|
||||
description = "VMID of the cloud-init base template to clone"
|
||||
type = number
|
||||
}
|
||||
|
||||
data "coder_workspace" "me" {}
|
||||
data "coder_workspace_owner" "me" {}
|
||||
|
||||
data "coder_parameter" "cpu_cores" {
|
||||
name = "cpu_cores"
|
||||
display_name = "CPU Cores"
|
||||
type = "number"
|
||||
default = 2
|
||||
mutable = true
|
||||
}
|
||||
|
||||
data "coder_parameter" "memory_mb" {
|
||||
name = "memory_mb"
|
||||
display_name = "Memory (MB)"
|
||||
type = "number"
|
||||
default = 4096
|
||||
mutable = true
|
||||
}
|
||||
|
||||
data "coder_parameter" "disk_size_gb" {
|
||||
name = "disk_size_gb"
|
||||
display_name = "Disk Size (GB)"
|
||||
type = "number"
|
||||
default = 20
|
||||
mutable = true
|
||||
validation {
|
||||
min = 10
|
||||
max = 100
|
||||
monotonic = "increasing"
|
||||
}
|
||||
}
|
||||
|
||||
resource "coder_agent" "dev" {
|
||||
arch = "amd64"
|
||||
os = "linux"
|
||||
|
||||
env = {
|
||||
GIT_AUTHOR_NAME = data.coder_workspace_owner.me.name
|
||||
GIT_AUTHOR_EMAIL = data.coder_workspace_owner.me.email
|
||||
}
|
||||
|
||||
startup_script_behavior = "non-blocking"
|
||||
startup_script = <<-EOT
|
||||
set -e
|
||||
# Add any startup scripts here
|
||||
EOT
|
||||
|
||||
metadata {
|
||||
display_name = "CPU Usage"
|
||||
key = "cpu_usage"
|
||||
script = "coder stat cpu"
|
||||
interval = 10
|
||||
timeout = 1
|
||||
order = 1
|
||||
}
|
||||
|
||||
metadata {
|
||||
display_name = "RAM Usage"
|
||||
key = "ram_usage"
|
||||
script = "coder stat mem"
|
||||
interval = 10
|
||||
timeout = 1
|
||||
order = 2
|
||||
}
|
||||
|
||||
metadata {
|
||||
display_name = "Disk Usage"
|
||||
key = "disk_usage"
|
||||
script = "coder stat disk"
|
||||
interval = 600
|
||||
timeout = 30
|
||||
order = 3
|
||||
}
|
||||
}
|
||||
|
||||
locals {
|
||||
hostname = lower(data.coder_workspace.me.name)
|
||||
vm_name = "coder-${lower(data.coder_workspace_owner.me.name)}-${local.hostname}"
|
||||
snippet_filename = "${local.vm_name}.yml"
|
||||
base_user = replace(replace(replace(lower(data.coder_workspace_owner.me.name), " ", "-"), "/", "-"), "@", "-") # to avoid special characters in the username
|
||||
linux_user = contains(["root", "admin", "daemon", "bin", "sys"], local.base_user) ? "${local.base_user}1" : local.base_user # to avoid conflict with system users
|
||||
|
||||
rendered_user_data = templatefile("${path.module}/cloud-init/user-data.tftpl", {
|
||||
coder_token = coder_agent.dev.token
|
||||
coder_init_script_b64 = base64encode(coder_agent.dev.init_script)
|
||||
hostname = local.vm_name
|
||||
linux_user = local.linux_user
|
||||
})
|
||||
}
|
||||
|
||||
resource "proxmox_virtual_environment_file" "cloud_init_user_data" {
|
||||
content_type = "snippets"
|
||||
datastore_id = var.snippet_storage
|
||||
node_name = var.proxmox_node
|
||||
|
||||
source_raw {
|
||||
data = local.rendered_user_data
|
||||
file_name = local.snippet_filename
|
||||
}
|
||||
}
|
||||
|
||||
resource "proxmox_virtual_environment_vm" "workspace" {
|
||||
name = local.vm_name
|
||||
node_name = var.proxmox_node
|
||||
|
||||
clone {
|
||||
node_name = var.proxmox_node
|
||||
vm_id = var.clone_template_vmid
|
||||
full = false
|
||||
retries = 5
|
||||
}
|
||||
|
||||
agent {
|
||||
enabled = true
|
||||
}
|
||||
|
||||
on_boot = true
|
||||
started = true
|
||||
|
||||
startup {
|
||||
order = 1
|
||||
}
|
||||
|
||||
scsi_hardware = "virtio-scsi-pci"
|
||||
boot_order = ["scsi0", "ide2"]
|
||||
|
||||
memory {
|
||||
dedicated = data.coder_parameter.memory_mb.value
|
||||
}
|
||||
|
||||
cpu {
|
||||
cores = data.coder_parameter.cpu_cores.value
|
||||
sockets = 1
|
||||
type = "host"
|
||||
}
|
||||
|
||||
network_device {
|
||||
bridge = var.bridge
|
||||
model = "virtio"
|
||||
vlan_id = var.vlan == 0 ? null : var.vlan
|
||||
}
|
||||
|
||||
vga {
|
||||
type = "serial0"
|
||||
}
|
||||
|
||||
serial_device {
|
||||
device = "socket"
|
||||
}
|
||||
|
||||
disk {
|
||||
interface = "scsi0"
|
||||
datastore_id = var.disk_storage
|
||||
size = data.coder_parameter.disk_size_gb.value
|
||||
}
|
||||
|
||||
initialization {
|
||||
type = "nocloud"
|
||||
datastore_id = var.disk_storage
|
||||
|
||||
user_data_file_id = proxmox_virtual_environment_file.cloud_init_user_data.id
|
||||
|
||||
ip_config {
|
||||
ipv4 {
|
||||
address = "dhcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tags = ["coder", "workspace", local.vm_name]
|
||||
|
||||
depends_on = [proxmox_virtual_environment_file.cloud_init_user_data]
|
||||
}
|
||||
|
||||
module "code-server" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/code-server/coder"
|
||||
version = "1.3.1"
|
||||
agent_id = coder_agent.dev.id
|
||||
}
|
||||
|
||||
module "cursor" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/cursor/coder"
|
||||
version = "1.3.0"
|
||||
agent_id = coder_agent.dev.id
|
||||
}
|
||||
+113
-508
@@ -2,216 +2,32 @@
|
||||
|
||||
# Tag Release Script
|
||||
# Automatically detects modules that need tagging and creates release tags
|
||||
# Usage: ./tag_release.sh [OPTIONS]
|
||||
# Usage: ./tag_release.sh
|
||||
# Operates on the current checked-out commit
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
MODULES_TO_TAG=()
|
||||
AUTO_APPROVE=false
|
||||
DRY_RUN=false
|
||||
VERBOSE=false
|
||||
QUIET=false
|
||||
OUTPUT_FORMAT="plain"
|
||||
TARGET_NAMESPACE=""
|
||||
TARGET_MODULE=""
|
||||
SKIP_PUSH=false
|
||||
|
||||
JSON_OUTPUT='{
|
||||
"metadata": {},
|
||||
"summary": {},
|
||||
"modules": [],
|
||||
"warnings": [],
|
||||
"errors": []
|
||||
}'
|
||||
|
||||
readonly EXIT_SUCCESS=0
|
||||
readonly EXIT_ERROR=1
|
||||
readonly EXIT_NO_ACTION_NEEDED=2
|
||||
|
||||
usage() {
|
||||
cat << EOF
|
||||
Usage: $0 [OPTIONS]
|
||||
|
||||
OPTIONS:
|
||||
-y, --auto-approve Skip confirmation prompt
|
||||
-d, --dry-run Preview without creating tags
|
||||
-v, --verbose Detailed output
|
||||
-q, --quiet Minimal output
|
||||
-f, --format=FORMAT Output format: 'plain' or 'json'
|
||||
-n, --namespace=NAME Target specific namespace
|
||||
-m, --module=NAME Target specific module
|
||||
-s, --skip-push Create tags but don't push
|
||||
-h, --help Show this help
|
||||
|
||||
EXAMPLES:
|
||||
$0 # Interactive mode
|
||||
$0 -y -q -f json # CI/CD automation
|
||||
$0 -d -v # Test with verbose output
|
||||
$0 -m code-server -d # Target specific module
|
||||
$0 -n coder -m code-server -d # Target module in namespace
|
||||
|
||||
Exit codes: 0=success, 1=error, 2=no action needed
|
||||
EOF
|
||||
exit 0
|
||||
}
|
||||
|
||||
log() {
|
||||
local level="$1"
|
||||
shift
|
||||
local message="$*"
|
||||
local timestamp
|
||||
timestamp=$(date -u '+%Y-%m-%dT%H:%M:%SZ')
|
||||
|
||||
case "$level" in
|
||||
"ERROR")
|
||||
if [[ "$OUTPUT_FORMAT" == "json" ]]; then
|
||||
add_json_error "script_error" "$message"
|
||||
elif [[ "$QUIET" != "true" ]]; then
|
||||
echo "❌ $message" >&2
|
||||
fi
|
||||
;;
|
||||
"WARN")
|
||||
if [[ "$OUTPUT_FORMAT" == "json" ]]; then
|
||||
add_json_warning "" "$message" "warning"
|
||||
elif [[ "$QUIET" != "true" ]]; then
|
||||
echo "⚠️ $message" >&2
|
||||
fi
|
||||
;;
|
||||
"INFO")
|
||||
if [[ "$QUIET" != "true" && "$OUTPUT_FORMAT" != "json" ]]; then
|
||||
echo "$message"
|
||||
fi
|
||||
;;
|
||||
"SUCCESS")
|
||||
if [[ "$QUIET" != "true" && "$OUTPUT_FORMAT" != "json" ]]; then
|
||||
echo "✅ $message"
|
||||
fi
|
||||
;;
|
||||
"DEBUG")
|
||||
if [[ "$VERBOSE" == "true" && "$OUTPUT_FORMAT" != "json" ]]; then
|
||||
echo "🔍 [$timestamp] $message" >&2
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
add_json_error() {
|
||||
local type="$1"
|
||||
local message="$2"
|
||||
local details="${3:-}"
|
||||
local exit_code="${4:-1}"
|
||||
|
||||
JSON_OUTPUT=$(echo "$JSON_OUTPUT" | jq --arg type "$type" --arg msg "$message" --arg details "$details" --argjson code "$exit_code" '.errors += [{"type": $type, "message": $msg, "details": $details, "exit_code": $code}]')
|
||||
}
|
||||
|
||||
add_json_warning() {
|
||||
local module="$1"
|
||||
local message="$2"
|
||||
local type="$3"
|
||||
|
||||
JSON_OUTPUT=$(echo "$JSON_OUTPUT" | jq --arg module "$module" --arg msg "$message" --arg type "$type" '.warnings += [{"module": $module, "message": $msg, "type": $type}]')
|
||||
}
|
||||
|
||||
add_json_module() {
|
||||
local namespace="$1"
|
||||
local module_name="$2"
|
||||
local path="$3"
|
||||
local version="$4"
|
||||
local tag_name="$5"
|
||||
local status="$6"
|
||||
local already_existed="$7"
|
||||
|
||||
JSON_OUTPUT=$(echo "$JSON_OUTPUT" | jq --arg ns "$namespace" --arg name "$module_name" --arg path "$path" --arg version "$version" --arg tag "$tag_name" --arg status "$status" --argjson existed "$already_existed" '.modules += [{"namespace": $ns, "module_name": $name, "path": $path, "version": $version, "tag_name": $tag, "status": $status, "already_existed": $existed}]')
|
||||
}
|
||||
|
||||
parse_arguments() {
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
-y | --auto-approve)
|
||||
AUTO_APPROVE=true
|
||||
shift
|
||||
;;
|
||||
-d | --dry-run)
|
||||
DRY_RUN=true
|
||||
shift
|
||||
;;
|
||||
-v | --verbose)
|
||||
VERBOSE=true
|
||||
shift
|
||||
;;
|
||||
-q | --quiet)
|
||||
QUIET=true
|
||||
shift
|
||||
;;
|
||||
-f | --format=* | --format)
|
||||
if [[ "$1" == "-f" || "$1" == "--format" ]]; then
|
||||
if [[ -z "$2" ]]; then
|
||||
log "ERROR" "Option $1 requires a value"
|
||||
exit $EXIT_ERROR
|
||||
fi
|
||||
OUTPUT_FORMAT="$2"
|
||||
shift 2
|
||||
else
|
||||
OUTPUT_FORMAT="${1#*=}"
|
||||
shift
|
||||
fi
|
||||
if [[ "$OUTPUT_FORMAT" != "plain" && "$OUTPUT_FORMAT" != "json" ]]; then
|
||||
log "ERROR" "Invalid format '$OUTPUT_FORMAT'. Must be 'plain' or 'json'"
|
||||
exit $EXIT_ERROR
|
||||
fi
|
||||
;;
|
||||
-n | --namespace=* | --namespace)
|
||||
if [[ "$1" == "-n" || "$1" == "--namespace" ]]; then
|
||||
if [[ -z "$2" ]]; then
|
||||
log "ERROR" "Option $1 requires a value"
|
||||
exit $EXIT_ERROR
|
||||
fi
|
||||
TARGET_NAMESPACE="$2"
|
||||
shift 2
|
||||
else
|
||||
TARGET_NAMESPACE="${1#*=}"
|
||||
shift
|
||||
fi
|
||||
;;
|
||||
-m | --module=* | --module)
|
||||
if [[ "$1" == "-m" || "$1" == "--module" ]]; then
|
||||
if [[ -z "$2" ]]; then
|
||||
log "ERROR" "Option $1 requires a value"
|
||||
exit $EXIT_ERROR
|
||||
fi
|
||||
TARGET_MODULE="$2"
|
||||
shift 2
|
||||
else
|
||||
TARGET_MODULE="${1#*=}"
|
||||
shift
|
||||
fi
|
||||
;;
|
||||
-s | --skip-push)
|
||||
SKIP_PUSH=true
|
||||
shift
|
||||
;;
|
||||
-h | --help)
|
||||
usage
|
||||
;;
|
||||
*)
|
||||
log "ERROR" "Unknown option: $1"
|
||||
echo "Use --help for usage information."
|
||||
exit $EXIT_ERROR
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ "$VERBOSE" == "true" && "$QUIET" == "true" ]]; then
|
||||
echo "❌ --verbose and --quiet cannot be used together" >&2
|
||||
exit $EXIT_ERROR
|
||||
fi
|
||||
echo "Usage: $0"
|
||||
echo ""
|
||||
echo "This script will:"
|
||||
echo " 1. Scan all modules in the registry"
|
||||
echo " 2. Check which modules need new release tags"
|
||||
echo " 3. Extract version information from README files"
|
||||
echo " 4. Generate a report for confirmation"
|
||||
echo " 5. Create and push release tags after confirmation"
|
||||
echo ""
|
||||
echo "The script operates on the current checked-out commit."
|
||||
echo "Make sure you have checked out the commit you want to tag before running."
|
||||
exit 1
|
||||
}
|
||||
|
||||
validate_version() {
|
||||
local version="$1"
|
||||
if ! [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
log "DEBUG" "Invalid version format: '$version'. Expected X.Y.Z format."
|
||||
echo "❌ Invalid version format: '$version'. Expected X.Y.Z format." >&2
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
@@ -222,45 +38,28 @@ extract_version_from_readme() {
|
||||
local namespace="$2"
|
||||
local module_name="$3"
|
||||
|
||||
log "DEBUG" "Extracting version from $readme_path for $namespace/$module_name"
|
||||
[ ! -f "$readme_path" ] && return 1
|
||||
|
||||
[ ! -f "$readme_path" ] && {
|
||||
log "DEBUG" "README file not found: $readme_path"
|
||||
return 1
|
||||
}
|
||||
local version_line
|
||||
version_line=$(grep -E "source\s*=\s*\"registry\.coder\.com/${namespace}/${module_name}" "$readme_path" | head -1 || echo "")
|
||||
|
||||
local version
|
||||
version=$(extract_version_from_module_block "$readme_path" "$namespace" "$module_name")
|
||||
if [ -n "$version_line" ]; then
|
||||
local version
|
||||
version=$(echo "$version_line" | sed -n 's/.*version\s*=\s*"\([^"]*\)".*/\1/p')
|
||||
if [ -n "$version" ]; then
|
||||
echo "$version"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -n "$version" ]; then
|
||||
log "DEBUG" "Found version '$version' from module block for $namespace/$module_name"
|
||||
echo "$version"
|
||||
local fallback_version
|
||||
fallback_version=$(grep -E 'version\s*=\s*"[0-9]+\.[0-9]+\.[0-9]+"' "$readme_path" | head -1 | sed 's/.*version\s*=\s*"\([^"]*\)".*/\1/' || echo "")
|
||||
|
||||
if [ -n "$fallback_version" ]; then
|
||||
echo "$fallback_version"
|
||||
return 0
|
||||
fi
|
||||
|
||||
log "DEBUG" "No version found in module block for $namespace/$module_name in $readme_path"
|
||||
return 1
|
||||
}
|
||||
|
||||
extract_version_from_module_block() {
|
||||
local readme_path="$1"
|
||||
local namespace="$2"
|
||||
local module_name="$3"
|
||||
|
||||
local version
|
||||
version=$(grep -A 10 "source[[:space:]]*=[[:space:]]*\"registry\.coder\.com/${namespace}/${module_name}/coder" "$readme_path" \
|
||||
| sed '/^[[:space:]]*}/q' \
|
||||
| grep -E "version[[:space:]]*=[[:space:]]*\"[^\"]+\"" \
|
||||
| head -1 \
|
||||
| sed 's/.*version[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/')
|
||||
|
||||
if [ -n "$version" ]; then
|
||||
log "DEBUG" "Found version '$version' for $namespace/$module_name"
|
||||
echo "$version"
|
||||
return 0
|
||||
fi
|
||||
|
||||
log "DEBUG" "No version found within module block for $namespace/$module_name"
|
||||
return 1
|
||||
}
|
||||
|
||||
@@ -271,56 +70,29 @@ check_module_needs_tagging() {
|
||||
|
||||
local tag_name="release/${namespace}/${module_name}/v${readme_version}"
|
||||
|
||||
log "DEBUG" "Checking if tag exists: $tag_name"
|
||||
|
||||
if git rev-parse --verify "$tag_name" > /dev/null 2>&1; then
|
||||
log "DEBUG" "Tag $tag_name already exists"
|
||||
return 1
|
||||
else
|
||||
log "DEBUG" "Tag $tag_name needs to be created"
|
||||
return 0
|
||||
fi
|
||||
}
|
||||
|
||||
should_process_module() {
|
||||
local namespace="$1"
|
||||
local module_name="$2"
|
||||
|
||||
if [[ -n "$TARGET_NAMESPACE" && "$TARGET_NAMESPACE" != "$namespace" ]]; then
|
||||
log "DEBUG" "Skipping $namespace/$module_name: namespace filter"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ -n "$TARGET_MODULE" && "$TARGET_MODULE" != "$module_name" ]]; then
|
||||
log "DEBUG" "Skipping $namespace/$module_name: module filter"
|
||||
return 1
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
detect_modules_needing_tags() {
|
||||
MODULES_TO_TAG=()
|
||||
|
||||
log "INFO" "🔍 Scanning all modules for missing release tags..."
|
||||
if [[ "$OUTPUT_FORMAT" != "json" ]]; then
|
||||
echo ""
|
||||
fi
|
||||
echo "🔍 Scanning all modules for missing release tags..."
|
||||
echo ""
|
||||
|
||||
local all_modules
|
||||
# Find all module directories, excluding hidden directories
|
||||
# This works on both macOS and Linux
|
||||
all_modules=$(find registry -mindepth 3 -maxdepth 3 -type d -path "*/modules/*" ! -name ".*" | sort -u || echo "")
|
||||
all_modules=$(find registry -mindepth 3 -maxdepth 3 -type d -path "*/modules/*" | sort -u || echo "")
|
||||
|
||||
[ -z "$all_modules" ] && {
|
||||
log "ERROR" "No modules found to check"
|
||||
return $EXIT_ERROR
|
||||
echo "❌ No modules found to check"
|
||||
return 1
|
||||
}
|
||||
|
||||
local total_checked=0
|
||||
local needs_tagging=0
|
||||
local already_tagged=0
|
||||
local skipped=0
|
||||
|
||||
while IFS= read -r module_path; do
|
||||
if [ -z "$module_path" ]; then continue; fi
|
||||
@@ -330,133 +102,64 @@ detect_modules_needing_tags() {
|
||||
local module_name
|
||||
module_name=$(echo "$module_path" | cut -d'/' -f4)
|
||||
|
||||
if ! should_process_module "$namespace" "$module_name"; then
|
||||
skipped=$((skipped + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
total_checked=$((total_checked + 1))
|
||||
|
||||
local readme_path="$module_path/README.md"
|
||||
local readme_version
|
||||
|
||||
if ! readme_version=$(extract_version_from_readme "$readme_path" "$namespace" "$module_name"); then
|
||||
log "WARN" "$namespace/$module_name: No version found in README, skipping"
|
||||
add_json_warning "$namespace/$module_name" "No version found in README, skipping" "missing_version"
|
||||
skipped=$((skipped + 1))
|
||||
echo "⚠️ $namespace/$module_name: No version found in README, skipping"
|
||||
continue
|
||||
fi
|
||||
|
||||
if ! validate_version "$readme_version"; then
|
||||
log "WARN" "$namespace/$module_name: Invalid version format '$readme_version', skipping"
|
||||
add_json_warning "$namespace/$module_name" "Invalid version format '$readme_version', skipping" "invalid_version"
|
||||
skipped=$((skipped + 1))
|
||||
echo "⚠️ $namespace/$module_name: Invalid version format '$readme_version', skipping"
|
||||
continue
|
||||
fi
|
||||
|
||||
local tag_name="release/$namespace/$module_name/v$readme_version"
|
||||
|
||||
if check_module_needs_tagging "$namespace" "$module_name" "$readme_version"; then
|
||||
log "INFO" "📦 $namespace/$module_name: v$readme_version (needs tag)"
|
||||
echo "📦 $namespace/$module_name: v$readme_version (needs tag)"
|
||||
MODULES_TO_TAG+=("$module_path:$namespace:$module_name:$readme_version")
|
||||
needs_tagging=$((needs_tagging + 1))
|
||||
|
||||
local status="needs_tagging"
|
||||
if [[ "$DRY_RUN" == "true" ]]; then
|
||||
status="would_be_tagged"
|
||||
fi
|
||||
add_json_module "$namespace" "$module_name" "$module_path" "$readme_version" "$tag_name" "$status" false
|
||||
else
|
||||
log "SUCCESS" "$namespace/$module_name: v$readme_version (already tagged)"
|
||||
already_tagged=$((already_tagged + 1))
|
||||
add_json_module "$namespace" "$module_name" "$module_path" "$readme_version" "$tag_name" "already_tagged" true
|
||||
echo "✅ $namespace/$module_name: v$readme_version (already tagged)"
|
||||
fi
|
||||
|
||||
done <<< "$all_modules"
|
||||
|
||||
JSON_OUTPUT=$(echo "$JSON_OUTPUT" | jq --argjson total "$total_checked" --argjson needs "$needs_tagging" \
|
||||
--argjson tagged "$already_tagged" --argjson skip "$skipped" \
|
||||
'.summary.total_scanned = $total | .summary.needs_tagging = $needs | .summary.already_tagged = $tagged | .summary.skipped = $skip')
|
||||
|
||||
if [[ "$OUTPUT_FORMAT" != "json" ]]; then
|
||||
echo ""
|
||||
log "INFO" "📊 Summary: $needs_tagging of $total_checked modules need tagging"
|
||||
echo ""
|
||||
fi
|
||||
echo ""
|
||||
echo "📊 Summary: $needs_tagging of $total_checked modules need tagging"
|
||||
echo ""
|
||||
|
||||
[ $needs_tagging -eq 0 ] && {
|
||||
if [[ "$OUTPUT_FORMAT" != "json" ]]; then
|
||||
log "SUCCESS" "🎉 All modules are up to date! No tags needed."
|
||||
fi
|
||||
JSON_OUTPUT=$(echo "$JSON_OUTPUT" | jq '.summary.operation_status = "no_action_needed"')
|
||||
return $EXIT_NO_ACTION_NEEDED
|
||||
echo "🎉 All modules are up to date! No tags needed."
|
||||
return 0
|
||||
}
|
||||
|
||||
if [[ "$OUTPUT_FORMAT" != "json" ]]; then
|
||||
echo "## Tags to be created:"
|
||||
for module_info in "${MODULES_TO_TAG[@]}"; do
|
||||
IFS=':' read -r module_path namespace module_name version <<< "$module_info"
|
||||
echo "- \`release/$namespace/$module_name/v$version\`"
|
||||
done
|
||||
echo ""
|
||||
fi
|
||||
echo "## Tags to be created:"
|
||||
for module_info in "${MODULES_TO_TAG[@]}"; do
|
||||
IFS=':' read -r module_path namespace module_name version <<< "$module_info"
|
||||
echo "- \`release/$namespace/$module_name/v$version\`"
|
||||
done
|
||||
echo ""
|
||||
|
||||
return $EXIT_SUCCESS
|
||||
}
|
||||
|
||||
pre_flight_checks() {
|
||||
log "DEBUG" "Running pre-flight checks..."
|
||||
|
||||
if ! git rev-parse --git-dir > /dev/null 2>&1; then
|
||||
log "ERROR" "Not in a git repository"
|
||||
return $EXIT_ERROR
|
||||
fi
|
||||
|
||||
if ! git remote get-url origin > /dev/null 2>&1; then
|
||||
log "ERROR" "No 'origin' remote found"
|
||||
return $EXIT_ERROR
|
||||
fi
|
||||
|
||||
if [[ "$SKIP_PUSH" != "true" && "$DRY_RUN" != "true" ]]; then
|
||||
log "DEBUG" "Testing remote connectivity..."
|
||||
if ! git ls-remote --exit-code origin > /dev/null 2>&1; then
|
||||
log "ERROR" "Cannot connect to remote repository"
|
||||
return $EXIT_ERROR
|
||||
fi
|
||||
fi
|
||||
|
||||
if ! git rev-parse HEAD > /dev/null 2>&1; then
|
||||
log "ERROR" "Cannot determine current commit"
|
||||
return $EXIT_ERROR
|
||||
fi
|
||||
|
||||
log "DEBUG" "Pre-flight checks passed"
|
||||
return $EXIT_SUCCESS
|
||||
return 0
|
||||
}
|
||||
|
||||
create_and_push_tags() {
|
||||
[ ${#MODULES_TO_TAG[@]} -eq 0 ] && {
|
||||
log "ERROR" "No modules to tag found"
|
||||
return $EXIT_ERROR
|
||||
echo "❌ No modules to tag found"
|
||||
return 1
|
||||
}
|
||||
|
||||
local current_commit
|
||||
current_commit=$(git rev-parse HEAD)
|
||||
|
||||
if [[ "$DRY_RUN" == "true" ]]; then
|
||||
log "INFO" "🏷️ [DRY RUN] Would create release tags for commit: $current_commit"
|
||||
JSON_OUTPUT=$(echo "$JSON_OUTPUT" | jq '.summary.operation_status = "dry_run" | .summary.tags_created = 0 | .summary.tags_pushed = 0')
|
||||
return $EXIT_SUCCESS
|
||||
fi
|
||||
|
||||
log "INFO" "🏷️ Creating release tags for commit: $current_commit"
|
||||
if [[ "$OUTPUT_FORMAT" != "json" ]]; then
|
||||
echo ""
|
||||
fi
|
||||
echo "🏷️ Creating release tags for commit: $current_commit"
|
||||
echo ""
|
||||
|
||||
local created_tags=0
|
||||
local failed_tags=0
|
||||
local created_tag_names=()
|
||||
|
||||
for module_info in "${MODULES_TO_TAG[@]}"; do
|
||||
IFS=':' read -r module_path namespace module_name version <<< "$module_info"
|
||||
@@ -464,56 +167,35 @@ create_and_push_tags() {
|
||||
local tag_name="release/$namespace/$module_name/v$version"
|
||||
local tag_message="Release $namespace/$module_name v$version"
|
||||
|
||||
log "DEBUG" "Creating tag: $tag_name"
|
||||
log "INFO" "Creating tag: $tag_name"
|
||||
echo "Creating tag: $tag_name"
|
||||
|
||||
if git tag -a "$tag_name" -m "$tag_message" "$current_commit" 2> /dev/null; then
|
||||
log "SUCCESS" "Created: $tag_name"
|
||||
if git tag -a "$tag_name" -m "$tag_message" "$current_commit"; then
|
||||
echo "✅ Created: $tag_name"
|
||||
created_tags=$((created_tags + 1))
|
||||
created_tag_names+=("$tag_name")
|
||||
|
||||
JSON_OUTPUT=$(echo "$JSON_OUTPUT" | jq --arg tag "$tag_name" \
|
||||
'(.modules[] | select(.tag_name == $tag) | .status) = "tag_created"')
|
||||
else
|
||||
log "ERROR" "Failed to create: $tag_name"
|
||||
add_json_error "tag_creation_failed" "Failed to create tag: $tag_name" "git tag -a $tag_name -m '$tag_message' $current_commit"
|
||||
echo "❌ Failed to create: $tag_name"
|
||||
failed_tags=$((failed_tags + 1))
|
||||
|
||||
JSON_OUTPUT=$(echo "$JSON_OUTPUT" | jq --arg tag "$tag_name" \
|
||||
'(.modules[] | select(.tag_name == $tag) | .status) = "tag_creation_failed"')
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ "$OUTPUT_FORMAT" != "json" ]]; then
|
||||
echo ""
|
||||
log "INFO" "📊 Tag creation summary:"
|
||||
log "INFO" " Created: $created_tags"
|
||||
log "INFO" " Failed: $failed_tags"
|
||||
echo ""
|
||||
fi
|
||||
echo ""
|
||||
echo "📊 Tag creation summary:"
|
||||
echo " Created: $created_tags"
|
||||
echo " Failed: $failed_tags"
|
||||
echo ""
|
||||
|
||||
[ $created_tags -eq 0 ] && {
|
||||
log "ERROR" "No tags were created successfully"
|
||||
JSON_OUTPUT=$(echo "$JSON_OUTPUT" | jq '.summary.operation_status = "failed" | .summary.tags_created = 0 | .summary.tags_pushed = 0')
|
||||
return $EXIT_ERROR
|
||||
echo "❌ No tags were created successfully"
|
||||
return 1
|
||||
}
|
||||
|
||||
JSON_OUTPUT=$(echo "$JSON_OUTPUT" | jq --argjson created "$created_tags" '.summary.tags_created = $created')
|
||||
|
||||
if [[ "$SKIP_PUSH" == "true" ]]; then
|
||||
log "INFO" "🚫 Skipping push (--skip-push specified)"
|
||||
JSON_OUTPUT=$(echo "$JSON_OUTPUT" | jq '.summary.operation_status = "tags_created_not_pushed" | .summary.tags_pushed = 0')
|
||||
for tag_name in "${created_tag_names[@]}"; do
|
||||
JSON_OUTPUT=$(echo "$JSON_OUTPUT" | jq --arg tag "$tag_name" \
|
||||
'(.modules[] | select(.tag_name == $tag) | .status) = "tag_created_not_pushed"')
|
||||
done
|
||||
return $EXIT_SUCCESS
|
||||
fi
|
||||
|
||||
log "INFO" "🚀 Pushing tags to origin..."
|
||||
echo "🚀 Pushing tags to origin..."
|
||||
|
||||
local tags_to_push=()
|
||||
for tag_name in "${created_tag_names[@]}"; do
|
||||
for module_info in "${MODULES_TO_TAG[@]}"; do
|
||||
IFS=':' read -r module_path namespace module_name version <<< "$module_info"
|
||||
local tag_name="release/$namespace/$module_name/v$version"
|
||||
|
||||
if git rev-parse --verify "$tag_name" > /dev/null 2>&1; then
|
||||
tags_to_push+=("$tag_name")
|
||||
fi
|
||||
@@ -523,148 +205,71 @@ create_and_push_tags() {
|
||||
local failed_pushes=0
|
||||
|
||||
if [ ${#tags_to_push[@]} -eq 0 ]; then
|
||||
log "ERROR" "No valid tags found to push"
|
||||
JSON_OUTPUT=$(echo "$JSON_OUTPUT" | jq '.summary.operation_status = "failed" | .summary.tags_pushed = 0')
|
||||
echo "❌ No valid tags found to push"
|
||||
else
|
||||
if git push --atomic origin "${tags_to_push[@]}" 2> /dev/null; then
|
||||
log "SUCCESS" "Successfully pushed all ${#tags_to_push[@]} tags"
|
||||
if git push --atomic origin "${tags_to_push[@]}"; then
|
||||
echo "✅ Successfully pushed all ${#tags_to_push[@]} tags"
|
||||
pushed_tags=${#tags_to_push[@]}
|
||||
|
||||
for tag_name in "${tags_to_push[@]}"; do
|
||||
JSON_OUTPUT=$(echo "$JSON_OUTPUT" | jq --arg tag "$tag_name" \
|
||||
'(.modules[] | select(.tag_name == $tag) | .status) = "tagged_and_pushed"')
|
||||
done
|
||||
else
|
||||
log "ERROR" "Failed to push tags"
|
||||
add_json_error "push_failed" "Failed to push tags to remote" "git push --atomic origin ${tags_to_push[*]}"
|
||||
echo "❌ Failed to push tags"
|
||||
failed_pushes=${#tags_to_push[@]}
|
||||
|
||||
for tag_name in "${tags_to_push[@]}"; do
|
||||
JSON_OUTPUT=$(echo "$JSON_OUTPUT" | jq --arg tag "$tag_name" \
|
||||
'(.modules[] | select(.tag_name == $tag) | .status) = "tag_created_push_failed"')
|
||||
done
|
||||
fi
|
||||
fi
|
||||
|
||||
JSON_OUTPUT=$(echo "$JSON_OUTPUT" | jq --argjson pushed "$pushed_tags" '.summary.tags_pushed = $pushed')
|
||||
echo ""
|
||||
echo "📊 Push summary:"
|
||||
echo " Pushed: $pushed_tags"
|
||||
echo " Failed: $failed_pushes"
|
||||
echo ""
|
||||
|
||||
if [[ "$OUTPUT_FORMAT" != "json" ]]; then
|
||||
echo ""
|
||||
log "INFO" "📊 Push summary:"
|
||||
log "INFO" " Pushed: $pushed_tags"
|
||||
log "INFO" " Failed: $failed_pushes"
|
||||
if [ $pushed_tags -gt 0 ]; then
|
||||
echo "🎉 Successfully created and pushed $pushed_tags release tags!"
|
||||
echo ""
|
||||
echo "📝 Next steps:"
|
||||
echo " - Tags will be automatically published to registry.coder.com"
|
||||
echo " - Monitor the registry website for updates"
|
||||
echo " - Check GitHub releases for any issues"
|
||||
fi
|
||||
|
||||
if [ "$pushed_tags" -gt 0 ]; then
|
||||
if [[ "$OUTPUT_FORMAT" != "json" ]]; then
|
||||
log "SUCCESS" "🎉 Successfully created and pushed $pushed_tags release tags!"
|
||||
echo ""
|
||||
log "INFO" "📝 Next steps:"
|
||||
log "INFO" " - Tags will be automatically published to registry.coder.com"
|
||||
log "INFO" " - Monitor the registry website for updates"
|
||||
log "INFO" " - Check GitHub releases for any issues"
|
||||
fi
|
||||
JSON_OUTPUT=$(echo "$JSON_OUTPUT" | jq '.summary.operation_status = "success"')
|
||||
return $EXIT_SUCCESS
|
||||
else
|
||||
JSON_OUTPUT=$(echo "$JSON_OUTPUT" | jq '.summary.operation_status = "failed"')
|
||||
return $EXIT_ERROR
|
||||
fi
|
||||
}
|
||||
|
||||
finalize_json_output() {
|
||||
local timestamp
|
||||
timestamp=$(date -u '+%Y-%m-%dT%H:%M:%SZ')
|
||||
local current_commit
|
||||
current_commit=$(git rev-parse HEAD 2> /dev/null || echo "unknown")
|
||||
local command_line="$0 $*"
|
||||
|
||||
JSON_OUTPUT=$(echo "$JSON_OUTPUT" | jq --arg ts "$timestamp" --arg commit "$current_commit" \
|
||||
--arg cmd "$command_line" \
|
||||
'.metadata.timestamp = $ts | .metadata.commit = $commit | .metadata.command = $cmd')
|
||||
|
||||
echo "$JSON_OUTPUT"
|
||||
return 0
|
||||
}
|
||||
|
||||
main() {
|
||||
parse_arguments "$@"
|
||||
[ $# -gt 0 ] && usage
|
||||
|
||||
if [[ "$OUTPUT_FORMAT" == "json" ]]; then
|
||||
if ! command -v jq > /dev/null 2>&1; then
|
||||
echo '{"error": "jq is required for JSON output format but not found"}' >&2
|
||||
exit $EXIT_ERROR
|
||||
fi
|
||||
echo "🚀 Coder Registry Tag Release Script"
|
||||
echo "Operating on commit: $(git rev-parse HEAD)"
|
||||
echo ""
|
||||
|
||||
if ! git rev-parse --git-dir > /dev/null 2>&1; then
|
||||
echo "❌ Not in a git repository"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$OUTPUT_FORMAT" != "json" ]]; then
|
||||
log "INFO" "🚀 Coder Registry Tag Release Script"
|
||||
log "INFO" "Operating on commit: $(git rev-parse HEAD 2> /dev/null || echo 'unknown')"
|
||||
echo ""
|
||||
fi
|
||||
detect_modules_needing_tags || exit 1
|
||||
|
||||
if ! pre_flight_checks; then
|
||||
if [[ "$OUTPUT_FORMAT" == "json" ]]; then
|
||||
JSON_OUTPUT=$(echo "$JSON_OUTPUT" | jq '.summary.operation_status = "preflight_failed"')
|
||||
finalize_json_output "$@"
|
||||
fi
|
||||
exit $EXIT_ERROR
|
||||
fi
|
||||
[ ${#MODULES_TO_TAG[@]} -eq 0 ] && {
|
||||
echo "✨ No modules need tagging. All done!"
|
||||
exit 0
|
||||
}
|
||||
|
||||
local detect_exit_code
|
||||
detect_modules_needing_tags
|
||||
detect_exit_code=$?
|
||||
echo ""
|
||||
echo "❓ Do you want to proceed with creating and pushing these release tags?"
|
||||
echo " This will create git tags and push them to the remote repository."
|
||||
echo ""
|
||||
read -p "Continue? [y/N]: " -r response
|
||||
|
||||
case $detect_exit_code in
|
||||
"$EXIT_NO_ACTION_NEEDED")
|
||||
if [[ "$OUTPUT_FORMAT" == "json" ]]; then
|
||||
finalize_json_output "$@"
|
||||
else
|
||||
log "SUCCESS" "✨ No modules need tagging. All done!"
|
||||
fi
|
||||
exit $EXIT_SUCCESS
|
||||
case "$response" in
|
||||
[yY] | [yY][eE][sS])
|
||||
echo ""
|
||||
create_and_push_tags
|
||||
;;
|
||||
"$EXIT_ERROR")
|
||||
if [[ "$OUTPUT_FORMAT" == "json" ]]; then
|
||||
JSON_OUTPUT=$(echo "$JSON_OUTPUT" | jq '.summary.operation_status = "scan_failed"')
|
||||
finalize_json_output "$@"
|
||||
fi
|
||||
exit $EXIT_ERROR
|
||||
*)
|
||||
echo ""
|
||||
echo "🚫 Operation cancelled by user"
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
|
||||
if [[ "$AUTO_APPROVE" != "true" && "$OUTPUT_FORMAT" != "json" && "$DRY_RUN" != "true" ]]; then
|
||||
echo ""
|
||||
log "INFO" "❓ Do you want to proceed with creating and pushing these release tags?"
|
||||
log "INFO" " This will create git tags and push them to the remote repository."
|
||||
echo ""
|
||||
read -p "Continue? [y/N]: " -r response
|
||||
|
||||
case "$response" in
|
||||
[yY] | [yY][eE][sS])
|
||||
echo ""
|
||||
;;
|
||||
*)
|
||||
echo ""
|
||||
log "INFO" "🚫 Operation cancelled by user"
|
||||
if [[ "$OUTPUT_FORMAT" == "json" ]]; then
|
||||
JSON_OUTPUT=$(echo "$JSON_OUTPUT" | jq '.summary.operation_status = "cancelled_by_user"')
|
||||
finalize_json_output "$@"
|
||||
fi
|
||||
exit $EXIT_SUCCESS
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
local create_exit_code
|
||||
create_and_push_tags
|
||||
create_exit_code=$?
|
||||
|
||||
if [[ "$OUTPUT_FORMAT" == "json" ]]; then
|
||||
finalize_json_output "$@"
|
||||
fi
|
||||
|
||||
exit $create_exit_code
|
||||
}
|
||||
|
||||
main "$@"
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Find all directories that contain any .tftest.hcl files and run terraform test in each
|
||||
|
||||
run_dir() {
|
||||
local dir="$1"
|
||||
echo "==> Running terraform test in $dir"
|
||||
(cd "$dir" && terraform init -upgrade -input=false -no-color > /dev/null && terraform test -no-color -verbose)
|
||||
}
|
||||
|
||||
mapfile -t test_dirs < <(find . -type f -name "*.tftest.hcl" -print0 | xargs -0 -I{} dirname {} | sort -u)
|
||||
|
||||
if [[ ${#test_dirs[@]} -eq 0 ]]; then
|
||||
echo "No .tftest.hcl tests found."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
status=0
|
||||
for d in "${test_dirs[@]}"; do
|
||||
if ! run_dir "$d"; then
|
||||
status=1
|
||||
fi
|
||||
done
|
||||
|
||||
exit $status
|
||||
Reference in New Issue
Block a user