Packages

Devcontainer setup for Elixir projects — operate at DevCon 4

Current section

Files

Jump to
devcon4 lib mix tasks devcon4.install.ex
Raw

lib/mix/tasks/devcon4.install.ex

defmodule Mix.Tasks.Devcon4.Install do
@shortdoc "Set up devcontainer in your project"
@moduledoc """
Sets up devcontainer files in your project.
mix devcon4.install
The generated files follow the open [Dev Containers](https://containers.dev/)
specification. To build and run the container, you need Docker and either the
[devcontainer CLI](https://github.com/devcontainers/cli) (`npm install -g @devcontainers/cli`),
VS Code with the Dev Containers extension, or any compatible tool.
## What it does
* Creates `.devcontainer/devcontainer.json` with container settings,
volumes, and firewall initialization
* Creates `.devcontainer/Dockerfile` with Elixir, Node.js, and
development tools
* Creates `.devcontainer/init-firewall.sh` with a restrictive outbound
firewall (GitHub, Hex.pm only by default)
## Options
* `--elixir-version` - Elixir version for the base image (default: detected from `mix.exs`)
* `--erlang-version` - Erlang/OTP version for the base image (default: detected from runtime)
* `--ubuntu-version` - Ubuntu release codename (default: `noble`)
* `--ubuntu-date-tag` - Ubuntu image date tag (default: `20260217`)
* `--timezone` - Default timezone fallback (default: `America/Sao_Paulo`)
* `--add` - Add an optional package (repeatable). See available packages below.
* `--no-firewall` - Skip firewall setup (no init-firewall.sh, no NET_ADMIN capability)
* `--force` - Overwrite existing files without prompting
## Available packages
#{Devcon4.available_packages() |> Enum.map_join("\n", fn {name, cfg} -> " * `#{name}` - #{cfg["description"]}" end)}
## Examples
# Install with defaults (no optional packages)
mix devcon4.install
# Install with Claude Code
mix devcon4.install --add claude
# Install with custom versions and Claude Code
mix devcon4.install --add claude --elixir-version 1.17.0
"""
use Mix.Task
@switches [
elixir_version: :string,
erlang_version: :string,
ubuntu_version: :string,
ubuntu_date_tag: :string,
timezone: :string,
add: [:string, :keep],
no_firewall: :boolean,
force: :boolean
]
@defaults [
ubuntu_version: "noble",
ubuntu_date_tag: "20260217",
timezone: "America/Sao_Paulo",
no_firewall: false,
force: false
]
@impl Mix.Task
def run(argv) do
{opts, _args} = OptionParser.parse!(argv, strict: @switches)
defaults =
@defaults
|> Keyword.put(:elixir_version, detect_elixir_version())
|> Keyword.put(:erlang_version, detect_erlang_version())
opts = Keyword.merge(defaults, opts)
app_name = detect_app_name()
project_name = humanize(app_name)
volume_prefix = String.replace(app_name, "_", "-")
add_names = Keyword.get_values(opts, :add)
packages = Devcon4.load_packages(add_names)
firewall = not opts[:no_firewall]
File.mkdir_p!(".devcontainer")
create_from_template(
"devcontainer.json.eex",
".devcontainer/devcontainer.json",
[
project_name: project_name,
volume_prefix: volume_prefix,
timezone: opts[:timezone],
firewall: firewall,
extra_build_args: extract_build_args(packages),
extra_mounts: extract_mounts(packages, volume_prefix),
extra_env: extract_env(packages)
],
opts[:force]
)
create_from_template(
"Dockerfile.eex",
".devcontainer/Dockerfile",
[
elixir_version: opts[:elixir_version],
erlang_version: opts[:erlang_version],
ubuntu_version: opts[:ubuntu_version],
ubuntu_date_tag: opts[:ubuntu_date_tag],
firewall: firewall,
package_args: extract_package_args(packages),
package_dirs: extract_package_dirs(packages),
package_installs: extract_package_installs(packages)
],
opts[:force]
)
if firewall do
create_from_template(
"init-firewall.sh.eex",
".devcontainer/init-firewall.sh",
[
extra_domains: extract_domains(packages)
],
opts[:force]
)
# Make firewall script executable
File.chmod!(".devcontainer/init-firewall.sh", 0o755)
end
added_label =
case add_names do
[] -> ""
names -> " (with #{Enum.join(names, ", ")})"
end
Mix.shell().info("""
Devcontainer files installed in .devcontainer/#{added_label}
Prerequisites:
- Docker (or compatible runtime)
- devcontainer CLI: npm install -g @devcontainers/cli
(or use VS Code with the Dev Containers extension)
To start the container:
# Via CLI
devcontainer up --workspace-folder .
devcontainer exec --workspace-folder . bash
# Via VS Code
Ctrl+Shift+P -> "Dev Containers: Reopen in Container"
To customize, edit the generated files directly:
- Add firewall domains in .devcontainer/init-firewall.sh
- Add system packages in .devcontainer/Dockerfile
- Change container settings in .devcontainer/devcontainer.json
""")
end
# -- Package extraction helpers --
defp extract_package_args(packages) do
Enum.flat_map(packages, fn {_name, pkg} ->
(get_in(pkg, ["dockerfile", "args"]) || %{}) |> Enum.to_list()
end)
end
defp extract_package_dirs(packages) do
Enum.flat_map(packages, fn {_name, pkg} ->
get_in(pkg, ["dockerfile", "dirs"]) || []
end)
end
defp extract_package_installs(packages) do
Enum.flat_map(packages, fn {_name, pkg} ->
desc = pkg["description"]
cmds = get_in(pkg, ["dockerfile", "run"]) || []
if cmds != [], do: [{desc, cmds}], else: []
end)
end
defp extract_build_args(packages) do
Enum.flat_map(packages, fn {_name, pkg} ->
(get_in(pkg, ["devcontainer", "build_args"]) || %{}) |> Enum.to_list()
end)
end
defp extract_mounts(packages, volume_prefix) do
Enum.flat_map(packages, fn {_name, pkg} ->
(get_in(pkg, ["devcontainer", "mounts"]) || [])
|> Enum.map(&String.replace(&1, "{volume_prefix}", volume_prefix))
end)
end
defp extract_env(packages) do
Enum.flat_map(packages, fn {_name, pkg} ->
(get_in(pkg, ["devcontainer", "container_env"]) || %{}) |> Enum.to_list()
end)
end
defp extract_domains(packages) do
Enum.flat_map(packages, fn {_name, pkg} ->
get_in(pkg, ["firewall", "domains"]) || []
end)
end
# -- File helpers --
defp create_from_template(template_name, target, assigns, force) do
template = Devcon4.template_path(template_name)
content = EEx.eval_file(template, assigns: assigns)
write_file(target, content, force)
end
defp write_file(target, content, force) do
if File.exists?(target) and not force do
Mix.shell().info("* skipping #{target} (already exists, use --force to overwrite)")
else
action = if File.exists?(target), do: "overwriting", else: "creating"
Mix.shell().info("* #{action} #{target}")
File.write!(target, content)
end
end
defp detect_app_name do
config = Mix.Project.config()
config[:app] |> to_string()
end
defp detect_elixir_version do
System.version()
end
defp detect_erlang_version do
otp_release = :erlang.system_info(:otp_release) |> to_string()
otp_version_file = Path.join([:code.root_dir(), "releases", otp_release, "OTP_VERSION"])
case File.read(otp_version_file) do
{:ok, content} ->
content |> String.trim() |> String.split(".") |> Enum.take(3) |> Enum.join(".")
_ ->
otp_release <> ".0.0"
end
end
defp humanize(name) do
name
|> String.replace("_", " ")
|> String.split()
|> Enum.map_join(" ", &String.capitalize/1)
end
end