Skip to content

Commit b9fa55d

Browse files
author
Your Name
committed
Add local API and expand smart install execution
1 parent 7615316 commit b9fa55d

9 files changed

Lines changed: 605 additions & 146 deletions

File tree

README.adoc

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ OPSM is the **first universal package manager** that combines:
3535
OPSM has a dedicated UI spec and shell in the `opsm-ui` repo. The UI uses the OPSM
3636
CLI for dry-runs, backend capability detection, and apply actions.
3737

38-
See: link:docs/OPSM-UI.adoc[OPSM UI Integration] | https://github.com/hyperpolymath/opsm-ui
38+
See: link:docs/OPSM-UI.adoc[OPSM UI Integration] | link:docs/OPSM-API.adoc[OPSM Local API] | https://github.com/hyperpolymath/opsm-ui
3939

4040
== Current Status (v1.0.0 - Released)
4141

docs/OPSM-API.adoc

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
// SPDX-License-Identifier: PMPL-1.0-or-later
2+
= OPSM Local API
3+
:toc: preamble
4+
:toclevels: 2
5+
6+
OPSM exposes a local REST API for UI integration. The API is intended for
7+
localhost use only and defaults to `127.0.0.1:4466`.
8+
9+
NOTE: GraphQL and gRPC endpoints are planned; REST is the current surface.
10+
11+
== Start the API
12+
13+
[source,bash]
14+
----
15+
opsm api --port 4466
16+
----
17+
18+
== Nickel-first payloads
19+
20+
JSON is supported for transport, but Nickel is the preferred authoring format.
21+
If `nickel` is installed, you may send `application/nickel` or `text/nickel`
22+
payloads that evaluate to a JSON object, for example:
23+
24+
[source,nickel]
25+
----
26+
{
27+
tokens = [
28+
"rpm-ostree: gcc",
29+
"toolbox: zig",
30+
],
31+
}
32+
----
33+
34+
== Endpoints
35+
36+
* `GET /health` -> `{ "status": "ok" }`
37+
* `GET /backends` -> availability map by backend
38+
* `POST /smart/plan` -> `{ "tokens": [...] }` or `{ "command": "opsm install ..." }`
39+
* `POST /smart/apply` -> executes plan (no dry-run)
40+
41+
== Response Formats
42+
43+
`/smart/plan` returns a grouped plan plus backend availability:
44+
45+
[source,json]
46+
----
47+
{
48+
"plan": { "rpm-ostree": ["gcc"], "toolbox": ["zig"] },
49+
"status": { "rpm-ostree": { "status": "ok" }, "toolbox": { "status": "ok" } }
50+
}
51+
----

docs/OPSM-UI.adoc

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,20 +3,21 @@
33
:toc: preamble
44
:toclevels: 2
55

6-
OPSM UI lives in the standalone repo `opsm-ui` and uses the OPSM CLI as its backend
7-
for discovery, dry-runs, capability detection, and apply actions.
6+
OPSM UI lives in the standalone repo `opsm-ui` and uses the OPSM CLI and local
7+
API for discovery, dry-runs, capability detection, and apply actions.
88

99
== Repo
1010

1111
- https://github.com/hyperpolymath/opsm-ui
1212

1313
== Integration Contract
1414

15-
- The UI shells out to `opsm` for:
15+
- The UI can shell out to `opsm` for:
1616
- `opsm install ...` dry-run plans
1717
- backend availability checks
18-
- apply operations (connection ports only)
19-
- The UI expects `opsm` to be on PATH and to support `--json` where relevant.
18+
- apply operations (connection ports + select backends)
19+
- The UI can also use the local API (`opsm api`) for plan/apply endpoints.
20+
- Nickel is the preferred authoring format for payloads; JSON remains supported.
2021

2122
== Ports
2223

@@ -25,5 +26,4 @@ for discovery, dry-runs, capability detection, and apply actions.
2526

2627
== Status
2728

28-
Initial wiring is documented; full IPC / daemon integration will follow once
29-
`opsm` gains a dedicated local API.
29+
Initial wiring is documented; REST is live, GraphQL/gRPC are planned.

man/opsm.1

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ Install a package from a specific registry.
1515
.B install [backend:] ...
1616
Smart install grouping (dry-run plan by backend).
1717
.TP
18+
.B api
19+
Run local OPSM API server for UI integration.
20+
.TP
1821
.B remove <pkg>
1922
Remove an installed package.
2023
.TP
@@ -35,7 +38,10 @@ List available system package managers.
3538
Show what would be done.
3639
.TP
3740
.B --apply
38-
Apply smart install plan (when wired).
41+
Apply smart install plan (connection ports + select backends).
42+
.TP
43+
.B --port
44+
Port for opsm api (default 4466).
3945
.SH CONFIG
4046
Default config lookup order:
4147
.BR $OPSM_CONFIG

