From b4c995049f7f97753465c7a50ed78c334574560c Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Fri, 24 Apr 2026 11:51:44 +0200 Subject: [PATCH] feat: build infrastructure Co-authored-by: Copilot --- .gitignore | 1 + README.md | 40 +++++++++ bin/bds-mcp | 5 -- config/prod.exs | 9 ++ lib/bds/mcp/agent_config.ex | 29 ++++-- lib/bds/release_packaging.ex | 132 ++++++++++++++++++++++++++++ lib/mix/tasks/bds.package.ex | 49 +++++++++++ mix.exs | 16 ++++ rel/overlays/mcp/bin/bds-mcp | 7 ++ rel/overlays/mcp/bin/bds-mcp.bat | 5 ++ scripts/release/build_platform.bat | 31 +++++++ scripts/release/build_platform.sh | 28 ++++++ test/bds/release_packaging_test.exs | 74 ++++++++++++++++ 13 files changed, 416 insertions(+), 10 deletions(-) delete mode 100755 bin/bds-mcp create mode 100644 config/prod.exs create mode 100644 lib/bds/release_packaging.ex create mode 100644 lib/mix/tasks/bds.package.ex create mode 100755 rel/overlays/mcp/bin/bds-mcp create mode 100644 rel/overlays/mcp/bin/bds-mcp.bat create mode 100644 scripts/release/build_platform.bat create mode 100755 scripts/release/build_platform.sh create mode 100644 test/bds/release_packaging_test.exs diff --git a/.gitignore b/.gitignore index f63ebe9..34ee326 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ /_build/ /cover/ /deps/ +/dist/ /doc/ /.elixir_ls/ /erl_crash.dump diff --git a/README.md b/README.md index b7391e6..eb45440 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,46 @@ The initial scaffold is intentionally small: This is not yet the desktop application. It is the base runtime that future work will extend with the domain model, persistence layer, content pipelines, and desktop-facing boundaries. +## MCP Packaging + +The MCP server is packaged as its own self-contained release artifact and must not depend on the repository checkout at runtime. + +- Build the distributable MCP release with `MIX_ENV=prod mix release bds_mcp`. +- The packaged artifact is assembled at `_build/prod/rel/bds_mcp`. +- The distributable executable is exposed as `mcp/bin/bds-mcp` on Unix-like systems and `mcp/bin/bds-mcp.bat` on Windows inside the installed payload. +- Agent configuration should point to the packaged executable inside the installed application resources, not to `mix`, not to source files, and not to repository-local scripts. + +This allows the later UI app to bundle the MCP payload as normal application resources on each supported operating system. + +## Release Build Flow + +The repository now has a thin platform build flow around Mix releases. + +- Unix-like systems: `scripts/release/build_platform.sh [macos|linux]` +- Windows: `scripts/release/build_platform.bat [windows]` + +The scripts do the standard sequence: + +1. `mix deps.get` +2. `mix test` unless `BDS_SKIP_TESTS=1` +3. `MIX_ENV=prod mix release bds` +4. `MIX_ENV=prod mix release bds_mcp` +5. `MIX_ENV=prod mix bds.package ` + +The packaging task creates a clean redistributable payload under `dist//` with this layout: + +- `app/`: the full main `bds` release +- `resources/`: the full `bds_mcp` release root +- `resources/mcp/`: the MCP executable payload used by agent integrations +- `manifest.json`: packaging metadata for downstream bundling + +The task also creates a platform archive alongside the payload: + +- macOS and Linux: `.tar.gz` +- Windows: `.zip` + +This is the intermediate redistributable artifact intended to be consumed by the later desktop app bundling layer. + ## Spec Hygiene When editing files in [specs/](/Users/gb/Projects/bDS2/specs): diff --git a/bin/bds-mcp b/bin/bds-mcp deleted file mode 100755 index 16c5f4d..0000000 --- a/bin/bds-mcp +++ /dev/null @@ -1,5 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -cd "$(dirname "$0")/.." -exec mix bds.mcp "$@" \ No newline at end of file diff --git a/config/prod.exs b/config/prod.exs new file mode 100644 index 0000000..3564a35 --- /dev/null +++ b/config/prod.exs @@ -0,0 +1,9 @@ +import Config + +config :bds, BDS.Repo, + database: Path.expand("../priv/data/bds_prod.db", __DIR__), + pool_size: 5, + stacktrace: false, + show_sensitive_data_on_connection_error: false + +config :logger, level: :info \ No newline at end of file diff --git a/lib/bds/mcp/agent_config.ex b/lib/bds/mcp/agent_config.ex index ba36c20..4d1ed7c 100644 --- a/lib/bds/mcp/agent_config.ex +++ b/lib/bds/mcp/agent_config.ex @@ -21,16 +21,24 @@ defmodule BDS.MCP.AgentConfig do def config_path(:claude_code, home_dir), do: Path.join(home_dir, ".claude.json") def config_path(:github_copilot, home_dir), do: Path.join([home_dir, "Library", "Application Support", "Code", "User", "mcp.json"]) + def packaged_executable_path(install_root, platform) when is_binary(install_root) do + executable_name = + case normalize_platform(platform) do + :windows -> "bds-mcp.bat" + _other -> "bds-mcp" + end + + Path.join([install_root, "mcp", "bin", executable_name]) + end + defp default_command(opts) do - Keyword.get(opts, :script_path, repo_script_path()) + install_root = Keyword.fetch!(opts, :install_root) + platform = Keyword.get(opts, :platform, current_platform()) + packaged_executable_path(install_root, platform) end defp default_args(_opts), do: [] - defp repo_script_path do - Path.expand("../../../bin/bds-mcp", __DIR__) - end - defp read_config(path) do if File.exists?(path) do path @@ -51,4 +59,15 @@ defmodule BDS.MCP.AgentConfig do servers = Map.get(config, "mcpServers", %{}) Map.put(config, "mcpServers", Map.put(servers, @server_name, %{"command" => command, "args" => args})) end + + defp current_platform do + case :os.type() do + {:win32, _type} -> :windows + {:unix, :darwin} -> :macos + {:unix, _type} -> :linux + end + end + + defp normalize_platform(:darwin), do: :macos + defp normalize_platform(platform), do: platform end diff --git a/lib/bds/release_packaging.ex b/lib/bds/release_packaging.ex new file mode 100644 index 0000000..29b3be9 --- /dev/null +++ b/lib/bds/release_packaging.ex @@ -0,0 +1,132 @@ +defmodule BDS.ReleasePackaging do + @moduledoc false + + defmodule Metadata do + @enforce_keys [ + :platform, + :version, + :output_dir, + :payload_name, + :payload_root, + :app_root, + :resources_root, + :mcp_root, + :archive_path + ] + defstruct [ + :platform, + :version, + :output_dir, + :payload_name, + :payload_root, + :app_root, + :resources_root, + :mcp_root, + :archive_path + ] + end + + def build_metadata(platform, version, output_dir) when is_binary(version) and is_binary(output_dir) do + normalized_platform = normalize_platform(platform) + payload_name = "bds2-#{normalized_platform}-#{version}" + payload_root = Path.join(output_dir, payload_name) + + %Metadata{ + platform: normalized_platform, + version: version, + output_dir: output_dir, + payload_name: payload_name, + payload_root: payload_root, + app_root: Path.join(payload_root, "app"), + resources_root: Path.join(payload_root, "resources"), + mcp_root: Path.join(payload_root, "resources/mcp"), + archive_path: Path.join(output_dir, payload_name <> archive_extension(normalized_platform)) + } + end + + def package(opts) when is_list(opts) do + metadata = + build_metadata( + Keyword.fetch!(opts, :platform), + Keyword.fetch!(opts, :version), + Keyword.fetch!(opts, :output_dir) + ) + + app_release_source = Keyword.fetch!(opts, :app_release_source) + mcp_release_source = Keyword.fetch!(opts, :mcp_release_source) + + with :ok <- reset_output(metadata), + :ok <- copy_release(app_release_source, metadata.app_root), + :ok <- copy_release(mcp_release_source, metadata.resources_root), + :ok <- write_manifest(metadata), + :ok <- create_archive(metadata) do + {:ok, metadata} + end + end + + defp normalize_platform(platform) when platform in [:macos, :linux, :windows], do: platform + defp normalize_platform(:darwin), do: :macos + defp normalize_platform(platform) when is_binary(platform), do: platform |> String.downcase() |> String.to_atom() + + defp archive_extension(:windows), do: ".zip" + defp archive_extension(_platform), do: ".tar.gz" + + defp reset_output(metadata) do + File.rm_rf!(metadata.payload_root) + File.rm_rf!(metadata.archive_path) + File.mkdir_p!(metadata.output_dir) + :ok + end + + defp copy_release(source, destination) do + File.mkdir_p!(Path.dirname(destination)) + + case File.cp_r(source, destination) do + {:ok, _files} -> :ok + {:error, reason, _file} -> {:error, reason} + end + end + + defp write_manifest(metadata) do + manifest = %{ + "platform" => Atom.to_string(metadata.platform), + "version" => metadata.version, + "layout" => %{ + "app" => "app", + "resources" => "resources", + "mcp" => "resources/mcp" + } + } + + manifest_path = Path.join(metadata.payload_root, "manifest.json") + File.write!(manifest_path, Jason.encode!(manifest, pretty: true)) + :ok + end + + defp create_archive(%Metadata{platform: :windows} = metadata) do + relative_entries = collect_entries(metadata.payload_root) + cwd = metadata.output_dir |> String.to_charlist() + archive = metadata.archive_path |> String.to_charlist() + entries = Enum.map(relative_entries, &String.to_charlist(Path.join(metadata.payload_name, &1))) + + case :zip.create(archive, entries, cwd: cwd) do + {:ok, _archive_path} -> :ok + {:error, reason} -> {:error, reason} + end + end + + defp create_archive(metadata) do + case System.cmd("tar", ["-czf", metadata.archive_path, "-C", metadata.output_dir, metadata.payload_name]) do + {_output, 0} -> :ok + {output, status} -> {:error, {:tar_failed, status, output}} + end + end + + defp collect_entries(root) do + root + |> Path.join("**/*") + |> Path.wildcard(match_dot: true) + |> Enum.reject(&File.dir?/1) + |> Enum.map(&Path.relative_to(&1, root)) + end +end \ No newline at end of file diff --git a/lib/mix/tasks/bds.package.ex b/lib/mix/tasks/bds.package.ex new file mode 100644 index 0000000..2afa322 --- /dev/null +++ b/lib/mix/tasks/bds.package.ex @@ -0,0 +1,49 @@ +defmodule Mix.Tasks.Bds.Package do + @moduledoc false + + use Mix.Task + + @shortdoc "Packages built releases into a redistributable platform payload" + + @impl Mix.Task + def run(args) do + {opts, positional, _invalid} = + OptionParser.parse(args, + strict: [output: :string, version: :string, app_release: :string, mcp_release: :string] + ) + + platform = positional |> List.first() |> normalize_platform_arg() + version = opts[:version] || Mix.Project.config()[:version] + env_name = Atom.to_string(Mix.env()) + + package_opts = [ + platform: platform, + version: version, + output_dir: opts[:output] || Path.expand("dist/#{platform}", File.cwd!()), + app_release_source: opts[:app_release] || Path.expand("_build/#{env_name}/rel/bds", File.cwd!()), + mcp_release_source: opts[:mcp_release] || Path.expand("_build/#{env_name}/rel/bds_mcp", File.cwd!()) + ] + + case BDS.ReleasePackaging.package(package_opts) do + {:ok, metadata} -> + Mix.shell().info("Packaged #{metadata.payload_name} at #{metadata.archive_path}") + + {:error, reason} -> + Mix.raise("Packaging failed: #{inspect(reason)}") + end + end + + defp normalize_platform_arg(nil), do: current_platform() + defp normalize_platform_arg("macos"), do: :macos + defp normalize_platform_arg("linux"), do: :linux + defp normalize_platform_arg("windows"), do: :windows + defp normalize_platform_arg(other), do: Mix.raise("Unsupported platform #{inspect(other)}") + + defp current_platform do + case :os.type() do + {:win32, _type} -> :windows + {:unix, :darwin} -> :macos + {:unix, _type} -> :linux + end + end +end \ No newline at end of file diff --git a/mix.exs b/mix.exs index c14ef75..b924744 100644 --- a/mix.exs +++ b/mix.exs @@ -6,6 +6,8 @@ defmodule BDS.MixProject do app: :bds, version: "0.1.0", elixir: "~> 1.17", + default_release: :bds, + releases: releases(), start_permanent: Mix.env() == :prod, aliases: aliases(), deps: deps() @@ -41,4 +43,18 @@ defmodule BDS.MixProject do test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"] ] end + + defp releases do + [ + bds: [ + include_executables_for: [:unix, :windows], + applications: [bds: :permanent] + ], + bds_mcp: [ + path: "_build/#{Mix.env()}/rel/bds_mcp", + include_executables_for: [:unix, :windows], + applications: [bds: :permanent] + ] + ] + end end diff --git a/rel/overlays/mcp/bin/bds-mcp b/rel/overlays/mcp/bin/bds-mcp new file mode 100755 index 0000000..c0f755c --- /dev/null +++ b/rel/overlays/mcp/bin/bds-mcp @@ -0,0 +1,7 @@ +#!/bin/sh +set -eu + +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +RELEASE_ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/../.." && pwd) + +exec "$RELEASE_ROOT/bin/bds_mcp" eval "Application.ensure_all_started(:bds); BDS.MCP.Stdio.main()" \ No newline at end of file diff --git a/rel/overlays/mcp/bin/bds-mcp.bat b/rel/overlays/mcp/bin/bds-mcp.bat new file mode 100644 index 0000000..9bb7665 --- /dev/null +++ b/rel/overlays/mcp/bin/bds-mcp.bat @@ -0,0 +1,5 @@ +@echo off +set SCRIPT_DIR=%~dp0 +set RELEASE_ROOT=%SCRIPT_DIR%..\.. + +call "%RELEASE_ROOT%\bin\bds_mcp.bat" eval "Application.ensure_all_started(:bds); BDS.MCP.Stdio.main()" \ No newline at end of file diff --git a/scripts/release/build_platform.bat b/scripts/release/build_platform.bat new file mode 100644 index 0000000..87ee60e --- /dev/null +++ b/scripts/release/build_platform.bat @@ -0,0 +1,31 @@ +@echo off +setlocal enabledelayedexpansion + +set ROOT_DIR=%~dp0\..\.. +pushd "%ROOT_DIR%" + +set PLATFORM=%1 +if "%PLATFORM%"=="" set PLATFORM=windows + +call mix deps.get +if errorlevel 1 goto :error + +if not "%BDS_SKIP_TESTS%"=="1" ( + call mix test + if errorlevel 1 goto :error +) + +call set MIX_ENV=prod +call mix release --overwrite bds +if errorlevel 1 goto :error +call mix release --overwrite bds_mcp +if errorlevel 1 goto :error +call mix bds.package %PLATFORM% +if errorlevel 1 goto :error + +popd +exit /b 0 + +:error +popd +exit /b 1 \ No newline at end of file diff --git a/scripts/release/build_platform.sh b/scripts/release/build_platform.sh new file mode 100755 index 0000000..327cfaa --- /dev/null +++ b/scripts/release/build_platform.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd) +PLATFORM=${1:-} + +if [[ -z "$PLATFORM" ]]; then + case "$(uname -s)" in + Darwin) PLATFORM="macos" ;; + Linux) PLATFORM="linux" ;; + *) + echo "Unsupported platform. Pass one of: macos, linux, windows" >&2 + exit 1 + ;; + esac +fi + +cd "$ROOT_DIR" + +mix deps.get + +if [[ "${BDS_SKIP_TESTS:-0}" != "1" ]]; then + mix test +fi + +MIX_ENV=prod mix release --overwrite bds +MIX_ENV=prod mix release --overwrite bds_mcp +MIX_ENV=prod mix bds.package "$PLATFORM" diff --git a/test/bds/release_packaging_test.exs b/test/bds/release_packaging_test.exs new file mode 100644 index 0000000..741e1af --- /dev/null +++ b/test/bds/release_packaging_test.exs @@ -0,0 +1,74 @@ +defmodule BDS.ReleasePackagingTest do + use ExUnit.Case, async: false + + alias BDS.ReleasePackaging + + setup do + base_dir = Path.join(System.tmp_dir!(), "bds-release-packaging-#{System.unique_integer([:positive])}") + output_dir = Path.join(base_dir, "dist") + app_release = Path.join(base_dir, "rel/bds") + mcp_release = Path.join(base_dir, "rel/bds_mcp") + + File.mkdir_p!(Path.join(app_release, "bin")) + File.mkdir_p!(Path.join(mcp_release, "mcp/bin")) + File.write!(Path.join(app_release, "bin/bds"), "app-release") + File.write!(Path.join(mcp_release, "mcp/bin/bds-mcp"), "mcp-release") + + on_exit(fn -> File.rm_rf(base_dir) end) + + %{base_dir: base_dir, output_dir: output_dir, app_release: app_release, mcp_release: mcp_release} + end + + test "build metadata uses a clean future-app payload layout" do + metadata = ReleasePackaging.build_metadata(:macos, "0.1.0", "/tmp/out") + + assert metadata.platform == :macos + assert metadata.version == "0.1.0" + assert metadata.archive_path =~ ".tar.gz" + assert metadata.payload_root =~ "bds2-macos-0.1.0" + assert metadata.resources_root == Path.join(metadata.payload_root, "resources") + assert metadata.mcp_root == Path.join(metadata.payload_root, "resources/mcp") + assert metadata.app_root == Path.join(metadata.payload_root, "app") + end + + test "package creates payload manifest and copies app and mcp releases", context do + assert {:ok, metadata} = + ReleasePackaging.package( + platform: :macos, + version: "0.1.0", + output_dir: context.output_dir, + app_release_source: context.app_release, + mcp_release_source: context.mcp_release + ) + + assert File.exists?(Path.join(metadata.app_root, "bin/bds")) + assert File.exists?(Path.join(metadata.mcp_root, "bin/bds-mcp")) + assert File.exists?(Path.join(metadata.payload_root, "manifest.json")) + + manifest = metadata.payload_root |> Path.join("manifest.json") |> File.read!() |> Jason.decode!() + + assert manifest == %{ + "platform" => "macos", + "version" => "0.1.0", + "layout" => %{ + "app" => "app", + "resources" => "resources", + "mcp" => "resources/mcp" + } + } + end + + test "package creates a platform archive for redistribution", context do + assert {:ok, metadata} = + ReleasePackaging.package( + platform: :windows, + version: "0.1.0", + output_dir: context.output_dir, + app_release_source: context.app_release, + mcp_release_source: context.mcp_release + ) + + assert File.exists?(metadata.archive_path) + assert String.ends_with?(metadata.archive_path, ".zip") + end +end \ No newline at end of file