36 Commits

Author SHA1 Message Date
octoturge 850337530f Add a git credential helper for Gitea, backed by tea's own login
Provision Coder Templates / build-images (push) Successful in 7s
Provision Coder Templates / provision (push) Successful in 2m7s
Coder's GIT_ASKPASS only knows about github.com; for git.octoturge.com
it falls through to a broken interactive prompt, so `git push`/`pull`
over HTTPS hangs or fails even after `tea login add` succeeds. A
credential helper answering `git credential fill` runs before
GIT_ASKPASS is consulted, so wire one up (gated on the wizard's
existing DID_GITEA check) that reads the live OAuth token straight out
of tea's own credentials.json - verified end-to-end against a running
workspace (git ls-remote over HTTPS authenticated successfully).

Bump every template's VERSION to publish it.
2026-08-31 09:17:55 +02:00
octoturge ecf927aef9 Bump every template's VERSION to publish the git.octoturge.com fix
Provision Coder Templates / build-images (push) Successful in 5s
Provision Coder Templates / provision (push) Successful in 2m13s
The host-gateway change in the previous commit only takes effect on a new
template version - bump every template's VERSION file from 1 to 2 so
coder-templates.yml actually pushes it instead of skipping as "already
pushed". Existing workspaces need a stop/start against the new version to
pick up the updated docker_container host mapping.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YRq8ts9zwge8Rttbuku9FQ
2026-08-28 18:04:51 +02:00
octoturge f377942e90 Add host-gateway mapping for git.octoturge.com in every workspace container
Provision Coder Templates / build-images (push) Successful in 10s
Provision Coder Templates / provision (push) Successful in 54s
install-skills.sh curls git.octoturge.com at workspace startup (coder_script)
to pull this repo's Agent Skills, but the workspace's public DNS answer for
that hostname NAT-hairpins back through the LAN and times out (curl: (28)
after ~147s). code.octoturge.com already has this exact host-gateway
workaround in every template's docker_container.workspace resource; add the
matching entry for git.octoturge.com.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YRq8ts9zwge8Rttbuku9FQ
2026-08-28 18:02:34 +02:00
octoturge 8b3653ee96 Fix provision job crashing on templates already at their current VERSION
Provision Coder Templates / build-images (push) Failing after 23s
Provision Coder Templates / provision (push) Successful in 58s
Run 109 failed: profiles-3d-printing (and the same would've hit every
other already-versioned template) already had a "v1" version from the
previous run, and the pre-check meant to detect that and skip
(coder templates versions list -o json piped through jq) apparently
doesn't match the CLI's actual JSON shape - it never found the
existing version, so the push was attempted anyway and coder rejected
the duplicate name, failing the whole job.

Simpler and more robust: just attempt the push and treat its specific
"A template version with name ... already exists" failure as the skip
signal, instead of trying to predict it from a separate list call.
2026-08-27 12:12:27 +02:00
octoturge fb74669414 Purge apt's downloaded .deb cache in every RUN, not just the package lists
Provision Coder Templates / provision (push) Failing after 42s
Provision Coder Templates / build-images (push) Successful in 16m56s
Splitting the big RUN blocks (previous commit) got several layers under
the registry's per-blob size cap, but a few packages are still huge
enough on their own to fail - libopencv-dev came in at 793MB, Chrome at
1.08GB. Neither of those RUN blocks was clearing
/var/cache/apt/archives, only /var/lib/apt/lists, so each apt install's
downloaded .deb files were sitting in the layer alongside the unpacked
files. Worth trying before reaching for a Cloudflare-side fix.
2026-08-27 11:14:36 +02:00
octoturge 2867db22af Split web image's large RUN blocks into per-layer steps; add per-template VERSION files to skip unchanged reprovisioning
Provision Coder Templates / provision (push) Successful in 1m56s
Provision Coder Templates / build-images (push) Successful in 14m45s
The registry sits behind a reverse proxy that 413s large blob pushes,
so templates/web/Dockerfile's oversized RUN blocks (apt installs,
rustup targets, cargo installs) are broken up so no single layer is
too big to push.

Also adds templates/<name>/VERSION (starting at "1" for all six
templates) and has the provision job's push step look up whether that
version is already pushed before running coder templates push, since
the workflow triggers on any change under templates/** and previously
reprovisioned every template on every push, not just the one that
changed.
2026-08-27 09:32:30 +02:00
octoturge c64987a840 ci: re-declare packages: write for forward compat with gitea#39070
Provision Coder Templates / build-images (push) Successful in 14s
Provision Coder Templates / provision (push) Successful in 1m50s
Currently a no-op given GITEA_TOKEN can't reach the registry at all yet,
but harmless to have in place now so nothing needs to change here once
that fix ships and PACKAGE_REGISTRY_TOKEN can eventually be retired.
2026-08-27 09:21:15 +02:00
octoturge 757a94557b ci: revert to a manual PAT for registry push - GITEA_TOKEN is broken
Provision Coder Templates / provision (push) Successful in 2m0s
Provision Coder Templates / build-images (push) Failing after 8s
secrets.GITEA_TOKEN cannot authenticate to Gitea's container registry in
any currently-shipped version, regardless of the permissions: block or
which account triggers the workflow - confirmed via
go-gitea/gitea#23642 (open since 1.19) and its fix, PR #39070, which is
dated 2026-08-24 and not yet merged. Every run since switching to it has
failed at docker login with a plain "unauthorized". Reverting to a
manually-created PACKAGE_REGISTRY_TOKEN secret (the GITEA_PACKAGE_TOKEN
attempt from before this branch used a reserved-prefix name Gitea
silently refuses to create).
2026-08-27 01:56:30 +02:00
octoturge 268fb97a1c docs: note that GITEA_TOKEN impersonates the pushing account, not the owner
Provision Coder Templates / build-images (push) Failing after 6s
Provision Coder Templates / provision (push) Successful in 2m1s
Documents what we just found the hard way: build-images's docker push
runs as whichever Gitea account authenticated the triggering push
(github.actor), which needs its own package-registry write access -
separate from that account having git push rights to the repo at all.
2026-08-27 01:54:07 +02:00
octoturge 32c71ccaf0 ci: trigger fresh credential prompt after clearing stale akadmin cache 2026-08-27 01:45:58 +02:00
octoturge 9798b522bd ci: use Gitea's built-in GITEA_TOKEN for registry push, drop manual PAT
Provision Coder Templates / build-images (push) Failing after 6s
Provision Coder Templates / provision (push) Has been cancelled
GITEA_PACKAGE_TOKEN was never a creatable secret name (GITEA_ prefix is
reserved), and Gitea Actions' built-in token can be granted registry
write access directly via `permissions: packages: write` (per
https://docs.gitea.com/usage/actions/token-permissions/) - the
"unauthorized: reqPackageAccess" issue noted earlier looks to have been
exactly this: the default restricted token mode denying package write
unless a job explicitly requests it, not an unfixable bug in the token
itself. Drops the manual-PAT requirement entirely - no secret to create
or maintain.
2026-08-27 01:40:07 +02:00
octoturge f27eb23804 ci: rename registry secret to PACKAGE_REGISTRY_TOKEN, fail loudly if unset
GITEA_PACKAGE_TOKEN was never actually creatable - Gitea Actions reserves
the GITEA_ prefix for its own built-in secrets, so Settings > Actions >
Secrets rejects a repo secret by that name. The workflow referenced a
secret that could never exist, so docker login always got an empty
password and failed with a confusing "interactive login from a non TTY
device" error. Renamed to PACKAGE_REGISTRY_TOKEN, and added an explicit
empty-secret check so a future misconfiguration fails with a clear
message instead of that confusing docker error.
2026-08-27 01:37:19 +02:00
octoturge ea4ab76724 ci: run build-images' shell steps under sh, not bash
Provision Coder Templates / build-images (push) Failing after 10s
Provision Coder Templates / provision (push) Successful in 2m0s
docker:27-cli (Alpine) has no bash, only the POSIX /bin/sh (busybox
ash). run: steps default to bash, so both steps in build-images failed
immediately with "exec: bash: executable file not found in $PATH" -
confirmed via the run's log right after the previous checkout fix
landed. Both steps are already plain POSIX shell, so declare shell: sh
as the job default instead of switching interpreters.
2026-08-27 01:31:28 +02:00
octoturge c1bc3566ba ci: replace actions/checkout with a raw git clone in build-images
Provision Coder Templates / build-images (push) Failing after 7s
Provision Coder Templates / provision (push) Successful in 2m0s
actions/checkout is a JS action and needs Node to run inside the job
container. The docker-build runner's job image (docker:27-cli, Alpine,
just the Docker CLI) has no Node, so the step failed immediately with
"exec: node: executable file not found in $PATH" - confirmed via the
run's actual log (build-images failed after 16s on every push since the
image-in-CI switch, including the just-merged duplicate-mount and
if:always() fixes). Alpine has apk/git, so clone directly instead.
2026-08-27 01:26:03 +02:00
octoturge 9482a0a2a7 ci: build every template's image independently, skip only failed ones
Provision Coder Templates / provision (push) Successful in 2m5s
Provision Coder Templates / build-images (push) Failing after 16s
Fixes a regression where the whole provision job (all 6 templates) was
skipped whenever build-web-image failed, since Actions skips a job whose
needs: dependency failed by default. Generalizes the single web-image
build step into a loop over every templates/*/Dockerfile, building and
pushing each independently so one failure doesn't block the rest, and
reports failures via a job output. provision now runs unconditionally
(if: always()) and skips pushing only the specific template(s) whose
image build failed this run, leaving their previous working version in
place instead of pushing one with no matching registry tag.
2026-08-27 01:18:25 +02:00
octoturge 278023e4c2 ci: drop redundant docker.sock mount in build-web-image
First real run of the new job failed at container creation:
"Error response from daemon: Duplicate mount point: /var/run/docker.sock".
The docker-build runner's config.yaml has docker_host: "" (not "-"),
which means act_runner already auto-injects the host socket into job
containers on its own - the workflow's explicit
container.volumes mount for the same path was a second, conflicting
request for it. Just dropping the explicit mount; the runner already
provides it.
2026-08-27 01:12:35 +02:00
octoturge 6de3196faa web: build image in CI, pull it in Terraform instead of building locally
Provision Coder Templates / build-web-image (push) Failing after 17s
Provision Coder Templates / provision (push) Has been skipped
templates/web's docker_image resource used a `build` block, so every
first-use of a new Dockerfile hash triggered a from-scratch build
(including the ~15-20min Rust toolchain compile) right at `terraform
apply` time - i.e. while someone was waiting to create a workspace.

