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