defmodule BDS.CLI do @moduledoc """ Command-line interface for managing content in a bDS2 workspace (issue #25). Runs inside the release VM (`BDS_MODE=cli`) against the same settings and cache database as the GUI/TUI application; mutations write `BDS.CliSync.Notification` rows so a concurrently running app picks them up through `BDS.CliSync.Watcher`. The launcher script (`cli/bin/bds-cli` in the release, installable to `~/.local/bin` via `BDS.CLI.Install`) passes the argv via `BDS_CLI_ARGV` (unit-separator joined) because `bin/bds eval` cannot forward arguments. """ alias BDS.CLI.Commands @argv_env "BDS_CLI_ARGV" @argv_separator <<0x1F>> @doc "Release entry point: run the command from `BDS_CLI_ARGV` and halt." @spec main() :: no_return() def main do System.halt(run(env_argv())) end @doc "Decodes the unit-separator-joined argv from the launcher script." @spec env_argv(String.t() | nil) :: [String.t()] def env_argv(value \\ System.get_env(@argv_env)) def env_argv(nil), do: [] def env_argv(value), do: String.split(value, @argv_separator, trim: true) @doc "Parses and executes an argv, returning the process exit code." @spec run([String.t()]) :: non_neg_integer() def run(argv) when is_list(argv) do optimus = optimus() case Optimus.parse(optimus, argv) do {:ok, %Optimus.ParseResult{}} -> print_help(optimus, []) 0 {:ok, subcommand_path, parse_result} -> execute(subcommand_path, parse_result) :help -> print_help(optimus, []) 0 {:help, subcommand_path} -> print_help(optimus, subcommand_path) 0 :version -> IO.puts(version()) 0 {:error, errors} -> print_errors(optimus, [], errors) 1 {:error, subcommand_path, errors} -> print_errors(optimus, subcommand_path, errors) 1 end end @doc "Executes a parsed subcommand, returning the process exit code." @spec execute([atom()], Optimus.ParseResult.t()) :: non_neg_integer() def execute(subcommand_path, parse_result) do {:ok, _apps} = Application.ensure_all_started(:bds) subcommand_path |> Commands.dispatch(parse_result) |> report() end defp report(:ok), do: 0 defp report({:ok, message}) when is_binary(message) do IO.puts(message) 0 end defp report({:error, message}) do IO.puts(:stderr, "Error: " <> format_error(message)) 1 end defp format_error(message) when is_binary(message), do: message defp format_error(%{message: message}) when is_binary(message), do: message defp format_error(other), do: inspect(other) @repair_parts ~w(post-links media-links thumbnails embeddings search) @doc "The Optimus command definition (public for tests and help rendering)." def optimus do Optimus.new!( name: "bds-cli", description: "bDS2 workspace CLI", about: "Manages content in a bDS2 workspace using the same settings and " <> "cache database as the desktop application.", version: version(), allow_unknown_args: false, parse_double_dash: true, subcommands: [ rebuild: [ name: "rebuild", about: "Rebuild the caching database from the workspace files", flags: [ incremental: [ long: "--incremental", help: "Run a metadata diff and auto-apply every difference from " <> "file to database instead of a full rebuild" ] ] ], repair: [ name: "repair", about: "Run one of the standard repair tasks outside the full rebuild", args: [ part: [ value_name: "PART", help: "One of: " <> Enum.join(@repair_parts, ", "), required: true, parser: &parse_repair_part/1 ] ] ], render: [ name: "render", about: "Render the blog from the current content", flags: [ incremental: [ long: "--incremental", help: "Validate the generated output and apply only the differences" ], force: [ long: "--force", help: "Full re-render ignoring (but updating) stored content hashes" ] ] ], upload: [ name: "upload", about: "Upload the rendered site to the configured host (rsync/scp)" ], push: [ name: "push", about: "Push the project repository to its origin" ], pull: [ name: "pull", about: "Pull the project repository and update the cache database" ], post: [ name: "post", about: "Create a post from parameters or from JSON on stdin " <> "(keys: title, content, excerpt, author, language, tags, categories, template)", flags: [ stdin: [long: "--stdin", help: "Read post data as JSON from stdin"], no_translate: [ long: "--no-translate", help: "Skip automatic translation of the created post" ] ], options: post_content_options() ], media: [ name: "media", about: "Import an image with automatically generated title, alt text, " <> "caption and translations", args: [ file: [ value_name: "FILE", help: "Path of the image file to import", required: true ] ], options: [ language: [ long: "--language", value_name: "LANG", help: "Language for the generated texts (defaults to the project main language)" ] ] ], gallery: [ name: "gallery", about: "Create a gallery post and import all referenced images as new " <> "media with generated texts and translations", allow_unknown_args: true, flags: [ stdin: [ long: "--stdin", help: "Read gallery data as JSON from stdin (adds an \"images\" key to post keys)" ] ], options: post_content_options() ], config: [ name: "config", about: "Read and write global preference values", subcommands: [ get: [ name: "get", about: "Print a preference value", args: [key: [value_name: "KEY", help: "Preference key", required: true]] ], set: [ name: "set", about: "Set a preference value", args: [ key: [value_name: "KEY", help: "Preference key", required: true], value: [value_name: "VALUE", help: "New value", required: true] ] ], list: [ name: "list", about: "List all preference keys and values" ] ] ], project: [ name: "project", about: "Manage the projects registered in the cache database", subcommands: [ list: [ name: "list", about: "List all projects" ], add: [ name: "add", about: "Open a project folder and add it to the cache database", args: [ path: [value_name: "PATH", help: "Project data folder", required: true] ], options: [ name: [ long: "--name", value_name: "NAME", help: "Project name (defaults to the folder name)" ] ] ], switch: [ name: "switch", about: "Switch the active project", args: [ project: [ value_name: "PROJECT", help: "Project id, slug, or name", required: true ] ] ] ] ], tui: [ name: "tui", about: "Open the app in TUI mode for interactive use" ], lua: [ name: "lua", about: "Run a utility (long-running task) Lua script from the database", allow_unknown_args: true, args: [ script: [ value_name: "SCRIPT", help: "Script slug in the active project", required: true ] ] ] ] ) end defp post_content_options do [ title: [long: "--title", value_name: "TITLE", help: "Post title"], content: [long: "--content", value_name: "MARKDOWN", help: "Post body (markdown)"], excerpt: [long: "--excerpt", value_name: "TEXT", help: "Post excerpt"], author: [long: "--author", value_name: "NAME", help: "Post author"], language: [ long: "--language", value_name: "LANG", help: "Post language (auto-detected from the content when omitted)" ], template: [long: "--template", value_name: "SLUG", help: "Template slug"], tags: [long: "--tags", value_name: "TAGS", help: "Comma-separated tags"], categories: [ long: "--categories", value_name: "CATEGORIES", help: "Comma-separated categories" ] ] end defp parse_repair_part(value) when value in @repair_parts, do: {:ok, value} defp parse_repair_part(value), do: {:error, "unknown repair part #{inspect(value)}; expected one of: #{Enum.join(@repair_parts, ", ")}"} defp print_help(optimus, subcommand_path) do optimus |> Optimus.Help.help(subcommand_path, terminal_width()) |> Enum.each(&IO.puts/1) end defp print_errors(optimus, [], errors) do optimus |> Optimus.Errors.format(errors) |> Enum.each(&IO.puts(:stderr, &1)) end defp print_errors(optimus, subcommand_path, errors) do optimus |> Optimus.Errors.format(subcommand_path, errors) |> Enum.each(&IO.puts(:stderr, &1)) end defp terminal_width do case Optimus.Term.width() do {:ok, width} -> width _other -> 80 end end defp version do to_string(Application.spec(:bds, :vsn) || "dev") end end