opsm_ex/lib/opsm/api/nickel.ex

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# SPDX-License-Identifier: PMPL-1.0
2+
defmodule Opsm.Api.Nickel do
3+
@moduledoc """
4+
Nickel decoding support for API payloads.
5+
"""
6+
7+
def decode(body) when is_binary(body) do
8+
case System.find_executable("nickel") do
9+
nil ->
10+
{:error, "nickel is not installed; send JSON or install nickel"}
11+
12+
_ ->
13+
path = write_temp(body)
14+
15+
case System.cmd("nickel", ["export", "--format", "json", path], stderr_to_stdout: true) do
16+
{output, 0} ->
17+
Jason.decode(output)
18+
19+
{error, _code} ->
20+
{:error, "nickel export failed: #{String.trim(error)}"}
21+
end
22+
end
23+
end
24+
25+
defp write_temp(body) do
26+
name = "opsm_api_#{System.system_time(:millisecond)}.ncl"
27+
path = Path.join(System.tmp_dir!(), name)
28+
File.write!(path, body)
29+
path
30+
end
31+
end

opsm_ex/lib/opsm/api/router.ex

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
# SPDX-License-Identifier: PMPL-1.0
2+
defmodule Opsm.Api.Router do
3+
@moduledoc """
4+
Minimal REST API for OPSM UI integration.
5+
"""
6+
7+
use Plug.Router
8+
9+
alias Opsm.SmartInstall
10+
alias Opsm.Api.Nickel
11+
12+
plug Plug.Logger
13+
14+
plug Plug.Parsers,
15+
parsers: [:json, :urlencoded],
16+
pass: ["application/nickel", "text/nickel"],
17+
json_decoder: Jason
18+
19+
plug :match
20+
plug :dispatch
21+
22+
get "/health" do
23+
send_json(conn, 200, %{status: "ok"})
24+
end
25+
26+
get "/backends" do
27+
statuses =
28+
SmartInstall.backends()
29+
|> Enum.map(fn backend ->
30+
{backend, SmartInstall.backend_availability(backend)}
31+
end)
32+
|> Enum.map(fn {backend, result} ->
33+
case result do
34+
{:ok, _} -> {backend, %{status: "ok"}}
35+
{:error, reason} -> {backend, %{status: "error", reason: reason}}
36+
end
37+
end)
38+
|> Map.new()
39+
40+
send_json(conn, 200, %{backends: statuses})
41+
end
42+
43+
post "/smart/plan" do
44+
with {:ok, payload} <- read_payload(conn),
45+
{:ok, tokens} <- fetch_tokens(payload) do
46+
plan = SmartInstall.parse(tokens)
47+
send_json(conn, 200, %{plan: plan, status: SmartInstall.plan_status(plan)})
48+
else
49+
{:error, reason} -> send_json(conn, 400, %{error: reason})
50+
end
51+
end
52+
53+
post "/smart/apply" do
54+
with {:ok, payload} <- read_payload(conn),
55+
{:ok, tokens} <- fetch_tokens(payload) do
56+
plan = SmartInstall.parse(tokens)
57+
results = SmartInstall.execute(plan, dry_run: false)
58+
send_json(conn, 200, %{results: results})
59+
else
60+
{:error, reason} -> send_json(conn, 400, %{error: reason})
61+
end
62+
end
63+
64+
match _ do
65+
send_json(conn, 404, %{error: "not found"})
66+
end
67+
68+
defp read_payload(conn) do
69+
case content_type(conn) do
70+
{:nickel, _} ->
71+
{:ok, body, _} = Plug.Conn.read_body(conn)
72+
Nickel.decode(body)
73+
74+
_ ->
75+
{:ok, conn.body_params}
76+
end
77+
end
78+
79+
defp fetch_tokens(%{"tokens" => tokens}) when is_list(tokens), do: {:ok, tokens}
80+
defp fetch_tokens(%{tokens: tokens}) when is_list(tokens), do: {:ok, tokens}
81+
defp fetch_tokens(%{"command" => command}) when is_binary(command), do: {:ok, command_to_tokens(command)}
82+
defp fetch_tokens(%{command: command}) when is_binary(command), do: {:ok, command_to_tokens(command)}
83+
defp fetch_tokens(_), do: {:error, "payload must include tokens or command"}
84+
85+
defp command_to_tokens(command) do
86+
command
87+
|> String.split()
88+
|> Enum.drop_while(&(&1 != "install"))
89+
|> Enum.drop(1)
90+
end
91+
92+
defp send_json(conn, status, data) do
93+
body = Jason.encode!(data)
94+
conn
95+
|> Plug.Conn.put_resp_content_type("application/json")
96+
|> Plug.Conn.send_resp(status, body)
97+
end
98+
99+
defp content_type(conn) do
100+
case Plug.Conn.get_req_header(conn, "content-type") do
101+
[type | _] when String.contains?(type, "application/nickel") -> {:nickel, type}
102+
[type | _] when String.contains?(type, "text/nickel") -> {:nickel, type}
103+
[type | _] -> {:other, type}
104+
_ -> :unknown
105+
end
106+
end
107+
end

opsm_ex/lib/opsm/api/server.ex

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# SPDX-License-Identifier: PMPL-1.0
2+
defmodule Opsm.Api.Server do
3+
@moduledoc """
4+
Local API server for OPSM UI integration.
5+
"""
6+
7+
def start_link(opts \\ []) do
8+
port = Keyword.get(opts, :port, 4466)
9+
Plug.Cowboy.http(Opsm.Api.Router, [], port: port, ip: {127, 0, 0, 1})
10+
end
11+
end

0 commit comments

Comments
 (0)