feat: issue #25 implemented - cli
This commit is contained in:
695
lib/bds/cli/commands.ex
Normal file
695
lib/bds/cli/commands.ex
Normal file
@@ -0,0 +1,695 @@
|
||||
defmodule BDS.CLI.Commands do
|
||||
@moduledoc """
|
||||
Implementations of the `bds-cli` subcommands (issue #25).
|
||||
|
||||
Every command runs synchronously against the engine modules the GUI uses
|
||||
and returns `:ok`, `{:ok, message}`, or `{:error, message}`. Mutations
|
||||
write `BDS.CliSync` notification rows so a concurrently running app
|
||||
refreshes its state (see `specs/cli_sync.allium`).
|
||||
"""
|
||||
|
||||
import Ecto.Query
|
||||
|
||||
alias BDS.AI
|
||||
alias BDS.CliSync
|
||||
alias BDS.Desktop.ShellLive.GalleryImport
|
||||
alias BDS.Generation
|
||||
alias BDS.Git
|
||||
alias BDS.Maintenance
|
||||
alias BDS.Media
|
||||
alias BDS.Metadata
|
||||
alias BDS.Posts
|
||||
alias BDS.Posts.AutoTranslation
|
||||
alias BDS.Projects
|
||||
alias BDS.Publishing
|
||||
alias BDS.Repo
|
||||
alias BDS.Scripting
|
||||
alias BDS.Scripts
|
||||
alias BDS.Scripts.Script
|
||||
alias BDS.Search
|
||||
alias BDS.Settings
|
||||
alias BDS.Tasks
|
||||
|
||||
@site_sections [:core, :single, :category, :tag, :date]
|
||||
@bulk_entity_types ~w(post media script template)
|
||||
@gallery_concurrency 2
|
||||
@poll_interval_ms 250
|
||||
|
||||
@doc "Dispatches a parsed subcommand path to its implementation."
|
||||
@spec dispatch([atom()], Optimus.ParseResult.t()) :: :ok | {:ok, String.t()} | {:error, term()}
|
||||
def dispatch([:rebuild], %{flags: %{incremental: true}}),
|
||||
do: with_project(&incremental_rebuild/1)
|
||||
|
||||
def dispatch([:rebuild], _parse_result), do: with_project(&full_rebuild/1)
|
||||
|
||||
def dispatch([:repair], %{args: %{part: part}}),
|
||||
do: with_project(&repair(part, &1))
|
||||
|
||||
def dispatch([:render], %{flags: %{incremental: true, force: true}}),
|
||||
do: {:error, "--incremental and --force are mutually exclusive"}
|
||||
|
||||
def dispatch([:render], %{flags: %{incremental: true}}),
|
||||
do: with_project(&incremental_render/1)
|
||||
|
||||
def dispatch([:render], %{flags: flags}),
|
||||
do: with_project(&full_render(&1, flags[:force] == true))
|
||||
|
||||
def dispatch([:upload], _parse_result), do: with_project(&upload/1)
|
||||
|
||||
def dispatch([:push], _parse_result) do
|
||||
with_project(fn project ->
|
||||
with {:ok, result} <- Git.push(project.id) do
|
||||
print_git_output(result)
|
||||
{:ok, "Pushed"}
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
def dispatch([:pull], _parse_result) do
|
||||
with_project(fn project ->
|
||||
with {:ok, result} <- Git.pull(project.id) do
|
||||
print_git_output(result)
|
||||
# Update the cache database from the pulled files, like the app's
|
||||
# incremental rebuild: file→db for every difference plus orphans.
|
||||
incremental_rebuild(project)
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
def dispatch([:post], %{flags: flags, options: options}) do
|
||||
with_project(fn project ->
|
||||
with {:ok, attrs} <- post_attrs(flags, options),
|
||||
{:ok, attrs} <- ensure_language(attrs),
|
||||
{:ok, post} <- create_post(project, attrs) do
|
||||
unless flags[:no_translate], do: translate_post(post)
|
||||
{:ok, "Created post #{post.id} (#{post.slug}, #{post.language})"}
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
def dispatch([:media], %{args: %{file: file}, options: options}) do
|
||||
with_project(fn project ->
|
||||
with {:ok, metadata} <- Metadata.get_project_metadata(project.id),
|
||||
:ok <- ensure_files_exist([file]) do
|
||||
language = present(options[:language]) || metadata.main_language
|
||||
targets = GalleryImport.translate_targets(metadata, language)
|
||||
|
||||
case GalleryImport.import_and_enrich(file, project.id, language, targets) do
|
||||
{:ok, media, title} ->
|
||||
:ok = notify("media", media.id, :created)
|
||||
{:ok, "Imported media #{media.id} (#{title})"}
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, format_reason(reason)}
|
||||
end
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
def dispatch([:gallery], %{flags: flags, options: options, unknown: unknown}) do
|
||||
with_project(fn project ->
|
||||
with {:ok, attrs, images} <- gallery_attrs(flags, options, unknown),
|
||||
:ok <- ensure_files_exist(images),
|
||||
{:ok, attrs} <- ensure_language(attrs),
|
||||
{:ok, post} <- create_post(project, attrs) do
|
||||
import_gallery_images(project, post, images)
|
||||
:ok = notify("media", "*", :created)
|
||||
:ok = notify("post", post.id, :updated)
|
||||
unless flags[:no_translate], do: translate_post(post)
|
||||
{:ok, "Created gallery post #{post.id} (#{post.slug}) with #{length(images)} images"}
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
def dispatch([:config, :get], %{args: %{key: key}}) do
|
||||
case Settings.get_global_setting(key) do
|
||||
nil -> {:error, "#{key} is not set"}
|
||||
value -> {:ok, value}
|
||||
end
|
||||
end
|
||||
|
||||
def dispatch([:config, :set], %{args: %{key: key, value: value}}) do
|
||||
with :ok <- Settings.put_global_setting(key, value) do
|
||||
:ok = notify("setting", key, :updated)
|
||||
{:ok, "#{key} = #{value}"}
|
||||
end
|
||||
end
|
||||
|
||||
def dispatch([:config, :list], _parse_result) do
|
||||
Settings.list_global_settings()
|
||||
|> Enum.each(fn {key, value} -> IO.puts("#{key}=#{value}") end)
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
def dispatch([:project, :list], _parse_result) do
|
||||
Enum.each(Projects.list_projects(), fn project ->
|
||||
marker = if project.is_active, do: "* ", else: " "
|
||||
IO.puts("#{marker}#{project.id} #{project.slug} #{project.name}")
|
||||
end)
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
def dispatch([:project, :add], %{args: %{path: path}, options: options}) do
|
||||
expanded = Path.expand(path)
|
||||
|
||||
if File.dir?(expanded) do
|
||||
name = present(options[:name]) || Path.basename(expanded)
|
||||
|
||||
with {:ok, project} <- Projects.create_project(%{name: name, data_path: expanded}) do
|
||||
:ok = notify("project", project.id, :created)
|
||||
{:ok, "Added project #{project.id} (#{project.slug}); switch with: project switch #{project.slug}"}
|
||||
end
|
||||
else
|
||||
{:error, "#{expanded} is not a directory"}
|
||||
end
|
||||
end
|
||||
|
||||
def dispatch([:project, :switch], %{args: %{project: reference}}) do
|
||||
with {:ok, project} <- resolve_project(reference),
|
||||
{:ok, project} <- Projects.set_active_project(project.id) do
|
||||
:ok = notify("project", project.id, :updated)
|
||||
{:ok, "Active project: #{project.name}"}
|
||||
else
|
||||
{:error, :not_found} -> {:error, "No project matches #{inspect(reference)}"}
|
||||
{:error, reason} -> {:error, format_reason(reason)}
|
||||
end
|
||||
end
|
||||
|
||||
def dispatch([:tui], _parse_result) do
|
||||
{:error,
|
||||
"The TUI is started by the bds-cli launcher script (BDS_MODE=tui); " <>
|
||||
"it is not available through a direct BDS.CLI invocation"}
|
||||
end
|
||||
|
||||
def dispatch([:lua], %{args: %{script: slug}, unknown: script_args}) do
|
||||
with_project(fn project ->
|
||||
case Repo.one(
|
||||
from script in Script,
|
||||
where: script.project_id == ^project.id and script.slug == ^slug
|
||||
) do
|
||||
nil ->
|
||||
{:error, "No script with slug #{inspect(slug)} in the active project"}
|
||||
|
||||
%Script{kind: kind} when kind != :utility ->
|
||||
{:error, "Script #{inspect(slug)} is a #{kind} script; only utility (task) scripts can be run"}
|
||||
|
||||
%Script{enabled: false} ->
|
||||
{:error, "Script #{inspect(slug)} is disabled"}
|
||||
|
||||
script ->
|
||||
run_lua_job(project, script, script_args)
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
# ── rebuild ───────────────────────────────────────────────────────────────
|
||||
|
||||
defp full_rebuild(project) do
|
||||
project.id
|
||||
|> Maintenance.full_rebuild_steps()
|
||||
|> Enum.reduce_while(:ok, fn step, :ok ->
|
||||
IO.puts("==> #{step.name}")
|
||||
|
||||
case step.work.(progress_printer()) do
|
||||
{:error, message} -> {:halt, {:error, message}}
|
||||
_result -> {:cont, :ok}
|
||||
end
|
||||
end)
|
||||
|> case do
|
||||
:ok ->
|
||||
notify_bulk_change()
|
||||
{:ok, "Rebuild complete"}
|
||||
|
||||
error ->
|
||||
error
|
||||
end
|
||||
end
|
||||
|
||||
defp incremental_rebuild(project) do
|
||||
reporter = progress_printer()
|
||||
{:ok, diff} = Maintenance.metadata_diff(project.id, on_progress: reporter)
|
||||
|
||||
items =
|
||||
Enum.map(diff.diff_reports, &%{entity_type: &1.entity_type, entity_id: &1.entity_id})
|
||||
|
||||
orphans = Enum.map(diff.orphan_reports, &%{file_path: &1.file_path})
|
||||
|
||||
if items != [] do
|
||||
{:ok, _repair} =
|
||||
Maintenance.repair_metadata_diff(project.id, :file_to_db, items, on_progress: reporter)
|
||||
end
|
||||
|
||||
if orphans != [] do
|
||||
{:ok, _import} =
|
||||
Maintenance.import_metadata_diff_orphans(project.id, orphans, on_progress: reporter)
|
||||
end
|
||||
|
||||
if items != [] or orphans != [], do: notify_bulk_change()
|
||||
|
||||
{:ok,
|
||||
"Applied #{length(items)} differences and imported #{length(orphans)} orphan files from the filesystem"}
|
||||
end
|
||||
|
||||
# ── repair ────────────────────────────────────────────────────────────────
|
||||
|
||||
defp repair("post-links", project) do
|
||||
:ok = Posts.rebuild_post_links(project.id, on_progress: progress_printer())
|
||||
:ok = notify("post", "*", :updated)
|
||||
{:ok, "Post links rebuilt"}
|
||||
end
|
||||
|
||||
defp repair("media-links", project) do
|
||||
{:ok, result} = Media.rebuild_media_links(project.id, on_progress: progress_printer())
|
||||
:ok = notify("media", "*", :updated)
|
||||
{:ok, "Media links rebuilt (#{result.links} links)"}
|
||||
end
|
||||
|
||||
defp repair("thumbnails", project) do
|
||||
result = Media.regenerate_missing_thumbnails(project.id, on_progress: progress_printer())
|
||||
:ok = notify("media", "*", :updated)
|
||||
{:ok, "Missing thumbnails regenerated (#{inspect(Map.get(result, :generated, 0))} generated)"}
|
||||
end
|
||||
|
||||
defp repair("embeddings", project) do
|
||||
case Maintenance.rebuild_embedding_index(project.id, on_progress: progress_printer()) do
|
||||
{:error, message} -> {:error, message}
|
||||
result -> {:ok, "Embedding index rebuilt (#{result.rebuilt_count} posts)"}
|
||||
end
|
||||
end
|
||||
|
||||
defp repair("search", project) do
|
||||
:ok = Search.reindex_posts(project.id, on_progress: progress_printer())
|
||||
:ok = Search.reindex_media(project.id, on_progress: progress_printer())
|
||||
{:ok, "Search text reindexed"}
|
||||
end
|
||||
|
||||
# ── render ────────────────────────────────────────────────────────────────
|
||||
|
||||
defp full_render(project, force?) do
|
||||
render_opts = if force?, do: [force: true], else: []
|
||||
|
||||
Enum.each(@site_sections, fn section ->
|
||||
IO.puts("==> Rendering #{section}")
|
||||
|
||||
{:ok, _result} =
|
||||
Generation.render_site_section(
|
||||
project.id,
|
||||
section,
|
||||
[on_progress: progress_printer()] ++ render_opts
|
||||
)
|
||||
end)
|
||||
|
||||
IO.puts("==> Building search index")
|
||||
|
||||
{:ok, _index} =
|
||||
Generation.build_site_search_index(
|
||||
project.id,
|
||||
[on_progress: progress_printer()] ++ render_opts
|
||||
)
|
||||
|
||||
{:ok, "Site rendered"}
|
||||
end
|
||||
|
||||
defp incremental_render(project) do
|
||||
{:ok, report} =
|
||||
Generation.validate_site(project.id, @site_sections, on_progress: progress_printer())
|
||||
|
||||
{:ok, applied} =
|
||||
Generation.apply_validation(project.id, report, on_progress: progress_printer())
|
||||
|
||||
{:ok,
|
||||
"Validation applied (#{applied.rendered_url_count} rendered, " <>
|
||||
"#{applied.deleted_url_count} deleted)"}
|
||||
end
|
||||
|
||||
# ── upload ────────────────────────────────────────────────────────────────
|
||||
|
||||
defp upload(project) do
|
||||
with {:ok, metadata} <- Metadata.get_project_metadata(project.id),
|
||||
{:ok, credentials} <- upload_credentials(metadata.publishing_preferences),
|
||||
{:ok, job} <- Publishing.upload_site(project.id, credentials) do
|
||||
await_task(job.task_id, "Upload complete")
|
||||
end
|
||||
end
|
||||
|
||||
defp upload_credentials(prefs) when is_map(prefs) do
|
||||
credentials = %{
|
||||
ssh_host: Map.get(prefs, "ssh_host"),
|
||||
ssh_user: Map.get(prefs, "ssh_user"),
|
||||
ssh_remote_path: Map.get(prefs, "ssh_remote_path"),
|
||||
ssh_mode: Map.get(prefs, "ssh_mode")
|
||||
}
|
||||
|
||||
if Enum.all?(
|
||||
[credentials.ssh_host, credentials.ssh_user, credentials.ssh_remote_path],
|
||||
&is_binary/1
|
||||
) do
|
||||
{:ok, credentials}
|
||||
else
|
||||
{:error, "Publishing preferences are incomplete; configure the upload host in Settings"}
|
||||
end
|
||||
end
|
||||
|
||||
defp upload_credentials(_prefs),
|
||||
do: {:error, "Publishing preferences are incomplete; configure the upload host in Settings"}
|
||||
|
||||
# ── post / gallery ────────────────────────────────────────────────────────
|
||||
|
||||
defp post_attrs(%{stdin: true}, _options) do
|
||||
with {:ok, data} <- read_stdin_json() do
|
||||
json_post_attrs(data)
|
||||
end
|
||||
end
|
||||
|
||||
defp post_attrs(_flags, options) do
|
||||
case present(options[:title]) do
|
||||
nil ->
|
||||
{:error, "--title is required (or pass --stdin with JSON post data)"}
|
||||
|
||||
title ->
|
||||
{:ok,
|
||||
%{
|
||||
title: title,
|
||||
content: options[:content] || "",
|
||||
excerpt: present(options[:excerpt]),
|
||||
author: present(options[:author]),
|
||||
language: present(options[:language]),
|
||||
template_slug: present(options[:template]),
|
||||
tags: split_list(options[:tags]),
|
||||
categories: split_list(options[:categories])
|
||||
}}
|
||||
end
|
||||
end
|
||||
|
||||
defp gallery_attrs(%{stdin: true}, _options, _unknown) do
|
||||
with {:ok, data} <- read_stdin_json(),
|
||||
{:ok, attrs} <- json_post_attrs(data) do
|
||||
case Map.get(data, "images") do
|
||||
images when is_list(images) and images != [] -> {:ok, attrs, images}
|
||||
_other -> {:error, "JSON gallery data needs a non-empty \"images\" array"}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp gallery_attrs(flags, options, unknown) do
|
||||
case unknown do
|
||||
[] ->
|
||||
{:error, "Pass the image files as arguments (or use --stdin with JSON gallery data)"}
|
||||
|
||||
images ->
|
||||
with {:ok, attrs} <- post_attrs(flags, options) do
|
||||
{:ok, attrs, images}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp json_post_attrs(data) when is_map(data) do
|
||||
case present(Map.get(data, "title")) do
|
||||
nil ->
|
||||
{:error, "JSON post data needs a \"title\""}
|
||||
|
||||
title ->
|
||||
{:ok,
|
||||
%{
|
||||
title: title,
|
||||
content: Map.get(data, "content") || "",
|
||||
excerpt: present(Map.get(data, "excerpt")),
|
||||
author: present(Map.get(data, "author")),
|
||||
language: present(Map.get(data, "language")),
|
||||
template_slug: present(Map.get(data, "template")),
|
||||
tags: json_list(Map.get(data, "tags")),
|
||||
categories: json_list(Map.get(data, "categories"))
|
||||
}}
|
||||
end
|
||||
end
|
||||
|
||||
defp json_post_attrs(_data), do: {:error, "JSON post data must be an object"}
|
||||
|
||||
defp read_stdin_json do
|
||||
case IO.read(:stdio, :eof) do
|
||||
:eof ->
|
||||
{:error, "No JSON data on stdin"}
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, "Could not read stdin: #{inspect(reason)}"}
|
||||
|
||||
data ->
|
||||
case Jason.decode(data) do
|
||||
{:ok, decoded} -> {:ok, decoded}
|
||||
{:error, error} -> {:error, "Invalid JSON on stdin: #{Exception.message(error)}"}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Language auto-detection (issue #25): ask the configured AI endpoint —
|
||||
# BDS.AI internally routes to the local model in airplane mode — and fall
|
||||
# back to the offline search heuristic, informing the user (the CLI
|
||||
# equivalent of the airplane-mode toast).
|
||||
defp ensure_language(%{language: language} = attrs) when is_binary(language), do: {:ok, attrs}
|
||||
|
||||
defp ensure_language(attrs) do
|
||||
text = String.trim("#{attrs.title}\n#{attrs.content}")
|
||||
|
||||
language =
|
||||
case AI.detect_language(text) do
|
||||
{:ok, %{language_code: language}} when is_binary(language) ->
|
||||
language
|
||||
|
||||
_other ->
|
||||
IO.puts("AI language detection unavailable; using the offline heuristic")
|
||||
Search.detect_language(text)
|
||||
end
|
||||
|
||||
{:ok, %{attrs | language: language}}
|
||||
end
|
||||
|
||||
defp create_post(project, attrs) do
|
||||
case Posts.create_post(Map.put(attrs, :project_id, project.id)) do
|
||||
{:ok, post} ->
|
||||
:ok = notify("post", post.id, :created)
|
||||
{:ok, post}
|
||||
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
{:error, changeset_message(changeset)}
|
||||
end
|
||||
end
|
||||
|
||||
# Automatic translation mirrors the GUI: schedule the background tasks and
|
||||
# wait for them, so the CLI exits only when the work is done. When nothing
|
||||
# gets scheduled (airplane mode without a local model, or a single-language
|
||||
# blog) the user is told instead of a silent no-op.
|
||||
defp translate_post(post) do
|
||||
before_count = length(active_tasks())
|
||||
:ok = AutoTranslation.maybe_schedule(post)
|
||||
|
||||
case length(active_tasks()) - before_count do
|
||||
queued when queued > 0 ->
|
||||
IO.puts("Waiting for #{queued} translation task(s)...")
|
||||
await_active_tasks()
|
||||
:ok = notify("post", post.id, :updated)
|
||||
|
||||
_none ->
|
||||
IO.puts("Automatic translation was not scheduled (offline, unconfigured AI, or nothing to translate)")
|
||||
end
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
defp import_gallery_images(project, post, images) do
|
||||
{:ok, metadata} = Metadata.get_project_metadata(project.id)
|
||||
language = post.language || metadata.main_language
|
||||
parent = self()
|
||||
|
||||
runner =
|
||||
Task.async(fn ->
|
||||
GalleryImport.start(
|
||||
images,
|
||||
project.id,
|
||||
post.id,
|
||||
language,
|
||||
@gallery_concurrency,
|
||||
parent
|
||||
)
|
||||
end)
|
||||
|
||||
print_gallery_progress()
|
||||
Task.await(runner, :infinity)
|
||||
end
|
||||
|
||||
defp print_gallery_progress do
|
||||
receive do
|
||||
{:add_image_processed, title} ->
|
||||
IO.puts("Imported #{title}")
|
||||
print_gallery_progress()
|
||||
|
||||
{:add_image_error, path, reason} ->
|
||||
IO.puts(:stderr, "Failed to import #{path}: #{format_reason(reason)}")
|
||||
print_gallery_progress()
|
||||
|
||||
{:add_images_complete, count} ->
|
||||
IO.puts("Processed #{count} image(s)")
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
defp ensure_files_exist(paths) do
|
||||
case Enum.reject(paths, &File.regular?/1) do
|
||||
[] -> :ok
|
||||
missing -> {:error, "No such file: #{Enum.join(missing, ", ")}"}
|
||||
end
|
||||
end
|
||||
|
||||
# ── lua ───────────────────────────────────────────────────────────────────
|
||||
|
||||
# Utility ("task") scripts run with the managed-job execution budget
|
||||
# (unlimited time/reductions) but synchronously — the CLI is the one
|
||||
# waiting for the long-running activity.
|
||||
defp run_lua_job(project, script, args) do
|
||||
scripting_config = Application.fetch_env!(:bds, :scripting)
|
||||
|
||||
case Scripting.execute_project_script(
|
||||
project.id,
|
||||
Scripts.resolved_content(script),
|
||||
script.entrypoint || "main",
|
||||
args,
|
||||
timeout: Keyword.get(scripting_config, :job_timeout, :infinity),
|
||||
max_reductions: Keyword.get(scripting_config, :job_max_reductions, :none)
|
||||
) do
|
||||
{:ok, nil} -> {:ok, "Script finished"}
|
||||
{:ok, result} -> {:ok, "Script finished: #{format_reason(result)}"}
|
||||
{:error, reason} -> {:error, "Script failed: #{format_reason(reason)}"}
|
||||
end
|
||||
end
|
||||
|
||||
# ── shared helpers ────────────────────────────────────────────────────────
|
||||
|
||||
defp with_project(fun) do
|
||||
case Projects.get_active_project() do
|
||||
nil -> {:error, "No active project selected; use: project switch <project>"}
|
||||
project -> fun.(project)
|
||||
end
|
||||
end
|
||||
|
||||
defp resolve_project(reference) do
|
||||
projects = Projects.list_projects()
|
||||
|
||||
found =
|
||||
Enum.find(projects, fn project ->
|
||||
reference in [project.id, project.slug, project.name]
|
||||
end)
|
||||
|
||||
if found, do: {:ok, found}, else: {:error, :not_found}
|
||||
end
|
||||
|
||||
defp await_task(task_id, success_message) do
|
||||
case Tasks.get_task(task_id) do
|
||||
nil ->
|
||||
{:error, "Task disappeared before completion"}
|
||||
|
||||
%{status: :completed} ->
|
||||
{:ok, success_message}
|
||||
|
||||
%{status: :failed} = task ->
|
||||
{:error, format_reason(task.error || "task failed")}
|
||||
|
||||
%{message: message} = task ->
|
||||
print_progress(task.progress || 0.0, message)
|
||||
Process.sleep(@poll_interval_ms)
|
||||
await_task(task_id, success_message)
|
||||
end
|
||||
end
|
||||
|
||||
defp active_tasks do
|
||||
Enum.filter(Tasks.list_tasks(), &(&1.status in [:pending, :running]))
|
||||
end
|
||||
|
||||
defp await_active_tasks do
|
||||
case active_tasks() do
|
||||
[] ->
|
||||
:ok
|
||||
|
||||
_tasks ->
|
||||
Process.sleep(@poll_interval_ms)
|
||||
await_active_tasks()
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
A 2-arity progress callback that prints deduplicated `[ 42%] message`
|
||||
lines to stdout.
|
||||
"""
|
||||
def progress_printer do
|
||||
&print_progress/2
|
||||
end
|
||||
|
||||
defp print_progress(value, message) do
|
||||
percent = value |> Kernel.*(100) |> round() |> min(100) |> max(0)
|
||||
line = {percent, message}
|
||||
|
||||
if Process.get({__MODULE__, :last_progress}) != line do
|
||||
Process.put({__MODULE__, :last_progress}, line)
|
||||
|
||||
if is_binary(message) and message != "" do
|
||||
IO.puts("[#{String.pad_leading(Integer.to_string(percent), 3)}%] #{message}")
|
||||
end
|
||||
end
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
defp print_git_output(%{output: output}) when is_binary(output) and output != "" do
|
||||
IO.puts(String.trim_trailing(output))
|
||||
end
|
||||
|
||||
defp print_git_output(_result), do: :ok
|
||||
|
||||
defp notify(entity_type, entity_id, action) do
|
||||
{:ok, _notification} = CliSync.cli_mutation_performed(entity_type, entity_id, action)
|
||||
:ok
|
||||
end
|
||||
|
||||
# Bulk maintenance touched an unknown set of rows; a wildcard notification
|
||||
# per entity type makes a running app reload its lists.
|
||||
defp notify_bulk_change do
|
||||
Enum.each(@bulk_entity_types, ¬ify(&1, "*", :updated))
|
||||
end
|
||||
|
||||
defp split_list(nil), do: []
|
||||
|
||||
defp split_list(value) when is_binary(value) do
|
||||
value
|
||||
|> String.split(",")
|
||||
|> Enum.map(&String.trim/1)
|
||||
|> Enum.reject(&(&1 == ""))
|
||||
end
|
||||
|
||||
defp json_list(value) when is_list(value), do: Enum.map(value, &to_string/1)
|
||||
defp json_list(value) when is_binary(value), do: split_list(value)
|
||||
defp json_list(_value), do: []
|
||||
|
||||
defp present(nil), do: nil
|
||||
|
||||
defp present(value) when is_binary(value) do
|
||||
case String.trim(value) do
|
||||
"" -> nil
|
||||
trimmed -> trimmed
|
||||
end
|
||||
end
|
||||
|
||||
defp changeset_message(%Ecto.Changeset{} = changeset) do
|
||||
changeset
|
||||
|> Ecto.Changeset.traverse_errors(fn {message, opts} ->
|
||||
Enum.reduce(opts, message, fn {key, value}, acc ->
|
||||
String.replace(acc, "%{#{key}}", to_string(value))
|
||||
end)
|
||||
end)
|
||||
|> Enum.map_join("; ", fn {field, messages} -> "#{field}: #{Enum.join(messages, ", ")}" end)
|
||||
end
|
||||
|
||||
defp format_reason(reason) when is_binary(reason), do: reason
|
||||
defp format_reason(%{message: message}) when is_binary(message), do: message
|
||||
defp format_reason(%{guidance: guidance}) when is_binary(guidance), do: guidance
|
||||
defp format_reason(reason), do: inspect(reason)
|
||||
end
|
||||
57
lib/bds/cli/install.ex
Normal file
57
lib/bds/cli/install.ex
Normal file
@@ -0,0 +1,57 @@
|
||||
defmodule BDS.CLI.Install do
|
||||
@moduledoc """
|
||||
Installs the `bds-cli` launcher into `~/.local/bin` (issue #25), used by
|
||||
the install buttons in the GUI and TUI settings.
|
||||
|
||||
The installed file is a two-line shim exec'ing the release's
|
||||
`cli/bin/bds-cli` launcher (shipped via `rel/overlays`), so the CLI always
|
||||
runs the same release — and therefore the same settings and cache
|
||||
database — as the installed application.
|
||||
"""
|
||||
|
||||
@launcher_name "bds-cli"
|
||||
|
||||
@doc """
|
||||
Writes the `~/.local/bin/bds-cli` shim. Returns `{:ok, installed_path}`,
|
||||
or `{:error, :no_release}` when not running from a packaged release (dev
|
||||
runs have no launcher to point at).
|
||||
"""
|
||||
@spec install(keyword()) :: {:ok, Path.t()} | {:error, :no_release | File.posix()}
|
||||
def install(opts \\ []) do
|
||||
bin_dir = Keyword.get(opts, :bin_dir, Path.expand("~/.local/bin"))
|
||||
|
||||
with {:ok, launcher} <- launcher_path(Keyword.get(opts, :release_root)) do
|
||||
installed = Path.join(bin_dir, @launcher_name)
|
||||
|
||||
with :ok <- File.mkdir_p(bin_dir),
|
||||
:ok <- File.write(installed, shim_script(launcher)),
|
||||
:ok <- File.chmod(installed, 0o755) do
|
||||
{:ok, installed}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
The release's CLI launcher script, resolved from `RELEASE_ROOT` (set by
|
||||
release scripts) or the code root dir (the release root when running a
|
||||
release). `{:error, :no_release}` when neither contains the launcher.
|
||||
"""
|
||||
@spec launcher_path(Path.t() | nil) :: {:ok, Path.t()} | {:error, :no_release}
|
||||
def launcher_path(release_root \\ nil) do
|
||||
[release_root, System.get_env("RELEASE_ROOT"), to_string(:code.root_dir())]
|
||||
|> Enum.reject(&(&1 in [nil, ""]))
|
||||
|> Enum.map(&Path.join([&1, "cli", "bin", @launcher_name]))
|
||||
|> Enum.find(&File.regular?/1)
|
||||
|> case do
|
||||
nil -> {:error, :no_release}
|
||||
path -> {:ok, path}
|
||||
end
|
||||
end
|
||||
|
||||
defp shim_script(launcher) do
|
||||
"""
|
||||
#!/bin/sh
|
||||
exec "#{launcher}" "$@"
|
||||
"""
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user