Adds a build-web-image job to coder-templates.yml that builds and pushes
git.octoturge.com/octo-tech/profiles-web:<dockerfile-sha1> to this
instance's container registry, tagged identically to what
docker_image.web now computes and pulls (no build block). provision now
depends on build-web-image so a template never gets pushed pointing at
an image that isn't there yet. Skips the build entirely if that tag's
already in the registry, so an unrelated templates/* change doesn't
pay any cost.

Runs on a new dedicated "docker-build" runner (profiles-web-build),
scoped to just this repo via a repo-level registration token, with
host Docker socket access - deliberately not added to the existing
shared runner-1, which has no such access and stays untouched. Repo is
public, so the pulled image needs no registry auth; the push does, via
a new GITEA_PACKAGE_TOKEN repo secret (write:package scope).

Since CI and this Coder deployment share the same Docker daemon, the
"pull" is normally a same-host cache hit, not a real network pull.

Verified: `terraform validate` passes against the updated
templates/web/main.tf (run directly inside the coder-server container,
which has terraform embedded).
2026-08-27 01:10:58 +02:00
octoturge 730af0a335 cli-setup-wizard: stop parsing tea's config.yml, use tea itself
Provision Coder Templates / provision (push) Successful in 2m4s
DID_GITEA detection and the SSH/GPG key uploads to Gitea were reading
tea's config.yml directly with awk, assuming 2-space indentation and a
plaintext `token:` field. Neither holds: the real format uses 6-space
indentation for fields under each login, and a login done via OAuth
(tea's default flow) has no token field in the file at all - it's held
elsewhere. Confirmed live: a workspace with a genuinely active `tea`
OAuth login was still reporting "not logged in" and skipping the whole
key-setup step because of this.

Replaced with tea's own subcommands, which handle auth internally
regardless of method:
  - detection: `tea whoami`
  - SSH key upload: `tea ssh-keys add`
  - GPG key upload: `tea api -X POST /user/gpg_keys -F armored_public_key=@-`

Verified all three directly against the live account (disposable test
SSH + GPG keys, added then removed) - SSH upload succeeded; the GPG
upload correctly failed for an unrelated, expected reason (Gitea
requires the key's email to match a verified account email, and the
test key used a throwaway address), confirming the request itself is
well-formed.
2026-08-27 00:49:20 +02:00
octoturge d60e44508a cli-setup-wizard: explain why SSH/GPG key setup got skipped
Provision Coder Templates / provision (push) Successful in 2m7s
The SSH/GPG key-generation question only fires when gh/tea are actually
logged in (DID_GITHUB/DID_GITEA), which is correct - but if login was
declined, failed, or never completed, the section was skipped with zero
explanation. From the user's side that looked like a missing feature
rather than an unfinished login.

Confirmed on a live workspace: gh was never installed, and tea was
installed but `tea login add` never actually completed (no
~/.config/tea/config.yml), so the gate correctly stayed closed - the
user just had no way to know why. Now prints a one-line hint (only when
gh or tea is installed at all) pointing at the login command and the
--force re-run.
2026-08-27 00:14:11 +02:00
octoturge 93721b9096 web: install Google Chrome for the Browse Lite extension
Provision Coder Templates / provision (push) Successful in 2m22s
Browse Lite (antfu.browse-lite, kept in the earlier Open VSX audit)
launches an embedded browser preview via a real Chrome/Chromium binary,
which templates/web's image never provided - it failed with "No Chrome
installation found, or no Chrome executable set in the settings".

Ubuntu's own chromium-browser apt package is a snap wrapper and doesn't
work in a container, so this installs Google Chrome's official .deb
instead (amd64 only, matching this repo's single x86_64 Docker host).
Also points browse-lite.chromeExecutable at it explicitly rather than
relying on auto-detection.

Verified the new apt-key/repo/install layer builds cleanly in isolation
on octo-winsrv (google-chrome-stable 152.0.7977.64).
2026-08-26 23:57:51 +02:00
octoturge 013567f02a feat: base git/gnupg install + auto SSH/GPG key setup for external git
Provision Coder Templates / provision (push) Successful in 2m11s
- Every template's coder_agent startup script now unconditionally
  installs git and gnupg as base packages (guarded on `command -v`, so
  it's a fast no-op where already present, e.g. templates/web's baked
  image). Not every base image ships gnupg by default.

- cli-setup-wizard.sh (all 6 templates) now tracks whether the user
  actually ended up authenticated against GitHub and/or Gitea via the
  existing gh/tea install-and-login prompts. If at least one succeeded,
  it asks once more whether to auto-generate an SSH key (ed25519) and a
  GPG signing key (ed25519, quick-gen) and register them with whichever
  host(s) are in play - stays completely silent for "local git only"
  (neither gh nor tea set up).

  - GitHub: `gh ssh-key add` / `gh gpg-key add` (official gh CLI
    subcommands).
  - Gitea: direct calls against `/api/v1/user/keys` and
    `/api/v1/user/gpg_keys`, reusing the token `tea login add` already
    stored in tea's config.yml (parsed with a small awk extractor).
  - Either upload failing (already added, API shape mismatch, etc.)
    just prints the manual command/URL and moves on - never blocks the
    rest of the wizard, consistent with every other step's style.
  - git is configured to sign commits with the new key
    (user.signingkey + commit.gpgsign) once a GPG key exists, whether
    freshly generated or already present from a prior run.

README updated to document both additions.
2026-08-26 23:16:25 +02:00
octoturge e8a8f93f9d fix: strip extension IDs unresolvable via Open VSX from all profiles
Provision Coder Templates / provision (push) Successful in 2m3s
code-server installs extensions from Open VSX by default (not the MS
Marketplace), and treats a single not-found extension as fatal to
startup - one bad ID in a profile takes the whole workspace down, as
seen live across two prior fixes for templates/web alone.

Checked every extension ID in all 6 templates against the Open VSX API
and dropped whichever 404'd:
- web: 91 -> 55 (mostly Pug/Tailwind-ecosystem extensions never
  published to Open VSX, plus the 5 private octoturge.octoturge-*
  devpacks, which can't resolve via any public registry)
- cobol: 27 -> 16 (rocketsoftware.rocket-cobol and Microsoft
  IntelliCode/Remote-Containers, license-restricted to the MS
  Marketplace, among others)
- python: 12 -> 8 (ms-python.vscode-pylance and the Remote-* family,
  also MS-Marketplace-only by license)
- 3d-printing: 34 -> 24
- default and ttrpg: no changes needed, already clean

octoturge's private devpacks aren't republished anywhere here - if
wanted, they'd need a private/self-hosted Open VSX-compatible registry
or manual .vsix installation post-boot.
2026-08-26 20:29:49 +02:00
octoturge 557d6a3d84 fix(web): drop unresolvable 89netram.hotheaded-vscode extension
Provision Coder Templates / provision (push) Successful in 2m6s
Blocked code-server startup entirely (extension install failure was
fatal, code-server never came up -> "connection was refused" on the
agent's proxy port). The id is also miscased in the profile (should be
89netraM, capital M) and the real extension is a Windows-only joke
("Hotheaded VS Code" - screams at you on errors), not something with
any use in a headless Linux code-server container, so dropping it
outright rather than fixing the casing.
2026-08-26 18:51:01 +02:00
octoturge 354536dad5 fix: use sudo for /opt/coder setup in cli_setup_wizard and install_skills
Provision Coder Templates / provision (push) Successful in 2m5s
The coder_agent's coder_script resources run as the unprivileged coder
user, which can't mkdir under root-owned /opt. Every template hit this
identically (mkdir: cannot create directory '/opt/coder': Permission
denied) since coder_script content is shared verbatim across all 6.
coder has passwordless sudo everywhere, so sudo mkdir + chown to the
running user unblocks the rest of each script's non-sudo writes.
2026-08-26 18:38:46 +02:00
octoturge 1be7c00b84 fix(web): resolve Docker build failures in templates/web
Provision Coder Templates / provision (push) Successful in 2m5s
- Drop `pip install --upgrade pip`: Debian-packaged pip 24.0 has no RECORD
  file (dpkg-installed, not pip-installed), so self-upgrade fails trying
  to uninstall in place. Unneeded - packages install fine under stock pip.
- Remove the pre-existing `ubuntu` user/group before `useradd --uid 1000
  coder`: Ubuntu 24.04's base image already provisions a uid/gid 1000
  `ubuntu` user, colliding with the explicit uid.
- Drop the now-pointless `pnpm setup || true` step: it was already
  silently no-oping (EACCES trying to self-manage into PNPM_HOME as a
  non-root user against a root-owned npm-global install) and standard
  pnpm usage (install/run) works fine without it.

Verified via repeated rebuilds directly on the Coder host.
2026-08-26 16:22:39 +02:00
octoturge 52cc84650a Re-declare hashicorp/http provider to unblock existing workspace state
Provision Coder Templates / provision (push) Successful in 2m3s
Removing the jetbrains module (previous commit) dropped the only thing in
these templates that used the hashicorp/http provider (the module fetched
IDE metadata via it internally). terraform init only installs providers the
current config declares, but any workspace whose state still has resources
from a template version that included the jetbrains module needs that
provider available to reconcile/destroy those entries - without it,
plan/apply fails with "Missing required provider ... hashicorp/http".

Re-declaring it in required_providers (all 6 templates) unblocks those
existing workspaces' next apply. Safe to remove again once every workspace
has updated past the jetbrains-module version.
2026-08-26 15:47:25 +02:00
octoturge 0c53bb6554 Add gh/tea to the CLI wizard, drop the JetBrains module from all templates
Provision Coder Templates / provision (push) Successful in 2m7s
- cli-setup-wizard.sh (identical across all 6 templates): add GitHub CLI
  (gh, installed via the official apt repo) and Gitea CLI (tea, official
  binary release to ~/.local/bin) as two more opt-in install-then-login
  prompts, alongside the existing Copilot/Antigravity/Claude Code CLI
  entries. Same pattern: skip if already installed, ask before installing,
  attempt login, never hard-fail the wizard.
- main.tf (all 6 templates): remove the `module "jetbrains"` block - not
  used, dropped per request.
- README.md: update the "Layout" blurb and CLI setup wizard tool list to
  match.
2026-08-26 14:58:51 +02:00
octoturge a302e21609 Bake full-stack toolchain into the web template's Docker image
Provision Coder Templates / provision (push) Successful in 2m20s
templates/web (Coder's "Web Applications" profile) previously just pulled
codercom/enterprise-base:ubuntu and installed Bun at workspace start. Add a
Dockerfile that builds a complete dev image: build-essential/clang/llvm,
Tauri 2 / WebKit GUI prerequisites, Postgres/Redis/SQLite CLI clients,
protobuf-compiler, Python 3 + OpenCV/ONNX/CPU-torch, Node LTS + Bun/pnpm/
yarn, and a full Rust toolchain via rustup (rustfmt/clippy/rust-analyzer/
rust-src, musl+gnu x86_64/aarch64 targets, cargo-watch/cargo-edit/cross/
bacon) under a passwordless-sudo `coder` user.

main.tf now builds this Dockerfile via the docker provider's `docker_image`
resource (context = the template's own directory, tag keyed on the
Dockerfile's hash) instead of pulling the generic base image, and drops the
now-redundant standalone Bun-install coder_script since Bun ships baked into
the image and lands in the persistent home volume via Docker's normal
empty-volume-populated-from-image behavior. Also add rust-analyzer and Tauri
extensions to profile.code-profile, which was otherwise all web/Vue tooling
with nothing for the new Rust/Tauri side of the stack.
2026-08-26 13:48:10 +02:00
octoturge 60e63edd61 Fix bogus templates/ self-match in delete-detection step
Provision Coder Templates / provision (push) Successful in 2m10s
git ls-tree -d --name-only \$SHA -- templates matched the templates/
directory entry itself (basename "templates"), so every run tried and
harmlessly failed to delete a nonexistent profiles-templates template.
Use 'templates/*' as the pathspec to list only the child directories.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 16:55:56 +02:00
octoturge 22429aa4e3 Make each Coder template self-contained (fix coder templates push failure)
Provision Coder Templates / provision (push) Successful in 2m10s
coder templates push -d templates/<env> only uploads that directory to the
Coder server, so main.tf's file() references reaching outside it via
../../profile-templates and ../../scripts failed at push/apply time
("Invalid function argument ... this function works only with files that
are distributed as part of the configuration source code"). Confirmed via
an actual failed run of coder-templates.yml once the runner network fix let
it get that far.

Fix: duplicate profile.code-profile, cli-setup-wizard.sh, and
install-skills.sh into each templates/<env>/ directory and drop the old
shared scripts/ and profile-templates/ directories.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 16:52:12 +02:00
octoturge 5b32539d65 Rename GITEA_ROTATION_TOKEN secret to ROTATION_PAT (GITEA_ prefix is reserved) 2026-08-23 16:20:13 +02:00
octoturge 9424746305 Document API/direct-URL fallback for missing Gitea Secrets tab
Provision Coder Templates / provision (push) Failing after 1m16s
Some Gitea/Forgejo versions drop the Secrets nav link from Settings ->
Actions while Runners and Variables still show, even though the page
and API underneath still work. Document the direct-URL and curl-based
workarounds so the token-rotation/auto-provisioning bootstrap isn't
blocked by it.
2026-08-15 12:32:42 +02:00
octoturge a5a760b24a Add daily Coder token auto-rotation workflow
This Coder deployment caps API token lifetime at 168h (7 days), so
rather than raising that cap deployment-wide, add
.gitea/workflows/rotate-coder-token.yml: runs daily, mints a new 168h
coder token, PUTs it into this repo's CODER_SESSION_TOKEN secret via
Gitea's actions/secrets API (confirmed against the live instance's
swagger.v1.json - PUT /repos/{owner}/{repo}/actions/secrets/{name} with
{"data": "..."}), then deletes the token(s) it replaced. Old token isn't
touched until the new one is confirmed live, so a failed run fails safe.

Needs a one-time GITEA_ROTATION_TOKEN secret (a Gitea PAT with
write:repository scope, no expiration) so the workflow can write to its
own repo's secrets going forward - documented in README. After that,
CODER_SESSION_TOKEN (used by coder-templates.yml) never needs manual
attention again.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 12:02:22 +02:00
octoturge a9583535f0 Add Gitea Actions workflow to auto-provision Coder templates
.gitea/workflows/coder-templates.yml pushes every templates/<env>/ dir
to Coder as profiles-<env> on every push to main that touches
templates/**, scripts/**, or profile-templates/** - coder templates push
creates the template on first run and updates it thereafter, so adding a
new templates/<env>/ directory is enough to provision it, no workflow
edits needed.

It also diffs templates/ against the previous commit and runs
`coder templates delete profiles-<env>` for any directory that was
removed. Deletion fails (loudly, as a job warning, not a hard failure)
rather than succeeding if the template still has active workspaces,
since coder templates delete already refuses that server-side.

Runs on the ubuntu-latest self-hosted Gitea runner already registered on
this instance (confirmed via gitea-runner-1's /data/.runner labels) and
installs the coder CLI itself. Needs CODER_URL and CODER_SESSION_TOKEN
as repo/org Actions secrets - documented in README, left for the user to
set up since token creation needs their own Coder login.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 11:49:56 +02:00
octoturge f6c432d21d Install Agent Skills into all 3 AI CLIs, and use Bun instead of npm
Adds scripts/install-skills.sh: pulls this repo's extensions/ skill
bundles (SKILL.md-format) at workspace startup and installs them into
Claude Code (~/.claude/skills), GitHub Copilot CLI (~/.copilot/skills),
and Antigravity CLI (~/.gemini/config/skills). Every env gets the common
awesome-skills-plugin bundle; COBOL/3D/TTRPG additionally get their
matching skill(s) from custom-specialty-plugin via a per-template
SPECIALTY_SKILLS value.

Each template also installs Bun via a coder_script and hooks ~/.bun/bin
onto PATH in .bashrc (the bun.sh installer doesn't reliably do this in a
scripted shell). The CLI setup wizard now uses `bun install -g` instead
of `npm install -g` for GitHub Copilot CLI and Claude Code CLI.

All six templates re-validated with terraform init/validate against the
real coder-server container on octo-winsrv.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 11:41:09 +02:00
octoturge 1beaac6e4f Split single-container Coder template into per-env templates
The old root main.tf tried to switch dev profiles inside one shared
container via a coder_parameter dropdown; the settings-application path
looked for a *.json cache file that never existed (the cache was written
as *.code-profile), so profile settings never actually applied, and VS
Code extensions were copied from extensions/ (which turns out to be
Claude Code plugin bundles, not real VS Code extension packages).

Replace it with one independent Coder template per environment
(templates/default, 3d-printing, cobol, python, ttrpg, web). Each reads
its matching profile-templates/*.code-profile file at template-push time
via file()/jsondecode(), feeds the extension ID list straight into the
code-server module's `extensions` input, and writes the raw settings.json
text via a coder_script - no runtime Gitea zip download needed anymore.

Also add scripts/cli-setup-wizard.sh, hooked into every new interactive
shell until completed, offering to install/log into GitHub Copilot CLI,
Google Antigravity CLI, and Claude Code CLI. VS Code extensions are
deliberately not asked about there since Terraform already handles them.

All six templates verified with `terraform init`/`validate` against the
real coder-server container on octo-winsrv.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 11:28:26 +02:00
39 changed files with 4980 additions and 4 deletions
+4
View File
@@ -0,0 +1,4 @@
* text=auto eol=lf
*.sh text eol=lf
*.tf text eol=lf
*.code-profile -text
+225
View File
@@ -0,0 +1,225 @@
name: Provision Coder Templates
# Keeps Coder templates in sync with templates/*/ in this repo:
# - every push to main pushes a new version of each templates/<env>/ dir
# whose VERSION file names a version not already pushed (coder templates
# push creates the template if it doesn't exist yet, so adding a new
# templates/<env>/ directory - with a VERSION file - is enough to
# provision a new one). This workflow triggers on any change under
# templates/**, not just a specific template's own directory, so VERSION
# is what keeps an edit to one template from generating a no-op new
# version for every other, unchanged template.
# - if a templates/<env>/ directory is removed on main, its template is
# deleted from Coder. `coder templates delete` refuses to delete a
# template that still has active workspaces, so this can't silently
# orphan running workspaces - it just fails loudly and needs a human.
#
# Requires three repo/org secrets (Settings > Actions > Secrets):
# CODER_URL e.g. https://code.octoturge.com
# CODER_SESSION_TOKEN a token from `coder tokens create`, ideally under a
# dedicated service account rather than a personal one
# PACKAGE_REGISTRY_TOKEN a Gitea access token (user Settings > Applications)
# with write:package scope, for pushing each
# Dockerfile-having template's image to this
# instance's container registry.
#
# Not secrets.GITEA_TOKEN (Gitea Actions' built-in token): as of this
# writing it cannot authenticate to the container registry in any shipped
# Gitea version - `permissions: packages: write` is a no-op because the
# Actions token's package scope isn't wired up server-side yet (open since
# Gitea 1.19: https://github.com/go-gitea/gitea/issues/23642; fix in
# https://github.com/go-gitea/gitea/pull/39070, not yet merged). Every
# attempt fails at docker login with a plain "unauthorized", regardless of
# the permissions: block or which account triggered the workflow. A
# manually-created PAT is the only thing that currently works. Named
# without a GITEA_ prefix because Gitea Actions reserves that prefix for
# its own built-in secrets and rejects creating one with that name.
#
# Any templates/<env>/ that has its own Dockerfile gets its image built and
# pushed here (build-images, on the dedicated "docker-build" runner - see
# templates/web/main.tf for why: that runner is scoped to this repo only and
# has host Docker socket access that the shared runner-1 deliberately
# doesn't). provision then just pulls the tag build-images produced, instead
# of building it itself at `terraform apply` time - keeps a slow toolchain
# compile off of "someone is waiting to create a workspace".
#
# Each template's image is built independently - one Dockerfile failing to
# build doesn't stop the others from building, and provision skips pushing
# only the specific template(s) whose image build failed this run (leaving
# their previous, already-working Coder template version in place) rather
# than skipping every template or pushing one with no matching image.
on:
push:
branches: [main]
paths:
- "templates/**"
- ".gitea/workflows/coder-templates.yml"
workflow_dispatch: {}
jobs:
build-images:
# The docker-build runner's docker_host: "" setting already auto-injects
# /var/run/docker.sock into job containers - an explicit
# container.volumes mount for the same path here fails at container
# creation with "Duplicate mount point: /var/run/docker.sock".
runs-on: docker-build
# Currently a no-op (see the PACKAGE_REGISTRY_TOKEN note above) but
# harmless to declare now - once go-gitea/gitea#39070 ships, GITEA_TOKEN
# will need this to get package write access, so this is one less thing
# to remember when PACKAGE_REGISTRY_TOKEN can eventually be retired.
permissions:
packages: write
outputs:
failed_templates: ${{ steps.build.outputs.failed_templates }}
# docker:27-cli (Alpine) has no bash - only the POSIX /bin/sh (busybox
# ash) - but run: steps default to bash, which fails with "exec: bash:
# executable file not found in $PATH". Both run: steps below are plain
# POSIX shell already, so just run them under sh.
defaults:
run:
shell: sh
steps:
# actions/checkout is a JS action and needs Node in the job container -
# this job runs in docker:27-cli (Alpine, just the Docker CLI) so it has
# no Node, and actions/checkout fails immediately with "exec: node:
# executable file not found in $PATH". Alpine does have apk/git though,
# so clone directly instead.
- name: Checkout
run: |
set -e
apk add --no-cache git
git clone --depth 1 --branch "${{ github.ref_name }}" "${{ github.server_url }}/${{ github.repository }}.git" .
- name: Build and push every template's image (skips a tag that's already in the registry)
id: build
run: |
set -e
echo "${{ secrets.PACKAGE_REGISTRY_TOKEN }}" | docker login git.octoturge.com -u octoturge --password-stdin
FAILED=""
for dockerfile in templates/*/Dockerfile; do
[ -e "$dockerfile" ] || continue
dir="$(dirname "$dockerfile")"
name="$(basename "$dir")"
TAG="$(sha1sum "$dockerfile" | cut -d' ' -f1)"
IMAGE="git.octoturge.com/octo-tech/profiles-${name}:${TAG}"
echo "::group::${name}"
if docker manifest inspect "$IMAGE" >/dev/null 2>&1; then
echo "$IMAGE already in the registry (Dockerfile unchanged), skipping build."
elif docker build -t "$IMAGE" "$dir" && docker push "$IMAGE"; then
echo "Built and pushed $IMAGE"
else
echo "::warning::Failed to build/push $IMAGE - templates/$name will be skipped this run."
FAILED="$FAILED $name"
fi
echo "::endgroup::"
done
echo "failed_templates=${FAILED# }" >> "$GITHUB_OUTPUT"
provision:
# needs: build-images orders this after the image builds (so a fresh
# template push never points at a tag that isn't in the registry yet)
# without making every template's reprovisioning depend on ALL builds
# succeeding - if:always() overrides the default "skip if a dependency
# failed" behavior, since build-images only fails outright on an
# infra-level problem (e.g. registry login); a single template's build
# failure is reported via failed_templates instead and only skips that
# one template below.
needs: build-images
if: always()
runs-on: ubuntu-latest
env:
CODER_URL: ${{ secrets.CODER_URL }}
CODER_SESSION_TOKEN: ${{ secrets.CODER_SESSION_TOKEN }}
FAILED_TEMPLATES: ${{ needs.build-images.outputs.failed_templates }}
steps:
- name: Checkout (full history, needed to detect removed templates)
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install coder CLI
run: |
set -e
curl -fsSL https://coder.com/install.sh | sh
coder version
- name: Push (create or update) every template
run: |
set -e
for dir in templates/*/; do
name="$(basename "$dir")"
full="profiles-$name"
case " $FAILED_TEMPLATES " in
*" $name "*)
echo "::warning::Skipping $full - its Docker image failed to build this run (see build-images), leaving the previous template version in place."
continue
;;
esac
# templates/<name>/VERSION lets a template opt out of being
# reprovisioned on every push: bump it and coder templates push
# names the new version "v<N>". This is a manual contract, not a
# content hash - editing a template without bumping its VERSION
# means the change won't go out until someone does. paths: on
# this workflow's trigger is templates/** as a whole, so without
# this every template gets a new (identical) version on any push
# under templates/, even ones whose own directory didn't change.
#
# Rather than pre-checking `coder templates versions list` for
# whether v<N> already exists (fragile - depends on its exact
# JSON shape matching what we expect), just attempt the push and
# treat its specific "version already exists" failure as the
# skip signal instead.
version=""
if [ -f "$dir/VERSION" ]; then
version="$(tr -d '[:space:]' < "$dir/VERSION")"
fi
args=(-d "$dir" --yes -m "auto-provisioned from ${GITHUB_SHA:0:12}")
[ -n "$version" ] && args+=(--name "v$version")
echo "::group::Pushing $full from $dir"
if push_output="$(coder templates push "$full" "${args[@]}" 2>&1)"; then
echo "$push_output"
else
push_status=$?
echo "$push_output"
if [ -n "$version" ] && printf '%s' "$push_output" | grep -qF "A template version with name \"v$version\" already exists"; then
echo "Version v$version (templates/$name/VERSION) is already pushed - nothing to do. Bump the VERSION file to push a new one."
else
echo "::endgroup::"
exit "$push_status"
fi
fi
echo "::endgroup::"
done
- name: Delete templates whose directory was removed
if: github.event_name == 'push'
run: |
set -e
PREV_SHA="$(git rev-parse HEAD~1 2>/dev/null || true)"
if [ -z "$PREV_SHA" ]; then
echo "No previous commit on this branch (first push), nothing to diff. Skipping."
exit 0
fi
OLD_DIRS="$(git ls-tree -d --name-only "$PREV_SHA" -- 'templates/*' 2>/dev/null | xargs -n1 basename 2>/dev/null || true)"
if [ -z "$OLD_DIRS" ]; then
echo "No templates/ directory at $PREV_SHA, nothing to diff. Skipping."
exit 0
fi
for old in $OLD_DIRS; do
if [ ! -d "templates/$old" ]; then
name="profiles-$old"
echo "::group::Deleting $name (templates/$old was removed)"
coder templates delete "$name" --yes \
|| echo "::warning::Failed to delete $name - check for active workspaces still using it."
echo "::endgroup::"
fi
done
+85
View File
@@ -0,0 +1,85 @@
name: Rotate Coder API Token
# Keeps the CODER_SESSION_TOKEN secret (used by coder-templates.yml) alive
# forever without anyone needing to remember to refresh it. This Coder
# deployment caps token lifetime at 168h (7 days), so this runs daily,
# mints a fresh 168h token, writes it back into this repo's
# CODER_SESSION_TOKEN secret via the Gitea API, then deletes the token(s)
# it replaced.
#
# One-time bootstrap (see README "Auto-provisioning" section): a
# ROTATION_PAT secret holding a Gitea personal access token (write:repository
# scope, no expiration) with permission to write this repo's Actions secrets.
# Not named GITEA_ROTATION_TOKEN because Gitea reserves the GITEA_ prefix for
# its own automatic tokens/variables and rejects secrets with that prefix.
# Nothing else needs to touch this ever again.
on:
schedule:
- cron: "0 3 * * *"
workflow_dispatch: {}
jobs:
rotate:
runs-on: ubuntu-latest
env:
CODER_URL: ${{ secrets.CODER_URL }}
CODER_SESSION_TOKEN: ${{ secrets.CODER_SESSION_TOKEN }}
ROTATION_PAT: ${{ secrets.ROTATION_PAT }}
GITEA_API_URL: ${{ github.server_url }}/api/v1
GITEA_REPO_PATH: ${{ github.repository }}
steps:
- name: Install coder CLI and jq
run: |
set -e
curl -fsSL https://coder.com/install.sh | sh
coder version
command -v jq >/dev/null 2>&1 || (apt-get update -qq && apt-get install -y -qq jq)
- name: Create a new token
id: new_token
run: |
set -e
NAME="gitea-ci-$(date -u +%Y%m%dT%H%M%SZ)"
TOKEN="$(coder tokens create --name "$NAME" --lifetime 168h)"
if [ -z "$TOKEN" ]; then
echo "::error::coder tokens create returned no token"
exit 1
fi
echo "::add-mask::$TOKEN"
echo "name=$NAME" >> "$GITHUB_OUTPUT"
echo "token=$TOKEN" >> "$GITHUB_OUTPUT"
- name: Publish new token to CODER_SESSION_TOKEN secret
env:
NEW_TOKEN: ${{ steps.new_token.outputs.token }}
run: |
set -e
BODY="$(jq -n --arg data "$NEW_TOKEN" '{data:$data}')"
HTTP_STATUS="$(curl -s -o /tmp/put-secret.out -w '%{http_code}' \
-X PUT \
-H "Authorization: token ${ROTATION_PAT}" \
-H "Content-Type: application/json" \
-d "$BODY" \
"${GITEA_API_URL}/repos/${GITEA_REPO_PATH}/actions/secrets/CODER_SESSION_TOKEN")"
if [ "$HTTP_STATUS" != "201" ] && [ "$HTTP_STATUS" != "204" ]; then
echo "::error::Failed to update CODER_SESSION_TOKEN secret (HTTP $HTTP_STATUS)"
cat /tmp/put-secret.out
exit 1
fi
echo "CODER_SESSION_TOKEN secret updated (HTTP $HTTP_STATUS)."
- name: Delete the token(s) this replaced
env:
KEEP_NAME: ${{ steps.new_token.outputs.name }}
run: |
set -e
coder tokens list -o json \
| jq -r --arg keep "$KEEP_NAME" \
'.[] | select(.token_name | startswith("gitea-ci-")) | select(.token_name != $keep) | .id' \
| while read -r id; do
[ -z "$id" ] && continue
echo "Removing superseded token $id"
coder tokens delete "$id" --delete \
|| echo "::warning::Failed to delete superseded token $id"
done
+264
View File
@@ -1,2 +1,266 @@
# Profiles-for-Coder # Profiles-for-Coder
Coder templates for per-discipline dev environments (Default, 3D Printing,
COBOL, Python, TTRPG, Web). Each environment is its own Coder **template** -
not a dropdown inside one shared container, which is what this repo used to
do and which didn't actually work.
## Layout
```
templates/
default/
main.tf # Standard Default Dev
profile.code-profile # VS Code .code-profile export for this env
cli-setup-wizard.sh # first-run wizard, see below
install-skills.sh # Agent Skills installer, see below
3d-printing/ (same 4 files) # 3D Printing & Engineering
cobol/ (same 4 files) # COBOL Modern Mainframe
python/ (same 4 files) # Python Engineering
ttrpg/ (same 4 files) # TTRPG & Lore Building
web/ (same 4 files, plus Dockerfile) # Web Applications
extensions/ # Agent Skills bundles, installed per env (see below)
```
### Web Applications: baked-in toolchain image
Every other template pulls `codercom/enterprise-base:ubuntu` straight from
Docker Hub and installs what little it needs (just Bun) via a `coder_script`
at workspace start. `templates/web/` doesn't do that: it needs a large,
slow-to-install native toolchain (Rust/rustup with cross targets, the Tauri 2
/ WebKit GUI dev libraries, Node + Bun + pnpm/yarn, Python with OpenCV/ONNX/
CPU-torch, Postgres/Redis/SQLite CLI clients, protoc, clang/llvm) that would
make every workspace start take many minutes if installed on the fly.
Instead `templates/web/main.tf` builds `templates/web/Dockerfile` at
apply/push time via the `docker` provider's `docker_image` resource (`build
{ context = path.module }`, same directory as `main.tf` so no `file()`
reaches outside the template per the constraint above) and runs the
container from that image instead of the enterprise-base one. The image tag
embeds `filesha1(Dockerfile)`, so editing the Dockerfile forces a rebuild on
the next apply/push while an unchanged Dockerfile reuses Docker's build
cache. Because the toolchain lives under `/home/coder` (rustup, cargo, bun),
and that path is a fresh *named* Docker volume on a workspace's first boot,
Docker's own "populate an empty volume from the image's directory contents"
behavior copies all of it into the persistent volume automatically - no
extra `coder_script` needed, matching how the `/etc/skel` copy in every
template's `startup_script` already relies on that same mechanism.
Each `templates/<env>/` is a complete, independent Coder template (agent,
docker container, code-server) with its own copy of everything `main.tf`
needs. They're deliberately not built from a shared Terraform
module or shared files elsewhere in the repo: `coder templates push -d
templates/<env>` only uploads that one directory to the Coder server, so any
`file()` reference reaching outside it (e.g. the old shared
`scripts/`/`profile-templates/` layout) fails at push/apply time with
`Invalid function argument: ... this function works only with files that
are distributed as part of the configuration source code` - discovered the
hard way once `coder-templates.yml` actually ran end-to-end. `main.tf`,
`profile.code-profile`, `cli-setup-wizard.sh`, and `install-skills.sh` are
duplicated per template rather than shared for this reason; only their
`locals` block and profile file differ meaningfully. The naming convention
is `profiles-<dir>` (matches what `.gitea/workflows/coder-templates.yml`
does automatically - see below). To push by hand:
```sh
coder templates push profiles-default -d templates/default
coder templates push profiles-3d-printing -d templates/3d-printing
coder templates push profiles-cobol -d templates/cobol
coder templates push profiles-python -d templates/python
coder templates push profiles-ttrpg -d templates/ttrpg
coder templates push profiles-web -d templates/web
```
### How the VS Code profile gets applied
`templates/<env>/profile.code-profile` is a real VS Code Profile export: a JSON
file whose `settings` and `extensions` fields are themselves JSON-encoded
strings (double/triple-nested). Each template's `main.tf` reads and decodes
its matching file **at `terraform apply`/push time** (via `file()` +
`jsondecode()`), then:
- passes the extension ID list straight into the `code-server` module's
`extensions` input, so code-server installs them on first boot - no
interactive prompt needed, Terraform handles it declaratively;
- writes the raw `settings.json` text (comments and all - VS Code tolerates
JSONC) to `~/.local/share/code-server/User/settings.json` via a
`coder_script`.
This replaces the old approach, which downloaded a zip of this repo from
Gitea *inside* the running container and tried to apply settings from
`~/.local/share/profiles-cache/<profile>.json` - a path that never matched
the actual `.code-profile` file extension, so settings never applied. That
bug (plus the single shared container) is why "one container, many envs"
never really worked.
### Bun
Every template except `web` installs [Bun](https://bun.sh) via a
`coder_script` (`curl -fsSL https://bun.sh/install | bash`) and hooks
`~/.bun/bin` onto `PATH` in `~/.bashrc` (the installer doesn't reliably do
this itself in a non-interactive/scripted shell). `web` instead bakes Bun
into its Dockerfile with `BUN_INSTALL`/`PATH` set as image `ENV` - see "Web
Applications: baked-in toolchain image" above. Either way, the CLI setup
wizard below uses `bun install -g <pkg>` instead of `npm install -g <pkg>`
for everything it installs.
### CLI setup wizard
`templates/<env>/cli-setup-wizard.sh` (identical across envs) is dropped
onto every workspace and hooked
into `~/.bashrc`. It runs in every new interactive terminal - until the user
finishes it - and offers to install + log into:
- **GitHub Copilot CLI** (`bun install -g @github/copilot`, then `copilot login`)
- **Google Antigravity CLI** (`curl -fsSL https://antigravity.google/cli/install.sh | bash`, binary `agy`)
- **Claude Code CLI** (`bun install -g @anthropic-ai/claude-code`, then `claude`)
- **GitHub CLI** (`gh`, via the official apt repo, then `gh auth login`)
- **Gitea CLI** (`tea`, official binary release downloaded to `~/.local/bin`, then `tea login add`)
`git` and `gnupg` themselves aren't part of this opt-in flow - every
template's `coder_agent` startup script installs them unconditionally as
base packages (a no-op where they're already present, e.g. baked into
`templates/web`'s image). If the wizard just authenticated GitHub and/or
Gitea above (skipped entirely for "local git only" - neither set up), it
asks once more whether to auto-generate an ed25519 SSH key and an ed25519
GPG signing key and register them with whichever host(s) got set up: `gh
ssh-key add` / `gh gpg-key add` for GitHub, a direct call against Gitea's
`/api/v1/user/keys` and `/api/v1/user/gpg_keys` (using the token `tea
login add` already stored) for Gitea. Either upload failing just prints
the manual command/URL to finish it yourself - never blocks the rest of
the wizard.
It does **not** ask about VS Code extensions, since those are handled by
Terraform (see above). Once the user confirms completion it writes a
sentinel file (`~/.cache/coder-cli-wizard/done`) and stops prompting. It can
always be re-run manually: `bash /opt/coder/cli-setup-wizard.sh --force`.
### `extensions/` directory -> Agent Skills
`extensions/awesome-skills-plugin` and `extensions/custom-specialty-plugin`
are **Agent Skills bundles** (`plugin.json` + `SKILL.md` files) - the old
root `main.tf` copied this folder straight into code-server's VS Code
extensions directory, which never worked since these aren't VS Code
extension packages.
Each template now runs its own copy of `install-skills.sh` (via a `coder_script`,
pulling a fresh zip of this repo from Gitea rather than embedding ~2.5MB
into Terraform state) to install skills into all three AI CLIs' personal
skills directories:
| CLI | Skills directory |
| --- | --- |
| Claude Code | `~/.claude/skills/<name>/` |
| GitHub Copilot CLI | `~/.copilot/skills/<name>/` |
| Antigravity CLI | `~/.gemini/config/skills/<name>/` (per antigravity.google/docs/skills - some third-party docs disagree on this path, worth a spot-check on a live workspace) |
Every environment gets the full `extensions/awesome-skills-plugin/skills/*`
bundle. On top of that, whichever env has a matching entry in
`extensions/custom-specialty-plugin/skills/` gets it installed too, set via
the `SPECIALTY_SKILLS` env var passed to the script from each template's
`install_skills` `coder_script`:
- COBOL -> `cobol-teacher`
- 3D Printing -> `openscad-parametric`
- TTRPG -> `foundryvtt-modding` and `ttrpg-lore-weaver`
- Default / Python / Web -> none (no matching specialty skill exists yet)
### Auto-provisioning (Gitea Actions)
`.gitea/workflows/coder-templates.yml` keeps Coder in sync with this repo on
every push to `main` that touches `templates/**`:
- **Add** a new `templates/<env>/` directory -> next push creates a new
Coder template `profiles-<env>` automatically. No workflow edits needed.
- **Edit** an existing `templates/<env>/main.tf` (or its `profile.code-profile`,
`cli-setup-wizard.sh`, or `install-skills.sh`) -> next push updates that
template with a new version.
- **Remove** a `templates/<env>/` directory -> next push deletes
`profiles-<env>` from Coder. `coder templates delete` refuses if the
template still has active workspaces, so this fails loudly instead of
silently orphaning anyone's running workspace - that failure only shows up
as a `::warning::` in the job log, it doesn't fail the whole run.
It runs on the `ubuntu-latest` self-hosted runner already registered on this
Gitea instance and installs the `coder` CLI itself via `coder.com/install.sh`.
**One-time setup required** (not something this workflow can do for itself -
needs a human with Coder access). This deployment caps API token lifetime at
168h (7 days), so rather than raising that cap deployment-wide,
`.gitea/workflows/rotate-coder-token.yml` (see below) keeps a fresh token
flowing into the secret automatically:
1. Create a Coder API token - ideally under a dedicated service account
rather than a personal login, since this token can create/delete
templates:
```sh
coder login https://code.octoturge.com
coder tokens create --name gitea-ci --lifetime 168h
```
2. In Gitea, go to this repo's **Settings -> Actions -> Secrets** (or the
org-level equivalent to share across repos) and add:
- `CODER_URL` = `https://code.octoturge.com`
- `CODER_SESSION_TOKEN` = the token printed by step 1
Until those secrets exist, `coder-templates.yml` will run and fail cleanly
at the `coder templates push` step rather than doing anything destructive.
**If the Secrets tab doesn't show up** (some Gitea/Forgejo versions drop the
*Secrets* nav link from Settings -> Actions while *Runners* and *Variables*
still show, even though the page and API underneath both still work - see
[forgejo#938](https://codeberg.org/forgejo/forgejo/issues/938)), try either:
1. Go straight to the URL the nav link would normally point at:
`https://<gitea-host>/<owner>/<repo>/settings/actions/secrets`. If a
working "Add Secret" form loads there, it's just a missing nav link - add
the secrets on that page as normal.
2. If that also won't load, set the secrets via the Actions Secrets API
instead, using a Gitea personal access token (`write:repository` scope,
Settings -> Applications -> Generate New Token):
```sh
GITEA_PAT="<your Gitea PAT>"
OWNER=octoturge
REPO=Profiles-for-Coder
curl -s -X PUT "https://<gitea-host>/api/v1/repos/$OWNER/$REPO/actions/secrets/CODER_URL" \
-H "Authorization: token $GITEA_PAT" -H "Content-Type: application/json" \
-d '{"data":"https://code.octoturge.com"}'
curl -s -X PUT "https://<gitea-host>/api/v1/repos/$OWNER/$REPO/actions/secrets/CODER_SESSION_TOKEN" \
-H "Authorization: token $GITEA_PAT" -H "Content-Type: application/json" \
-d "{\"data\":\"$(coder tokens create --name gitea-ci --lifetime 168h)\"}"
```
A `201`/`204` response means the secret was saved. This is the exact same
endpoint `rotate-coder-token.yml` uses at runtime, so if it works here it
confirms the workflow itself will be able to update the secret later too.
Never paste a PAT or Coder token into a chat/ticket - run these commands
from a trusted shell only.
### Token rotation (Gitea Actions)
`.gitea/workflows/rotate-coder-token.yml` runs daily and keeps
`CODER_SESSION_TOKEN` alive forever without anyone needing to remember to
refresh it: it mints a new 168h Coder token, writes it into the
`CODER_SESSION_TOKEN` secret via the Gitea API, then deletes the token(s) it
just replaced. If a run ever fails, the previous token is still untouched
and still valid (nothing gets deleted until the new one is confirmed live),
so it fails safe rather than locking you out.
**One-time bootstrap** (also needs a human - this is what lets the rotation
workflow write to its own repo's secrets):
1. Create a Gitea personal access token with **write:repository** scope and
**no expiration** (Settings -> Applications -> Generate New Token). This
one doesn't rotate itself, so give it a long life up front.
2. Add it as a repo/org Actions secret named `ROTATION_PAT` (same
Settings -> Actions -> Secrets page as above - if that tab is missing,
see the nav-link workaround / API fallback in the "Auto-provisioning"
section above, same `curl -X PUT .../actions/secrets/<name>` pattern,
just with `ROTATION_PAT` as the secret name and the PAT itself as the
value). Not named `GITEA_ROTATION_TOKEN` - Gitea reserves the `GITEA_`
prefix for its own automatic tokens/variables and rejects secrets with
that prefix (`Error: invalid variable or secret name`).
After that, `CODER_SESSION_TOKEN` never needs manual attention again - you
can also trigger a rotation on demand from Gitea's Actions tab
(`workflow_dispatch`) instead of waiting for the daily schedule.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
3
+305
View File
@@ -0,0 +1,305 @@
#!/usr/bin/env bash
# Coder workspace first-run CLI setup wizard.
#
# Meant to be `source`d from a new interactive shell (e.g. via .bashrc). It asks,
# once per user per workspace, whether to install and log into a few optional
# AI coding CLIs. It re-runs on every new terminal until the user lets it finish
# (or explicitly skips it for good), then gets out of the way.
#
# VS Code / code-server extensions are intentionally NOT asked about here -
# they're installed declaratively by the Coder template itself (the
# `code-server` module's `extensions` input, populated from the matching
# profile-templates/*.code-profile file at template-push time).
#
# Manual re-run: bash /opt/coder/cli-setup-wizard.sh --force
set -u
WIZARD_DONE_FILE="${HOME}/.cache/coder-cli-wizard/done"
FORCE=0
[ "${1:-}" = "--force" ] && FORCE=1
# Tracks whether the user actually ended up authenticated against GitHub
# and/or Gitea below, so the SSH/GPG key step can ask about exactly the
# host(s) in play (and stay silent - "local git only" - if neither).
DID_GITHUB=0
DID_GITEA=0
export BUN_INSTALL="${HOME}/.bun"
export PATH="${BUN_INSTALL}/bin:${HOME}/.local/bin:${PATH}"
# Only bother interactive shells with a real terminal attached, and only until
# the user marks the wizard as done.
if [ "$FORCE" -ne 1 ]; then
case "$-" in
*i*) : ;;
*) return 0 2>/dev/null || exit 0 ;;
esac
[ -t 0 ] || { return 0 2>/dev/null || exit 0; }
[ -f "$WIZARD_DONE_FILE" ] && { return 0 2>/dev/null || exit 0; }
fi
mkdir -p "$(dirname "$WIZARD_DONE_FILE")"
ask_yes_no() {
local prompt="$1" reply
read -r -p "$prompt [y/N] " reply
case "$reply" in
[Yy]*) return 0 ;;
*) return 1 ;;
esac
}
echo ""
echo "==================================================================="
echo " Coder workspace setup wizard"
echo " Runs once per new terminal until you finish it. Ctrl+C any time"
echo " to skip for now - it'll ask again next terminal."
echo "==================================================================="
# --- GitHub Copilot CLI ---
if command -v copilot >/dev/null 2>&1; then
echo "GitHub Copilot CLI already installed, skipping."
elif command -v bun >/dev/null 2>&1; then
if ask_yes_no "Install GitHub Copilot CLI and log in?"; then
if bun install -g @github/copilot; then
copilot login || echo "Install succeeded but login didn't complete. Retry any time with: copilot login"
else
echo "Copilot CLI install failed. Retry later with: bun install -g @github/copilot && copilot login"
fi
fi
else
echo "Skipping GitHub Copilot CLI: bun not found on this workspace image."
fi
# --- Google Antigravity CLI (agy) ---
if command -v agy >/dev/null 2>&1; then
echo "Antigravity CLI already installed, skipping."
else
if ask_yes_no "Install Google Antigravity CLI (agy) and log in?"; then
if curl -fsSL https://antigravity.google/cli/install.sh | bash; then
echo "Launching 'agy' once to complete sign-in (exit with /logout or Ctrl+D when done)..."
agy || echo "Sign-in didn't complete. Retry any time by running: agy"
else
echo "Antigravity CLI install failed. Retry later with: curl -fsSL https://antigravity.google/cli/install.sh | bash"
fi
fi
fi
# --- Claude Code CLI ---
if command -v claude >/dev/null 2>&1; then
echo "Claude Code CLI already installed, skipping."
elif command -v bun >/dev/null 2>&1; then
if ask_yes_no "Install Claude Code CLI and log in?"; then
if bun install -g @anthropic-ai/claude-code; then
echo "Launching 'claude' once to complete sign-in (use /login if not prompted; Ctrl+C to exit when done)..."
claude || echo "Sign-in didn't complete. Retry any time by running: claude"
else
echo "Claude Code CLI install failed. Retry later with: bun install -g @anthropic-ai/claude-code"
fi
fi
else
echo "Skipping Claude Code CLI: bun not found on this workspace image."
fi
# --- GitHub CLI (gh) ---
if command -v gh >/dev/null 2>&1; then
echo "GitHub CLI already installed, skipping."
else
if ask_yes_no "Install GitHub CLI (gh) and log in?"; then
if (sudo mkdir -p -m 755 /etc/apt/keyrings \
&& curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | sudo tee /etc/apt/keyrings/githubcli-archive-keyring.gpg >/dev/null \
&& sudo chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg \
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list >/dev/null \
&& sudo apt-get update -qq && sudo apt-get install -y gh); then
gh auth login || echo "Install succeeded but login didn't complete. Retry any time with: gh auth login"
else
echo "GitHub CLI install failed. Retry later with: gh auth login (once gh is installed)"
fi
fi
fi
command -v gh >/dev/null 2>&1 && gh auth status >/dev/null 2>&1 && DID_GITHUB=1
# --- Gitea CLI (tea) ---
if command -v tea >/dev/null 2>&1; then
echo "Gitea CLI already installed, skipping."
else
if ask_yes_no "Install Gitea CLI (tea) and log in?"; then
TEA_ARCH="$(uname -m)"
case "$TEA_ARCH" in
x86_64) TEA_ARCH="amd64" ;;
aarch64) TEA_ARCH="arm64" ;;
esac
TEA_VERSION="$(curl -fsSL https://gitea.com/api/v1/repos/gitea/tea/releases/latest | grep -o '"tag_name":[^,]*' | grep -o 'v[0-9][^"]*')"
mkdir -p "$HOME/.local/bin"
if [ -n "$TEA_VERSION" ] \
&& curl -fsSL "https://gitea.com/gitea/tea/releases/download/${TEA_VERSION}/tea-${TEA_VERSION#v}-linux-${TEA_ARCH}" -o "$HOME/.local/bin/tea" \
&& chmod +x "$HOME/.local/bin/tea"; then
echo "Add this Gitea instance now (e.g. https://git.octoturge.com)..."
tea login add || echo "Login didn't complete. Retry any time with: tea login add"
else
echo "Gitea CLI install failed. Retry later from: https://gitea.com/gitea/tea/releases"
rm -f "$HOME/.local/bin/tea"
fi
fi
fi
# `tea whoami` succeeds regardless of how the login was done (personal
# access token or OAuth) - more reliable than parsing tea's own
# config.yml, whose indentation and fields (no plaintext `token:` at all
# for an OAuth login) vary by auth method.
command -v tea >/dev/null 2>&1 && tea whoami >/dev/null 2>&1 && DID_GITEA=1
# --- Git credential helper for Gitea, backed by tea's own login ---
# Coder injects GIT_ASKPASS to answer git's own credential prompts, but it
# only recognizes external auth providers it knows about (github.com is
# configured on the Coder deployment itself) - for any other host it falls
# through to a broken interactive prompt that just hangs a non-interactive
# git subprocess. A credential helper that successfully answers
# `git credential fill` runs *before* GIT_ASKPASS is ever consulted, so
# registering one for git.octoturge.com sidesteps that broken path
# entirely, without touching how Coder handles github.com.
#
# The token itself lives in tea's own store
# (~/.config/tea/credentials.json), which tea keeps fresh (via its
# refresh_token) as a side effect of any authenticated call - this helper
# never keeps its own copy, just re-reads tea's live value every time git
# asks.
if [ "$DID_GITEA" -eq 1 ]; then
GITEA_LOGIN_NAME="$(awk '
/^[[:space:]]*- name:/ { name = $NF }
/url: https:\/\/git\.octoturge\.com/ { print name; exit }
' "$HOME/.config/tea/config.yml" 2>/dev/null)"
if [ -n "$GITEA_LOGIN_NAME" ]; then
mkdir -p "$HOME/.local/bin"
HELPER="$HOME/.local/bin/git-credential-gitea-tea"
cat > "$HELPER" <<'HELPER_EOT'
#!/bin/sh
# Auto-generated by cli-setup-wizard.sh - re-run the wizard with --force to
# regenerate this after logging into a different Gitea account.
LOGIN_NAME="__GITEA_LOGIN_NAME__"
CRED_FILE="$HOME/.config/tea/credentials.json"
case "$1" in
get)
# Authenticated no-op call: gives tea a chance to refresh and persist
# an expired access_token before we read it below.
tea whoami >/dev/null 2>&1
token="$(grep -oP "(?<=\"$LOGIN_NAME\":).*" "$CRED_FILE" 2>/dev/null \
| grep -oP '(?<=access_token\\":\\")[^\\"]*' | head -n1)"
if [ -n "$token" ]; then
echo "username=oauth2"
echo "password=$token"
fi
;;
store|erase)
# No-op: credentials.json (managed by tea) is the single source of
# truth, nothing for git to persist or clear on this end.
;;
esac
HELPER_EOT
sed -i "s/__GITEA_LOGIN_NAME__/$GITEA_LOGIN_NAME/" "$HELPER"
chmod +x "$HELPER"
git config --global credential."https://git.octoturge.com".helper "$HELPER"
echo "Configured git to push/pull https://git.octoturge.com using your tea login."
fi
fi
# --- SSH + GPG keys for the external git host(s) selected above ---
# Only asks if the user actually set up GitHub and/or Gitea just now -
# stays silent for "local git only" (neither was set up).
if [ "$DID_GITHUB" -eq 1 ] || [ "$DID_GITEA" -eq 1 ]; then
if [ "$DID_GITHUB" -eq 1 ] && [ "$DID_GITEA" -eq 1 ]; then
KEY_HOSTS_DESC="GitHub and Gitea"
elif [ "$DID_GITHUB" -eq 1 ]; then
KEY_HOSTS_DESC="GitHub"
else
KEY_HOSTS_DESC="Gitea"
fi
if ask_yes_no "Auto-generate an SSH key and a GPG signing key, and register them with $KEY_HOSTS_DESC?"; then
KEY_NAME="${GIT_AUTHOR_NAME:-$(whoami)}"
KEY_EMAIL="${GIT_AUTHOR_EMAIL:-$(whoami)@$(hostname)}"
# SSH key: ed25519, no passphrase (disposable dev workspace convenience;
# add one manually afterwards with `ssh-keygen -p` if you want one).
SSH_KEY="$HOME/.ssh/id_ed25519"
if [ ! -f "$SSH_KEY" ]; then
mkdir -p "$HOME/.ssh" && chmod 700 "$HOME/.ssh"
ssh-keygen -t ed25519 -N "" -C "$KEY_EMAIL" -f "$SSH_KEY" -q
echo "Generated SSH key: ${SSH_KEY}.pub"
else
echo "SSH key already exists at ${SSH_KEY}.pub, reusing it."
fi
if [ "$DID_GITHUB" -eq 1 ]; then
if gh ssh-key add "${SSH_KEY}.pub" --title "coder-$(hostname)" 2>/dev/null; then
echo "SSH key added to GitHub."
else
echo "Couldn't add the SSH key to GitHub automatically (may already be added). Add manually: gh ssh-key add ${SSH_KEY}.pub"
fi
fi
if [ "$DID_GITEA" -eq 1 ]; then
if tea ssh-keys add "${SSH_KEY}.pub" --title "coder-$(hostname)" >/dev/null 2>&1; then
echo "SSH key added to Gitea."
else
echo "Couldn't add the SSH key to Gitea automatically (may already be added). Add manually with: tea ssh-keys add ${SSH_KEY}.pub"
fi
fi
# GPG key: ed25519 signing key, no passphrase, no expiry.
if gpg --list-secret-keys --with-colons "$KEY_EMAIL" 2>/dev/null | grep -q '^sec'; then
echo "GPG key for $KEY_EMAIL already exists, reusing it."
else
mkdir -p "$HOME/.gnupg" && chmod 700 "$HOME/.gnupg"
grep -qF "allow-loopback-pinentry" "$HOME/.gnupg/gpg-agent.conf" 2>/dev/null \
|| echo "allow-loopback-pinentry" >> "$HOME/.gnupg/gpg-agent.conf"
gpgconf --kill gpg-agent 2>/dev/null
if gpg --batch --pinentry-mode loopback --passphrase '' --quick-gen-key "$KEY_NAME <$KEY_EMAIL>" ed25519 sign 0 2>/dev/null; then
echo "Generated GPG signing key for $KEY_EMAIL."
else
echo "GPG key generation failed. Generate manually with: gpg --quick-gen-key \"$KEY_NAME <$KEY_EMAIL>\" ed25519 sign 0"
fi
fi
GPG_KEY_ID="$(gpg --list-secret-keys --with-colons "$KEY_EMAIL" 2>/dev/null | awk -F: '/^sec/{print $5; exit}')"
if [ -n "$GPG_KEY_ID" ]; then
git config --global user.signingkey "$GPG_KEY_ID"
git config --global commit.gpgsign true
echo "Configured git to sign commits with this key."
if [ "$DID_GITHUB" -eq 1 ]; then
if gpg --armor --export "$GPG_KEY_ID" | gh gpg-key add - 2>/dev/null; then
echo "GPG key added to GitHub."
else
echo "Couldn't add the GPG key to GitHub automatically (may already be added). Add manually: gpg --armor --export $GPG_KEY_ID | gh gpg-key add -"
fi
fi
if [ "$DID_GITEA" -eq 1 ]; then
if gpg --armor --export "$GPG_KEY_ID" | tea api -X POST /user/gpg_keys -F armored_public_key=@- >/dev/null 2>&1; then
echo "GPG key added to Gitea."
else
echo "Couldn't add the GPG key to Gitea automatically (may already be added). Add manually: gpg --armor --export $GPG_KEY_ID | tea api -X POST /user/gpg_keys -F armored_public_key=@-"
fi
fi
fi
fi
elif command -v gh >/dev/null 2>&1 || command -v tea >/dev/null 2>&1; then
# gh and/or tea are installed but neither is actually logged in yet (login
# was declined, failed, or never completed) - say why the key-generation
# question above got skipped instead of just silently not asking.
echo "Skipping SSH/GPG key setup: not logged in to GitHub or Gitea yet."
echo "Log in (gh auth login / tea login add) then re-run: bash /opt/coder/cli-setup-wizard.sh --force"
fi
echo ""
if ask_yes_no "Mark setup wizard as complete so it stops asking on new terminals?"; then
touch "$WIZARD_DONE_FILE"
echo "Done. Re-run any time with: bash /opt/coder/cli-setup-wizard.sh --force"
else
echo "OK, this'll ask again next time you open a terminal."
fi
return 0 2>/dev/null || exit 0
+68
View File
@@ -0,0 +1,68 @@
#!/usr/bin/env bash
# Installs this repo's Agent Skills (extensions/{awesome-skills-plugin,
# custom-specialty-plugin}/skills/*, each a SKILL.md-based skill directory)
# into every AI CLI's personal skills directory:
#
# Claude Code CLI -> ~/.claude/skills/<name>/
# GitHub Copilot CLI -> ~/.copilot/skills/<name>/
# Antigravity CLI -> ~/.gemini/config/skills/<name>/ (per antigravity.google/docs/skills;
# worth a spot-check if agy doesn't pick these up, some third-party
# docs disagree on the exact path)
#
# Run once at workspace startup via coder_script. Pulls this repo fresh from
# Gitea rather than embedding ~2.5MB of skill files into Terraform state.
#
# Env vars:
# SPECIALTY_SKILLS - optional space-separated skill names from
# extensions/custom-specialty-plugin/skills/ to install in addition to
# the common awesome-skills-plugin bundle (every env gets that one).
set -e
REPO_ZIP_URL="https://git.octoturge.com/octoturge/Profiles-for-Coder/archive/main.zip"
ZIP_PATH="/tmp/coder-skills-src.zip"
WORK_DIR="/tmp/coder-skills-src"
rm -rf "$WORK_DIR" "$ZIP_PATH"
curl -fsSL "$REPO_ZIP_URL" -o "$ZIP_PATH"
mkdir -p "$WORK_DIR"
unzip -q -o "$ZIP_PATH" -d "$WORK_DIR"
INNER_DIR=$(find "$WORK_DIR" -mindepth 1 -maxdepth 1 -type d | head -n1)
if [ -z "$INNER_DIR" ]; then
echo "install-skills: couldn't find extracted repo contents, skipping." >&2
rm -rf "$WORK_DIR" "$ZIP_PATH"
exit 0
fi
TARGET_DIRS=("$HOME/.claude/skills" "$HOME/.copilot/skills" "$HOME/.gemini/config/skills")
for dir in "${TARGET_DIRS[@]}"; do
mkdir -p "$dir"
done
# Common skill bundle, installed for every environment.
COMMON_SKILLS_SRC="$INNER_DIR/extensions/awesome-skills-plugin/skills"
if [ -d "$COMMON_SKILLS_SRC" ]; then
for dir in "${TARGET_DIRS[@]}"; do
cp -r "$COMMON_SKILLS_SRC/." "$dir/"
done
echo "install-skills: installed common skill bundle into ${TARGET_DIRS[*]}"
else
echo "install-skills: common skill bundle not found at $COMMON_SKILLS_SRC, skipping." >&2
fi
# Environment-specific specialty skills, if any were requested.
for skill in ${SPECIALTY_SKILLS:-}; do
SRC="$INNER_DIR/extensions/custom-specialty-plugin/skills/$skill"
if [ -d "$SRC" ]; then
for dir in "${TARGET_DIRS[@]}"; do
cp -r "$SRC" "$dir/$skill"
done
echo "install-skills: installed specialty skill '$skill'"
else
echo "install-skills: specialty skill '$skill' not found at $SRC, skipping." >&2
fi
done
rm -rf "$WORK_DIR" "$ZIP_PATH"
echo "install-skills: done."
+330
View File
@@ -0,0 +1,330 @@
terraform {
required_providers {
coder = {
source = "coder/coder"
}
docker = {
source = "kreuzwerker/docker"
}
# Not used directly in this config. Existing workspace state from before
# the jetbrains module was removed still has resources tagged under this
# provider (the module used it internally to fetch IDE metadata) -
# terraform init only installs providers the current config declares, so
# without this, plan/apply fails with "Missing required provider" while
# trying to reconcile/destroy those leftover state entries. Safe to drop
# once every workspace has updated past the jetbrains-module version.
http = {
source = "hashicorp/http"
}
}
}
locals {
env_name = "3D Printing & Engineering"
profile = jsondecode(file("${path.module}/profile.code-profile"))
settings_raw = jsondecode(local.profile.settings).settings
extensions = [for e in jsondecode(local.profile.extensions) : e.identifier.id]
}
variable "docker_socket" {
default = ""
description = "(Optional) Docker socket URI"
type = string
}
provider "docker" {
# Defaulting to null if the variable is an empty string lets us have an optional variable without having to set our own default
host = var.docker_socket != "" ? var.docker_socket : null
}
data "coder_provisioner" "me" {}
data "coder_workspace" "me" {}
data "coder_workspace_owner" "me" {}
resource "coder_agent" "main" {
arch = data.coder_provisioner.me.arch
os = "linux"
startup_script = <<-EOT
set -e
# Prepare user home with default files on first start.
if [ ! -f ~/.init_done ]; then
cp -rT /etc/skel ~
touch ~/.init_done
fi
# Ensure git and gnupg (commit signing) are present as base packages -
# not every base image ships gnupg by default. No-op once both are
# present (e.g. templates/web already bakes them into its image).
if ! command -v git >/dev/null 2>&1 || ! command -v gpg >/dev/null 2>&1; then
sudo apt-get update -qq
sudo apt-get install -y --no-install-recommends git gnupg
fi
# Add any commands that should be executed at workspace startup (e.g install requirements, start a program, etc) here
EOT
# These environment variables allow you to make Git commits right away after creating a
# workspace. Note that they take precedence over configuration defined in ~/.gitconfig!
# You can remove this block if you'd prefer to configure Git manually or using
# dotfiles. (see docs/dotfiles.md)
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}"
}
# 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
}
metadata {
display_name = "Load Average (Host)"
key = "6_load_host"
# get load avg scaled by number of cores
script = <<EOT
echo "`cat /proc/loadavg | awk '{ print $1 }'` `nproc`" | awk '{ printf "%0.2f", $1/$2 }'
EOT
interval = 60
timeout = 1
}
metadata {
display_name = "Swap Usage (Host)"
key = "7_swap_host"
script = <<EOT
free -b | awk '/^Swap/ { printf("%.1f/%.1f", $3/1024.0/1024.0/1024.0, $2/1024.0/1024.0/1024.0) }'
EOT
interval = 10
timeout = 1
}
}
# See https://registry.coder.com/modules/coder/code-server
# `extensions` is populated at template-push time from this env's
# profile-templates/*.code-profile file, so no interactive prompt is
# needed for VS Code extensions - Terraform handles it declaratively.
module "code-server" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/code-server/coder"
version = "~> 1.0"
agent_id = coder_agent.main.id
# Pass the target folder here natively
folder = "/home/coder/workspace"
extensions = local.extensions
order = 1
}
resource "docker_volume" "home_volume" {
name = "coder-${data.coder_workspace.me.id}-home"
# Protect the volume from being deleted due to changes in attributes.
lifecycle {
ignore_changes = all
}
# Add labels in Docker to keep track of orphan resources.
labels {
label = "coder.owner"
value = data.coder_workspace_owner.me.name
}
labels {
label = "coder.owner_id"
value = data.coder_workspace_owner.me.id
}
labels {
label = "coder.workspace_id"
value = data.coder_workspace.me.id
}
# This field becomes outdated if the workspace is renamed but can
# be useful for debugging or cleaning out dangling volumes.
labels {
label = "coder.workspace_name_at_creation"
value = data.coder_workspace.me.name
}
}
resource "docker_container" "workspace" {
count = data.coder_workspace.me.start_count
image = "codercom/enterprise-base:ubuntu"
# Uses lower() to avoid Docker restriction on container names.
name = "coder-${data.coder_workspace_owner.me.name}-${lower(data.coder_workspace.me.name)}"
# Hostname makes the shell more user friendly: coder@my-workspace:~$
hostname = data.coder_workspace.me.name
# Use the docker gateway if the access URL is 127.0.0.1
entrypoint = ["sh", "-c", replace(coder_agent.main.init_script, "/localhost|127\\.0\\.0\\.1/", "host.docker.internal")]
env = ["CODER_AGENT_TOKEN=${coder_agent.main.token}"]
host {
host = "host.docker.internal"
ip = "host-gateway"
}
host {
host = "code.octoturge.com"
ip = "host-gateway"
}
# install-skills.sh curls this at startup to pull the repo's Agent Skills;
# without this the workspace's public DNS answer for git.octoturge.com
# NAT-hairpins back to the LAN and times out (curl: (28)).
host {
host = "git.octoturge.com"
ip = "host-gateway"
}
volumes {
container_path = "/home/coder"
volume_name = docker_volume.home_volume.name
read_only = false
}
# Add labels in Docker to keep track of orphan resources.
labels {
label = "coder.owner"
value = data.coder_workspace_owner.me.name
}
labels {
label = "coder.owner_id"
value = data.coder_workspace_owner.me.id
}
labels {
label = "coder.workspace_id"
value = data.coder_workspace.me.id
}
labels {
label = "coder.workspace_name"
value = data.coder_workspace.me.name
}
labels {
label = "coder.profile"
value = local.env_name
}
}
# Writes this env's VS Code settings.json, sourced straight from the
# matching profile-templates/*.code-profile file at template-push time.
resource "coder_script" "apply_settings" {
agent_id = coder_agent.main.id
display_name = "Apply ${local.env_name} VS Code Settings"
run_on_start = true
script = <<-EOT
#!/bin/bash
set -e
mkdir -p "$HOME/workspace"
mkdir -p "$HOME/.local/share/code-server/User"
echo '${base64encode(local.settings_raw)}' | base64 -d > "$HOME/.local/share/code-server/User/settings.json"
EOT
}
# Drops the shared CLI setup wizard onto the workspace and hooks it into
# every new interactive shell (via .bashrc) until the user completes it.
# See ./cli-setup-wizard.sh for what it actually asks.
resource "coder_script" "cli_setup_wizard" {
agent_id = coder_agent.main.id
display_name = "Install CLI Setup Wizard"
run_on_start = true
script = <<-EOT
#!/bin/bash
set -e
sudo mkdir -p /opt/coder
sudo chown "$(id -u):$(id -g)" /opt/coder
echo '${base64encode(file("${path.module}/cli-setup-wizard.sh"))}' | base64 -d > /opt/coder/cli-setup-wizard.sh
chmod +x /opt/coder/cli-setup-wizard.sh
MARKER="# >>> coder cli setup wizard >>>"
if ! grep -qF "$MARKER" "$HOME/.bashrc" 2>/dev/null; then
{
echo ""
echo "$MARKER"
echo 'export PATH="$HOME/.local/bin:$PATH"'
echo 'source /opt/coder/cli-setup-wizard.sh'
echo "# <<< coder cli setup wizard <<<"
} >> "$HOME/.bashrc"
fi
EOT
}
# Installs Bun and uses it (instead of npm) for the CLI installs the wizard
# script runs. The installer doesn't reliably add ~/.bun/bin to PATH in
# non-interactive shells, so that's hooked into .bashrc explicitly here.
resource "coder_script" "install_bun" {
agent_id = coder_agent.main.id
display_name = "Install Bun"
run_on_start = true
script = <<-EOT
#!/bin/bash
set -e
export BUN_INSTALL="$HOME/.bun"
if [ ! -x "$BUN_INSTALL/bin/bun" ]; then
curl -fsSL https://bun.sh/install | bash
fi
MARKER="# >>> coder bun path >>>"
if ! grep -qF "$MARKER" "$HOME/.bashrc" 2>/dev/null; then
{
echo ""
echo "$MARKER"
echo 'export BUN_INSTALL="$HOME/.bun"'
echo 'export PATH="$BUN_INSTALL/bin:$PATH"'
echo "# <<< coder bun path <<<"
} >> "$HOME/.bashrc"
fi
EOT
}
# Installs this repo's Agent Skills into Claude Code, GitHub Copilot CLI, and
# Antigravity CLI's skills directories, plus the openscad-parametric skill
# from extensions/custom-specialty-plugin. See ./install-skills.sh.
resource "coder_script" "install_skills" {
agent_id = coder_agent.main.id
display_name = "Install Agent Skills"
run_on_start = true
script = <<-EOT
#!/bin/bash
set -e
sudo mkdir -p /opt/coder
sudo chown "$(id -u):$(id -g)" /opt/coder
echo '${base64encode(file("${path.module}/install-skills.sh"))}' | base64 -d > /opt/coder/install-skills.sh
chmod +x /opt/coder/install-skills.sh
SPECIALTY_SKILLS="openscad-parametric" /opt/coder/install-skills.sh
EOT
}
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
3
+305
View File
@@ -0,0 +1,305 @@
#!/usr/bin/env bash
# Coder workspace first-run CLI setup wizard.
#
# Meant to be `source`d from a new interactive shell (e.g. via .bashrc). It asks,
# once per user per workspace, whether to install and log into a few optional
# AI coding CLIs. It re-runs on every new terminal until the user lets it finish
# (or explicitly skips it for good), then gets out of the way.
#
# VS Code / code-server extensions are intentionally NOT asked about here -
# they're installed declaratively by the Coder template itself (the
# `code-server` module's `extensions` input, populated from the matching
# profile-templates/*.code-profile file at template-push time).
#
# Manual re-run: bash /opt/coder/cli-setup-wizard.sh --force
set -u
WIZARD_DONE_FILE="${HOME}/.cache/coder-cli-wizard/done"
FORCE=0
[ "${1:-}" = "--force" ] && FORCE=1
# Tracks whether the user actually ended up authenticated against GitHub
# and/or Gitea below, so the SSH/GPG key step can ask about exactly the
# host(s) in play (and stay silent - "local git only" - if neither).
DID_GITHUB=0
DID_GITEA=0
export BUN_INSTALL="${HOME}/.bun"
export PATH="${BUN_INSTALL}/bin:${HOME}/.local/bin:${PATH}"
# Only bother interactive shells with a real terminal attached, and only until
# the user marks the wizard as done.
if [ "$FORCE" -ne 1 ]; then
case "$-" in
*i*) : ;;
*) return 0 2>/dev/null || exit 0 ;;
esac
[ -t 0 ] || { return 0 2>/dev/null || exit 0; }
[ -f "$WIZARD_DONE_FILE" ] && { return 0 2>/dev/null || exit 0; }
fi
mkdir -p "$(dirname "$WIZARD_DONE_FILE")"
ask_yes_no() {
local prompt="$1" reply
read -r -p "$prompt [y/N] " reply
case "$reply" in
[Yy]*) return 0 ;;
*) return 1 ;;
esac
}
echo ""
echo "==================================================================="
echo " Coder workspace setup wizard"
echo " Runs once per new terminal until you finish it. Ctrl+C any time"
echo " to skip for now - it'll ask again next terminal."
echo "==================================================================="
# --- GitHub Copilot CLI ---
if command -v copilot >/dev/null 2>&1; then
echo "GitHub Copilot CLI already installed, skipping."
elif command -v bun >/dev/null 2>&1; then
if ask_yes_no "Install GitHub Copilot CLI and log in?"; then
if bun install -g @github/copilot; then
copilot login || echo "Install succeeded but login didn't complete. Retry any time with: copilot login"
else
echo "Copilot CLI install failed. Retry later with: bun install -g @github/copilot && copilot login"
fi
fi
else
echo "Skipping GitHub Copilot CLI: bun not found on this workspace image."
fi
# --- Google Antigravity CLI (agy) ---
if command -v agy >/dev/null 2>&1; then
echo "Antigravity CLI already installed, skipping."
else
if ask_yes_no "Install Google Antigravity CLI (agy) and log in?"; then
if curl -fsSL https://antigravity.google/cli/install.sh | bash; then
echo "Launching 'agy' once to complete sign-in (exit with /logout or Ctrl+D when done)..."
agy || echo "Sign-in didn't complete. Retry any time by running: agy"
else
echo "Antigravity CLI install failed. Retry later with: curl -fsSL https://antigravity.google/cli/install.sh | bash"
fi
fi
fi
# --- Claude Code CLI ---
if command -v claude >/dev/null 2>&1; then
echo "Claude Code CLI already installed, skipping."
elif command -v bun >/dev/null 2>&1; then
if ask_yes_no "Install Claude Code CLI and log in?"; then
if bun install -g @anthropic-ai/claude-code; then
echo "Launching 'claude' once to complete sign-in (use /login if not prompted; Ctrl+C to exit when done)..."
claude || echo "Sign-in didn't complete. Retry any time by running: claude"
else
echo "Claude Code CLI install failed. Retry later with: bun install -g @anthropic-ai/claude-code"
fi
fi
else
echo "Skipping Claude Code CLI: bun not found on this workspace image."
fi
# --- GitHub CLI (gh) ---
if command -v gh >/dev/null 2>&1; then
echo "GitHub CLI already installed, skipping."
else
if ask_yes_no "Install GitHub CLI (gh) and log in?"; then
if (sudo mkdir -p -m 755 /etc/apt/keyrings \
&& curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | sudo tee /etc/apt/keyrings/githubcli-archive-keyring.gpg >/dev/null \
&& sudo chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg \
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list >/dev/null \
&& sudo apt-get update -qq && sudo apt-get install -y gh); then
gh auth login || echo "Install succeeded but login didn't complete. Retry any time with: gh auth login"
else
echo "GitHub CLI install failed. Retry later with: gh auth login (once gh is installed)"
fi
fi
fi
command -v gh >/dev/null 2>&1 && gh auth status >/dev/null 2>&1 && DID_GITHUB=1
# --- Gitea CLI (tea) ---
if command -v tea >/dev/null 2>&1; then
echo "Gitea CLI already installed, skipping."
else
if ask_yes_no "Install Gitea CLI (tea) and log in?"; then
TEA_ARCH="$(uname -m)"
case "$TEA_ARCH" in
x86_64) TEA_ARCH="amd64" ;;
aarch64) TEA_ARCH="arm64" ;;
esac
TEA_VERSION="$(curl -fsSL https://gitea.com/api/v1/repos/gitea/tea/releases/latest | grep -o '"tag_name":[^,]*' | grep -o 'v[0-9][^"]*')"
mkdir -p "$HOME/.local/bin"
if [ -n "$TEA_VERSION" ] \
&& curl -fsSL "https://gitea.com/gitea/tea/releases/download/${TEA_VERSION}/tea-${TEA_VERSION#v}-linux-${TEA_ARCH}" -o "$HOME/.local/bin/tea" \
&& chmod +x "$HOME/.local/bin/tea"; then
echo "Add this Gitea instance now (e.g. https://git.octoturge.com)..."
tea login add || echo "Login didn't complete. Retry any time with: tea login add"
else
echo "Gitea CLI install failed. Retry later from: https://gitea.com/gitea/tea/releases"
rm -f "$HOME/.local/bin/tea"
fi
fi
fi
# `tea whoami` succeeds regardless of how the login was done (personal
# access token or OAuth) - more reliable than parsing tea's own
# config.yml, whose indentation and fields (no plaintext `token:` at all
# for an OAuth login) vary by auth method.
command -v tea >/dev/null 2>&1 && tea whoami >/dev/null 2>&1 && DID_GITEA=1
# --- Git credential helper for Gitea, backed by tea's own login ---
# Coder injects GIT_ASKPASS to answer git's own credential prompts, but it
# only recognizes external auth providers it knows about (github.com is
# configured on the Coder deployment itself) - for any other host it falls
# through to a broken interactive prompt that just hangs a non-interactive
# git subprocess. A credential helper that successfully answers
# `git credential fill` runs *before* GIT_ASKPASS is ever consulted, so
# registering one for git.octoturge.com sidesteps that broken path
# entirely, without touching how Coder handles github.com.
#
# The token itself lives in tea's own store
# (~/.config/tea/credentials.json), which tea keeps fresh (via its
# refresh_token) as a side effect of any authenticated call - this helper
# never keeps its own copy, just re-reads tea's live value every time git
# asks.
if [ "$DID_GITEA" -eq 1 ]; then
GITEA_LOGIN_NAME="$(awk '
/^[[:space:]]*- name:/ { name = $NF }
/url: https:\/\/git\.octoturge\.com/ { print name; exit }
' "$HOME/.config/tea/config.yml" 2>/dev/null)"
if [ -n "$GITEA_LOGIN_NAME" ]; then
mkdir -p "$HOME/.local/bin"
HELPER="$HOME/.local/bin/git-credential-gitea-tea"
cat > "$HELPER" <<'HELPER_EOT'
#!/bin/sh
# Auto-generated by cli-setup-wizard.sh - re-run the wizard with --force to
# regenerate this after logging into a different Gitea account.
LOGIN_NAME="__GITEA_LOGIN_NAME__"
CRED_FILE="$HOME/.config/tea/credentials.json"
case "$1" in
get)
# Authenticated no-op call: gives tea a chance to refresh and persist
# an expired access_token before we read it below.
tea whoami >/dev/null 2>&1
token="$(grep -oP "(?<=\"$LOGIN_NAME\":).*" "$CRED_FILE" 2>/dev/null \
| grep -oP '(?<=access_token\\":\\")[^\\"]*' | head -n1)"
if [ -n "$token" ]; then
echo "username=oauth2"
echo "password=$token"
fi
;;
store|erase)
# No-op: credentials.json (managed by tea) is the single source of
# truth, nothing for git to persist or clear on this end.
;;
esac
HELPER_EOT
sed -i "s/__GITEA_LOGIN_NAME__/$GITEA_LOGIN_NAME/" "$HELPER"
chmod +x "$HELPER"
git config --global credential."https://git.octoturge.com".helper "$HELPER"
echo "Configured git to push/pull https://git.octoturge.com using your tea login."
fi
fi
# --- SSH + GPG keys for the external git host(s) selected above ---
# Only asks if the user actually set up GitHub and/or Gitea just now -
# stays silent for "local git only" (neither was set up).
if [ "$DID_GITHUB" -eq 1 ] || [ "$DID_GITEA" -eq 1 ]; then
if [ "$DID_GITHUB" -eq 1 ] && [ "$DID_GITEA" -eq 1 ]; then
KEY_HOSTS_DESC="GitHub and Gitea"
elif [ "$DID_GITHUB" -eq 1 ]; then
KEY_HOSTS_DESC="GitHub"
else
KEY_HOSTS_DESC="Gitea"
fi
if ask_yes_no "Auto-generate an SSH key and a GPG signing key, and register them with $KEY_HOSTS_DESC?"; then
KEY_NAME="${GIT_AUTHOR_NAME:-$(whoami)}"
KEY_EMAIL="${GIT_AUTHOR_EMAIL:-$(whoami)@$(hostname)}"
# SSH key: ed25519, no passphrase (disposable dev workspace convenience;
# add one manually afterwards with `ssh-keygen -p` if you want one).
SSH_KEY="$HOME/.ssh/id_ed25519"
if [ ! -f "$SSH_KEY" ]; then
mkdir -p "$HOME/.ssh" && chmod 700 "$HOME/.ssh"
ssh-keygen -t ed25519 -N "" -C "$KEY_EMAIL" -f "$SSH_KEY" -q
echo "Generated SSH key: ${SSH_KEY}.pub"
else
echo "SSH key already exists at ${SSH_KEY}.pub, reusing it."
fi
if [ "$DID_GITHUB" -eq 1 ]; then
if gh ssh-key add "${SSH_KEY}.pub" --title "coder-$(hostname)" 2>/dev/null; then
echo "SSH key added to GitHub."
else
echo "Couldn't add the SSH key to GitHub automatically (may already be added). Add manually: gh ssh-key add ${SSH_KEY}.pub"
fi
fi
if [ "$DID_GITEA" -eq 1 ]; then
if tea ssh-keys add "${SSH_KEY}.pub" --title "coder-$(hostname)" >/dev/null 2>&1; then
echo "SSH key added to Gitea."
else
echo "Couldn't add the SSH key to Gitea automatically (may already be added). Add manually with: tea ssh-keys add ${SSH_KEY}.pub"
fi
fi
# GPG key: ed25519 signing key, no passphrase, no expiry.
if gpg --list-secret-keys --with-colons "$KEY_EMAIL" 2>/dev/null | grep -q '^sec'; then
echo "GPG key for $KEY_EMAIL already exists, reusing it."
else
mkdir -p "$HOME/.gnupg" && chmod 700 "$HOME/.gnupg"
grep -qF "allow-loopback-pinentry" "$HOME/.gnupg/gpg-agent.conf" 2>/dev/null \
|| echo "allow-loopback-pinentry" >> "$HOME/.gnupg/gpg-agent.conf"
gpgconf --kill gpg-agent 2>/dev/null
if gpg --batch --pinentry-mode loopback --passphrase '' --quick-gen-key "$KEY_NAME <$KEY_EMAIL>" ed25519 sign 0 2>/dev/null; then
echo "Generated GPG signing key for $KEY_EMAIL."
else
echo "GPG key generation failed. Generate manually with: gpg --quick-gen-key \"$KEY_NAME <$KEY_EMAIL>\" ed25519 sign 0"
fi
fi
GPG_KEY_ID="$(gpg --list-secret-keys --with-colons "$KEY_EMAIL" 2>/dev/null | awk -F: '/^sec/{print $5; exit}')"
if [ -n "$GPG_KEY_ID" ]; then
git config --global user.signingkey "$GPG_KEY_ID"
git config --global commit.gpgsign true
echo "Configured git to sign commits with this key."
if [ "$DID_GITHUB" -eq 1 ]; then
if gpg --armor --export "$GPG_KEY_ID" | gh gpg-key add - 2>/dev/null; then
echo "GPG key added to GitHub."
else
echo "Couldn't add the GPG key to GitHub automatically (may already be added). Add manually: gpg --armor --export $GPG_KEY_ID | gh gpg-key add -"
fi
fi
if [ "$DID_GITEA" -eq 1 ]; then
if gpg --armor --export "$GPG_KEY_ID" | tea api -X POST /user/gpg_keys -F armored_public_key=@- >/dev/null 2>&1; then
echo "GPG key added to Gitea."
else
echo "Couldn't add the GPG key to Gitea automatically (may already be added). Add manually: gpg --armor --export $GPG_KEY_ID | tea api -X POST /user/gpg_keys -F armored_public_key=@-"
fi
fi
fi
fi
elif command -v gh >/dev/null 2>&1 || command -v tea >/dev/null 2>&1; then
# gh and/or tea are installed but neither is actually logged in yet (login
# was declined, failed, or never completed) - say why the key-generation
# question above got skipped instead of just silently not asking.
echo "Skipping SSH/GPG key setup: not logged in to GitHub or Gitea yet."
echo "Log in (gh auth login / tea login add) then re-run: bash /opt/coder/cli-setup-wizard.sh --force"
fi
echo ""
if ask_yes_no "Mark setup wizard as complete so it stops asking on new terminals?"; then
touch "$WIZARD_DONE_FILE"
echo "Done. Re-run any time with: bash /opt/coder/cli-setup-wizard.sh --force"
else
echo "OK, this'll ask again next time you open a terminal."
fi
return 0 2>/dev/null || exit 0
+68
View File
@@ -0,0 +1,68 @@
#!/usr/bin/env bash
# Installs this repo's Agent Skills (extensions/{awesome-skills-plugin,
# custom-specialty-plugin}/skills/*, each a SKILL.md-based skill directory)
# into every AI CLI's personal skills directory:
#
# Claude Code CLI -> ~/.claude/skills/<name>/
# GitHub Copilot CLI -> ~/.copilot/skills/<name>/
# Antigravity CLI -> ~/.gemini/config/skills/<name>/ (per antigravity.google/docs/skills;
# worth a spot-check if agy doesn't pick these up, some third-party
# docs disagree on the exact path)
#
# Run once at workspace startup via coder_script. Pulls this repo fresh from
# Gitea rather than embedding ~2.5MB of skill files into Terraform state.
#
# Env vars:
# SPECIALTY_SKILLS - optional space-separated skill names from
# extensions/custom-specialty-plugin/skills/ to install in addition to
# the common awesome-skills-plugin bundle (every env gets that one).
set -e
REPO_ZIP_URL="https://git.octoturge.com/octoturge/Profiles-for-Coder/archive/main.zip"
ZIP_PATH="/tmp/coder-skills-src.zip"
WORK_DIR="/tmp/coder-skills-src"
rm -rf "$WORK_DIR" "$ZIP_PATH"
curl -fsSL "$REPO_ZIP_URL" -o "$ZIP_PATH"
mkdir -p "$WORK_DIR"
unzip -q -o "$ZIP_PATH" -d "$WORK_DIR"
INNER_DIR=$(find "$WORK_DIR" -mindepth 1 -maxdepth 1 -type d | head -n1)
if [ -z "$INNER_DIR" ]; then
echo "install-skills: couldn't find extracted repo contents, skipping." >&2
rm -rf "$WORK_DIR" "$ZIP_PATH"
exit 0
fi
TARGET_DIRS=("$HOME/.claude/skills" "$HOME/.copilot/skills" "$HOME/.gemini/config/skills")
for dir in "${TARGET_DIRS[@]}"; do
mkdir -p "$dir"
done
# Common skill bundle, installed for every environment.
COMMON_SKILLS_SRC="$INNER_DIR/extensions/awesome-skills-plugin/skills"
if [ -d "$COMMON_SKILLS_SRC" ]; then
for dir in "${TARGET_DIRS[@]}"; do
cp -r "$COMMON_SKILLS_SRC/." "$dir/"
done
echo "install-skills: installed common skill bundle into ${TARGET_DIRS[*]}"
else
echo "install-skills: common skill bundle not found at $COMMON_SKILLS_SRC, skipping." >&2
fi
# Environment-specific specialty skills, if any were requested.
for skill in ${SPECIALTY_SKILLS:-}; do
SRC="$INNER_DIR/extensions/custom-specialty-plugin/skills/$skill"
if [ -d "$SRC" ]; then
for dir in "${TARGET_DIRS[@]}"; do
cp -r "$SRC" "$dir/$skill"
done
echo "install-skills: installed specialty skill '$skill'"
else
echo "install-skills: specialty skill '$skill' not found at $SRC, skipping." >&2
fi
done
rm -rf "$WORK_DIR" "$ZIP_PATH"
echo "install-skills: done."
+330
View File
@@ -0,0 +1,330 @@
terraform {
required_providers {
coder = {
source = "coder/coder"
}
docker = {
source = "kreuzwerker/docker"
}
# Not used directly in this config. Existing workspace state from before
# the jetbrains module was removed still has resources tagged under this
# provider (the module used it internally to fetch IDE metadata) -
# terraform init only installs providers the current config declares, so
# without this, plan/apply fails with "Missing required provider" while
# trying to reconcile/destroy those leftover state entries. Safe to drop
# once every workspace has updated past the jetbrains-module version.
http = {
source = "hashicorp/http"
}
}
}
locals {
env_name = "COBOL Modern Mainframe"
profile = jsondecode(file("${path.module}/profile.code-profile"))
settings_raw = jsondecode(local.profile.settings).settings
extensions = [for e in jsondecode(local.profile.extensions) : e.identifier.id]
}
variable "docker_socket" {
default = ""
description = "(Optional) Docker socket URI"
type = string
}
provider "docker" {
# Defaulting to null if the variable is an empty string lets us have an optional variable without having to set our own default
host = var.docker_socket != "" ? var.docker_socket : null
}
data "coder_provisioner" "me" {}
data "coder_workspace" "me" {}
data "coder_workspace_owner" "me" {}
resource "coder_agent" "main" {
arch = data.coder_provisioner.me.arch
os = "linux"
startup_script = <<-EOT
set -e
# Prepare user home with default files on first start.
if [ ! -f ~/.init_done ]; then
cp -rT /etc/skel ~
touch ~/.init_done
fi
# Ensure git and gnupg (commit signing) are present as base packages -
# not every base image ships gnupg by default. No-op once both are
# present (e.g. templates/web already bakes them into its image).
if ! command -v git >/dev/null 2>&1 || ! command -v gpg >/dev/null 2>&1; then
sudo apt-get update -qq
sudo apt-get install -y --no-install-recommends git gnupg
fi
# Add any commands that should be executed at workspace startup (e.g install requirements, start a program, etc) here
EOT
# These environment variables allow you to make Git commits right away after creating a
# workspace. Note that they take precedence over configuration defined in ~/.gitconfig!
# You can remove this block if you'd prefer to configure Git manually or using
# dotfiles. (see docs/dotfiles.md)
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}"
}
# 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
}
metadata {
display_name = "Load Average (Host)"
key = "6_load_host"
# get load avg scaled by number of cores
script = <<EOT
echo "`cat /proc/loadavg | awk '{ print $1 }'` `nproc`" | awk '{ printf "%0.2f", $1/$2 }'
EOT
interval = 60
timeout = 1
}
metadata {
display_name = "Swap Usage (Host)"
key = "7_swap_host"
script = <<EOT
free -b | awk '/^Swap/ { printf("%.1f/%.1f", $3/1024.0/1024.0/1024.0, $2/1024.0/1024.0/1024.0) }'
EOT
interval = 10
timeout = 1
}
}
# See https://registry.coder.com/modules/coder/code-server
# `extensions` is populated at template-push time from this env's
# profile-templates/*.code-profile file, so no interactive prompt is
# needed for VS Code extensions - Terraform handles it declaratively.
module "code-server" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/code-server/coder"
version = "~> 1.0"
agent_id = coder_agent.main.id
# Pass the target folder here natively
folder = "/home/coder/workspace"
extensions = local.extensions
order = 1
}
resource "docker_volume" "home_volume" {
name = "coder-${data.coder_workspace.me.id}-home"
# Protect the volume from being deleted due to changes in attributes.
lifecycle {
ignore_changes = all
}
# Add labels in Docker to keep track of orphan resources.
labels {
label = "coder.owner"
value = data.coder_workspace_owner.me.name
}
labels {
label = "coder.owner_id"
value = data.coder_workspace_owner.me.id
}
labels {
label = "coder.workspace_id"
value = data.coder_workspace.me.id
}
# This field becomes outdated if the workspace is renamed but can
# be useful for debugging or cleaning out dangling volumes.
labels {
label = "coder.workspace_name_at_creation"
value = data.coder_workspace.me.name
}
}
resource "docker_container" "workspace" {
count = data.coder_workspace.me.start_count
image = "codercom/enterprise-base:ubuntu"
# Uses lower() to avoid Docker restriction on container names.
name = "coder-${data.coder_workspace_owner.me.name}-${lower(data.coder_workspace.me.name)}"
# Hostname makes the shell more user friendly: coder@my-workspace:~$
hostname = data.coder_workspace.me.name
# Use the docker gateway if the access URL is 127.0.0.1
entrypoint = ["sh", "-c", replace(coder_agent.main.init_script, "/localhost|127\\.0\\.0\\.1/", "host.docker.internal")]
env = ["CODER_AGENT_TOKEN=${coder_agent.main.token}"]
host {
host = "host.docker.internal"
ip = "host-gateway"
}
host {
host = "code.octoturge.com"
ip = "host-gateway"
}
# install-skills.sh curls this at startup to pull the repo's Agent Skills;
# without this the workspace's public DNS answer for git.octoturge.com
# NAT-hairpins back to the LAN and times out (curl: (28)).
host {
host = "git.octoturge.com"
ip = "host-gateway"
}
volumes {
container_path = "/home/coder"
volume_name = docker_volume.home_volume.name
read_only = false
}
# Add labels in Docker to keep track of orphan resources.
labels {
label = "coder.owner"
value = data.coder_workspace_owner.me.name
}
labels {
label = "coder.owner_id"
value = data.coder_workspace_owner.me.id
}
labels {
label = "coder.workspace_id"
value = data.coder_workspace.me.id
}
labels {
label = "coder.workspace_name"
value = data.coder_workspace.me.name
}
labels {
label = "coder.profile"
value = local.env_name
}
}
# Writes this env's VS Code settings.json, sourced straight from the
# matching profile-templates/*.code-profile file at template-push time.
resource "coder_script" "apply_settings" {
agent_id = coder_agent.main.id
display_name = "Apply ${local.env_name} VS Code Settings"
run_on_start = true
script = <<-EOT
#!/bin/bash
set -e
mkdir -p "$HOME/workspace"
mkdir -p "$HOME/.local/share/code-server/User"
echo '${base64encode(local.settings_raw)}' | base64 -d > "$HOME/.local/share/code-server/User/settings.json"
EOT
}
# Drops the shared CLI setup wizard onto the workspace and hooks it into
# every new interactive shell (via .bashrc) until the user completes it.
# See ./cli-setup-wizard.sh for what it actually asks.
resource "coder_script" "cli_setup_wizard" {
agent_id = coder_agent.main.id
display_name = "Install CLI Setup Wizard"
run_on_start = true
script = <<-EOT
#!/bin/bash
set -e
sudo mkdir -p /opt/coder
sudo chown "$(id -u):$(id -g)" /opt/coder
echo '${base64encode(file("${path.module}/cli-setup-wizard.sh"))}' | base64 -d > /opt/coder/cli-setup-wizard.sh
chmod +x /opt/coder/cli-setup-wizard.sh
MARKER="# >>> coder cli setup wizard >>>"
if ! grep -qF "$MARKER" "$HOME/.bashrc" 2>/dev/null; then
{
echo ""
echo "$MARKER"
echo 'export PATH="$HOME/.local/bin:$PATH"'
echo 'source /opt/coder/cli-setup-wizard.sh'
echo "# <<< coder cli setup wizard <<<"
} >> "$HOME/.bashrc"
fi
EOT
}
# Installs Bun and uses it (instead of npm) for the CLI installs the wizard
# script runs. The installer doesn't reliably add ~/.bun/bin to PATH in
# non-interactive shells, so that's hooked into .bashrc explicitly here.
resource "coder_script" "install_bun" {
agent_id = coder_agent.main.id
display_name = "Install Bun"
run_on_start = true
script = <<-EOT
#!/bin/bash
set -e
export BUN_INSTALL="$HOME/.bun"
if [ ! -x "$BUN_INSTALL/bin/bun" ]; then
curl -fsSL https://bun.sh/install | bash
fi
MARKER="# >>> coder bun path >>>"
if ! grep -qF "$MARKER" "$HOME/.bashrc" 2>/dev/null; then
{
echo ""
echo "$MARKER"
echo 'export BUN_INSTALL="$HOME/.bun"'
echo 'export PATH="$BUN_INSTALL/bin:$PATH"'
echo "# <<< coder bun path <<<"
} >> "$HOME/.bashrc"
fi
EOT
}
# Installs this repo's Agent Skills into Claude Code, GitHub Copilot CLI, and
# Antigravity CLI's skills directories, plus the cobol-teacher skill from
# extensions/custom-specialty-plugin. See ./install-skills.sh.
resource "coder_script" "install_skills" {
agent_id = coder_agent.main.id
display_name = "Install Agent Skills"
run_on_start = true
script = <<-EOT
#!/bin/bash
set -e
sudo mkdir -p /opt/coder
sudo chown "$(id -u):$(id -g)" /opt/coder
echo '${base64encode(file("${path.module}/install-skills.sh"))}' | base64 -d > /opt/coder/install-skills.sh
chmod +x /opt/coder/install-skills.sh
SPECIALTY_SKILLS="cobol-teacher" /opt/coder/install-skills.sh
EOT
}
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
3
+305
View File
@@ -0,0 +1,305 @@
#!/usr/bin/env bash
# Coder workspace first-run CLI setup wizard.
#
# Meant to be `source`d from a new interactive shell (e.g. via .bashrc). It asks,
# once per user per workspace, whether to install and log into a few optional
# AI coding CLIs. It re-runs on every new terminal until the user lets it finish
# (or explicitly skips it for good), then gets out of the way.
#
# VS Code / code-server extensions are intentionally NOT asked about here -
# they're installed declaratively by the Coder template itself (the
# `code-server` module's `extensions` input, populated from the matching
# profile-templates/*.code-profile file at template-push time).
#
# Manual re-run: bash /opt/coder/cli-setup-wizard.sh --force
set -u
WIZARD_DONE_FILE="${HOME}/.cache/coder-cli-wizard/done"
FORCE=0
[ "${1:-}" = "--force" ] && FORCE=1
# Tracks whether the user actually ended up authenticated against GitHub
# and/or Gitea below, so the SSH/GPG key step can ask about exactly the
# host(s) in play (and stay silent - "local git only" - if neither).
DID_GITHUB=0
DID_GITEA=0
export BUN_INSTALL="${HOME}/.bun"
export PATH="${BUN_INSTALL}/bin:${HOME}/.local/bin:${PATH}"
# Only bother interactive shells with a real terminal attached, and only until
# the user marks the wizard as done.
if [ "$FORCE" -ne 1 ]; then
case "$-" in
*i*) : ;;
*) return 0 2>/dev/null || exit 0 ;;
esac
[ -t 0 ] || { return 0 2>/dev/null || exit 0; }
[ -f "$WIZARD_DONE_FILE" ] && { return 0 2>/dev/null || exit 0; }
fi
mkdir -p "$(dirname "$WIZARD_DONE_FILE")"
ask_yes_no() {
local prompt="$1" reply
read -r -p "$prompt [y/N] " reply
case "$reply" in
[Yy]*) return 0 ;;
*) return 1 ;;
esac
}
echo ""
echo "==================================================================="
echo " Coder workspace setup wizard"
echo " Runs once per new terminal until you finish it. Ctrl+C any time"
echo " to skip for now - it'll ask again next terminal."
echo "==================================================================="
# --- GitHub Copilot CLI ---
if command -v copilot >/dev/null 2>&1; then
echo "GitHub Copilot CLI already installed, skipping."
elif command -v bun >/dev/null 2>&1; then
if ask_yes_no "Install GitHub Copilot CLI and log in?"; then
if bun install -g @github/copilot; then
copilot login || echo "Install succeeded but login didn't complete. Retry any time with: copilot login"
else
echo "Copilot CLI install failed. Retry later with: bun install -g @github/copilot && copilot login"
fi
fi
else
echo "Skipping GitHub Copilot CLI: bun not found on this workspace image."
fi
# --- Google Antigravity CLI (agy) ---
if command -v agy >/dev/null 2>&1; then
echo "Antigravity CLI already installed, skipping."
else
if ask_yes_no "Install Google Antigravity CLI (agy) and log in?"; then
if curl -fsSL https://antigravity.google/cli/install.sh | bash; then
echo "Launching 'agy' once to complete sign-in (exit with /logout or Ctrl+D when done)..."
agy || echo "Sign-in didn't complete. Retry any time by running: agy"
else
echo "Antigravity CLI install failed. Retry later with: curl -fsSL https://antigravity.google/cli/install.sh | bash"
fi
fi
fi
# --- Claude Code CLI ---
if command -v claude >/dev/null 2>&1; then
echo "Claude Code CLI already installed, skipping."
elif command -v bun >/dev/null 2>&1; then
if ask_yes_no "Install Claude Code CLI and log in?"; then
if bun install -g @anthropic-ai/claude-code; then
echo "Launching 'claude' once to complete sign-in (use /login if not prompted; Ctrl+C to exit when done)..."
claude || echo "Sign-in didn't complete. Retry any time by running: claude"
else
echo "Claude Code CLI install failed. Retry later with: bun install -g @anthropic-ai/claude-code"
fi
fi
else
echo "Skipping Claude Code CLI: bun not found on this workspace image."
fi
# --- GitHub CLI (gh) ---
if command -v gh >/dev/null 2>&1; then
echo "GitHub CLI already installed, skipping."
else
if ask_yes_no "Install GitHub CLI (gh) and log in?"; then
if (sudo mkdir -p -m 755 /etc/apt/keyrings \
&& curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | sudo tee /etc/apt/keyrings/githubcli-archive-keyring.gpg >/dev/null \
&& sudo chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg \
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list >/dev/null \
&& sudo apt-get update -qq && sudo apt-get install -y gh); then
gh auth login || echo "Install succeeded but login didn't complete. Retry any time with: gh auth login"
else
echo "GitHub CLI install failed. Retry later with: gh auth login (once gh is installed)"
fi
fi
fi
command -v gh >/dev/null 2>&1 && gh auth status >/dev/null 2>&1 && DID_GITHUB=1
# --- Gitea CLI (tea) ---
if command -v tea >/dev/null 2>&1; then
echo "Gitea CLI already installed, skipping."
else
if ask_yes_no "Install Gitea CLI (tea) and log in?"; then
TEA_ARCH="$(uname -m)"
case "$TEA_ARCH" in
x86_64) TEA_ARCH="amd64" ;;
aarch64) TEA_ARCH="arm64" ;;
esac
TEA_VERSION="$(curl -fsSL https://gitea.com/api/v1/repos/gitea/tea/releases/latest | grep -o '"tag_name":[^,]*' | grep -o 'v[0-9][^"]*')"
mkdir -p "$HOME/.local/bin"
if [ -n "$TEA_VERSION" ] \
&& curl -fsSL "https://gitea.com/gitea/tea/releases/download/${TEA_VERSION}/tea-${TEA_VERSION#v}-linux-${TEA_ARCH}" -o "$HOME/.local/bin/tea" \
&& chmod +x "$HOME/.local/bin/tea"; then
echo "Add this Gitea instance now (e.g. https://git.octoturge.com)..."
tea login add || echo "Login didn't complete. Retry any time with: tea login add"
else
echo "Gitea CLI install failed. Retry later from: https://gitea.com/gitea/tea/releases"
rm -f "$HOME/.local/bin/tea"
fi
fi
fi
# `tea whoami` succeeds regardless of how the login was done (personal
# access token or OAuth) - more reliable than parsing tea's own
# config.yml, whose indentation and fields (no plaintext `token:` at all
# for an OAuth login) vary by auth method.
command -v tea >/dev/null 2>&1 && tea whoami >/dev/null 2>&1 && DID_GITEA=1
# --- Git credential helper for Gitea, backed by tea's own login ---
# Coder injects GIT_ASKPASS to answer git's own credential prompts, but it
# only recognizes external auth providers it knows about (github.com is
# configured on the Coder deployment itself) - for any other host it falls
# through to a broken interactive prompt that just hangs a non-interactive
# git subprocess. A credential helper that successfully answers
# `git credential fill` runs *before* GIT_ASKPASS is ever consulted, so
# registering one for git.octoturge.com sidesteps that broken path
# entirely, without touching how Coder handles github.com.
#
# The token itself lives in tea's own store
# (~/.config/tea/credentials.json), which tea keeps fresh (via its
# refresh_token) as a side effect of any authenticated call - this helper
# never keeps its own copy, just re-reads tea's live value every time git
# asks.
if [ "$DID_GITEA" -eq 1 ]; then
GITEA_LOGIN_NAME="$(awk '
/^[[:space:]]*- name:/ { name = $NF }
/url: https:\/\/git\.octoturge\.com/ { print name; exit }
' "$HOME/.config/tea/config.yml" 2>/dev/null)"
if [ -n "$GITEA_LOGIN_NAME" ]; then
mkdir -p "$HOME/.local/bin"
HELPER="$HOME/.local/bin/git-credential-gitea-tea"
cat > "$HELPER" <<'HELPER_EOT'
#!/bin/sh
# Auto-generated by cli-setup-wizard.sh - re-run the wizard with --force to
# regenerate this after logging into a different Gitea account.
LOGIN_NAME="__GITEA_LOGIN_NAME__"
CRED_FILE="$HOME/.config/tea/credentials.json"
case "$1" in
get)
# Authenticated no-op call: gives tea a chance to refresh and persist
# an expired access_token before we read it below.
tea whoami >/dev/null 2>&1
token="$(grep -oP "(?<=\"$LOGIN_NAME\":).*" "$CRED_FILE" 2>/dev/null \
| grep -oP '(?<=access_token\\":\\")[^\\"]*' | head -n1)"
if [ -n "$token" ]; then
echo "username=oauth2"
echo "password=$token"
fi
;;
store|erase)
# No-op: credentials.json (managed by tea) is the single source of
# truth, nothing for git to persist or clear on this end.
;;
esac
HELPER_EOT
sed -i "s/__GITEA_LOGIN_NAME__/$GITEA_LOGIN_NAME/" "$HELPER"
chmod +x "$HELPER"
git config --global credential."https://git.octoturge.com".helper "$HELPER"
echo "Configured git to push/pull https://git.octoturge.com using your tea login."
fi
fi
# --- SSH + GPG keys for the external git host(s) selected above ---
# Only asks if the user actually set up GitHub and/or Gitea just now -
# stays silent for "local git only" (neither was set up).
if [ "$DID_GITHUB" -eq 1 ] || [ "$DID_GITEA" -eq 1 ]; then
if [ "$DID_GITHUB" -eq 1 ] && [ "$DID_GITEA" -eq 1 ]; then
KEY_HOSTS_DESC="GitHub and Gitea"
elif [ "$DID_GITHUB" -eq 1 ]; then
KEY_HOSTS_DESC="GitHub"
else
KEY_HOSTS_DESC="Gitea"
fi
if ask_yes_no "Auto-generate an SSH key and a GPG signing key, and register them with $KEY_HOSTS_DESC?"; then
KEY_NAME="${GIT_AUTHOR_NAME:-$(whoami)}"
KEY_EMAIL="${GIT_AUTHOR_EMAIL:-$(whoami)@$(hostname)}"
# SSH key: ed25519, no passphrase (disposable dev workspace convenience;
# add one manually afterwards with `ssh-keygen -p` if you want one).
SSH_KEY="$HOME/.ssh/id_ed25519"
if [ ! -f "$SSH_KEY" ]; then
mkdir -p "$HOME/.ssh" && chmod 700 "$HOME/.ssh"
ssh-keygen -t ed25519 -N "" -C "$KEY_EMAIL" -f "$SSH_KEY" -q
echo "Generated SSH key: ${SSH_KEY}.pub"
else
echo "SSH key already exists at ${SSH_KEY}.pub, reusing it."
fi
if [ "$DID_GITHUB" -eq 1 ]; then
if gh ssh-key add "${SSH_KEY}.pub" --title "coder-$(hostname)" 2>/dev/null; then
echo "SSH key added to GitHub."
else
echo "Couldn't add the SSH key to GitHub automatically (may already be added). Add manually: gh ssh-key add ${SSH_KEY}.pub"
fi
fi
if [ "$DID_GITEA" -eq 1 ]; then
if tea ssh-keys add "${SSH_KEY}.pub" --title "coder-$(hostname)" >/dev/null 2>&1; then
echo "SSH key added to Gitea."
else
echo "Couldn't add the SSH key to Gitea automatically (may already be added). Add manually with: tea ssh-keys add ${SSH_KEY}.pub"
fi
fi
# GPG key: ed25519 signing key, no passphrase, no expiry.
if gpg --list-secret-keys --with-colons "$KEY_EMAIL" 2>/dev/null | grep -q '^sec'; then
echo "GPG key for $KEY_EMAIL already exists, reusing it."
else
mkdir -p "$HOME/.gnupg" && chmod 700 "$HOME/.gnupg"
grep -qF "allow-loopback-pinentry" "$HOME/.gnupg/gpg-agent.conf" 2>/dev/null \
|| echo "allow-loopback-pinentry" >> "$HOME/.gnupg/gpg-agent.conf"
gpgconf --kill gpg-agent 2>/dev/null
if gpg --batch --pinentry-mode loopback --passphrase '' --quick-gen-key "$KEY_NAME <$KEY_EMAIL>" ed25519 sign 0 2>/dev/null; then
echo "Generated GPG signing key for $KEY_EMAIL."
else
echo "GPG key generation failed. Generate manually with: gpg --quick-gen-key \"$KEY_NAME <$KEY_EMAIL>\" ed25519 sign 0"
fi
fi
GPG_KEY_ID="$(gpg --list-secret-keys --with-colons "$KEY_EMAIL" 2>/dev/null | awk -F: '/^sec/{print $5; exit}')"
if [ -n "$GPG_KEY_ID" ]; then
git config --global user.signingkey "$GPG_KEY_ID"
git config --global commit.gpgsign true
echo "Configured git to sign commits with this key."
if [ "$DID_GITHUB" -eq 1 ]; then
if gpg --armor --export "$GPG_KEY_ID" | gh gpg-key add - 2>/dev/null; then
echo "GPG key added to GitHub."
else
echo "Couldn't add the GPG key to GitHub automatically (may already be added). Add manually: gpg --armor --export $GPG_KEY_ID | gh gpg-key add -"
fi
fi
if [ "$DID_GITEA" -eq 1 ]; then
if gpg --armor --export "$GPG_KEY_ID" | tea api -X POST /user/gpg_keys -F armored_public_key=@- >/dev/null 2>&1; then
echo "GPG key added to Gitea."
else
echo "Couldn't add the GPG key to Gitea automatically (may already be added). Add manually: gpg --armor --export $GPG_KEY_ID | tea api -X POST /user/gpg_keys -F armored_public_key=@-"
fi
fi
fi
fi
elif command -v gh >/dev/null 2>&1 || command -v tea >/dev/null 2>&1; then
# gh and/or tea are installed but neither is actually logged in yet (login
# was declined, failed, or never completed) - say why the key-generation
# question above got skipped instead of just silently not asking.
echo "Skipping SSH/GPG key setup: not logged in to GitHub or Gitea yet."
echo "Log in (gh auth login / tea login add) then re-run: bash /opt/coder/cli-setup-wizard.sh --force"
fi
echo ""
if ask_yes_no "Mark setup wizard as complete so it stops asking on new terminals?"; then
touch "$WIZARD_DONE_FILE"
echo "Done. Re-run any time with: bash /opt/coder/cli-setup-wizard.sh --force"
else
echo "OK, this'll ask again next time you open a terminal."
fi
return 0 2>/dev/null || exit 0
+68
View File
@@ -0,0 +1,68 @@
#!/usr/bin/env bash
# Installs this repo's Agent Skills (extensions/{awesome-skills-plugin,
# custom-specialty-plugin}/skills/*, each a SKILL.md-based skill directory)
# into every AI CLI's personal skills directory:
#
# Claude Code CLI -> ~/.claude/skills/<name>/
# GitHub Copilot CLI -> ~/.copilot/skills/<name>/
# Antigravity CLI -> ~/.gemini/config/skills/<name>/ (per antigravity.google/docs/skills;
# worth a spot-check if agy doesn't pick these up, some third-party
# docs disagree on the exact path)
#
# Run once at workspace startup via coder_script. Pulls this repo fresh from
# Gitea rather than embedding ~2.5MB of skill files into Terraform state.
#
# Env vars:
# SPECIALTY_SKILLS - optional space-separated skill names from
# extensions/custom-specialty-plugin/skills/ to install in addition to
# the common awesome-skills-plugin bundle (every env gets that one).
set -e
REPO_ZIP_URL="https://git.octoturge.com/octoturge/Profiles-for-Coder/archive/main.zip"
ZIP_PATH="/tmp/coder-skills-src.zip"
WORK_DIR="/tmp/coder-skills-src"
rm -rf "$WORK_DIR" "$ZIP_PATH"
curl -fsSL "$REPO_ZIP_URL" -o "$ZIP_PATH"
mkdir -p "$WORK_DIR"
unzip -q -o "$ZIP_PATH" -d "$WORK_DIR"
INNER_DIR=$(find "$WORK_DIR" -mindepth 1 -maxdepth 1 -type d | head -n1)
if [ -z "$INNER_DIR" ]; then
echo "install-skills: couldn't find extracted repo contents, skipping." >&2
rm -rf "$WORK_DIR" "$ZIP_PATH"
exit 0
fi
TARGET_DIRS=("$HOME/.claude/skills" "$HOME/.copilot/skills" "$HOME/.gemini/config/skills")
for dir in "${TARGET_DIRS[@]}"; do
mkdir -p "$dir"
done
# Common skill bundle, installed for every environment.
COMMON_SKILLS_SRC="$INNER_DIR/extensions/awesome-skills-plugin/skills"
if [ -d "$COMMON_SKILLS_SRC" ]; then
for dir in "${TARGET_DIRS[@]}"; do
cp -r "$COMMON_SKILLS_SRC/." "$dir/"
done
echo "install-skills: installed common skill bundle into ${TARGET_DIRS[*]}"
else
echo "install-skills: common skill bundle not found at $COMMON_SKILLS_SRC, skipping." >&2
fi
# Environment-specific specialty skills, if any were requested.
for skill in ${SPECIALTY_SKILLS:-}; do
SRC="$INNER_DIR/extensions/custom-specialty-plugin/skills/$skill"
if [ -d "$SRC" ]; then
for dir in "${TARGET_DIRS[@]}"; do
cp -r "$SRC" "$dir/$skill"
done
echo "install-skills: installed specialty skill '$skill'"
else
echo "install-skills: specialty skill '$skill' not found at $SRC, skipping." >&2
fi
done
rm -rf "$WORK_DIR" "$ZIP_PATH"
echo "install-skills: done."
+331
View File
@@ -0,0 +1,331 @@
terraform {
required_providers {
coder = {
source = "coder/coder"
}
docker = {
source = "kreuzwerker/docker"
}
# Not used directly in this config. Existing workspace state from before
# the jetbrains module was removed still has resources tagged under this
# provider (the module used it internally to fetch IDE metadata) -
# terraform init only installs providers the current config declares, so
# without this, plan/apply fails with "Missing required provider" while
# trying to reconcile/destroy those leftover state entries. Safe to drop
# once every workspace has updated past the jetbrains-module version.
http = {
source = "hashicorp/http"
}
}
}
locals {
env_name = "Default"
profile = jsondecode(file("${path.module}/profile.code-profile"))
settings_raw = jsondecode(local.profile.settings).settings
extensions = [for e in jsondecode(local.profile.extensions) : e.identifier.id]
}
variable "docker_socket" {
default = ""
description = "(Optional) Docker socket URI"
type = string
}
provider "docker" {
# Defaulting to null if the variable is an empty string lets us have an optional variable without having to set our own default
host = var.docker_socket != "" ? var.docker_socket : null
}
data "coder_provisioner" "me" {}
data "coder_workspace" "me" {}
data "coder_workspace_owner" "me" {}
resource "coder_agent" "main" {
arch = data.coder_provisioner.me.arch
os = "linux"
startup_script = <<-EOT
set -e
# Prepare user home with default files on first start.
if [ ! -f ~/.init_done ]; then
cp -rT /etc/skel ~
touch ~/.init_done
fi
# Ensure git and gnupg (commit signing) are present as base packages -
# not every base image ships gnupg by default. No-op once both are
# present (e.g. templates/web already bakes them into its image).
if ! command -v git >/dev/null 2>&1 || ! command -v gpg >/dev/null 2>&1; then
sudo apt-get update -qq
sudo apt-get install -y --no-install-recommends git gnupg
fi
# Add any commands that should be executed at workspace startup (e.g install requirements, start a program, etc) here
EOT
# These environment variables allow you to make Git commits right away after creating a
# workspace. Note that they take precedence over configuration defined in ~/.gitconfig!
# You can remove this block if you'd prefer to configure Git manually or using
# dotfiles. (see docs/dotfiles.md)
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}"
}
# 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
}
metadata {
display_name = "Load Average (Host)"
key = "6_load_host"
# get load avg scaled by number of cores
script = <<EOT
echo "`cat /proc/loadavg | awk '{ print $1 }'` `nproc`" | awk '{ printf "%0.2f", $1/$2 }'
EOT
interval = 60
timeout = 1
}
metadata {
display_name = "Swap Usage (Host)"
key = "7_swap_host"
script = <<EOT
free -b | awk '/^Swap/ { printf("%.1f/%.1f", $3/1024.0/1024.0/1024.0, $2/1024.0/1024.0/1024.0) }'
EOT
interval = 10
timeout = 1
}
}
# See https://registry.coder.com/modules/coder/code-server
# `extensions` is populated at template-push time from this env's
# profile-templates/*.code-profile file, so no interactive prompt is
# needed for VS Code extensions - Terraform handles it declaratively.
module "code-server" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/code-server/coder"
version = "~> 1.0"
agent_id = coder_agent.main.id
# Pass the target folder here natively
folder = "/home/coder/workspace"
extensions = local.extensions
order = 1
}
resource "docker_volume" "home_volume" {
name = "coder-${data.coder_workspace.me.id}-home"
# Protect the volume from being deleted due to changes in attributes.
lifecycle {
ignore_changes = all
}
# Add labels in Docker to keep track of orphan resources.
labels {
label = "coder.owner"
value = data.coder_workspace_owner.me.name
}
labels {
label = "coder.owner_id"
value = data.coder_workspace_owner.me.id
}
labels {
label = "coder.workspace_id"
value = data.coder_workspace.me.id
}
# This field becomes outdated if the workspace is renamed but can
# be useful for debugging or cleaning out dangling volumes.
labels {
label = "coder.workspace_name_at_creation"
value = data.coder_workspace.me.name
}
}
resource "docker_container" "workspace" {
count = data.coder_workspace.me.start_count
image = "codercom/enterprise-base:ubuntu"
# Uses lower() to avoid Docker restriction on container names.
name = "coder-${data.coder_workspace_owner.me.name}-${lower(data.coder_workspace.me.name)}"
# Hostname makes the shell more user friendly: coder@my-workspace:~$
hostname = data.coder_workspace.me.name
# Use the docker gateway if the access URL is 127.0.0.1
entrypoint = ["sh", "-c", replace(coder_agent.main.init_script, "/localhost|127\\.0\\.0\\.1/", "host.docker.internal")]
env = ["CODER_AGENT_TOKEN=${coder_agent.main.token}"]
host {
host = "host.docker.internal"
ip = "host-gateway"
}
host {
host = "code.octoturge.com"
ip = "host-gateway"
}
# install-skills.sh curls this at startup to pull the repo's Agent Skills;
# without this the workspace's public DNS answer for git.octoturge.com
# NAT-hairpins back to the LAN and times out (curl: (28)).
host {
host = "git.octoturge.com"
ip = "host-gateway"
}
volumes {
container_path = "/home/coder"
volume_name = docker_volume.home_volume.name
read_only = false
}
# Add labels in Docker to keep track of orphan resources.
labels {
label = "coder.owner"
value = data.coder_workspace_owner.me.name
}
labels {
label = "coder.owner_id"
value = data.coder_workspace_owner.me.id
}
labels {
label = "coder.workspace_id"
value = data.coder_workspace.me.id
}
labels {
label = "coder.workspace_name"
value = data.coder_workspace.me.name
}
labels {
label = "coder.profile"
value = local.env_name
}
}
# Writes this env's VS Code settings.json, sourced straight from the
# matching profile-templates/*.code-profile file at template-push time.
resource "coder_script" "apply_settings" {
agent_id = coder_agent.main.id
display_name = "Apply ${local.env_name} VS Code Settings"
run_on_start = true
script = <<-EOT
#!/bin/bash
set -e
mkdir -p "$HOME/workspace"
mkdir -p "$HOME/.local/share/code-server/User"
echo '${base64encode(local.settings_raw)}' | base64 -d > "$HOME/.local/share/code-server/User/settings.json"
EOT
}
# Drops the shared CLI setup wizard onto the workspace and hooks it into
# every new interactive shell (via .bashrc) until the user completes it.
# See ./cli-setup-wizard.sh for what it actually asks.
resource "coder_script" "cli_setup_wizard" {
agent_id = coder_agent.main.id
display_name = "Install CLI Setup Wizard"
run_on_start = true
script = <<-EOT
#!/bin/bash
set -e
sudo mkdir -p /opt/coder
sudo chown "$(id -u):$(id -g)" /opt/coder
echo '${base64encode(file("${path.module}/cli-setup-wizard.sh"))}' | base64 -d > /opt/coder/cli-setup-wizard.sh
chmod +x /opt/coder/cli-setup-wizard.sh
MARKER="# >>> coder cli setup wizard >>>"
if ! grep -qF "$MARKER" "$HOME/.bashrc" 2>/dev/null; then
{
echo ""
echo "$MARKER"
echo 'export PATH="$HOME/.local/bin:$PATH"'
echo 'source /opt/coder/cli-setup-wizard.sh'
echo "# <<< coder cli setup wizard <<<"
} >> "$HOME/.bashrc"
fi
EOT
}
# Installs Bun and uses it (instead of npm) for the CLI installs the wizard
# script runs. The installer doesn't reliably add ~/.bun/bin to PATH in
# non-interactive shells, so that's hooked into .bashrc explicitly here.
resource "coder_script" "install_bun" {
agent_id = coder_agent.main.id
display_name = "Install Bun"
run_on_start = true
script = <<-EOT
#!/bin/bash
set -e
export BUN_INSTALL="$HOME/.bun"
if [ ! -x "$BUN_INSTALL/bin/bun" ]; then
curl -fsSL https://bun.sh/install | bash
fi
MARKER="# >>> coder bun path >>>"
if ! grep -qF "$MARKER" "$HOME/.bashrc" 2>/dev/null; then
{
echo ""
echo "$MARKER"
echo 'export BUN_INSTALL="$HOME/.bun"'
echo 'export PATH="$BUN_INSTALL/bin:$PATH"'
echo "# <<< coder bun path <<<"
} >> "$HOME/.bashrc"
fi
EOT
}
# Installs this repo's Agent Skills into Claude Code, GitHub Copilot CLI, and
# Antigravity CLI's skills directories. See ./install-skills.sh.
# Default has no matching entry in extensions/custom-specialty-plugin/skills,
# so it only gets the common awesome-skills-plugin bundle.
resource "coder_script" "install_skills" {
agent_id = coder_agent.main.id
display_name = "Install Agent Skills"
run_on_start = true
script = <<-EOT
#!/bin/bash
set -e
sudo mkdir -p /opt/coder
sudo chown "$(id -u):$(id -g)" /opt/coder
echo '${base64encode(file("${path.module}/install-skills.sh"))}' | base64 -d > /opt/coder/install-skills.sh
chmod +x /opt/coder/install-skills.sh
SPECIALTY_SKILLS="" /opt/coder/install-skills.sh
EOT
}
+1
View File
@@ -0,0 +1 @@
3
+305
View File
@@ -0,0 +1,305 @@
#!/usr/bin/env bash
# Coder workspace first-run CLI setup wizard.
#
# Meant to be `source`d from a new interactive shell (e.g. via .bashrc). It asks,
# once per user per workspace, whether to install and log into a few optional
# AI coding CLIs. It re-runs on every new terminal until the user lets it finish
# (or explicitly skips it for good), then gets out of the way.
#
# VS Code / code-server extensions are intentionally NOT asked about here -
# they're installed declaratively by the Coder template itself (the
# `code-server` module's `extensions` input, populated from the matching
# profile-templates/*.code-profile file at template-push time).
#
# Manual re-run: bash /opt/coder/cli-setup-wizard.sh --force
set -u
WIZARD_DONE_FILE="${HOME}/.cache/coder-cli-wizard/done"
FORCE=0
[ "${1:-}" = "--force" ] && FORCE=1
# Tracks whether the user actually ended up authenticated against GitHub
# and/or Gitea below, so the SSH/GPG key step can ask about exactly the
# host(s) in play (and stay silent - "local git only" - if neither).
DID_GITHUB=0
DID_GITEA=0
export BUN_INSTALL="${HOME}/.bun"
export PATH="${BUN_INSTALL}/bin:${HOME}/.local/bin:${PATH}"
# Only bother interactive shells with a real terminal attached, and only until
# the user marks the wizard as done.
if [ "$FORCE" -ne 1 ]; then
case "$-" in
*i*) : ;;
*) return 0 2>/dev/null || exit 0 ;;
esac
[ -t 0 ] || { return 0 2>/dev/null || exit 0; }
[ -f "$WIZARD_DONE_FILE" ] && { return 0 2>/dev/null || exit 0; }
fi
mkdir -p "$(dirname "$WIZARD_DONE_FILE")"
ask_yes_no() {
local prompt="$1" reply
read -r -p "$prompt [y/N] " reply
case "$reply" in
[Yy]*) return 0 ;;
*) return 1 ;;
esac
}
echo ""
echo "==================================================================="
echo " Coder workspace setup wizard"
echo " Runs once per new terminal until you finish it. Ctrl+C any time"
echo " to skip for now - it'll ask again next terminal."
echo "==================================================================="
# --- GitHub Copilot CLI ---
if command -v copilot >/dev/null 2>&1; then
echo "GitHub Copilot CLI already installed, skipping."
elif command -v bun >/dev/null 2>&1; then
if ask_yes_no "Install GitHub Copilot CLI and log in?"; then
if bun install -g @github/copilot; then
copilot login || echo "Install succeeded but login didn't complete. Retry any time with: copilot login"
else
echo "Copilot CLI install failed. Retry later with: bun install -g @github/copilot && copilot login"
fi
fi
else
echo "Skipping GitHub Copilot CLI: bun not found on this workspace image."
fi
# --- Google Antigravity CLI (agy) ---
if command -v agy >/dev/null 2>&1; then
echo "Antigravity CLI already installed, skipping."
else
if ask_yes_no "Install Google Antigravity CLI (agy) and log in?"; then
if curl -fsSL https://antigravity.google/cli/install.sh | bash; then
echo "Launching 'agy' once to complete sign-in (exit with /logout or Ctrl+D when done)..."
agy || echo "Sign-in didn't complete. Retry any time by running: agy"
else
echo "Antigravity CLI install failed. Retry later with: curl -fsSL https://antigravity.google/cli/install.sh | bash"
fi
fi
fi
# --- Claude Code CLI ---
if command -v claude >/dev/null 2>&1; then
echo "Claude Code CLI already installed, skipping."
elif command -v bun >/dev/null 2>&1; then
if ask_yes_no "Install Claude Code CLI and log in?"; then
if bun install -g @anthropic-ai/claude-code; then
echo "Launching 'claude' once to complete sign-in (use /login if not prompted; Ctrl+C to exit when done)..."
claude || echo "Sign-in didn't complete. Retry any time by running: claude"
else
echo "Claude Code CLI install failed. Retry later with: bun install -g @anthropic-ai/claude-code"
fi
fi
else
echo "Skipping Claude Code CLI: bun not found on this workspace image."
fi
# --- GitHub CLI (gh) ---
if command -v gh >/dev/null 2>&1; then
echo "GitHub CLI already installed, skipping."
else
if ask_yes_no "Install GitHub CLI (gh) and log in?"; then
if (sudo mkdir -p -m 755 /etc/apt/keyrings \
&& curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | sudo tee /etc/apt/keyrings/githubcli-archive-keyring.gpg >/dev/null \
&& sudo chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg \
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list >/dev/null \
&& sudo apt-get update -qq && sudo apt-get install -y gh); then
gh auth login || echo "Install succeeded but login didn't complete. Retry any time with: gh auth login"
else
echo "GitHub CLI install failed. Retry later with: gh auth login (once gh is installed)"
fi
fi
fi
command -v gh >/dev/null 2>&1 && gh auth status >/dev/null 2>&1 && DID_GITHUB=1
# --- Gitea CLI (tea) ---
if command -v tea >/dev/null 2>&1; then
echo "Gitea CLI already installed, skipping."
else
if ask_yes_no "Install Gitea CLI (tea) and log in?"; then
TEA_ARCH="$(uname -m)"
case "$TEA_ARCH" in
x86_64) TEA_ARCH="amd64" ;;
aarch64) TEA_ARCH="arm64" ;;
esac
TEA_VERSION="$(curl -fsSL https://gitea.com/api/v1/repos/gitea/tea/releases/latest | grep -o '"tag_name":[^,]*' | grep -o 'v[0-9][^"]*')"
mkdir -p "$HOME/.local/bin"
if [ -n "$TEA_VERSION" ] \
&& curl -fsSL "https://gitea.com/gitea/tea/releases/download/${TEA_VERSION}/tea-${TEA_VERSION#v}-linux-${TEA_ARCH}" -o "$HOME/.local/bin/tea" \
&& chmod +x "$HOME/.local/bin/tea"; then
echo "Add this Gitea instance now (e.g. https://git.octoturge.com)..."
tea login add || echo "Login didn't complete. Retry any time with: tea login add"
else
echo "Gitea CLI install failed. Retry later from: https://gitea.com/gitea/tea/releases"
rm -f "$HOME/.local/bin/tea"
fi
fi
fi
# `tea whoami` succeeds regardless of how the login was done (personal
# access token or OAuth) - more reliable than parsing tea's own
# config.yml, whose indentation and fields (no plaintext `token:` at all
# for an OAuth login) vary by auth method.
command -v tea >/dev/null 2>&1 && tea whoami >/dev/null 2>&1 && DID_GITEA=1
# --- Git credential helper for Gitea, backed by tea's own login ---
# Coder injects GIT_ASKPASS to answer git's own credential prompts, but it
# only recognizes external auth providers it knows about (github.com is
# configured on the Coder deployment itself) - for any other host it falls
# through to a broken interactive prompt that just hangs a non-interactive
# git subprocess. A credential helper that successfully answers
# `git credential fill` runs *before* GIT_ASKPASS is ever consulted, so
# registering one for git.octoturge.com sidesteps that broken path
# entirely, without touching how Coder handles github.com.
#
# The token itself lives in tea's own store
# (~/.config/tea/credentials.json), which tea keeps fresh (via its
# refresh_token) as a side effect of any authenticated call - this helper
# never keeps its own copy, just re-reads tea's live value every time git
# asks.
if [ "$DID_GITEA" -eq 1 ]; then
GITEA_LOGIN_NAME="$(awk '
/^[[:space:]]*- name:/ { name = $NF }
/url: https:\/\/git\.octoturge\.com/ { print name; exit }
' "$HOME/.config/tea/config.yml" 2>/dev/null)"
if [ -n "$GITEA_LOGIN_NAME" ]; then
mkdir -p "$HOME/.local/bin"
HELPER="$HOME/.local/bin/git-credential-gitea-tea"
cat > "$HELPER" <<'HELPER_EOT'
#!/bin/sh
# Auto-generated by cli-setup-wizard.sh - re-run the wizard with --force to
# regenerate this after logging into a different Gitea account.
LOGIN_NAME="__GITEA_LOGIN_NAME__"
CRED_FILE="$HOME/.config/tea/credentials.json"
case "$1" in
get)
# Authenticated no-op call: gives tea a chance to refresh and persist
# an expired access_token before we read it below.
tea whoami >/dev/null 2>&1
token="$(grep -oP "(?<=\"$LOGIN_NAME\":).*" "$CRED_FILE" 2>/dev/null \
| grep -oP '(?<=access_token\\":\\")[^\\"]*' | head -n1)"
if [ -n "$token" ]; then
echo "username=oauth2"
echo "password=$token"
fi
;;
store|erase)
# No-op: credentials.json (managed by tea) is the single source of
# truth, nothing for git to persist or clear on this end.
;;
esac
HELPER_EOT
sed -i "s/__GITEA_LOGIN_NAME__/$GITEA_LOGIN_NAME/" "$HELPER"
chmod +x "$HELPER"
git config --global credential."https://git.octoturge.com".helper "$HELPER"
echo "Configured git to push/pull https://git.octoturge.com using your tea login."
fi
fi
# --- SSH + GPG keys for the external git host(s) selected above ---
# Only asks if the user actually set up GitHub and/or Gitea just now -
# stays silent for "local git only" (neither was set up).
if [ "$DID_GITHUB" -eq 1 ] || [ "$DID_GITEA" -eq 1 ]; then
if [ "$DID_GITHUB" -eq 1 ] && [ "$DID_GITEA" -eq 1 ]; then
KEY_HOSTS_DESC="GitHub and Gitea"
elif [ "$DID_GITHUB" -eq 1 ]; then
KEY_HOSTS_DESC="GitHub"
else
KEY_HOSTS_DESC="Gitea"
fi
if ask_yes_no "Auto-generate an SSH key and a GPG signing key, and register them with $KEY_HOSTS_DESC?"; then
KEY_NAME="${GIT_AUTHOR_NAME:-$(whoami)}"
KEY_EMAIL="${GIT_AUTHOR_EMAIL:-$(whoami)@$(hostname)}"
# SSH key: ed25519, no passphrase (disposable dev workspace convenience;
# add one manually afterwards with `ssh-keygen -p` if you want one).
SSH_KEY="$HOME/.ssh/id_ed25519"
if [ ! -f "$SSH_KEY" ]; then
mkdir -p "$HOME/.ssh" && chmod 700 "$HOME/.ssh"
ssh-keygen -t ed25519 -N "" -C "$KEY_EMAIL" -f "$SSH_KEY" -q
echo "Generated SSH key: ${SSH_KEY}.pub"
else
echo "SSH key already exists at ${SSH_KEY}.pub, reusing it."
fi
if [ "$DID_GITHUB" -eq 1 ]; then
if gh ssh-key add "${SSH_KEY}.pub" --title "coder-$(hostname)" 2>/dev/null; then
echo "SSH key added to GitHub."
else
echo "Couldn't add the SSH key to GitHub automatically (may already be added). Add manually: gh ssh-key add ${SSH_KEY}.pub"
fi
fi
if [ "$DID_GITEA" -eq 1 ]; then
if tea ssh-keys add "${SSH_KEY}.pub" --title "coder-$(hostname)" >/dev/null 2>&1; then
echo "SSH key added to Gitea."
else
echo "Couldn't add the SSH key to Gitea automatically (may already be added). Add manually with: tea ssh-keys add ${SSH_KEY}.pub"
fi
fi
# GPG key: ed25519 signing key, no passphrase, no expiry.
if gpg --list-secret-keys --with-colons "$KEY_EMAIL" 2>/dev/null | grep -q '^sec'; then
echo "GPG key for $KEY_EMAIL already exists, reusing it."
else
mkdir -p "$HOME/.gnupg" && chmod 700 "$HOME/.gnupg"
grep -qF "allow-loopback-pinentry" "$HOME/.gnupg/gpg-agent.conf" 2>/dev/null \
|| echo "allow-loopback-pinentry" >> "$HOME/.gnupg/gpg-agent.conf"
gpgconf --kill gpg-agent 2>/dev/null
if gpg --batch --pinentry-mode loopback --passphrase '' --quick-gen-key "$KEY_NAME <$KEY_EMAIL>" ed25519 sign 0 2>/dev/null; then
echo "Generated GPG signing key for $KEY_EMAIL."
else
echo "GPG key generation failed. Generate manually with: gpg --quick-gen-key \"$KEY_NAME <$KEY_EMAIL>\" ed25519 sign 0"
fi
fi
GPG_KEY_ID="$(gpg --list-secret-keys --with-colons "$KEY_EMAIL" 2>/dev/null | awk -F: '/^sec/{print $5; exit}')"
if [ -n "$GPG_KEY_ID" ]; then
git config --global user.signingkey "$GPG_KEY_ID"
git config --global commit.gpgsign true
echo "Configured git to sign commits with this key."
if [ "$DID_GITHUB" -eq 1 ]; then
if gpg --armor --export "$GPG_KEY_ID" | gh gpg-key add - 2>/dev/null; then
echo "GPG key added to GitHub."
else
echo "Couldn't add the GPG key to GitHub automatically (may already be added). Add manually: gpg --armor --export $GPG_KEY_ID | gh gpg-key add -"
fi
fi
if [ "$DID_GITEA" -eq 1 ]; then
if gpg --armor --export "$GPG_KEY_ID" | tea api -X POST /user/gpg_keys -F armored_public_key=@- >/dev/null 2>&1; then
echo "GPG key added to Gitea."
else
echo "Couldn't add the GPG key to Gitea automatically (may already be added). Add manually: gpg --armor --export $GPG_KEY_ID | tea api -X POST /user/gpg_keys -F armored_public_key=@-"
fi
fi
fi
fi
elif command -v gh >/dev/null 2>&1 || command -v tea >/dev/null 2>&1; then
# gh and/or tea are installed but neither is actually logged in yet (login
# was declined, failed, or never completed) - say why the key-generation
# question above got skipped instead of just silently not asking.
echo "Skipping SSH/GPG key setup: not logged in to GitHub or Gitea yet."
echo "Log in (gh auth login / tea login add) then re-run: bash /opt/coder/cli-setup-wizard.sh --force"
fi
echo ""
if ask_yes_no "Mark setup wizard as complete so it stops asking on new terminals?"; then
touch "$WIZARD_DONE_FILE"
echo "Done. Re-run any time with: bash /opt/coder/cli-setup-wizard.sh --force"
else
echo "OK, this'll ask again next time you open a terminal."
fi
return 0 2>/dev/null || exit 0
+68
View File
@@ -0,0 +1,68 @@
#!/usr/bin/env bash
# Installs this repo's Agent Skills (extensions/{awesome-skills-plugin,
# custom-specialty-plugin}/skills/*, each a SKILL.md-based skill directory)
# into every AI CLI's personal skills directory:
#
# Claude Code CLI -> ~/.claude/skills/<name>/
# GitHub Copilot CLI -> ~/.copilot/skills/<name>/
# Antigravity CLI -> ~/.gemini/config/skills/<name>/ (per antigravity.google/docs/skills;
# worth a spot-check if agy doesn't pick these up, some third-party
# docs disagree on the exact path)
#
# Run once at workspace startup via coder_script. Pulls this repo fresh from
# Gitea rather than embedding ~2.5MB of skill files into Terraform state.
#
# Env vars:
# SPECIALTY_SKILLS - optional space-separated skill names from
# extensions/custom-specialty-plugin/skills/ to install in addition to
# the common awesome-skills-plugin bundle (every env gets that one).
set -e
REPO_ZIP_URL="https://git.octoturge.com/octoturge/Profiles-for-Coder/archive/main.zip"
ZIP_PATH="/tmp/coder-skills-src.zip"
WORK_DIR="/tmp/coder-skills-src"
rm -rf "$WORK_DIR" "$ZIP_PATH"
curl -fsSL "$REPO_ZIP_URL" -o "$ZIP_PATH"
mkdir -p "$WORK_DIR"
unzip -q -o "$ZIP_PATH" -d "$WORK_DIR"
INNER_DIR=$(find "$WORK_DIR" -mindepth 1 -maxdepth 1 -type d | head -n1)
if [ -z "$INNER_DIR" ]; then
echo "install-skills: couldn't find extracted repo contents, skipping." >&2
rm -rf "$WORK_DIR" "$ZIP_PATH"
exit 0
fi
TARGET_DIRS=("$HOME/.claude/skills" "$HOME/.copilot/skills" "$HOME/.gemini/config/skills")
for dir in "${TARGET_DIRS[@]}"; do
mkdir -p "$dir"
done
# Common skill bundle, installed for every environment.
COMMON_SKILLS_SRC="$INNER_DIR/extensions/awesome-skills-plugin/skills"
if [ -d "$COMMON_SKILLS_SRC" ]; then
for dir in "${TARGET_DIRS[@]}"; do
cp -r "$COMMON_SKILLS_SRC/." "$dir/"
done
echo "install-skills: installed common skill bundle into ${TARGET_DIRS[*]}"
else
echo "install-skills: common skill bundle not found at $COMMON_SKILLS_SRC, skipping." >&2
fi
# Environment-specific specialty skills, if any were requested.
for skill in ${SPECIALTY_SKILLS:-}; do
SRC="$INNER_DIR/extensions/custom-specialty-plugin/skills/$skill"
if [ -d "$SRC" ]; then
for dir in "${TARGET_DIRS[@]}"; do
cp -r "$SRC" "$dir/$skill"
done
echo "install-skills: installed specialty skill '$skill'"
else
echo "install-skills: specialty skill '$skill' not found at $SRC, skipping." >&2
fi
done
rm -rf "$WORK_DIR" "$ZIP_PATH"
echo "install-skills: done."
+331
View File
@@ -0,0 +1,331 @@
terraform {
required_providers {
coder = {
source = "coder/coder"
}
docker = {
source = "kreuzwerker/docker"
}
# Not used directly in this config. Existing workspace state from before
# the jetbrains module was removed still has resources tagged under this
# provider (the module used it internally to fetch IDE metadata) -
# terraform init only installs providers the current config declares, so
# without this, plan/apply fails with "Missing required provider" while
# trying to reconcile/destroy those leftover state entries. Safe to drop
# once every workspace has updated past the jetbrains-module version.
http = {
source = "hashicorp/http"
}
}
}
locals {
env_name = "Python Engineering"
profile = jsondecode(file("${path.module}/profile.code-profile"))
settings_raw = jsondecode(local.profile.settings).settings
extensions = [for e in jsondecode(local.profile.extensions) : e.identifier.id]
}
variable "docker_socket" {
default = ""
description = "(Optional) Docker socket URI"
type = string
}
provider "docker" {
# Defaulting to null if the variable is an empty string lets us have an optional variable without having to set our own default
host = var.docker_socket != "" ? var.docker_socket : null
}
data "coder_provisioner" "me" {}
data "coder_workspace" "me" {}
data "coder_workspace_owner" "me" {}
resource "coder_agent" "main" {
arch = data.coder_provisioner.me.arch
os = "linux"
startup_script = <<-EOT
set -e
# Prepare user home with default files on first start.
if [ ! -f ~/.init_done ]; then
cp -rT /etc/skel ~
touch ~/.init_done
fi
# Ensure git and gnupg (commit signing) are present as base packages -
# not every base image ships gnupg by default. No-op once both are
# present (e.g. templates/web already bakes them into its image).
if ! command -v git >/dev/null 2>&1 || ! command -v gpg >/dev/null 2>&1; then
sudo apt-get update -qq
sudo apt-get install -y --no-install-recommends git gnupg
fi
# Add any commands that should be executed at workspace startup (e.g install requirements, start a program, etc) here
EOT
# These environment variables allow you to make Git commits right away after creating a
# workspace. Note that they take precedence over configuration defined in ~/.gitconfig!
# You can remove this block if you'd prefer to configure Git manually or using
# dotfiles. (see docs/dotfiles.md)
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}"
}
# 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
}
metadata {
display_name = "Load Average (Host)"
key = "6_load_host"
# get load avg scaled by number of cores
script = <<EOT
echo "`cat /proc/loadavg | awk '{ print $1 }'` `nproc`" | awk '{ printf "%0.2f", $1/$2 }'
EOT
interval = 60
timeout = 1
}
metadata {
display_name = "Swap Usage (Host)"
key = "7_swap_host"
script = <<EOT
free -b | awk '/^Swap/ { printf("%.1f/%.1f", $3/1024.0/1024.0/1024.0, $2/1024.0/1024.0/1024.0) }'
EOT
interval = 10
timeout = 1
}
}
# See https://registry.coder.com/modules/coder/code-server
# `extensions` is populated at template-push time from this env's
# profile-templates/*.code-profile file, so no interactive prompt is
# needed for VS Code extensions - Terraform handles it declaratively.
module "code-server" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/code-server/coder"
version = "~> 1.0"
agent_id = coder_agent.main.id
# Pass the target folder here natively
folder = "/home/coder/workspace"
extensions = local.extensions
order = 1
}
resource "docker_volume" "home_volume" {
name = "coder-${data.coder_workspace.me.id}-home"
# Protect the volume from being deleted due to changes in attributes.
lifecycle {
ignore_changes = all
}
# Add labels in Docker to keep track of orphan resources.
labels {
label = "coder.owner"
value = data.coder_workspace_owner.me.name
}
labels {
label = "coder.owner_id"
value = data.coder_workspace_owner.me.id
}
labels {
label = "coder.workspace_id"
value = data.coder_workspace.me.id
}
# This field becomes outdated if the workspace is renamed but can
# be useful for debugging or cleaning out dangling volumes.
labels {
label = "coder.workspace_name_at_creation"
value = data.coder_workspace.me.name
}
}
resource "docker_container" "workspace" {
count = data.coder_workspace.me.start_count
image = "codercom/enterprise-base:ubuntu"
# Uses lower() to avoid Docker restriction on container names.
name = "coder-${data.coder_workspace_owner.me.name}-${lower(data.coder_workspace.me.name)}"
# Hostname makes the shell more user friendly: coder@my-workspace:~$
hostname = data.coder_workspace.me.name
# Use the docker gateway if the access URL is 127.0.0.1
entrypoint = ["sh", "-c", replace(coder_agent.main.init_script, "/localhost|127\\.0\\.0\\.1/", "host.docker.internal")]
env = ["CODER_AGENT_TOKEN=${coder_agent.main.token}"]
host {
host = "host.docker.internal"
ip = "host-gateway"
}
host {
host = "code.octoturge.com"
ip = "host-gateway"
}
# install-skills.sh curls this at startup to pull the repo's Agent Skills;
# without this the workspace's public DNS answer for git.octoturge.com
# NAT-hairpins back to the LAN and times out (curl: (28)).
host {
host = "git.octoturge.com"
ip = "host-gateway"
}
volumes {
container_path = "/home/coder"
volume_name = docker_volume.home_volume.name
read_only = false
}
# Add labels in Docker to keep track of orphan resources.
labels {
label = "coder.owner"
value = data.coder_workspace_owner.me.name
}
labels {
label = "coder.owner_id"
value = data.coder_workspace_owner.me.id
}
labels {
label = "coder.workspace_id"
value = data.coder_workspace.me.id
}
labels {
label = "coder.workspace_name"
value = data.coder_workspace.me.name
}
labels {
label = "coder.profile"
value = local.env_name
}
}
# Writes this env's VS Code settings.json, sourced straight from the
# matching profile-templates/*.code-profile file at template-push time.
resource "coder_script" "apply_settings" {
agent_id = coder_agent.main.id
display_name = "Apply ${local.env_name} VS Code Settings"
run_on_start = true
script = <<-EOT
#!/bin/bash
set -e
mkdir -p "$HOME/workspace"
mkdir -p "$HOME/.local/share/code-server/User"
echo '${base64encode(local.settings_raw)}' | base64 -d > "$HOME/.local/share/code-server/User/settings.json"
EOT
}
# Drops the shared CLI setup wizard onto the workspace and hooks it into
# every new interactive shell (via .bashrc) until the user completes it.
# See ./cli-setup-wizard.sh for what it actually asks.
resource "coder_script" "cli_setup_wizard" {
agent_id = coder_agent.main.id
display_name = "Install CLI Setup Wizard"
run_on_start = true
script = <<-EOT
#!/bin/bash
set -e
sudo mkdir -p /opt/coder
sudo chown "$(id -u):$(id -g)" /opt/coder
echo '${base64encode(file("${path.module}/cli-setup-wizard.sh"))}' | base64 -d > /opt/coder/cli-setup-wizard.sh
chmod +x /opt/coder/cli-setup-wizard.sh
MARKER="# >>> coder cli setup wizard >>>"
if ! grep -qF "$MARKER" "$HOME/.bashrc" 2>/dev/null; then
{
echo ""
echo "$MARKER"
echo 'export PATH="$HOME/.local/bin:$PATH"'
echo 'source /opt/coder/cli-setup-wizard.sh'
echo "# <<< coder cli setup wizard <<<"
} >> "$HOME/.bashrc"
fi
EOT
}
# Installs Bun and uses it (instead of npm) for the CLI installs the wizard
# script runs. The installer doesn't reliably add ~/.bun/bin to PATH in
# non-interactive shells, so that's hooked into .bashrc explicitly here.
resource "coder_script" "install_bun" {
agent_id = coder_agent.main.id
display_name = "Install Bun"
run_on_start = true
script = <<-EOT
#!/bin/bash
set -e
export BUN_INSTALL="$HOME/.bun"
if [ ! -x "$BUN_INSTALL/bin/bun" ]; then
curl -fsSL https://bun.sh/install | bash
fi
MARKER="# >>> coder bun path >>>"
if ! grep -qF "$MARKER" "$HOME/.bashrc" 2>/dev/null; then
{
echo ""
echo "$MARKER"
echo 'export BUN_INSTALL="$HOME/.bun"'
echo 'export PATH="$BUN_INSTALL/bin:$PATH"'
echo "# <<< coder bun path <<<"
} >> "$HOME/.bashrc"
fi
EOT
}
# Installs this repo's Agent Skills into Claude Code, GitHub Copilot CLI, and
# Antigravity CLI's skills directories. See ./install-skills.sh.
# Python has no matching entry in extensions/custom-specialty-plugin/skills,
# so it only gets the common awesome-skills-plugin bundle.
resource "coder_script" "install_skills" {
agent_id = coder_agent.main.id
display_name = "Install Agent Skills"
run_on_start = true
script = <<-EOT
#!/bin/bash
set -e
sudo mkdir -p /opt/coder
sudo chown "$(id -u):$(id -g)" /opt/coder
echo '${base64encode(file("${path.module}/install-skills.sh"))}' | base64 -d > /opt/coder/install-skills.sh
chmod +x /opt/coder/install-skills.sh
SPECIALTY_SKILLS="" /opt/coder/install-skills.sh
EOT
}
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
3
+305
View File
@@ -0,0 +1,305 @@
#!/usr/bin/env bash
# Coder workspace first-run CLI setup wizard.
#
# Meant to be `source`d from a new interactive shell (e.g. via .bashrc). It asks,
# once per user per workspace, whether to install and log into a few optional
# AI coding CLIs. It re-runs on every new terminal until the user lets it finish
# (or explicitly skips it for good), then gets out of the way.
#
# VS Code / code-server extensions are intentionally NOT asked about here -
# they're installed declaratively by the Coder template itself (the
# `code-server` module's `extensions` input, populated from the matching
# profile-templates/*.code-profile file at template-push time).
#
# Manual re-run: bash /opt/coder/cli-setup-wizard.sh --force
set -u
WIZARD_DONE_FILE="${HOME}/.cache/coder-cli-wizard/done"
FORCE=0
[ "${1:-}" = "--force" ] && FORCE=1
# Tracks whether the user actually ended up authenticated against GitHub
# and/or Gitea below, so the SSH/GPG key step can ask about exactly the
# host(s) in play (and stay silent - "local git only" - if neither).
DID_GITHUB=0
DID_GITEA=0
export BUN_INSTALL="${HOME}/.bun"
export PATH="${BUN_INSTALL}/bin:${HOME}/.local/bin:${PATH}"
# Only bother interactive shells with a real terminal attached, and only until
# the user marks the wizard as done.
if [ "$FORCE" -ne 1 ]; then
case "$-" in
*i*) : ;;
*) return 0 2>/dev/null || exit 0 ;;
esac
[ -t 0 ] || { return 0 2>/dev/null || exit 0; }
[ -f "$WIZARD_DONE_FILE" ] && { return 0 2>/dev/null || exit 0; }
fi
mkdir -p "$(dirname "$WIZARD_DONE_FILE")"
ask_yes_no() {
local prompt="$1" reply
read -r -p "$prompt [y/N] " reply
case "$reply" in
[Yy]*) return 0 ;;
*) return 1 ;;
esac
}
echo ""
echo "==================================================================="
echo " Coder workspace setup wizard"
echo " Runs once per new terminal until you finish it. Ctrl+C any time"
echo " to skip for now - it'll ask again next terminal."
echo "==================================================================="
# --- GitHub Copilot CLI ---
if command -v copilot >/dev/null 2>&1; then
echo "GitHub Copilot CLI already installed, skipping."
elif command -v bun >/dev/null 2>&1; then
if ask_yes_no "Install GitHub Copilot CLI and log in?"; then
if bun install -g @github/copilot; then
copilot login || echo "Install succeeded but login didn't complete. Retry any time with: copilot login"
else
echo "Copilot CLI install failed. Retry later with: bun install -g @github/copilot && copilot login"
fi
fi
else
echo "Skipping GitHub Copilot CLI: bun not found on this workspace image."
fi
# --- Google Antigravity CLI (agy) ---
if command -v agy >/dev/null 2>&1; then
echo "Antigravity CLI already installed, skipping."
else
if ask_yes_no "Install Google Antigravity CLI (agy) and log in?"; then
if curl -fsSL https://antigravity.google/cli/install.sh | bash; then
echo "Launching 'agy' once to complete sign-in (exit with /logout or Ctrl+D when done)..."
agy || echo "Sign-in didn't complete. Retry any time by running: agy"
else
echo "Antigravity CLI install failed. Retry later with: curl -fsSL https://antigravity.google/cli/install.sh | bash"
fi
fi
fi
# --- Claude Code CLI ---
if command -v claude >/dev/null 2>&1; then
echo "Claude Code CLI already installed, skipping."
elif command -v bun >/dev/null 2>&1; then
if ask_yes_no "Install Claude Code CLI and log in?"; then
if bun install -g @anthropic-ai/claude-code; then
echo "Launching 'claude' once to complete sign-in (use /login if not prompted; Ctrl+C to exit when done)..."
claude || echo "Sign-in didn't complete. Retry any time by running: claude"
else
echo "Claude Code CLI install failed. Retry later with: bun install -g @anthropic-ai/claude-code"
fi
fi
else
echo "Skipping Claude Code CLI: bun not found on this workspace image."
fi
# --- GitHub CLI (gh) ---
if command -v gh >/dev/null 2>&1; then
echo "GitHub CLI already installed, skipping."
else
if ask_yes_no "Install GitHub CLI (gh) and log in?"; then
if (sudo mkdir -p -m 755 /etc/apt/keyrings \
&& curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | sudo tee /etc/apt/keyrings/githubcli-archive-keyring.gpg >/dev/null \
&& sudo chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg \
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list >/dev/null \
&& sudo apt-get update -qq && sudo apt-get install -y gh); then
gh auth login || echo "Install succeeded but login didn't complete. Retry any time with: gh auth login"
else
echo "GitHub CLI install failed. Retry later with: gh auth login (once gh is installed)"
fi
fi
fi
command -v gh >/dev/null 2>&1 && gh auth status >/dev/null 2>&1 && DID_GITHUB=1
# --- Gitea CLI (tea) ---
if command -v tea >/dev/null 2>&1; then
echo "Gitea CLI already installed, skipping."
else
if ask_yes_no "Install Gitea CLI (tea) and log in?"; then
TEA_ARCH="$(uname -m)"
case "$TEA_ARCH" in
x86_64) TEA_ARCH="amd64" ;;
aarch64) TEA_ARCH="arm64" ;;
esac
TEA_VERSION="$(curl -fsSL https://gitea.com/api/v1/repos/gitea/tea/releases/latest | grep -o '"tag_name":[^,]*' | grep -o 'v[0-9][^"]*')"
mkdir -p "$HOME/.local/bin"
if [ -n "$TEA_VERSION" ] \
&& curl -fsSL "https://gitea.com/gitea/tea/releases/download/${TEA_VERSION}/tea-${TEA_VERSION#v}-linux-${TEA_ARCH}" -o "$HOME/.local/bin/tea" \
&& chmod +x "$HOME/.local/bin/tea"; then
echo "Add this Gitea instance now (e.g. https://git.octoturge.com)..."
tea login add || echo "Login didn't complete. Retry any time with: tea login add"
else
echo "Gitea CLI install failed. Retry later from: https://gitea.com/gitea/tea/releases"
rm -f "$HOME/.local/bin/tea"
fi
fi
fi
# `tea whoami` succeeds regardless of how the login was done (personal
# access token or OAuth) - more reliable than parsing tea's own
# config.yml, whose indentation and fields (no plaintext `token:` at all
# for an OAuth login) vary by auth method.
command -v tea >/dev/null 2>&1 && tea whoami >/dev/null 2>&1 && DID_GITEA=1
# --- Git credential helper for Gitea, backed by tea's own login ---
# Coder injects GIT_ASKPASS to answer git's own credential prompts, but it
# only recognizes external auth providers it knows about (github.com is
# configured on the Coder deployment itself) - for any other host it falls
# through to a broken interactive prompt that just hangs a non-interactive
# git subprocess. A credential helper that successfully answers
# `git credential fill` runs *before* GIT_ASKPASS is ever consulted, so
# registering one for git.octoturge.com sidesteps that broken path
# entirely, without touching how Coder handles github.com.
#
# The token itself lives in tea's own store
# (~/.config/tea/credentials.json), which tea keeps fresh (via its
# refresh_token) as a side effect of any authenticated call - this helper
# never keeps its own copy, just re-reads tea's live value every time git
# asks.
if [ "$DID_GITEA" -eq 1 ]; then
GITEA_LOGIN_NAME="$(awk '
/^[[:space:]]*- name:/ { name = $NF }
/url: https:\/\/git\.octoturge\.com/ { print name; exit }
' "$HOME/.config/tea/config.yml" 2>/dev/null)"
if [ -n "$GITEA_LOGIN_NAME" ]; then
mkdir -p "$HOME/.local/bin"
HELPER="$HOME/.local/bin/git-credential-gitea-tea"
cat > "$HELPER" <<'HELPER_EOT'
#!/bin/sh
# Auto-generated by cli-setup-wizard.sh - re-run the wizard with --force to
# regenerate this after logging into a different Gitea account.
LOGIN_NAME="__GITEA_LOGIN_NAME__"
CRED_FILE="$HOME/.config/tea/credentials.json"
case "$1" in
get)
# Authenticated no-op call: gives tea a chance to refresh and persist
# an expired access_token before we read it below.
tea whoami >/dev/null 2>&1
token="$(grep -oP "(?<=\"$LOGIN_NAME\":).*" "$CRED_FILE" 2>/dev/null \
| grep -oP '(?<=access_token\\":\\")[^\\"]*' | head -n1)"
if [ -n "$token" ]; then
echo "username=oauth2"
echo "password=$token"
fi
;;
store|erase)
# No-op: credentials.json (managed by tea) is the single source of
# truth, nothing for git to persist or clear on this end.
;;
esac
HELPER_EOT
sed -i "s/__GITEA_LOGIN_NAME__/$GITEA_LOGIN_NAME/" "$HELPER"
chmod +x "$HELPER"
git config --global credential."https://git.octoturge.com".helper "$HELPER"
echo "Configured git to push/pull https://git.octoturge.com using your tea login."
fi
fi
# --- SSH + GPG keys for the external git host(s) selected above ---
# Only asks if the user actually set up GitHub and/or Gitea just now -
# stays silent for "local git only" (neither was set up).
if [ "$DID_GITHUB" -eq 1 ] || [ "$DID_GITEA" -eq 1 ]; then
if [ "$DID_GITHUB" -eq 1 ] && [ "$DID_GITEA" -eq 1 ]; then
KEY_HOSTS_DESC="GitHub and Gitea"
elif [ "$DID_GITHUB" -eq 1 ]; then
KEY_HOSTS_DESC="GitHub"
else
KEY_HOSTS_DESC="Gitea"
fi
if ask_yes_no "Auto-generate an SSH key and a GPG signing key, and register them with $KEY_HOSTS_DESC?"; then
KEY_NAME="${GIT_AUTHOR_NAME:-$(whoami)}"
KEY_EMAIL="${GIT_AUTHOR_EMAIL:-$(whoami)@$(hostname)}"
# SSH key: ed25519, no passphrase (disposable dev workspace convenience;
# add one manually afterwards with `ssh-keygen -p` if you want one).
SSH_KEY="$HOME/.ssh/id_ed25519"
if [ ! -f "$SSH_KEY" ]; then
mkdir -p "$HOME/.ssh" && chmod 700 "$HOME/.ssh"
ssh-keygen -t ed25519 -N "" -C "$KEY_EMAIL" -f "$SSH_KEY" -q
echo "Generated SSH key: ${SSH_KEY}.pub"
else
echo "SSH key already exists at ${SSH_KEY}.pub, reusing it."
fi
if [ "$DID_GITHUB" -eq 1 ]; then
if gh ssh-key add "${SSH_KEY}.pub" --title "coder-$(hostname)" 2>/dev/null; then
echo "SSH key added to GitHub."
else
echo "Couldn't add the SSH key to GitHub automatically (may already be added). Add manually: gh ssh-key add ${SSH_KEY}.pub"
fi
fi
if [ "$DID_GITEA" -eq 1 ]; then
if tea ssh-keys add "${SSH_KEY}.pub" --title "coder-$(hostname)" >/dev/null 2>&1; then
echo "SSH key added to Gitea."
else
echo "Couldn't add the SSH key to Gitea automatically (may already be added). Add manually with: tea ssh-keys add ${SSH_KEY}.pub"
fi
fi
# GPG key: ed25519 signing key, no passphrase, no expiry.
if gpg --list-secret-keys --with-colons "$KEY_EMAIL" 2>/dev/null | grep -q '^sec'; then
echo "GPG key for $KEY_EMAIL already exists, reusing it."
else
mkdir -p "$HOME/.gnupg" && chmod 700 "$HOME/.gnupg"
grep -qF "allow-loopback-pinentry" "$HOME/.gnupg/gpg-agent.conf" 2>/dev/null \
|| echo "allow-loopback-pinentry" >> "$HOME/.gnupg/gpg-agent.conf"
gpgconf --kill gpg-agent 2>/dev/null
if gpg --batch --pinentry-mode loopback --passphrase '' --quick-gen-key "$KEY_NAME <$KEY_EMAIL>" ed25519 sign 0 2>/dev/null; then
echo "Generated GPG signing key for $KEY_EMAIL."
else
echo "GPG key generation failed. Generate manually with: gpg --quick-gen-key \"$KEY_NAME <$KEY_EMAIL>\" ed25519 sign 0"
fi
fi
GPG_KEY_ID="$(gpg --list-secret-keys --with-colons "$KEY_EMAIL" 2>/dev/null | awk -F: '/^sec/{print $5; exit}')"
if [ -n "$GPG_KEY_ID" ]; then
git config --global user.signingkey "$GPG_KEY_ID"
git config --global commit.gpgsign true
echo "Configured git to sign commits with this key."
if [ "$DID_GITHUB" -eq 1 ]; then
if gpg --armor --export "$GPG_KEY_ID" | gh gpg-key add - 2>/dev/null; then
echo "GPG key added to GitHub."
else
echo "Couldn't add the GPG key to GitHub automatically (may already be added). Add manually: gpg --armor --export $GPG_KEY_ID | gh gpg-key add -"
fi
fi
if [ "$DID_GITEA" -eq 1 ]; then
if gpg --armor --export "$GPG_KEY_ID" | tea api -X POST /user/gpg_keys -F armored_public_key=@- >/dev/null 2>&1; then
echo "GPG key added to Gitea."
else
echo "Couldn't add the GPG key to Gitea automatically (may already be added). Add manually: gpg --armor --export $GPG_KEY_ID | tea api -X POST /user/gpg_keys -F armored_public_key=@-"
fi
fi
fi
fi
elif command -v gh >/dev/null 2>&1 || command -v tea >/dev/null 2>&1; then
# gh and/or tea are installed but neither is actually logged in yet (login
# was declined, failed, or never completed) - say why the key-generation
# question above got skipped instead of just silently not asking.
echo "Skipping SSH/GPG key setup: not logged in to GitHub or Gitea yet."
echo "Log in (gh auth login / tea login add) then re-run: bash /opt/coder/cli-setup-wizard.sh --force"
fi
echo ""
if ask_yes_no "Mark setup wizard as complete so it stops asking on new terminals?"; then
touch "$WIZARD_DONE_FILE"
echo "Done. Re-run any time with: bash /opt/coder/cli-setup-wizard.sh --force"
else
echo "OK, this'll ask again next time you open a terminal."
fi
return 0 2>/dev/null || exit 0
+68
View File
@@ -0,0 +1,68 @@
#!/usr/bin/env bash
# Installs this repo's Agent Skills (extensions/{awesome-skills-plugin,
# custom-specialty-plugin}/skills/*, each a SKILL.md-based skill directory)
# into every AI CLI's personal skills directory:
#
# Claude Code CLI -> ~/.claude/skills/<name>/
# GitHub Copilot CLI -> ~/.copilot/skills/<name>/
# Antigravity CLI -> ~/.gemini/config/skills/<name>/ (per antigravity.google/docs/skills;
# worth a spot-check if agy doesn't pick these up, some third-party
# docs disagree on the exact path)
#
# Run once at workspace startup via coder_script. Pulls this repo fresh from
# Gitea rather than embedding ~2.5MB of skill files into Terraform state.
#
# Env vars:
# SPECIALTY_SKILLS - optional space-separated skill names from
# extensions/custom-specialty-plugin/skills/ to install in addition to
# the common awesome-skills-plugin bundle (every env gets that one).
set -e
REPO_ZIP_URL="https://git.octoturge.com/octoturge/Profiles-for-Coder/archive/main.zip"
ZIP_PATH="/tmp/coder-skills-src.zip"
WORK_DIR="/tmp/coder-skills-src"
rm -rf "$WORK_DIR" "$ZIP_PATH"
curl -fsSL "$REPO_ZIP_URL" -o "$ZIP_PATH"
mkdir -p "$WORK_DIR"
unzip -q -o "$ZIP_PATH" -d "$WORK_DIR"
INNER_DIR=$(find "$WORK_DIR" -mindepth 1 -maxdepth 1 -type d | head -n1)
if [ -z "$INNER_DIR" ]; then
echo "install-skills: couldn't find extracted repo contents, skipping." >&2
rm -rf "$WORK_DIR" "$ZIP_PATH"
exit 0
fi
TARGET_DIRS=("$HOME/.claude/skills" "$HOME/.copilot/skills" "$HOME/.gemini/config/skills")
for dir in "${TARGET_DIRS[@]}"; do
mkdir -p "$dir"
done
# Common skill bundle, installed for every environment.
COMMON_SKILLS_SRC="$INNER_DIR/extensions/awesome-skills-plugin/skills"
if [ -d "$COMMON_SKILLS_SRC" ]; then
for dir in "${TARGET_DIRS[@]}"; do
cp -r "$COMMON_SKILLS_SRC/." "$dir/"
done
echo "install-skills: installed common skill bundle into ${TARGET_DIRS[*]}"
else
echo "install-skills: common skill bundle not found at $COMMON_SKILLS_SRC, skipping." >&2
fi
# Environment-specific specialty skills, if any were requested.
for skill in ${SPECIALTY_SKILLS:-}; do
SRC="$INNER_DIR/extensions/custom-specialty-plugin/skills/$skill"
if [ -d "$SRC" ]; then
for dir in "${TARGET_DIRS[@]}"; do
cp -r "$SRC" "$dir/$skill"
done
echo "install-skills: installed specialty skill '$skill'"
else
echo "install-skills: specialty skill '$skill' not found at $SRC, skipping." >&2
fi
done
rm -rf "$WORK_DIR" "$ZIP_PATH"
echo "install-skills: done."
+331
View File
@@ -0,0 +1,331 @@
terraform {
required_providers {
coder = {
source = "coder/coder"
}
docker = {
source = "kreuzwerker/docker"
}
# Not used directly in this config. Existing workspace state from before
# the jetbrains module was removed still has resources tagged under this
# provider (the module used it internally to fetch IDE metadata) -
# terraform init only installs providers the current config declares, so
# without this, plan/apply fails with "Missing required provider" while
# trying to reconcile/destroy those leftover state entries. Safe to drop
# once every workspace has updated past the jetbrains-module version.
http = {
source = "hashicorp/http"
}
}
}
locals {
env_name = "TTRPG & Lore Building"
profile = jsondecode(file("${path.module}/profile.code-profile"))
settings_raw = jsondecode(local.profile.settings).settings
extensions = [for e in jsondecode(local.profile.extensions) : e.identifier.id]
}
variable "docker_socket" {
default = ""
description = "(Optional) Docker socket URI"
type = string
}
provider "docker" {
# Defaulting to null if the variable is an empty string lets us have an optional variable without having to set our own default
host = var.docker_socket != "" ? var.docker_socket : null
}
data "coder_provisioner" "me" {}
data "coder_workspace" "me" {}
data "coder_workspace_owner" "me" {}
resource "coder_agent" "main" {
arch = data.coder_provisioner.me.arch
os = "linux"
startup_script = <<-EOT
set -e
# Prepare user home with default files on first start.
if [ ! -f ~/.init_done ]; then
cp -rT /etc/skel ~
touch ~/.init_done
fi
# Ensure git and gnupg (commit signing) are present as base packages -
# not every base image ships gnupg by default. No-op once both are
# present (e.g. templates/web already bakes them into its image).
if ! command -v git >/dev/null 2>&1 || ! command -v gpg >/dev/null 2>&1; then
sudo apt-get update -qq
sudo apt-get install -y --no-install-recommends git gnupg
fi
# Add any commands that should be executed at workspace startup (e.g install requirements, start a program, etc) here
EOT
# These environment variables allow you to make Git commits right away after creating a
# workspace. Note that they take precedence over configuration defined in ~/.gitconfig!
# You can remove this block if you'd prefer to configure Git manually or using
# dotfiles. (see docs/dotfiles.md)
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}"
}
# 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
}
metadata {
display_name = "Load Average (Host)"
key = "6_load_host"
# get load avg scaled by number of cores
script = <<EOT
echo "`cat /proc/loadavg | awk '{ print $1 }'` `nproc`" | awk '{ printf "%0.2f", $1/$2 }'
EOT
interval = 60
timeout = 1
}
metadata {
display_name = "Swap Usage (Host)"
key = "7_swap_host"
script = <<EOT
free -b | awk '/^Swap/ { printf("%.1f/%.1f", $3/1024.0/1024.0/1024.0, $2/1024.0/1024.0/1024.0) }'
EOT
interval = 10
timeout = 1
}
}
# See https://registry.coder.com/modules/coder/code-server
# `extensions` is populated at template-push time from this env's
# profile-templates/*.code-profile file, so no interactive prompt is
# needed for VS Code extensions - Terraform handles it declaratively.
module "code-server" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/code-server/coder"
version = "~> 1.0"
agent_id = coder_agent.main.id
# Pass the target folder here natively
folder = "/home/coder/workspace"
extensions = local.extensions
order = 1
}
resource "docker_volume" "home_volume" {
name = "coder-${data.coder_workspace.me.id}-home"
# Protect the volume from being deleted due to changes in attributes.
lifecycle {
ignore_changes = all
}
# Add labels in Docker to keep track of orphan resources.
labels {
label = "coder.owner"
value = data.coder_workspace_owner.me.name
}
labels {
label = "coder.owner_id"
value = data.coder_workspace_owner.me.id
}
labels {
label = "coder.workspace_id"
value = data.coder_workspace.me.id
}
# This field becomes outdated if the workspace is renamed but can
# be useful for debugging or cleaning out dangling volumes.
labels {
label = "coder.workspace_name_at_creation"
value = data.coder_workspace.me.name
}
}
resource "docker_container" "workspace" {
count = data.coder_workspace.me.start_count
image = "codercom/enterprise-base:ubuntu"
# Uses lower() to avoid Docker restriction on container names.
name = "coder-${data.coder_workspace_owner.me.name}-${lower(data.coder_workspace.me.name)}"
# Hostname makes the shell more user friendly: coder@my-workspace:~$
hostname = data.coder_workspace.me.name
# Use the docker gateway if the access URL is 127.0.0.1
entrypoint = ["sh", "-c", replace(coder_agent.main.init_script, "/localhost|127\\.0\\.0\\.1/", "host.docker.internal")]
env = ["CODER_AGENT_TOKEN=${coder_agent.main.token}"]
host {
host = "host.docker.internal"
ip = "host-gateway"
}
host {
host = "code.octoturge.com"
ip = "host-gateway"
}
# install-skills.sh curls this at startup to pull the repo's Agent Skills;
# without this the workspace's public DNS answer for git.octoturge.com
# NAT-hairpins back to the LAN and times out (curl: (28)).
host {
host = "git.octoturge.com"
ip = "host-gateway"
}
volumes {
container_path = "/home/coder"
volume_name = docker_volume.home_volume.name
read_only = false
}
# Add labels in Docker to keep track of orphan resources.
labels {
label = "coder.owner"
value = data.coder_workspace_owner.me.name
}
labels {
label = "coder.owner_id"
value = data.coder_workspace_owner.me.id
}
labels {
label = "coder.workspace_id"
value = data.coder_workspace.me.id
}
labels {
label = "coder.workspace_name"
value = data.coder_workspace.me.name
}
labels {
label = "coder.profile"
value = local.env_name
}
}
# Writes this env's VS Code settings.json, sourced straight from the
# matching profile-templates/*.code-profile file at template-push time.
resource "coder_script" "apply_settings" {
agent_id = coder_agent.main.id
display_name = "Apply ${local.env_name} VS Code Settings"
run_on_start = true
script = <<-EOT
#!/bin/bash
set -e
mkdir -p "$HOME/workspace"
mkdir -p "$HOME/.local/share/code-server/User"
echo '${base64encode(local.settings_raw)}' | base64 -d > "$HOME/.local/share/code-server/User/settings.json"
EOT
}
# Drops the shared CLI setup wizard onto the workspace and hooks it into
# every new interactive shell (via .bashrc) until the user completes it.
# See ./cli-setup-wizard.sh for what it actually asks.
resource "coder_script" "cli_setup_wizard" {
agent_id = coder_agent.main.id
display_name = "Install CLI Setup Wizard"
run_on_start = true
script = <<-EOT
#!/bin/bash
set -e
sudo mkdir -p /opt/coder
sudo chown "$(id -u):$(id -g)" /opt/coder
echo '${base64encode(file("${path.module}/cli-setup-wizard.sh"))}' | base64 -d > /opt/coder/cli-setup-wizard.sh
chmod +x /opt/coder/cli-setup-wizard.sh
MARKER="# >>> coder cli setup wizard >>>"
if ! grep -qF "$MARKER" "$HOME/.bashrc" 2>/dev/null; then
{
echo ""
echo "$MARKER"
echo 'export PATH="$HOME/.local/bin:$PATH"'
echo 'source /opt/coder/cli-setup-wizard.sh'
echo "# <<< coder cli setup wizard <<<"
} >> "$HOME/.bashrc"
fi
EOT
}
# Installs Bun and uses it (instead of npm) for the CLI installs the wizard
# script runs. The installer doesn't reliably add ~/.bun/bin to PATH in
# non-interactive shells, so that's hooked into .bashrc explicitly here.
resource "coder_script" "install_bun" {
agent_id = coder_agent.main.id
display_name = "Install Bun"
run_on_start = true
script = <<-EOT
#!/bin/bash
set -e
export BUN_INSTALL="$HOME/.bun"
if [ ! -x "$BUN_INSTALL/bin/bun" ]; then
curl -fsSL https://bun.sh/install | bash
fi
MARKER="# >>> coder bun path >>>"
if ! grep -qF "$MARKER" "$HOME/.bashrc" 2>/dev/null; then
{
echo ""
echo "$MARKER"
echo 'export BUN_INSTALL="$HOME/.bun"'
echo 'export PATH="$BUN_INSTALL/bin:$PATH"'
echo "# <<< coder bun path <<<"
} >> "$HOME/.bashrc"
fi
EOT
}
# Installs this repo's Agent Skills into Claude Code, GitHub Copilot CLI, and
# Antigravity CLI's skills directories, plus the foundryvtt-modding and
# ttrpg-lore-weaver skills from extensions/custom-specialty-plugin.
# See ./install-skills.sh.
resource "coder_script" "install_skills" {
agent_id = coder_agent.main.id
display_name = "Install Agent Skills"
run_on_start = true
script = <<-EOT
#!/bin/bash
set -e
sudo mkdir -p /opt/coder
sudo chown "$(id -u):$(id -g)" /opt/coder
echo '${base64encode(file("${path.module}/install-skills.sh"))}' | base64 -d > /opt/coder/install-skills.sh
chmod +x /opt/coder/install-skills.sh
SPECIALTY_SKILLS="foundryvtt-modding ttrpg-lore-weaver" /opt/coder/install-skills.sh
EOT
}
+167
View File
@@ -0,0 +1,167 @@
# Web Applications workspace image: Rust (Tauri 2 / gRPC), Bun + Node/pnpm,
# Python + CV/ONNX prototyping, and DB CLI clients baked in at build time so
# workspace start doesn't pay for a from-scratch toolchain install.
#
# Built by templates/web/main.tf via the docker provider's `build` block
# (context = this directory), not pulled from a registry.
FROM ubuntu:24.04
ARG DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y --no-install-recommends \
locales sudo ca-certificates gnupg curl wget \
&& locale-gen en_US.UTF-8 \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*
ENV LANG=en_US.UTF-8 \
LANGUAGE=en_US:en \
LC_ALL=en_US.UTF-8
# Google Chrome (stable), for the Browse Lite VS Code extension's embedded
# browser preview. Ubuntu's own `chromium-browser` apt package is just a
# snap wrapper and doesn't work in a container (no snapd) - Google's own
# .deb is the reliable way to get a real Chrome binary here. amd64 only
# (Google doesn't publish a Chrome .deb for arm64), which matches this
# repo's single x86_64 Docker host.
RUN curl -fsSL https://dl.google.com/linux/linux_signing_key.pub \
| gpg --dearmor -o /usr/share/keyrings/google-chrome.gpg \
&& echo "deb [arch=amd64 signed-by=/usr/share/keyrings/google-chrome.gpg] http://dl.google.com/linux/chrome/deb/ stable main" \
> /etc/apt/sources.list.d/google-chrome.list \
&& apt-get update && apt-get install -y google-chrome-stable \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*
# Core build toolchain, crypto/DB headers, Tauri 2 / WebKit GUI prerequisites,
# X11 dev libs, DB CLI clients, Python + OpenCV, protobuf compiler.
#
# Split into several RUN steps (rather than one big apt-get install) so no
# single resulting layer is too large to push to the registry - it sits
# behind a reverse proxy with a request body size cap, and a couple of these
# packages (llvm, libopencv-dev, libwebkit2gtk-4.1-dev) are individually
# large enough to blow past it if lumped together with everything else.
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential pkg-config cmake \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*
RUN apt-get update && apt-get install -y --no-install-recommends \
clang llvm \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*
RUN apt-get update && apt-get install -y --no-install-recommends \
git git-lfs jq unzip tar file htop tree tmux zsh openssh-client \
&& git lfs install --system \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*
RUN apt-get update && apt-get install -y --no-install-recommends \
libssl-dev libpq-dev libsqlite3-dev \
postgresql-client redis-tools sqlite3 \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*
RUN apt-get update && apt-get install -y --no-install-recommends \
libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev libxdo-dev \
libgtk-3-dev libsoup-3.0-dev \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*
RUN apt-get update && apt-get install -y --no-install-recommends \
libx11-dev libxext-dev libxrender-dev libxtst-dev libxi-dev \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 python3-pip python3-venv python3-dev \
protobuf-compiler \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*
RUN apt-get update && apt-get install -y --no-install-recommends \
libopencv-dev \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*
# Node.js LTS (22.x) plus npm/pnpm/yarn as root so global bins land on the
# system PATH for every user.
RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
&& apt-get install -y --no-install-recommends nodejs \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*
RUN npm install -g pnpm yarn
# Global Python prototyping packages: CV, ONNX runtime, CPU-only torch wheel.
# Ubuntu 24.04's system Python is PEP 668 externally-managed; this is a
# throwaway container image, so --break-system-packages is the right call
# instead of forcing every user into a venv for basic prototyping. Not
# upgrading pip itself first: the Debian-packaged pip 24.0 has no RECORD
# file (dpkg-installed, not pip-installed), so `pip install --upgrade pip`
# fails trying to uninstall it in place - and it's unneeded anyway, the
# packages below install fine under the stock version.
#
# Each package gets its own RUN/layer for the same reverse-proxy body-size
# reason as the apt-get split above - torch's CPU wheel and opencv-python's
# wheel are each large enough on their own to be worth isolating.
RUN python3 -m pip install --break-system-packages --no-cache-dir numpy
RUN python3 -m pip install --break-system-packages --no-cache-dir opencv-python-headless
RUN python3 -m pip install --break-system-packages --no-cache-dir onnxruntime
RUN python3 -m pip install --break-system-packages --no-cache-dir \
torch --index-url https://download.pytorch.org/whl/cpu
# Standard non-root dev user with passwordless sudo. Ubuntu 24.04's base
# image already ships a default `ubuntu` user/group at uid/gid 1000, which
# collides with the explicit --uid 1000 below - drop it first so `coder`
# can take that uid.
RUN userdel -r ubuntu 2>/dev/null; \
groupdel ubuntu 2>/dev/null; \
useradd --uid 1000 --create-home --shell /bin/bash coder \
&& echo "coder ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/coder \
&& chmod 0440 /etc/sudoers.d/coder
ENV RUST_BACKTRACE=1 \
RUSTUP_HOME=/home/coder/.rustup \
CARGO_HOME=/home/coder/.cargo \
BUN_INSTALL=/home/coder/.bun \
PNPM_HOME=/home/coder/.local/share/pnpm \
PATH=/home/coder/.cargo/bin:/home/coder/.bun/bin:/home/coder/.local/share/pnpm:/home/coder/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
USER coder
WORKDIR /home/coder
# Rust via rustup: stable toolchain, rust-analyzer/clippy/rustfmt/rust-src,
# native + musl targets for x86_64/aarch64, and cargo helper utilities.
#
# Split into one RUN per target/tool (rather than one big chained command) so
# no single layer is too large to push to the registry, for the same
# reverse-proxy body-size reason as the apt-get split above - the base
# toolchain and each additional target's std library are each sizeable, and
# `cargo install` leaves a build/registry cache behind that needs clearing
# inside its own RUN, or it would just bloat that same layer instead.
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y \
--default-toolchain stable --profile default \
&& rm -rf "$RUSTUP_HOME"/tmp "$RUSTUP_HOME"/downloads
RUN rustup component add rustfmt clippy rust-analyzer rust-src
RUN rustup target add x86_64-unknown-linux-gnu
RUN rustup target add x86_64-unknown-linux-musl
RUN rustup target add aarch64-unknown-linux-gnu
RUN rustup target add aarch64-unknown-linux-musl
RUN cargo install --locked cargo-watch \
&& rm -rf "$CARGO_HOME"/registry "$CARGO_HOME"/git
RUN cargo install --locked cargo-edit \
&& rm -rf "$CARGO_HOME"/registry "$CARGO_HOME"/git
RUN cargo install --locked cross \
&& rm -rf "$CARGO_HOME"/registry "$CARGO_HOME"/git
RUN cargo install --locked bacon \
&& rm -rf "$CARGO_HOME"/registry "$CARGO_HOME"/git
# Bun: global runtime for the ElysiaJS backend and fast scripting.
RUN curl -fsSL https://bun.sh/install | bash
RUN mkdir -p /home/coder/workspace
WORKDIR /home/coder/workspace
+1
View File
@@ -0,0 +1 @@
3
+305
View File
@@ -0,0 +1,305 @@
#!/usr/bin/env bash
# Coder workspace first-run CLI setup wizard.
#
# Meant to be `source`d from a new interactive shell (e.g. via .bashrc). It asks,
# once per user per workspace, whether to install and log into a few optional
# AI coding CLIs. It re-runs on every new terminal until the user lets it finish
# (or explicitly skips it for good), then gets out of the way.
#
# VS Code / code-server extensions are intentionally NOT asked about here -
# they're installed declaratively by the Coder template itself (the
# `code-server` module's `extensions` input, populated from the matching
# profile-templates/*.code-profile file at template-push time).
#
# Manual re-run: bash /opt/coder/cli-setup-wizard.sh --force
set -u
WIZARD_DONE_FILE="${HOME}/.cache/coder-cli-wizard/done"
FORCE=0
[ "${1:-}" = "--force" ] && FORCE=1
# Tracks whether the user actually ended up authenticated against GitHub
# and/or Gitea below, so the SSH/GPG key step can ask about exactly the
# host(s) in play (and stay silent - "local git only" - if neither).
DID_GITHUB=0
DID_GITEA=0
export BUN_INSTALL="${HOME}/.bun"
export PATH="${BUN_INSTALL}/bin:${HOME}/.local/bin:${PATH}"
# Only bother interactive shells with a real terminal attached, and only until
# the user marks the wizard as done.
if [ "$FORCE" -ne 1 ]; then
case "$-" in
*i*) : ;;
*) return 0 2>/dev/null || exit 0 ;;
esac
[ -t 0 ] || { return 0 2>/dev/null || exit 0; }
[ -f "$WIZARD_DONE_FILE" ] && { return 0 2>/dev/null || exit 0; }
fi
mkdir -p "$(dirname "$WIZARD_DONE_FILE")"
ask_yes_no() {
local prompt="$1" reply
read -r -p "$prompt [y/N] " reply
case "$reply" in
[Yy]*) return 0 ;;
*) return 1 ;;
esac
}
echo ""
echo "==================================================================="
echo " Coder workspace setup wizard"
echo " Runs once per new terminal until you finish it. Ctrl+C any time"
echo " to skip for now - it'll ask again next terminal."
echo "==================================================================="
# --- GitHub Copilot CLI ---
if command -v copilot >/dev/null 2>&1; then
echo "GitHub Copilot CLI already installed, skipping."
elif command -v bun >/dev/null 2>&1; then
if ask_yes_no "Install GitHub Copilot CLI and log in?"; then
if bun install -g @github/copilot; then
copilot login || echo "Install succeeded but login didn't complete. Retry any time with: copilot login"
else
echo "Copilot CLI install failed. Retry later with: bun install -g @github/copilot && copilot login"
fi
fi
else
echo "Skipping GitHub Copilot CLI: bun not found on this workspace image."
fi
# --- Google Antigravity CLI (agy) ---
if command -v agy >/dev/null 2>&1; then
echo "Antigravity CLI already installed, skipping."
else
if ask_yes_no "Install Google Antigravity CLI (agy) and log in?"; then
if curl -fsSL https://antigravity.google/cli/install.sh | bash; then
echo "Launching 'agy' once to complete sign-in (exit with /logout or Ctrl+D when done)..."
agy || echo "Sign-in didn't complete. Retry any time by running: agy"
else
echo "Antigravity CLI install failed. Retry later with: curl -fsSL https://antigravity.google/cli/install.sh | bash"
fi
fi
fi
# --- Claude Code CLI ---
if command -v claude >/dev/null 2>&1; then
echo "Claude Code CLI already installed, skipping."
elif command -v bun >/dev/null 2>&1; then
if ask_yes_no "Install Claude Code CLI and log in?"; then
if bun install -g @anthropic-ai/claude-code; then
echo "Launching 'claude' once to complete sign-in (use /login if not prompted; Ctrl+C to exit when done)..."
claude || echo "Sign-in didn't complete. Retry any time by running: claude"
else
echo "Claude Code CLI install failed. Retry later with: bun install -g @anthropic-ai/claude-code"
fi
fi
else
echo "Skipping Claude Code CLI: bun not found on this workspace image."
fi
# --- GitHub CLI (gh) ---
if command -v gh >/dev/null 2>&1; then
echo "GitHub CLI already installed, skipping."
else
if ask_yes_no "Install GitHub CLI (gh) and log in?"; then
if (sudo mkdir -p -m 755 /etc/apt/keyrings \
&& curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | sudo tee /etc/apt/keyrings/githubcli-archive-keyring.gpg >/dev/null \
&& sudo chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg \
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list >/dev/null \
&& sudo apt-get update -qq && sudo apt-get install -y gh); then
gh auth login || echo "Install succeeded but login didn't complete. Retry any time with: gh auth login"
else
echo "GitHub CLI install failed. Retry later with: gh auth login (once gh is installed)"
fi
fi
fi
command -v gh >/dev/null 2>&1 && gh auth status >/dev/null 2>&1 && DID_GITHUB=1
# --- Gitea CLI (tea) ---
if command -v tea >/dev/null 2>&1; then
echo "Gitea CLI already installed, skipping."
else
if ask_yes_no "Install Gitea CLI (tea) and log in?"; then
TEA_ARCH="$(uname -m)"
case "$TEA_ARCH" in
x86_64) TEA_ARCH="amd64" ;;
aarch64) TEA_ARCH="arm64" ;;
esac
TEA_VERSION="$(curl -fsSL https://gitea.com/api/v1/repos/gitea/tea/releases/latest | grep -o '"tag_name":[^,]*' | grep -o 'v[0-9][^"]*')"
mkdir -p "$HOME/.local/bin"
if [ -n "$TEA_VERSION" ] \
&& curl -fsSL "https://gitea.com/gitea/tea/releases/download/${TEA_VERSION}/tea-${TEA_VERSION#v}-linux-${TEA_ARCH}" -o "$HOME/.local/bin/tea" \
&& chmod +x "$HOME/.local/bin/tea"; then
echo "Add this Gitea instance now (e.g. https://git.octoturge.com)..."
tea login add || echo "Login didn't complete. Retry any time with: tea login add"
else
echo "Gitea CLI install failed. Retry later from: https://gitea.com/gitea/tea/releases"
rm -f "$HOME/.local/bin/tea"
fi
fi
fi
# `tea whoami` succeeds regardless of how the login was done (personal
# access token or OAuth) - more reliable than parsing tea's own
# config.yml, whose indentation and fields (no plaintext `token:` at all
# for an OAuth login) vary by auth method.
command -v tea >/dev/null 2>&1 && tea whoami >/dev/null 2>&1 && DID_GITEA=1
# --- Git credential helper for Gitea, backed by tea's own login ---
# Coder injects GIT_ASKPASS to answer git's own credential prompts, but it
# only recognizes external auth providers it knows about (github.com is
# configured on the Coder deployment itself) - for any other host it falls
# through to a broken interactive prompt that just hangs a non-interactive
# git subprocess. A credential helper that successfully answers
# `git credential fill` runs *before* GIT_ASKPASS is ever consulted, so
# registering one for git.octoturge.com sidesteps that broken path
# entirely, without touching how Coder handles github.com.
#
# The token itself lives in tea's own store
# (~/.config/tea/credentials.json), which tea keeps fresh (via its
# refresh_token) as a side effect of any authenticated call - this helper
# never keeps its own copy, just re-reads tea's live value every time git
# asks.
if [ "$DID_GITEA" -eq 1 ]; then
GITEA_LOGIN_NAME="$(awk '
/^[[:space:]]*- name:/ { name = $NF }
/url: https:\/\/git\.octoturge\.com/ { print name; exit }
' "$HOME/.config/tea/config.yml" 2>/dev/null)"
if [ -n "$GITEA_LOGIN_NAME" ]; then
mkdir -p "$HOME/.local/bin"
HELPER="$HOME/.local/bin/git-credential-gitea-tea"
cat > "$HELPER" <<'HELPER_EOT'
#!/bin/sh
# Auto-generated by cli-setup-wizard.sh - re-run the wizard with --force to
# regenerate this after logging into a different Gitea account.
LOGIN_NAME="__GITEA_LOGIN_NAME__"
CRED_FILE="$HOME/.config/tea/credentials.json"
case "$1" in
get)
# Authenticated no-op call: gives tea a chance to refresh and persist
# an expired access_token before we read it below.
tea whoami >/dev/null 2>&1
token="$(grep -oP "(?<=\"$LOGIN_NAME\":).*" "$CRED_FILE" 2>/dev/null \
| grep -oP '(?<=access_token\\":\\")[^\\"]*' | head -n1)"
if [ -n "$token" ]; then
echo "username=oauth2"
echo "password=$token"
fi
;;
store|erase)
# No-op: credentials.json (managed by tea) is the single source of
# truth, nothing for git to persist or clear on this end.
;;
esac
HELPER_EOT
sed -i "s/__GITEA_LOGIN_NAME__/$GITEA_LOGIN_NAME/" "$HELPER"
chmod +x "$HELPER"
git config --global credential."https://git.octoturge.com".helper "$HELPER"
echo "Configured git to push/pull https://git.octoturge.com using your tea login."
fi
fi
# --- SSH + GPG keys for the external git host(s) selected above ---
# Only asks if the user actually set up GitHub and/or Gitea just now -
# stays silent for "local git only" (neither was set up).
if [ "$DID_GITHUB" -eq 1 ] || [ "$DID_GITEA" -eq 1 ]; then
if [ "$DID_GITHUB" -eq 1 ] && [ "$DID_GITEA" -eq 1 ]; then
KEY_HOSTS_DESC="GitHub and Gitea"
elif [ "$DID_GITHUB" -eq 1 ]; then
KEY_HOSTS_DESC="GitHub"
else
KEY_HOSTS_DESC="Gitea"
fi
if ask_yes_no "Auto-generate an SSH key and a GPG signing key, and register them with $KEY_HOSTS_DESC?"; then
KEY_NAME="${GIT_AUTHOR_NAME:-$(whoami)}"
KEY_EMAIL="${GIT_AUTHOR_EMAIL:-$(whoami)@$(hostname)}"
# SSH key: ed25519, no passphrase (disposable dev workspace convenience;
# add one manually afterwards with `ssh-keygen -p` if you want one).
SSH_KEY="$HOME/.ssh/id_ed25519"
if [ ! -f "$SSH_KEY" ]; then
mkdir -p "$HOME/.ssh" && chmod 700 "$HOME/.ssh"
ssh-keygen -t ed25519 -N "" -C "$KEY_EMAIL" -f "$SSH_KEY" -q
echo "Generated SSH key: ${SSH_KEY}.pub"
else
echo "SSH key already exists at ${SSH_KEY}.pub, reusing it."
fi
if [ "$DID_GITHUB" -eq 1 ]; then
if gh ssh-key add "${SSH_KEY}.pub" --title "coder-$(hostname)" 2>/dev/null; then
echo "SSH key added to GitHub."
else
echo "Couldn't add the SSH key to GitHub automatically (may already be added). Add manually: gh ssh-key add ${SSH_KEY}.pub"
fi
fi
if [ "$DID_GITEA" -eq 1 ]; then
if tea ssh-keys add "${SSH_KEY}.pub" --title "coder-$(hostname)" >/dev/null 2>&1; then
echo "SSH key added to Gitea."
else
echo "Couldn't add the SSH key to Gitea automatically (may already be added). Add manually with: tea ssh-keys add ${SSH_KEY}.pub"
fi
fi
# GPG key: ed25519 signing key, no passphrase, no expiry.
if gpg --list-secret-keys --with-colons "$KEY_EMAIL" 2>/dev/null | grep -q '^sec'; then
echo "GPG key for $KEY_EMAIL already exists, reusing it."
else
mkdir -p "$HOME/.gnupg" && chmod 700 "$HOME/.gnupg"
grep -qF "allow-loopback-pinentry" "$HOME/.gnupg/gpg-agent.conf" 2>/dev/null \
|| echo "allow-loopback-pinentry" >> "$HOME/.gnupg/gpg-agent.conf"
gpgconf --kill gpg-agent 2>/dev/null
if gpg --batch --pinentry-mode loopback --passphrase '' --quick-gen-key "$KEY_NAME <$KEY_EMAIL>" ed25519 sign 0 2>/dev/null; then
echo "Generated GPG signing key for $KEY_EMAIL."
else
echo "GPG key generation failed. Generate manually with: gpg --quick-gen-key \"$KEY_NAME <$KEY_EMAIL>\" ed25519 sign 0"
fi
fi
GPG_KEY_ID="$(gpg --list-secret-keys --with-colons "$KEY_EMAIL" 2>/dev/null | awk -F: '/^sec/{print $5; exit}')"
if [ -n "$GPG_KEY_ID" ]; then
git config --global user.signingkey "$GPG_KEY_ID"
git config --global commit.gpgsign true
echo "Configured git to sign commits with this key."
if [ "$DID_GITHUB" -eq 1 ]; then
if gpg --armor --export "$GPG_KEY_ID" | gh gpg-key add - 2>/dev/null; then
echo "GPG key added to GitHub."
else
echo "Couldn't add the GPG key to GitHub automatically (may already be added). Add manually: gpg --armor --export $GPG_KEY_ID | gh gpg-key add -"
fi
fi
if [ "$DID_GITEA" -eq 1 ]; then
if gpg --armor --export "$GPG_KEY_ID" | tea api -X POST /user/gpg_keys -F armored_public_key=@- >/dev/null 2>&1; then
echo "GPG key added to Gitea."
else
echo "Couldn't add the GPG key to Gitea automatically (may already be added). Add manually: gpg --armor --export $GPG_KEY_ID | tea api -X POST /user/gpg_keys -F armored_public_key=@-"
fi
fi
fi
fi
elif command -v gh >/dev/null 2>&1 || command -v tea >/dev/null 2>&1; then
# gh and/or tea are installed but neither is actually logged in yet (login
# was declined, failed, or never completed) - say why the key-generation
# question above got skipped instead of just silently not asking.
echo "Skipping SSH/GPG key setup: not logged in to GitHub or Gitea yet."
echo "Log in (gh auth login / tea login add) then re-run: bash /opt/coder/cli-setup-wizard.sh --force"
fi
echo ""
if ask_yes_no "Mark setup wizard as complete so it stops asking on new terminals?"; then
touch "$WIZARD_DONE_FILE"
echo "Done. Re-run any time with: bash /opt/coder/cli-setup-wizard.sh --force"
else
echo "OK, this'll ask again next time you open a terminal."
fi
return 0 2>/dev/null || exit 0
+68
View File
@@ -0,0 +1,68 @@
#!/usr/bin/env bash
# Installs this repo's Agent Skills (extensions/{awesome-skills-plugin,
# custom-specialty-plugin}/skills/*, each a SKILL.md-based skill directory)
# into every AI CLI's personal skills directory:
#
# Claude Code CLI -> ~/.claude/skills/<name>/
# GitHub Copilot CLI -> ~/.copilot/skills/<name>/
# Antigravity CLI -> ~/.gemini/config/skills/<name>/ (per antigravity.google/docs/skills;
# worth a spot-check if agy doesn't pick these up, some third-party
# docs disagree on the exact path)
#
# Run once at workspace startup via coder_script. Pulls this repo fresh from
# Gitea rather than embedding ~2.5MB of skill files into Terraform state.
#
# Env vars:
# SPECIALTY_SKILLS - optional space-separated skill names from
# extensions/custom-specialty-plugin/skills/ to install in addition to
# the common awesome-skills-plugin bundle (every env gets that one).
set -e
REPO_ZIP_URL="https://git.octoturge.com/octoturge/Profiles-for-Coder/archive/main.zip"
ZIP_PATH="/tmp/coder-skills-src.zip"
WORK_DIR="/tmp/coder-skills-src"
rm -rf "$WORK_DIR" "$ZIP_PATH"
curl -fsSL "$REPO_ZIP_URL" -o "$ZIP_PATH"
mkdir -p "$WORK_DIR"
unzip -q -o "$ZIP_PATH" -d "$WORK_DIR"
INNER_DIR=$(find "$WORK_DIR" -mindepth 1 -maxdepth 1 -type d | head -n1)
if [ -z "$INNER_DIR" ]; then
echo "install-skills: couldn't find extracted repo contents, skipping." >&2
rm -rf "$WORK_DIR" "$ZIP_PATH"
exit 0
fi
TARGET_DIRS=("$HOME/.claude/skills" "$HOME/.copilot/skills" "$HOME/.gemini/config/skills")
for dir in "${TARGET_DIRS[@]}"; do
mkdir -p "$dir"
done
# Common skill bundle, installed for every environment.
COMMON_SKILLS_SRC="$INNER_DIR/extensions/awesome-skills-plugin/skills"
if [ -d "$COMMON_SKILLS_SRC" ]; then
for dir in "${TARGET_DIRS[@]}"; do
cp -r "$COMMON_SKILLS_SRC/." "$dir/"
done
echo "install-skills: installed common skill bundle into ${TARGET_DIRS[*]}"
else
echo "install-skills: common skill bundle not found at $COMMON_SKILLS_SRC, skipping." >&2
fi
# Environment-specific specialty skills, if any were requested.
for skill in ${SPECIALTY_SKILLS:-}; do
SRC="$INNER_DIR/extensions/custom-specialty-plugin/skills/$skill"
if [ -d "$SRC" ]; then
for dir in "${TARGET_DIRS[@]}"; do
cp -r "$SRC" "$dir/$skill"
done
echo "install-skills: installed specialty skill '$skill'"
else
echo "install-skills: specialty skill '$skill' not found at $SRC, skipping." >&2
fi
done
rm -rf "$WORK_DIR" "$ZIP_PATH"
echo "install-skills: done."
+334
View File
@@ -0,0 +1,334 @@
terraform {
required_providers {
coder = {
source = "coder/coder"
}
docker = {
source = "kreuzwerker/docker"
}
# Not used directly in this config. Existing workspace state from before
# the jetbrains module was removed still has resources tagged under this
# provider (the module used it internally to fetch IDE metadata) -
# terraform init only installs providers the current config declares, so
# without this, plan/apply fails with "Missing required provider" while
# trying to reconcile/destroy those leftover state entries. Safe to drop
# once every workspace has updated past the jetbrains-module version.
http = {
source = "hashicorp/http"
}
}
}
locals {
env_name = "Web Applications"
profile = jsondecode(file("${path.module}/profile.code-profile"))
settings_raw = jsondecode(local.profile.settings).settings
extensions = [for e in jsondecode(local.profile.extensions) : e.identifier.id]
}
variable "docker_socket" {
default = ""
description = "(Optional) Docker socket URI"
type = string
}
provider "docker" {
# Defaulting to null if the variable is an empty string lets us have an optional variable without having to set our own default
host = var.docker_socket != "" ? var.docker_socket : null
}
data "coder_provisioner" "me" {}
data "coder_workspace" "me" {}
data "coder_workspace_owner" "me" {}
resource "coder_agent" "main" {
arch = data.coder_provisioner.me.arch
os = "linux"
startup_script = <<-EOT
set -e
# Prepare user home with default files on first start.
if [ ! -f ~/.init_done ]; then
cp -rT /etc/skel ~
touch ~/.init_done
fi
# Ensure git and gnupg (commit signing) are present as base packages -
# not every base image ships gnupg by default. No-op once both are
# present (e.g. templates/web already bakes them into its image).
if ! command -v git >/dev/null 2>&1 || ! command -v gpg >/dev/null 2>&1; then
sudo apt-get update -qq
sudo apt-get install -y --no-install-recommends git gnupg
fi
# Add any commands that should be executed at workspace startup (e.g install requirements, start a program, etc) here
EOT
# These environment variables allow you to make Git commits right away after creating a
# workspace. Note that they take precedence over configuration defined in ~/.gitconfig!
# You can remove this block if you'd prefer to configure Git manually or using
# dotfiles. (see docs/dotfiles.md)
#
# RUST_BACKTRACE/PNPM_HOME/BUN_INSTALL are also baked in as image ENV (see
# Dockerfile) so every process picks them up; restated here so they surface
# on the workspace dashboard too.
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}"
RUST_BACKTRACE = "1"
PNPM_HOME = "/home/coder/.local/share/pnpm"
BUN_INSTALL = "/home/coder/.bun"
}
# 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
}
metadata {
display_name = "Load Average (Host)"
key = "6_load_host"
# get load avg scaled by number of cores
script = <<EOT
echo "`cat /proc/loadavg | awk '{ print $1 }'` `nproc`" | awk '{ printf "%0.2f", $1/$2 }'
EOT
interval = 60
timeout = 1
}
metadata {
display_name = "Swap Usage (Host)"
key = "7_swap_host"
script = <<EOT
free -b | awk '/^Swap/ { printf("%.1f/%.1f", $3/1024.0/1024.0/1024.0, $2/1024.0/1024.0/1024.0) }'
EOT
interval = 10
timeout = 1
}
}
# See https://registry.coder.com/modules/coder/code-server
# `extensions` is populated at template-push time from this env's
# profile-templates/*.code-profile file, so no interactive prompt is
# needed for VS Code extensions - Terraform handles it declaratively.
module "code-server" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/code-server/coder"
version = "~> 1.0"
agent_id = coder_agent.main.id
# Pass the target folder here natively
folder = "/home/coder/workspace"
extensions = local.extensions
order = 1
}
resource "docker_volume" "home_volume" {
name = "coder-${data.coder_workspace.me.id}-home"
# Protect the volume from being deleted due to changes in attributes.
lifecycle {
ignore_changes = all
}
# Add labels in Docker to keep track of orphan resources.
labels {
label = "coder.owner"
value = data.coder_workspace_owner.me.name
}
labels {
label = "coder.owner_id"
value = data.coder_workspace_owner.me.id
}
labels {
label = "coder.workspace_id"
value = data.coder_workspace.me.id
}
# This field becomes outdated if the workspace is renamed but can
# be useful for debugging or cleaning out dangling volumes.
labels {
label = "coder.workspace_name_at_creation"
value = data.coder_workspace.me.name
}
}
# Pulls the full Web Applications toolchain (Rust/Tauri 2, Bun/Node/pnpm,
# Python CV/ONNX, DB clients - see ./Dockerfile) instead of building it here.
# The image is built and pushed by .gitea/workflows/coder-templates.yml's
# build-web-image job, tagged with the same Dockerfile hash this resource
# computes - so a Dockerfile edit always resolves to the matching image, and
# an unchanged Dockerfile resolves to one already built (CI skips rebuilding
# it, and this pull is normally a same-host cache hit rather than a real
# network pull, since CI and this Coder deployment share one Docker daemon).
# Moves the ~15-20min Rust toolchain compile off of "someone is waiting to
# create a workspace" and onto CI, where it runs once per Dockerfile change.
resource "docker_image" "web" {
name = "git.octoturge.com/octo-tech/profiles-web:${filesha1("${path.module}/Dockerfile")}"
keep_locally = true
}
resource "docker_container" "workspace" {
count = data.coder_workspace.me.start_count
image = docker_image.web.image_id
# Uses lower() to avoid Docker restriction on container names.
name = "coder-${data.coder_workspace_owner.me.name}-${lower(data.coder_workspace.me.name)}"
# Hostname makes the shell more user friendly: coder@my-workspace:~$
hostname = data.coder_workspace.me.name
# Use the docker gateway if the access URL is 127.0.0.1
entrypoint = ["sh", "-c", replace(coder_agent.main.init_script, "/localhost|127\\.0\\.0\\.1/", "host.docker.internal")]
env = ["CODER_AGENT_TOKEN=${coder_agent.main.token}"]
host {
host = "host.docker.internal"
ip = "host-gateway"
}
host {
host = "code.octoturge.com"
ip = "host-gateway"
}
# install-skills.sh curls this at startup to pull the repo's Agent Skills;
# without this the workspace's public DNS answer for git.octoturge.com
# NAT-hairpins back to the LAN and times out (curl: (28)).
host {
host = "git.octoturge.com"
ip = "host-gateway"
}
volumes {
container_path = "/home/coder"
volume_name = docker_volume.home_volume.name
read_only = false
}
# Add labels in Docker to keep track of orphan resources.
labels {
label = "coder.owner"
value = data.coder_workspace_owner.me.name
}
labels {
label = "coder.owner_id"
value = data.coder_workspace_owner.me.id
}
labels {
label = "coder.workspace_id"
value = data.coder_workspace.me.id
}
labels {
label = "coder.workspace_name"
value = data.coder_workspace.me.name
}
labels {
label = "coder.profile"
value = local.env_name
}
}
# Writes this env's VS Code settings.json, sourced straight from the
# matching profile-templates/*.code-profile file at template-push time.
resource "coder_script" "apply_settings" {
agent_id = coder_agent.main.id
display_name = "Apply ${local.env_name} VS Code Settings"
run_on_start = true
script = <<-EOT
#!/bin/bash
set -e
mkdir -p "$HOME/workspace"
mkdir -p "$HOME/.local/share/code-server/User"
echo '${base64encode(local.settings_raw)}' | base64 -d > "$HOME/.local/share/code-server/User/settings.json"
EOT
}
# Drops the shared CLI setup wizard onto the workspace and hooks it into
# every new interactive shell (via .bashrc) until the user completes it.
# See ./cli-setup-wizard.sh for what it actually asks.
resource "coder_script" "cli_setup_wizard" {
agent_id = coder_agent.main.id
display_name = "Install CLI Setup Wizard"
run_on_start = true
script = <<-EOT
#!/bin/bash
set -e
sudo mkdir -p /opt/coder
sudo chown "$(id -u):$(id -g)" /opt/coder
echo '${base64encode(file("${path.module}/cli-setup-wizard.sh"))}' | base64 -d > /opt/coder/cli-setup-wizard.sh
chmod +x /opt/coder/cli-setup-wizard.sh
MARKER="# >>> coder cli setup wizard >>>"
if ! grep -qF "$MARKER" "$HOME/.bashrc" 2>/dev/null; then
{
echo ""
echo "$MARKER"
echo 'export PATH="$HOME/.local/bin:$PATH"'
echo 'source /opt/coder/cli-setup-wizard.sh'
echo "# <<< coder cli setup wizard <<<"
} >> "$HOME/.bashrc"
fi
EOT
}
# Bun (and the rest of the toolchain - Rust, Node/pnpm/yarn, Python CV/ONNX
# packages, DB clients) is baked into the workspace image at build time (see
# Dockerfile) rather than installed here on every start. Docker populates a
# fresh, empty named volume from the image's directory contents on first
# mount, so $HOME/.bun, $HOME/.cargo, $HOME/.rustup etc. land in the
# persistent home_volume automatically the first time a workspace boots -
# same mechanism the /etc/skel copy above relies on. BUN_INSTALL and PATH
# are set as image ENV plus restated on coder_agent.env above.
# Installs this repo's Agent Skills into Claude Code, GitHub Copilot CLI, and
# Antigravity CLI's skills directories. See ./install-skills.sh.
# Web has no matching entry in extensions/custom-specialty-plugin/skills,
# so it only gets the common awesome-skills-plugin bundle.
resource "coder_script" "install_skills" {
agent_id = coder_agent.main.id
display_name = "Install Agent Skills"
run_on_start = true
script = <<-EOT
#!/bin/bash
set -e
sudo mkdir -p /opt/coder
sudo chown "$(id -u):$(id -g)" /opt/coder
echo '${base64encode(file("${path.module}/install-skills.sh"))}' | base64 -d > /opt/coder/install-skills.sh
chmod +x /opt/coder/install-skills.sh
SPECIALTY_SKILLS="" /opt/coder/install-skills.sh
EOT
}
File diff suppressed because one or more lines are too long