diff --git a/lib/cleat/cli.ex b/lib/cleat/cli.ex index 6954e1d..e83023e 100644 --- a/lib/cleat/cli.ex +++ b/lib/cleat/cli.ex @@ -57,6 +57,7 @@ defmodule Cleat.CLI do mode: :string, slug: :string, runtime: :string, + apt: :string, binaries: :string, port: :integer, yes: :boolean, @@ -183,12 +184,13 @@ defmodule Cleat.CLI do apps list List apps apps show APP Show one app (id or slug) apps create --name N --repo O/R \\ - --host H --server ID [--runtime R] Create an app + --host H --server ID [--runtime R] [--apt pkg,pkg] + Create an app apps update APP [--branch B] \\ [--auto-deploy|--no-auto-deploy] \\ [--indexable|--no-indexable] \\ [--host H] [--port N] [--repo O/R] \\ - [--runtime R] Edit repo / branch / auto-deploy / indexing / host / port / runtime + [--runtime R] [--apt pkg,pkg] Edit repo / branch / auto-deploy / indexing / host / port / runtime / apt packages apps logs APP [--tail N] [--since S] [--grep T] [--follow] Runtime logs (systemd unit) servers logs ID [--unit U] [--tail N] [--since S] [--grep T] [--follow] diff --git a/lib/cleat/commands/apps.ex b/lib/cleat/commands/apps.ex index 2245060..531599c 100644 --- a/lib/cleat/commands/apps.ex +++ b/lib/cleat/commands/apps.ex @@ -3,7 +3,7 @@ defmodule Cleat.Commands.Apps do alias Cleat.{Client, Commands, Output, Runtime} - @usage "usage: cleat apps list | cleat apps show APP | cleat apps create --name N --repo owner/repo --host H --server ID | cleat apps update APP [--branch B] [--auto-deploy|--no-auto-deploy] [--indexable|--no-indexable] [--host H] [--port N] [--repo owner/repo] [--runtime R] | cleat apps delete APP --yes | cleat apps logs APP [--tail N] [--since S] [--grep T] [--follow]" + @usage "usage: cleat apps list | cleat apps show APP | cleat apps create --name N --repo owner/repo --host H --server ID [--apt pkg,pkg] | cleat apps update APP [--branch B] [--auto-deploy|--no-auto-deploy] [--indexable|--no-indexable] [--host H] [--port N] [--repo owner/repo] [--runtime R] [--apt pkg,pkg] | cleat apps delete APP --yes | cleat apps logs APP [--tail N] [--since S] [--grep T] [--follow]" def run([], opts), do: list(opts) def run(["list"], opts), do: list(opts) @@ -60,6 +60,7 @@ defmodule Cleat.Commands.Apps do ["Host", data["host"]], ["Port", data["port"]], ["Runtime", data["runtime"]], + ["Apt packages", format_apt(data["runtime_apt_packages"])], ["Auto deploy", data["auto_deploy"]], ["Indexable", data["indexable"]], ["Server", server_name(data)], @@ -87,16 +88,18 @@ defmodule Cleat.Commands.Apps do {:error, :missing_host} -> nil end - attrs = %{ - "name" => opts[:name], - "slug" => opts[:slug] || Cleat.Slug.from_name(opts[:name]), - "github_repo" => opts[:repo], - "branch" => opts[:branch] || "main", - "host" => host, - "port" => opts[:port] || 4000, - "runtime" => runtime(opts), - "server_id" => opts[:server] - } + attrs = + %{ + "name" => opts[:name], + "slug" => opts[:slug] || Cleat.Slug.from_name(opts[:name]), + "github_repo" => opts[:repo], + "branch" => opts[:branch] || "main", + "host" => host, + "port" => opts[:port] || 4000, + "runtime" => runtime(opts), + "server_id" => opts[:server] + } + |> maybe_put_apt(opts) case missing(attrs, [ {"name", "--name"}, @@ -127,6 +130,7 @@ defmodule Cleat.Commands.Apps do |> maybe_put_port(opts) |> maybe_put_repo(opts) |> maybe_put_runtime(opts) + |> maybe_put_apt(opts) |> with_host(opts) case attrs do @@ -135,7 +139,7 @@ defmodule Cleat.Commands.Apps do attrs when map_size(attrs) == 0 -> {:error, - "nothing to update: pass --branch, --auto-deploy, --indexable, --host, --port, --repo or --runtime"} + "nothing to update: pass --branch, --auto-deploy, --indexable, --host, --port, --repo, --runtime or --apt"} attrs -> with {:ok, client} <- Commands.client(opts), @@ -289,6 +293,29 @@ defmodule Cleat.Commands.Apps do end end + defp maybe_put_apt(attrs, opts) do + case apt_packages(opts[:apt]) do + nil -> attrs + packages -> Map.put(attrs, "runtime_apt_packages", packages) + end + end + + defp apt_packages(nil), do: nil + + defp apt_packages(packages) when is_list(packages), do: packages + + defp apt_packages(packages) when is_binary(packages) do + packages + |> String.split(",", trim: true) + |> Enum.map(&String.trim/1) + |> Enum.reject(&(&1 == "")) + end + + defp format_apt(packages) when is_list(packages) and packages != [], + do: Enum.join(packages, ", ") + + defp format_apt(_), do: "—" + defp missing(attrs, required) do required |> Enum.filter(fn {key, _flag} -> attrs[key] in [nil, ""] end) diff --git a/lib/cleat/mcp/tools.ex b/lib/cleat/mcp/tools.ex index 3dc5eb5..ac6c67b 100644 --- a/lib/cleat/mcp/tools.ex +++ b/lib/cleat/mcp/tools.ex @@ -13,6 +13,13 @@ defmodule Cleat.MCP.Tools do @token %{"type" => "string", "description" => "Bearer token override"} @app %{"type" => "string", "description" => "App id or slug"} + @runtime_apt_packages %{ + "type" => "array", + "items" => %{"type" => "string"}, + "description" => + "apt packages to install on the VM at deploy. Alternative: commit .cleat_deploy/runtime-packages (or .cleat_deploy/post-install.sh) in the repo" + } + def list, do: Enum.map(tools(), &Map.take(&1, ["name", "description", "inputSchema"])) def call(name, args) when is_map(args) do @@ -296,7 +303,8 @@ defmodule Cleat.MCP.Tools do }, %{ "name" => "apps_create", - "description" => "Create an app", + "description" => + "Create an app. runtime_apt_packages installs apt deps on the VM; alternatively commit .cleat_deploy/runtime-packages", "inputSchema" => %{ "type" => "object", "properties" => %{ @@ -308,6 +316,7 @@ defmodule Cleat.MCP.Tools do "slug" => %{"type" => "string"}, "branch" => %{"type" => "string"}, "port" => %{"type" => "integer"}, + "runtime_apt_packages" => @runtime_apt_packages, "panel" => @panel, "token" => @token }, @@ -323,7 +332,8 @@ defmodule Cleat.MCP.Tools do "slug" => args["slug"], "branch" => args["branch"], "port" => args["port"], - "runtime" => args["runtime"] + "runtime" => args["runtime"], + "runtime_apt_packages" => args["runtime_apt_packages"] } |> Map.reject(fn {_k, v} -> is_nil(v) or v == "" end) @@ -335,7 +345,7 @@ defmodule Cleat.MCP.Tools do %{ "name" => "apps_update", "description" => - "Edit an app (branch, host, port, repo, runtime, auto_deploy, indexable)", + "Edit an app (branch, host, port, repo, runtime, auto_deploy, indexable, runtime_apt_packages). Alternative for apt deps: .cleat_deploy/runtime-packages", "inputSchema" => %{ "type" => "object", "properties" => %{ @@ -350,6 +360,7 @@ defmodule Cleat.MCP.Tools do "type" => "boolean", "description" => "Allow search engines to index a static site" }, + "runtime_apt_packages" => @runtime_apt_packages, "panel" => @panel, "token" => @token }, @@ -364,13 +375,14 @@ defmodule Cleat.MCP.Tools do "github_repo" => args["repo"], "runtime" => args["runtime"], "auto_deploy" => args["auto_deploy"], - "indexable" => args["indexable"] + "indexable" => args["indexable"], + "runtime_apt_packages" => args["runtime_apt_packages"] } |> Map.reject(fn {_k, v} -> is_nil(v) end) if attrs == %{} do {:error, - "nothing to update: pass branch, host, port, repo, runtime, auto_deploy or indexable"} + "nothing to update: pass branch, host, port, repo, runtime, auto_deploy, indexable or runtime_apt_packages"} else with_client(args, fn client -> with {:ok, body} <- Client.update_app(client, args["app"], attrs), diff --git a/test/cleat/commands/apps_test.exs b/test/cleat/commands/apps_test.exs index 008519e..36aa4d4 100644 --- a/test/cleat/commands/apps_test.exs +++ b/test/cleat/commands/apps_test.exs @@ -131,6 +131,57 @@ defmodule Cleat.Commands.AppsTest do assert :ok = Apps.run(["update", "my-app"], Map.put(@conn, :runtime, "node")) end + test "updates runtime_apt_packages from --apt" do + Req.Test.stub(__MODULE__, fn conn -> + assert conn.method == "PATCH" + + assert Jason.decode!(Req.Test.raw_body(conn)) == %{ + "runtime_apt_packages" => ["ffmpeg", "webp"] + } + + Req.Test.json(conn, %{ + "data" => %{ + "slug" => "my-app", + "runtime_apt_packages" => ["ffmpeg", "webp"], + "github_repo" => "owner/app", + "branch" => "main", + "auto_deploy" => true, + "indexable" => false, + "host" => "my-app.example.com", + "port" => 4000, + "runtime" => "node" + } + }) + end) + + assert :ok = Apps.run(["update", "my-app"], Map.put(@conn, :apt, "ffmpeg,webp")) + end + + test "creates an app with --apt packages" do + Req.Test.stub(__MODULE__, fn conn -> + assert conn.method == "POST" + assert Jason.decode!(Req.Test.raw_body(conn))["runtime_apt_packages"] == ["ffmpeg", "webp"] + + conn + |> Plug.Conn.put_status(201) + |> Req.Test.json(%{"data" => %{"id" => 9, "slug" => "gowa"}}) + end) + + assert :ok = + Apps.run( + ["create"], + %{ + panel: "https://panel.test", + token: "tok", + name: "gowa", + repo: "owner/gowa", + host: "gowa.example.com", + server: "5", + apt: "ffmpeg, webp" + } + ) + end + test "creates an app using the detected runtime" do dir = Path.join(System.tmp_dir!(), "cleat-apps-proj-#{System.unique_integer([:positive])}") diff --git a/test/cleat/contract_test.exs b/test/cleat/contract_test.exs index b3e7576..e8a177d 100644 --- a/test/cleat/contract_test.exs +++ b/test/cleat/contract_test.exs @@ -15,7 +15,7 @@ defmodule Cleat.ContractTest do # Keys the CLI reads from each resource type. @used %{ "app" => - ~w(id name slug github_repo branch host port runtime auto_deploy indexable systemd_unit release_path data_dir server), + ~w(id name slug github_repo branch host port runtime runtime_apt_packages auto_deploy indexable systemd_unit release_path data_dir server), "server" => ~w(id name host_ip ssh_user region provider deploy_mode instance_status bundle_name cpu_count ram_mb disk_gb), "deployment" => diff --git a/test/cleat/mcp/tools_test.exs b/test/cleat/mcp/tools_test.exs index 7ba5023..080d6ed 100644 --- a/test/cleat/mcp/tools_test.exs +++ b/test/cleat/mcp/tools_test.exs @@ -215,6 +215,59 @@ defmodule Cleat.MCP.ToolsTest do }) end + test "apps_create posts runtime_apt_packages" do + Req.Test.stub(__MODULE__, fn conn -> + assert conn.method == "POST" + body = Jason.decode!(Req.Test.raw_body(conn)) + assert body["runtime_apt_packages"] == ["ffmpeg", "webp"] + Req.Test.json(conn, %{"data" => %{"id" => 1, "slug" => "gowa"}}) + end) + + assert {:ok, _text} = + Tools.call("apps_create", %{ + "name" => "gowa", + "repo" => "owner/gowa", + "host" => "gowa.example.com", + "server" => "5", + "runtime_apt_packages" => ["ffmpeg", "webp"], + "panel" => "https://panel.test", + "token" => "tok" + }) + end + + test "apps_update patches runtime_apt_packages" do + Req.Test.stub(__MODULE__, fn conn -> + assert conn.method == "PATCH" + assert conn.request_path == "/api/v1/apps/gowa" + + assert Jason.decode!(Req.Test.raw_body(conn)) == %{ + "runtime_apt_packages" => ["ffmpeg", "webp"] + } + + Req.Test.json(conn, %{ + "data" => %{"slug" => "gowa", "runtime_apt_packages" => ["ffmpeg", "webp"]} + }) + end) + + assert {:ok, _text} = + Tools.call("apps_update", %{ + "app" => "gowa", + "runtime_apt_packages" => ["ffmpeg", "webp"], + "panel" => "https://panel.test", + "token" => "tok" + }) + end + + test "apps_create and apps_update schemas document runtime_apt_packages" do + for name <- ["apps_create", "apps_update"] do + tool = Enum.find(Tools.list(), &(&1["name"] == name)) + field = tool["inputSchema"]["properties"]["runtime_apt_packages"] + assert field["type"] == "array" + assert field["items"] == %{"type" => "string"} + assert field["description"] =~ ".cleat_deploy/runtime-packages" + end + end + test "env_set PUTs vars to the app env endpoint" do Req.Test.stub(__MODULE__, fn conn -> assert conn.method == "PUT"