feat: issue #25 implemented - cli
This commit is contained in:
335
lib/bds/cli.ex
Normal file
335
lib/bds/cli.ex
Normal file
@@ -0,0 +1,335 @@
|
||||
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
|
||||
Reference in New Issue
Block a user