Elixir SDK for Pocketenv
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add initial Pocketenv Elixir SDK

+1832
+4
.formatter.exs
··· 1 + # Used by "mix format" 2 + [ 3 + inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 + ]
+24
.gitignore
··· 1 + # The directory Mix will write compiled artifacts to. 2 + /_build/ 3 + 4 + # If you run "mix test --cover", coverage assets end up here. 5 + /cover/ 6 + 7 + # The directory Mix downloads your dependencies sources to. 8 + /deps/ 9 + 10 + # Where third-party dependencies like ExDoc output generated docs. 11 + /doc/ 12 + 13 + # Temporary files, for example, from tests. 14 + /tmp/ 15 + 16 + # If the VM crashes, it generates a dump, let's ignore it too. 17 + erl_crash.dump 18 + 19 + # Also ignore archive artifacts (built via "mix archive.build"). 20 + *.ez 21 + 22 + # Ignore package tarball (built via "mix hex.build"). 23 + pocketenv-*.tar 24 +
+312
README.md
··· 1 + # pocketenv-elixir 2 + 3 + Elixir SDK for the [Pocketenv](https://pocketenv.io) sandbox platform. 4 + 5 + Pocketenv lets you spin up isolated cloud sandbox environments on demand. 6 + This library wraps the Pocketenv XRPC API so you can manage sandboxes 7 + directly from your Elixir applications. 8 + 9 + --- 10 + 11 + ## Installation 12 + 13 + Add `pocketenv` to your list of dependencies in `mix.exs`: 14 + 15 + ```elixir 16 + def deps do 17 + [ 18 + {:pocketenv, "~> 0.1"} 19 + ] 20 + end 21 + ``` 22 + 23 + ```sh 24 + mix deps.get 25 + ``` 26 + 27 + --- 28 + 29 + ## Configuration 30 + 31 + ### `config/config.exs` 32 + 33 + ```elixir 34 + import Config 35 + 36 + config :pocketenv, 37 + token: "your-pocketenv-token", 38 + api_url: "https://api.pocketenv.io" # optional — this is the default 39 + ``` 40 + 41 + ### Environment variables 42 + 43 + ```sh 44 + export POCKETENV_TOKEN="your-pocketenv-token" 45 + export POCKETENV_API_URL="https://api.pocketenv.io" # optional 46 + ``` 47 + 48 + Application config takes precedence over environment variables. 49 + 50 + --- 51 + 52 + ## Quick start 53 + 54 + `Pocketenv` is the entry point. It returns `%Sandbox{}` structs that you 55 + pipe operations on: 56 + 57 + ```elixir 58 + {:ok, sandbox} = 59 + Pocketenv.create_sandbox("my-sandbox") 60 + |> Sandbox.start() 61 + |> Sandbox.wait_until_running() 62 + 63 + {:ok, result} = sandbox |> Sandbox.exec("echo", ["hello"]) 64 + IO.puts(result.stdout) # => "hello" 65 + 66 + {:ok, url} = sandbox |> Sandbox.expose(3000) 67 + IO.puts(url) # => "https://3000-my-sandbox.sbx.pocketenv.io" 68 + 69 + {:ok, vscode_url} = sandbox |> Sandbox.vscode() 70 + 71 + sandbox 72 + |> Sandbox.stop() 73 + |> Sandbox.delete() 74 + ``` 75 + 76 + Every `Sandbox` function accepts either a bare `%Sandbox{}` **or** an 77 + `{:ok, %Sandbox{}}` tuple as its first argument, so you can pipe from any 78 + previous step without manually unwrapping. 79 + 80 + --- 81 + 82 + ## API reference 83 + 84 + All functions return `{:ok, result}` on success and `{:error, reason}` on 85 + failure. Every function accepts an optional `:token` keyword argument to 86 + override the globally configured token for that single call. 87 + 88 + --- 89 + 90 + ### `Pocketenv` — entry point 91 + 92 + #### Sandboxes 93 + 94 + | Function | Returns | Description | 95 + |---|---|---| 96 + | `Pocketenv.create_sandbox(name, opts)` | `{:ok, %Sandbox{}}` | Create a new sandbox | 97 + | `Pocketenv.get_sandbox(id, opts)` | `{:ok, %Sandbox{} \| nil}` | Fetch a sandbox by id or name | 98 + | `Pocketenv.list_sandboxes(opts)` | `{:ok, {[%Sandbox{}], total}}` | List the public sandbox catalog | 99 + | `Pocketenv.list_sandboxes_by_actor(did, opts)` | `{:ok, {[%Sandbox{}], total}}` | List all sandboxes for a user | 100 + 101 + ##### `create_sandbox/2` options 102 + 103 + | Option | Type | Default | Description | 104 + |---|---|---|---| 105 + | `:base` | `string` | official `openclaw` image | AT-URI of the base sandbox image | 106 + | `:provider` | `string` | `"cloudflare"` | `"cloudflare"`, `"daytona"`, `"deno"`, `"vercel"`, or `"sprites"` | 107 + | `:repo` | `string` | `nil` | GitHub repo URL to clone on start | 108 + | `:keep_alive` | `boolean` | `nil` | Keep the sandbox alive after the session ends | 109 + | `:token` | `string` | global config | Bearer token override | 110 + 111 + ##### `list_sandboxes/1` and `list_sandboxes_by_actor/2` options 112 + 113 + | Option | Type | Default | Description | 114 + |---|---|---|---| 115 + | `:limit` | `integer` | `30` | Max results | 116 + | `:offset` | `integer` | `0` | Pagination offset | 117 + | `:token` | `string` | global config | Bearer token override | 118 + 119 + #### Actor / profile 120 + 121 + | Function | Returns | Description | 122 + |---|---|---| 123 + | `Pocketenv.me(opts)` | `{:ok, %Profile{}}` | Fetch the authenticated user's profile | 124 + | `Pocketenv.get_profile(did, opts)` | `{:ok, %Profile{}}` | Fetch any user's profile by DID or handle | 125 + 126 + ```elixir 127 + {:ok, me} = Pocketenv.me() 128 + IO.puts("Logged in as @#{me.handle}") 129 + 130 + {:ok, profile} = Pocketenv.get_profile("alice.bsky.social") 131 + ``` 132 + 133 + --- 134 + 135 + ### `Sandbox` — operations on a sandbox 136 + 137 + All functions take a `%Sandbox{}` or `{:ok, %Sandbox{}}` as their first 138 + argument. 139 + 140 + #### Lifecycle 141 + 142 + | Function | Returns | Description | 143 + |---|---|---| 144 + | `Sandbox.start(sandbox, opts)` | `{:ok, %Sandbox{}}` | Start the sandbox, re-fetches state | 145 + | `Sandbox.stop(sandbox, opts)` | `{:ok, %Sandbox{}}` | Stop the sandbox, re-fetches state | 146 + | `Sandbox.delete(sandbox, opts)` | `{:ok, %Sandbox{}}` | Delete the sandbox permanently | 147 + | `Sandbox.wait_until_running(sandbox, opts)` | `{:ok, %Sandbox{}}` | Poll until status is `:running` | 148 + 149 + `start/2` and `stop/2` re-fetch the sandbox after the API call so the 150 + returned struct always has the latest status. `delete/2` returns the last 151 + known state. 152 + 153 + ##### `wait_until_running/2` options 154 + 155 + | Option | Type | Default | Description | 156 + |---|---|---|---| 157 + | `:timeout_ms` | `integer` | `60_000` | Total wait time in ms | 158 + | `:interval_ms` | `integer` | `2_000` | Polling interval in ms | 159 + | `:token` | `string` | global config | Bearer token override | 160 + 161 + #### Commands 162 + 163 + ```elixir 164 + {:ok, result} = sandbox |> Sandbox.exec("mix", ["test", "--trace"]) 165 + 166 + IO.puts(result.stdout) 167 + IO.puts(result.stderr) 168 + IO.inspect(result.exit_code) 169 + ``` 170 + 171 + | Function | Returns | Description | 172 + |---|---|---| 173 + | `Sandbox.exec(sandbox, cmd, args \\ [], opts)` | `{:ok, %ExecResult{}}` | Run a shell command inside the sandbox | 174 + 175 + #### Ports 176 + 177 + ```elixir 178 + {:ok, url} = sandbox |> Sandbox.expose(4000, description: "Phoenix") 179 + {:ok, ports} = sandbox |> Sandbox.list_ports() 180 + {:ok, _} = sandbox |> Sandbox.unexpose(4000) 181 + ``` 182 + 183 + | Function | Returns | Description | 184 + |---|---|---| 185 + | `Sandbox.expose(sandbox, port, opts)` | `{:ok, url \| nil}` | Expose a port publicly | 186 + | `Sandbox.unexpose(sandbox, port, opts)` | `{:ok, %Sandbox{}}` | Remove an exposed port | 187 + | `Sandbox.list_ports(sandbox, opts)` | `{:ok, [%Port{}]}` | List all exposed ports | 188 + 189 + #### VS Code 190 + 191 + ```elixir 192 + {:ok, url} = sandbox |> Sandbox.vscode() 193 + IO.puts("Open VS Code at: #{url}") 194 + ``` 195 + 196 + | Function | Returns | Description | 197 + |---|---|---| 198 + | `Sandbox.vscode(sandbox, opts)` | `{:ok, url \| nil}` | Expose VS Code Server and return its URL | 199 + 200 + If VS Code is already exposed the existing URL is returned immediately. 201 + 202 + --- 203 + 204 + ## Types 205 + 206 + ### `%Sandbox{}` 207 + 208 + The central type of the SDK. Returned by `Pocketenv.create_sandbox/2`, 209 + `Pocketenv.get_sandbox/2`, and all `Sandbox.*` lifecycle functions. 210 + 211 + ``` 212 + %Sandbox{ 213 + id: String.t() | nil, 214 + name: String.t() | nil, 215 + provider: String.t() | nil, 216 + base_sandbox: String.t() | nil, 217 + display_name: String.t() | nil, 218 + uri: String.t() | nil, 219 + description: String.t() | nil, 220 + topics: [String.t()] | nil, 221 + logo: String.t() | nil, 222 + readme: String.t() | nil, 223 + repo: String.t() | nil, 224 + vcpus: integer() | nil, 225 + memory: integer() | nil, 226 + disk: integer() | nil, 227 + installs: integer(), 228 + status: :running | :stopped | :unknown, 229 + started_at: String.t() | nil, 230 + created_at: String.t() | nil, 231 + owner: %Sandbox.Types.Profile{} | nil 232 + } 233 + ``` 234 + 235 + ### `%Sandbox.Types.ExecResult{}` 236 + 237 + Returned by `Sandbox.exec/4`. 238 + 239 + ``` 240 + %Sandbox.Types.ExecResult{ 241 + stdout: String.t(), 242 + stderr: String.t(), 243 + exit_code: integer() 244 + } 245 + ``` 246 + 247 + ### `%Sandbox.Types.Port{}` 248 + 249 + Returned in the list by `Sandbox.list_ports/2`. 250 + 251 + ``` 252 + %Sandbox.Types.Port{ 253 + port: integer(), 254 + description: String.t() | nil, 255 + preview_url: String.t() | nil 256 + } 257 + ``` 258 + 259 + ### `%Sandbox.Types.Profile{}` 260 + 261 + Returned by `Pocketenv.me/1` and `Pocketenv.get_profile/2`. 262 + 263 + ``` 264 + %Sandbox.Types.Profile{ 265 + id: String.t() | nil, 266 + did: String.t(), 267 + handle: String.t(), 268 + display_name: String.t() | nil, 269 + avatar: String.t() | nil, 270 + created_at: String.t() | nil, 271 + updated_at: String.t() | nil 272 + } 273 + ``` 274 + 275 + --- 276 + 277 + ## Low-level client 278 + 279 + If you need to call an endpoint not yet covered by the high-level API, 280 + use `Pocketenv.Client` directly: 281 + 282 + ```elixir 283 + {:ok, body} = Pocketenv.Client.get( 284 + "/xrpc/io.pocketenv.sandbox.getSandbox", 285 + params: %{"id" => "my-sandbox"}, 286 + token: "override-token" 287 + ) 288 + 289 + {:ok, body} = Pocketenv.Client.post( 290 + "/xrpc/io.pocketenv.sandbox.startSandbox", 291 + %{"keepAlive" => true}, 292 + params: %{"id" => "my-sandbox"} 293 + ) 294 + ``` 295 + 296 + --- 297 + 298 + ## Running tests 299 + 300 + ```sh 301 + mix test 302 + ``` 303 + 304 + The test suite does **not** make real HTTP calls. Integration tests that 305 + exercise the live API require a valid `POCKETENV_TOKEN` and are not 306 + included by default. 307 + 308 + --- 309 + 310 + ## License 311 + 312 + MIT
+146
lib/pocketenv.ex
··· 1 + defmodule Pocketenv do 2 + @moduledoc """ 3 + Elixir SDK for the [Pocketenv](https://pocketenv.io) sandbox platform. 4 + 5 + `Pocketenv` is the single entry point for the SDK. It returns `%Sandbox{}` 6 + structs that you pipe operations on: 7 + 8 + {:ok, sandbox} = 9 + Pocketenv.create_sandbox("my-sandbox") 10 + |> Sandbox.start() 11 + |> Sandbox.wait_until_running() 12 + 13 + {:ok, result} = sandbox |> Sandbox.exec("mix", ["test"]) 14 + IO.puts(result.stdout) 15 + 16 + {:ok, url} = sandbox |> Sandbox.expose(3000) 17 + 18 + sandbox 19 + |> Sandbox.stop() 20 + |> Sandbox.delete() 21 + 22 + ## Configuration 23 + 24 + ### `config/config.exs` 25 + 26 + import Config 27 + 28 + config :pocketenv, 29 + token: "your-token", 30 + api_url: "https://api.pocketenv.io" # optional 31 + 32 + ### Environment variables 33 + 34 + export POCKETENV_TOKEN="your-token" 35 + export POCKETENV_API_URL="https://api.pocketenv.io" # optional 36 + 37 + Application config takes precedence over environment variables. 38 + """ 39 + 40 + alias Pocketenv.API 41 + 42 + # --------------------------------------------------------------------------- 43 + # Sandboxes 44 + # --------------------------------------------------------------------------- 45 + 46 + @doc """ 47 + Creates a new sandbox and returns a `%Sandbox{}`. 48 + 49 + ## Options 50 + 51 + - `:base` — AT-URI of the base sandbox image (default: `openclaw`). 52 + - `:provider` — `"cloudflare"` (default), `"daytona"`, `"deno"`, 53 + `"vercel"`, or `"sprites"`. 54 + - `:repo` — GitHub repo URL to clone into the sandbox on start. 55 + - `:keep_alive` — keep the sandbox alive after the session ends. 56 + - `:token` — bearer token override. 57 + 58 + ## Example 59 + 60 + {:ok, sandbox} = Pocketenv.create_sandbox("my-sandbox") 61 + {:ok, sandbox} = Pocketenv.create_sandbox("ml-box", repo: "github.com/me/repo") 62 + """ 63 + @spec create_sandbox(String.t(), keyword()) :: {:ok, Sandbox.t()} | {:error, term()} 64 + defdelegate create_sandbox(name, opts \\ []), to: API 65 + 66 + @doc """ 67 + Fetches a single sandbox by id or name. 68 + 69 + ## Example 70 + 71 + {:ok, sandbox} = Pocketenv.get_sandbox("my-sandbox") 72 + {:ok, nil} = Pocketenv.get_sandbox("nonexistent") 73 + """ 74 + @spec get_sandbox(String.t(), keyword()) :: {:ok, Sandbox.t() | nil} | {:error, term()} 75 + defdelegate get_sandbox(id, opts \\ []), to: API 76 + 77 + @doc """ 78 + Lists the official public sandbox catalog. 79 + 80 + Returns `{:ok, {[%Sandbox{}], total}}`. 81 + 82 + ## Options 83 + 84 + - `:limit` — max results (default: `30`). 85 + - `:offset` — pagination offset (default: `0`). 86 + - `:token` — bearer token override. 87 + 88 + ## Example 89 + 90 + {:ok, {sandboxes, total}} = Pocketenv.list_sandboxes() 91 + """ 92 + @spec list_sandboxes(keyword()) :: 93 + {:ok, {[Sandbox.t()], non_neg_integer()}} | {:error, term()} 94 + defdelegate list_sandboxes(opts \\ []), to: API 95 + 96 + @doc """ 97 + Lists all sandboxes belonging to a specific actor (user). 98 + 99 + Returns `{:ok, {[%Sandbox{}], total}}`. 100 + 101 + ## Parameters 102 + 103 + - `did` — the actor's DID (`"did:plc:..."`) or handle 104 + (`"alice.bsky.social"`). 105 + 106 + ## Options 107 + 108 + - `:limit` — max results (default: `30`). 109 + - `:offset` — pagination offset (default: `0`). 110 + - `:token` — bearer token override. 111 + 112 + ## Example 113 + 114 + {:ok, {sandboxes, total}} = Pocketenv.list_sandboxes_by_actor("alice.bsky.social") 115 + """ 116 + @spec list_sandboxes_by_actor(String.t(), keyword()) :: 117 + {:ok, {[Sandbox.t()], non_neg_integer()}} | {:error, term()} 118 + defdelegate list_sandboxes_by_actor(did, opts \\ []), to: API 119 + 120 + # --------------------------------------------------------------------------- 121 + # Actor / profile 122 + # --------------------------------------------------------------------------- 123 + 124 + @doc """ 125 + Fetches the profile of the currently authenticated user. 126 + 127 + ## Example 128 + 129 + {:ok, me} = Pocketenv.me() 130 + IO.puts("Logged in as @\#{me.handle}") 131 + """ 132 + @spec me(keyword()) :: {:ok, Sandbox.Types.Profile.t()} | {:error, term()} 133 + defdelegate me(opts \\ []), to: API 134 + 135 + @doc """ 136 + Fetches the profile of any actor by DID or handle. 137 + 138 + ## Example 139 + 140 + {:ok, profile} = Pocketenv.get_profile("alice.bsky.social") 141 + {:ok, profile} = Pocketenv.get_profile("did:plc:abc123") 142 + """ 143 + @spec get_profile(String.t(), keyword()) :: 144 + {:ok, Sandbox.Types.Profile.t()} | {:error, term()} 145 + defdelegate get_profile(did, opts \\ []), to: API 146 + end
+243
lib/pocketenv/api.ex
··· 1 + defmodule Pocketenv.API do 2 + @moduledoc false 3 + # Internal HTTP layer. Consumers should use the `Pocketenv` module and 4 + # pipe on `%Sandbox{}` structs. This module is not part of the public API. 5 + 6 + alias Pocketenv.Client 7 + alias Sandbox.Types.{ExecResult, Port, Profile} 8 + 9 + @default_base "at://did:plc:aturpi2ls3yvsmhc6wybomun/io.pocketenv.sandbox/openclaw" 10 + 11 + # --------------------------------------------------------------------------- 12 + # Sandbox CRUD 13 + # --------------------------------------------------------------------------- 14 + 15 + def create_sandbox(name, opts \\ []) do 16 + body = 17 + %{ 18 + "name" => name, 19 + "base" => Keyword.get(opts, :base, @default_base), 20 + "provider" => Keyword.get(opts, :provider, "cloudflare") 21 + } 22 + |> maybe_put("repo", Keyword.get(opts, :repo)) 23 + |> maybe_put("keepAlive", Keyword.get(opts, :keep_alive)) 24 + 25 + case Client.post("/xrpc/io.pocketenv.sandbox.createSandbox", body, take_token(opts)) do 26 + {:ok, data} -> {:ok, Sandbox.from_map(data)} 27 + {:error, _} = err -> err 28 + end 29 + end 30 + 31 + def start_sandbox(id, opts \\ []) do 32 + body = 33 + %{} 34 + |> maybe_put("repo", Keyword.get(opts, :repo)) 35 + |> maybe_put("keepAlive", Keyword.get(opts, :keep_alive)) 36 + 37 + Client.post( 38 + "/xrpc/io.pocketenv.sandbox.startSandbox", 39 + body, 40 + take_token(opts) ++ [params: %{"id" => id}] 41 + ) 42 + end 43 + 44 + def stop_sandbox(id, opts \\ []) do 45 + Client.post( 46 + "/xrpc/io.pocketenv.sandbox.stopSandbox", 47 + nil, 48 + take_token(opts) ++ [params: %{"id" => id}] 49 + ) 50 + end 51 + 52 + def delete_sandbox(id, opts \\ []) do 53 + Client.post( 54 + "/xrpc/io.pocketenv.sandbox.deleteSandbox", 55 + nil, 56 + take_token(opts) ++ [params: %{"id" => id}] 57 + ) 58 + end 59 + 60 + # --------------------------------------------------------------------------- 61 + # Sandbox queries 62 + # --------------------------------------------------------------------------- 63 + 64 + def get_sandbox(id, opts \\ []) do 65 + case Client.get( 66 + "/xrpc/io.pocketenv.sandbox.getSandbox", 67 + take_token(opts) ++ [params: %{"id" => id}] 68 + ) do 69 + {:ok, %{"sandbox" => nil}} -> {:ok, nil} 70 + {:ok, %{"sandbox" => data}} -> {:ok, Sandbox.from_map(data)} 71 + {:ok, data} when is_map(data) -> {:ok, Sandbox.from_map(data)} 72 + {:error, _} = err -> err 73 + end 74 + end 75 + 76 + def list_sandboxes(opts \\ []) do 77 + params = %{ 78 + "limit" => Keyword.get(opts, :limit, 30), 79 + "offset" => Keyword.get(opts, :offset, 0) 80 + } 81 + 82 + case Client.get( 83 + "/xrpc/io.pocketenv.sandbox.getSandboxes", 84 + take_token(opts) ++ [params: params] 85 + ) do 86 + {:ok, %{"sandboxes" => items, "total" => total}} -> 87 + {:ok, {Enum.map(items, &Sandbox.from_map/1), total}} 88 + 89 + {:error, _} = err -> 90 + err 91 + end 92 + end 93 + 94 + def list_sandboxes_by_actor(did, opts \\ []) do 95 + params = %{ 96 + "did" => did, 97 + "limit" => Keyword.get(opts, :limit, 30), 98 + "offset" => Keyword.get(opts, :offset, 0) 99 + } 100 + 101 + case Client.get( 102 + "/xrpc/io.pocketenv.actor.getActorSandboxes", 103 + take_token(opts) ++ [params: params] 104 + ) do 105 + {:ok, %{"sandboxes" => items, "total" => total}} -> 106 + {:ok, {Enum.map(items, &Sandbox.from_map/1), total}} 107 + 108 + {:error, _} = err -> 109 + err 110 + end 111 + end 112 + 113 + def wait_until_running(id, opts \\ []) do 114 + timeout_ms = Keyword.get(opts, :timeout_ms, 60_000) 115 + interval_ms = Keyword.get(opts, :interval_ms, 2_000) 116 + deadline = System.monotonic_time(:millisecond) + timeout_ms 117 + do_wait(id, opts, deadline, interval_ms) 118 + end 119 + 120 + # --------------------------------------------------------------------------- 121 + # Exec 122 + # --------------------------------------------------------------------------- 123 + 124 + def exec(id, cmd, args \\ [], opts \\ []) do 125 + command = Enum.join([cmd | args], " ") 126 + 127 + case Client.post( 128 + "/xrpc/io.pocketenv.sandbox.exec", 129 + %{"command" => command}, 130 + take_token(opts) ++ [params: %{"id" => id}] 131 + ) do 132 + {:ok, data} -> {:ok, ExecResult.from_map(data)} 133 + {:error, _} = err -> err 134 + end 135 + end 136 + 137 + # --------------------------------------------------------------------------- 138 + # Ports 139 + # --------------------------------------------------------------------------- 140 + 141 + def expose_port(id, port, opts \\ []) do 142 + body = 143 + %{"port" => port} 144 + |> maybe_put("description", Keyword.get(opts, :description)) 145 + 146 + case Client.post( 147 + "/xrpc/io.pocketenv.sandbox.exposePort", 148 + body, 149 + take_token(opts) ++ [params: %{"id" => id}] 150 + ) do 151 + {:ok, %{"previewUrl" => url}} -> {:ok, url} 152 + {:ok, _} -> {:ok, nil} 153 + {:error, _} = err -> err 154 + end 155 + end 156 + 157 + def unexpose_port(id, port, opts \\ []) do 158 + Client.post( 159 + "/xrpc/io.pocketenv.sandbox.unexposePort", 160 + %{"port" => port}, 161 + take_token(opts) ++ [params: %{"id" => id}] 162 + ) 163 + end 164 + 165 + def list_ports(id, opts \\ []) do 166 + case Client.get( 167 + "/xrpc/io.pocketenv.sandbox.getExposedPorts", 168 + take_token(opts) ++ [params: %{"id" => id}] 169 + ) do 170 + {:ok, %{"ports" => ports}} -> {:ok, Enum.map(ports, &Port.from_map/1)} 171 + {:error, _} = err -> err 172 + end 173 + end 174 + 175 + # --------------------------------------------------------------------------- 176 + # VS Code 177 + # --------------------------------------------------------------------------- 178 + 179 + def expose_vscode(id, opts \\ []) do 180 + case Client.post( 181 + "/xrpc/io.pocketenv.sandbox.exposeVscode", 182 + nil, 183 + take_token(opts) ++ [params: %{"id" => id}] 184 + ) do 185 + {:ok, %{"previewUrl" => url}} -> {:ok, url} 186 + {:ok, _} -> {:ok, nil} 187 + {:error, _} = err -> err 188 + end 189 + end 190 + 191 + # --------------------------------------------------------------------------- 192 + # Actor / profile 193 + # --------------------------------------------------------------------------- 194 + 195 + def me(opts \\ []) do 196 + case Client.get("/xrpc/io.pocketenv.actor.getProfile", take_token(opts)) do 197 + {:ok, data} -> {:ok, Profile.from_map(data)} 198 + {:error, _} = err -> err 199 + end 200 + end 201 + 202 + def get_profile(did, opts \\ []) do 203 + case Client.get( 204 + "/xrpc/io.pocketenv.actor.getProfile", 205 + take_token(opts) ++ [params: %{"did" => did}] 206 + ) do 207 + {:ok, data} -> {:ok, Profile.from_map(data)} 208 + {:error, _} = err -> err 209 + end 210 + end 211 + 212 + # --------------------------------------------------------------------------- 213 + # Private helpers 214 + # --------------------------------------------------------------------------- 215 + 216 + defp do_wait(id, opts, deadline, interval_ms) do 217 + if System.monotonic_time(:millisecond) >= deadline do 218 + {:error, :timeout} 219 + else 220 + case get_sandbox(id, opts) do 221 + {:ok, %Sandbox{status: :running} = sandbox} -> 222 + {:ok, sandbox} 223 + 224 + {:ok, _} -> 225 + Process.sleep(interval_ms) 226 + do_wait(id, opts, deadline, interval_ms) 227 + 228 + {:error, _} = err -> 229 + err 230 + end 231 + end 232 + end 233 + 234 + defp take_token(opts) do 235 + case Keyword.fetch(opts, :token) do 236 + {:ok, token} -> [token: token] 237 + :error -> [] 238 + end 239 + end 240 + 241 + defp maybe_put(map, _key, nil), do: map 242 + defp maybe_put(map, key, value), do: Map.put(map, key, value) 243 + end
+165
lib/pocketenv/client.ex
··· 1 + defmodule Pocketenv.Client do 2 + @moduledoc """ 3 + Low-level HTTP client for the Pocketenv XRPC API. 4 + 5 + Wraps `Req` to provide authenticated requests to the 6 + `https://api.pocketenv.io` base URL (configurable via the 7 + `:pocketenv` application environment or the `POCKETENV_API_URL` 8 + environment variable). 9 + 10 + ## Configuration 11 + 12 + You can configure the API URL and token in `config/config.exs`: 13 + 14 + config :pocketenv, 15 + api_url: "https://api.pocketenv.io", 16 + token: "your-token-here" 17 + 18 + Or set the `POCKETENV_API_URL` / `POCKETENV_TOKEN` environment 19 + variables at runtime. 20 + 21 + ## Usage 22 + 23 + iex> Pocketenv.Client.get("/xrpc/io.pocketenv.actor.getProfile", token: "...", params: %{did: "did:plc:..."}) 24 + {:ok, %{"did" => "did:plc:...", ...}} 25 + """ 26 + 27 + @default_api_url "https://api.pocketenv.io" 28 + 29 + @doc """ 30 + Returns the base URL for the API, resolved in order from: 31 + 32 + 1. The `:api_url` key in the `:pocketenv` application environment. 33 + 2. The `POCKETENV_API_URL` environment variable. 34 + 3. The hardcoded default `https://api.pocketenv.io`. 35 + """ 36 + @spec base_url() :: String.t() 37 + def base_url do 38 + Application.get_env(:pocketenv, :api_url) || 39 + System.get_env("POCKETENV_API_URL") || 40 + @default_api_url 41 + end 42 + 43 + @doc """ 44 + Returns the bearer token, resolved in order from: 45 + 46 + 1. The `:token` key in the `:pocketenv` application environment. 47 + 2. The `POCKETENV_TOKEN` environment variable. 48 + 3. `nil` (unauthenticated requests are allowed for some endpoints). 49 + """ 50 + @spec token() :: {:ok, String.t()} | {:error, :not_logged_in} 51 + def token do 52 + case Application.get_env(:pocketenv, :token) || 53 + System.get_env("POCKETENV_TOKEN") || 54 + read_token_file() do 55 + nil -> {:error, :not_logged_in} 56 + token -> {:ok, token} 57 + end 58 + end 59 + 60 + defp read_token_file do 61 + path = Path.join([System.user_home!(), ".pocketenv", "token.json"]) 62 + 63 + with {:ok, contents} <- File.read(path), 64 + {:ok, %{"token" => token}} when is_binary(token) <- Jason.decode(contents) do 65 + token 66 + else 67 + _ -> nil 68 + end 69 + end 70 + 71 + @doc """ 72 + Perform an HTTP GET request against the Pocketenv API. 73 + 74 + ## Options 75 + 76 + * `:token` – bearer token; defaults to `token/0`. 77 + * `:params` – query-string parameters as a map. 78 + 79 + Returns `{:ok, body}` on a 2xx response, or `{:error, reason}`. 80 + """ 81 + @spec get(String.t(), keyword()) :: {:ok, map()} | {:error, term()} 82 + def get(path, opts \\ []) do 83 + request(:get, path, nil, opts) 84 + end 85 + 86 + @doc """ 87 + Perform an HTTP POST request against the Pocketenv API. 88 + 89 + ## Options 90 + 91 + * `:token` – bearer token; defaults to `token/0`. 92 + * `:params` – query-string parameters as a map. 93 + 94 + Returns `{:ok, body}` on a 2xx response, or `{:error, reason}`. 95 + """ 96 + @spec post(String.t(), map() | nil, keyword()) :: {:ok, map()} | {:error, term()} 97 + def post(path, body \\ nil, opts \\ []) do 98 + request(:post, path, body, opts) 99 + end 100 + 101 + @doc """ 102 + Perform an HTTP DELETE request against the Pocketenv API. 103 + 104 + ## Options 105 + 106 + * `:token` – bearer token; defaults to `token/0`. 107 + * `:params` – query-string parameters as a map. 108 + 109 + Returns `{:ok, body}` on a 2xx response, or `{:error, reason}`. 110 + """ 111 + @spec delete(String.t(), keyword()) :: {:ok, map()} | {:error, term()} 112 + def delete(path, opts \\ []) do 113 + request(:delete, path, nil, opts) 114 + end 115 + 116 + # --------------------------------------------------------------------------- 117 + # Private helpers 118 + # --------------------------------------------------------------------------- 119 + 120 + defp request(method, path, body, opts) do 121 + bearer = 122 + case Keyword.fetch(opts, :token) do 123 + {:ok, t} -> {:ok, t} 124 + :error -> token() 125 + end 126 + 127 + case bearer do 128 + {:error, :not_logged_in} -> 129 + {:error, :not_logged_in} 130 + 131 + {:ok, t} -> 132 + params = Keyword.get(opts, :params, %{}) 133 + 134 + req_opts = 135 + [ 136 + method: method, 137 + url: base_url() <> path, 138 + headers: build_headers(t), 139 + params: params 140 + ] 141 + |> maybe_put_json_body(body) 142 + 143 + case Req.request(req_opts) do 144 + {:ok, %Req.Response{status: status, body: body}} when status in 200..299 -> 145 + {:ok, body} 146 + 147 + {:ok, %Req.Response{status: status, body: body}} -> 148 + {:error, %{status: status, body: body}} 149 + 150 + {:error, reason} -> 151 + {:error, reason} 152 + end 153 + end 154 + end 155 + 156 + defp build_headers(token) do 157 + [ 158 + {"content-type", "application/json"}, 159 + {"authorization", "Bearer #{token}"} 160 + ] 161 + end 162 + 163 + defp maybe_put_json_body(req_opts, nil), do: req_opts 164 + defp maybe_put_json_body(req_opts, body), do: Keyword.put(req_opts, :json, body) 165 + end
+379
lib/sandbox.ex
··· 1 + defmodule Sandbox do 2 + @moduledoc """ 3 + Represents a Pocketenv sandbox environment. 4 + 5 + `%Sandbox{}` is the central type of the SDK. You get one back from 6 + `Pocketenv.create_sandbox/2` or `Pocketenv.get_sandbox/2`, and then 7 + pipe operations directly on it: 8 + 9 + {:ok, sandbox} = 10 + Pocketenv.create_sandbox("my-sandbox") 11 + |> Sandbox.start() 12 + |> Sandbox.wait_until_running() 13 + 14 + {:ok, result} = sandbox |> Sandbox.exec("mix", ["test"]) 15 + IO.puts(result.stdout) 16 + 17 + {:ok, url} = sandbox |> Sandbox.expose(3000) 18 + 19 + sandbox 20 + |> Sandbox.stop() 21 + |> Sandbox.delete() 22 + 23 + ## Pipe safety 24 + 25 + Every function accepts either a bare `%Sandbox{}` **or** an 26 + `{:ok, %Sandbox{}}` tuple as its first argument, so you can pipe from 27 + any previous step without manually unwrapping the result. 28 + 29 + An `{:error, reason}` value is never silently swallowed — passing one 30 + raises `FunctionClauseError`, keeping error handling explicit. 31 + """ 32 + 33 + alias Pocketenv.API 34 + alias Sandbox.Types.{ExecResult, Port, Profile} 35 + 36 + @type status :: :running | :stopped | :unknown 37 + 38 + @type t :: %__MODULE__{ 39 + id: String.t() | nil, 40 + name: String.t() | nil, 41 + provider: String.t() | nil, 42 + base_sandbox: String.t() | nil, 43 + display_name: String.t() | nil, 44 + uri: String.t() | nil, 45 + description: String.t() | nil, 46 + topics: [String.t()] | nil, 47 + logo: String.t() | nil, 48 + readme: String.t() | nil, 49 + repo: String.t() | nil, 50 + vcpus: integer() | nil, 51 + memory: integer() | nil, 52 + disk: integer() | nil, 53 + installs: integer(), 54 + status: status(), 55 + started_at: String.t() | nil, 56 + created_at: String.t() | nil, 57 + owner: Profile.t() | nil 58 + } 59 + 60 + defstruct [ 61 + :id, 62 + :name, 63 + :provider, 64 + :base_sandbox, 65 + :display_name, 66 + :uri, 67 + :description, 68 + :topics, 69 + :logo, 70 + :readme, 71 + :repo, 72 + :vcpus, 73 + :memory, 74 + :disk, 75 + :installs, 76 + :status, 77 + :started_at, 78 + :created_at, 79 + :owner 80 + ] 81 + 82 + @doc false 83 + @spec from_map(map()) :: t() 84 + def from_map(map) when is_map(map) do 85 + %__MODULE__{ 86 + id: map["id"], 87 + name: map["name"], 88 + provider: map["provider"], 89 + base_sandbox: map["baseSandbox"], 90 + display_name: map["displayName"], 91 + uri: map["uri"], 92 + description: map["description"], 93 + topics: map["topics"], 94 + logo: map["logo"], 95 + readme: map["readme"], 96 + repo: map["repo"], 97 + vcpus: map["vcpus"], 98 + memory: map["memory"], 99 + disk: map["disk"], 100 + installs: map["installs"] || 0, 101 + status: parse_status(map["status"]), 102 + started_at: map["startedAt"], 103 + created_at: map["createdAt"], 104 + owner: map["owner"] && Profile.from_map(map["owner"]) 105 + } 106 + end 107 + 108 + # --------------------------------------------------------------------------- 109 + # Lifecycle 110 + # --------------------------------------------------------------------------- 111 + 112 + @doc """ 113 + Starts the sandbox. 114 + 115 + Re-fetches the sandbox after the API call so the returned struct always 116 + reflects the latest state. 117 + 118 + ## Options 119 + 120 + - `:repo` — clone a GitHub repo into the sandbox on start. 121 + - `:keep_alive` — keep the sandbox alive after the session ends. 122 + - `:token` — bearer token override. 123 + 124 + ## Example 125 + 126 + sandbox |> Sandbox.start() 127 + sandbox |> Sandbox.start(repo: "github.com/me/app") 128 + Pocketenv.get_sandbox("my-sandbox") |> Sandbox.start() 129 + """ 130 + @spec start(t() | {:ok, t()}, keyword()) :: {:ok, t()} | {:error, term()} 131 + def start(sandbox_or_result, opts \\ []) 132 + def start({:ok, %__MODULE__{} = sandbox}, opts), do: start(sandbox, opts) 133 + 134 + def start(%__MODULE__{} = sandbox, opts) do 135 + with {:ok, _} <- API.start_sandbox(sandbox.name, opts) do 136 + API.get_sandbox(sandbox.name, opts) 137 + end 138 + end 139 + 140 + @doc """ 141 + Stops the sandbox. 142 + 143 + Re-fetches the sandbox after the API call so the returned struct always 144 + reflects the latest state. 145 + 146 + ## Options 147 + 148 + - `:token` — bearer token override. 149 + 150 + ## Example 151 + 152 + sandbox |> Sandbox.stop() 153 + Pocketenv.get_sandbox("my-sandbox") |> Sandbox.stop() 154 + """ 155 + @spec stop(t() | {:ok, t()}, keyword()) :: {:ok, t()} | {:error, term()} 156 + def stop(sandbox_or_result, opts \\ []) 157 + def stop({:ok, %__MODULE__{} = sandbox}, opts), do: stop(sandbox, opts) 158 + 159 + def stop(%__MODULE__{} = sandbox, opts) do 160 + with {:ok, _} <- API.stop_sandbox(sandbox.name, opts) do 161 + API.get_sandbox(sandbox.name, opts) 162 + end 163 + end 164 + 165 + @doc """ 166 + Deletes the sandbox permanently. 167 + 168 + Returns `{:ok, %Sandbox{}}` with the last known state — the sandbox 169 + will no longer be fetchable after this call. 170 + 171 + ## Options 172 + 173 + - `:token` — bearer token override. 174 + 175 + ## Example 176 + 177 + sandbox |> Sandbox.delete() 178 + sandbox |> Sandbox.stop() |> Sandbox.delete() 179 + """ 180 + @spec delete(t() | {:ok, t()}, keyword()) :: {:ok, t()} | {:error, term()} 181 + def delete(sandbox_or_result, opts \\ []) 182 + def delete({:ok, %__MODULE__{} = sandbox}, opts), do: delete(sandbox, opts) 183 + 184 + def delete(%__MODULE__{} = sandbox, opts) do 185 + with {:ok, _} <- API.delete_sandbox(sandbox.name, opts) do 186 + {:ok, sandbox} 187 + end 188 + end 189 + 190 + @doc """ 191 + Polls until the sandbox status becomes `:running`, then returns the 192 + refreshed `%Sandbox{}`. 193 + 194 + Useful after `start/2` when you need the sandbox to be fully ready 195 + before running commands. 196 + 197 + ## Options 198 + 199 + - `:timeout_ms` — total wait time in ms (default: `60_000`). 200 + - `:interval_ms` — polling interval in ms (default: `2_000`). 201 + - `:token` — bearer token override. 202 + 203 + ## Returns 204 + 205 + - `{:ok, %Sandbox{status: :running}}` on success. 206 + - `{:error, :timeout}` if the deadline is exceeded. 207 + 208 + ## Example 209 + 210 + sandbox 211 + |> Sandbox.start() 212 + |> Sandbox.wait_until_running() 213 + |> Sandbox.exec("mix", ["test"]) 214 + """ 215 + @spec wait_until_running(t() | {:ok, t()}, keyword()) :: 216 + {:ok, t()} | {:error, :timeout | term()} 217 + def wait_until_running(sandbox_or_result, opts \\ []) 218 + 219 + def wait_until_running({:ok, %__MODULE__{} = sandbox}, opts), 220 + do: wait_until_running(sandbox, opts) 221 + 222 + def wait_until_running(%__MODULE__{} = sandbox, opts) do 223 + API.wait_until_running(sandbox.name, opts) 224 + end 225 + 226 + # --------------------------------------------------------------------------- 227 + # Commands 228 + # --------------------------------------------------------------------------- 229 + 230 + @doc """ 231 + Executes a shell command inside the sandbox. 232 + 233 + ## Parameters 234 + 235 + - `cmd` — the executable (e.g. `"mix"`, `"echo"`). 236 + - `args` — list of string arguments (default: `[]`). 237 + 238 + ## Options 239 + 240 + - `:token` — bearer token override. 241 + 242 + ## Returns 243 + 244 + `{:ok, %Sandbox.Types.ExecResult{stdout, stderr, exit_code}}` 245 + 246 + ## Example 247 + 248 + sandbox |> Sandbox.exec("echo", ["hello"]) 249 + sandbox |> Sandbox.exec("mix", ["test", "--trace"]) 250 + """ 251 + @spec exec(t() | {:ok, t()}, String.t(), [String.t()], keyword()) :: 252 + {:ok, ExecResult.t()} | {:error, term()} 253 + def exec(sandbox_or_result, cmd, args \\ [], opts \\ []) 254 + def exec({:ok, %__MODULE__{} = sandbox}, cmd, args, opts), do: exec(sandbox, cmd, args, opts) 255 + 256 + def exec(%__MODULE__{} = sandbox, cmd, args, opts) do 257 + API.exec(sandbox.name, cmd, args, opts) 258 + end 259 + 260 + # --------------------------------------------------------------------------- 261 + # Ports 262 + # --------------------------------------------------------------------------- 263 + 264 + @doc """ 265 + Exposes a port on the sandbox so it is publicly accessible. 266 + 267 + ## Options 268 + 269 + - `:description` — a human-readable label for the port. 270 + - `:token` — bearer token override. 271 + 272 + ## Returns 273 + 274 + `{:ok, preview_url}` — `preview_url` is `nil` when the provider does 275 + not return one. 276 + 277 + ## Example 278 + 279 + sandbox |> Sandbox.expose(3000) 280 + sandbox |> Sandbox.expose(8080, description: "API server") 281 + """ 282 + @spec expose(t() | {:ok, t()}, pos_integer(), keyword()) :: 283 + {:ok, String.t() | nil} | {:error, term()} 284 + def expose(sandbox_or_result, port, opts \\ []) 285 + def expose({:ok, %__MODULE__{} = sandbox}, port, opts), do: expose(sandbox, port, opts) 286 + 287 + def expose(%__MODULE__{} = sandbox, port, opts) do 288 + API.expose_port(sandbox.name, port, opts) 289 + end 290 + 291 + @doc """ 292 + Removes an exposed port from the sandbox. 293 + 294 + Returns `{:ok, %Sandbox{}}` (same struct passed in) so the pipe can 295 + continue. 296 + 297 + ## Options 298 + 299 + - `:token` — bearer token override. 300 + 301 + ## Example 302 + 303 + sandbox |> Sandbox.unexpose(3000) 304 + """ 305 + @spec unexpose(t() | {:ok, t()}, pos_integer(), keyword()) :: 306 + {:ok, t()} | {:error, term()} 307 + def unexpose(sandbox_or_result, port, opts \\ []) 308 + def unexpose({:ok, %__MODULE__{} = sandbox}, port, opts), do: unexpose(sandbox, port, opts) 309 + 310 + def unexpose(%__MODULE__{} = sandbox, port, opts) do 311 + with {:ok, _} <- API.unexpose_port(sandbox.name, port, opts) do 312 + {:ok, sandbox} 313 + end 314 + end 315 + 316 + @doc """ 317 + Lists all currently exposed ports on the sandbox. 318 + 319 + ## Options 320 + 321 + - `:token` — bearer token override. 322 + 323 + ## Returns 324 + 325 + `{:ok, [%Sandbox.Types.Port{port, description, preview_url}]}` 326 + 327 + ## Example 328 + 329 + sandbox |> Sandbox.list_ports() 330 + """ 331 + @spec list_ports(t() | {:ok, t()}, keyword()) :: 332 + {:ok, [Port.t()]} | {:error, term()} 333 + def list_ports(sandbox_or_result, opts \\ []) 334 + def list_ports({:ok, %__MODULE__{} = sandbox}, opts), do: list_ports(sandbox, opts) 335 + 336 + def list_ports(%__MODULE__{} = sandbox, opts) do 337 + API.list_ports(sandbox.name, opts) 338 + end 339 + 340 + # --------------------------------------------------------------------------- 341 + # VS Code 342 + # --------------------------------------------------------------------------- 343 + 344 + @doc """ 345 + Exposes a VS Code Server instance for the sandbox and returns its URL. 346 + 347 + If VS Code is already exposed the existing URL is returned immediately 348 + without re-provisioning. 349 + 350 + ## Options 351 + 352 + - `:token` — bearer token override. 353 + 354 + ## Returns 355 + 356 + `{:ok, preview_url}` — `preview_url` is `nil` when the provider does 357 + not return one. 358 + 359 + ## Example 360 + 361 + {:ok, url} = sandbox |> Sandbox.vscode() 362 + IO.puts("Open VS Code at: \#{url}") 363 + """ 364 + @spec vscode(t() | {:ok, t()}, keyword()) :: {:ok, String.t() | nil} | {:error, term()} 365 + def vscode(sandbox_or_result, opts \\ []) 366 + def vscode({:ok, %__MODULE__{} = sandbox}, opts), do: vscode(sandbox, opts) 367 + 368 + def vscode(%__MODULE__{} = sandbox, opts) do 369 + API.expose_vscode(sandbox.name, opts) 370 + end 371 + 372 + # --------------------------------------------------------------------------- 373 + # Private 374 + # --------------------------------------------------------------------------- 375 + 376 + defp parse_status("RUNNING"), do: :running 377 + defp parse_status("STOPPED"), do: :stopped 378 + defp parse_status(_), do: :unknown 379 + end
+76
lib/sandbox/types.ex
··· 1 + defmodule Sandbox.Types do 2 + @moduledoc """ 3 + Types and structs used by the Pocketenv Sandbox SDK. 4 + """ 5 + 6 + defmodule Profile do 7 + @moduledoc "Represents a Pocketenv user profile." 8 + 9 + @type t :: %__MODULE__{ 10 + id: String.t() | nil, 11 + did: String.t(), 12 + handle: String.t(), 13 + display_name: String.t() | nil, 14 + avatar: String.t() | nil, 15 + created_at: String.t() | nil, 16 + updated_at: String.t() | nil 17 + } 18 + 19 + defstruct [:id, :did, :handle, :display_name, :avatar, :created_at, :updated_at] 20 + 21 + @doc "Build a Profile from the raw API map." 22 + def from_map(map) when is_map(map) do 23 + %__MODULE__{ 24 + id: map["id"], 25 + did: map["did"], 26 + handle: map["handle"], 27 + display_name: map["displayName"], 28 + avatar: map["avatar"], 29 + created_at: map["createdAt"], 30 + updated_at: map["updatedAt"] 31 + } 32 + end 33 + end 34 + 35 + defmodule Port do 36 + @moduledoc "Represents an exposed port on a sandbox." 37 + 38 + @type t :: %__MODULE__{ 39 + port: integer(), 40 + description: String.t() | nil, 41 + preview_url: String.t() | nil 42 + } 43 + 44 + defstruct [:port, :description, :preview_url] 45 + 46 + @doc "Build a Port from the raw API map." 47 + def from_map(map) when is_map(map) do 48 + %__MODULE__{ 49 + port: map["port"], 50 + description: map["description"], 51 + preview_url: map["previewUrl"] 52 + } 53 + end 54 + end 55 + 56 + defmodule ExecResult do 57 + @moduledoc "Represents the result of executing a command in a sandbox." 58 + 59 + @type t :: %__MODULE__{ 60 + stdout: String.t(), 61 + stderr: String.t(), 62 + exit_code: integer() 63 + } 64 + 65 + defstruct stdout: "", stderr: "", exit_code: 0 66 + 67 + @doc "Build an ExecResult from the raw API map." 68 + def from_map(map) when is_map(map) do 69 + %__MODULE__{ 70 + stdout: map["stdout"] || "", 71 + stderr: map["stderr"] || "", 72 + exit_code: map["exitCode"] || 0 73 + } 74 + end 75 + end 76 + end
+36
mix.exs
··· 1 + defmodule Pocketenv.MixProject do 2 + use Mix.Project 3 + 4 + def project do 5 + [ 6 + app: :pocketenv, 7 + version: "0.1.0", 8 + elixir: "~> 1.15", 9 + start_permanent: Mix.env() == :prod, 10 + deps: deps(), 11 + description: "Elixir SDK for the Pocketenv sandbox API", 12 + package: package() 13 + ] 14 + end 15 + 16 + def application do 17 + [ 18 + extra_applications: [:logger] 19 + ] 20 + end 21 + 22 + defp deps do 23 + [ 24 + {:req, "~> 0.5"}, 25 + {:jason, "~> 1.4"}, 26 + {:ex_doc, "~> 0.31", only: :dev, runtime: false} 27 + ] 28 + end 29 + 30 + defp package do 31 + [ 32 + licenses: ["MIT"], 33 + links: %{"GitHub" => "https://github.com/pocketenv-io/pocketenv-elixir"} 34 + ] 35 + end 36 + end
+17
mix.lock
··· 1 + %{ 2 + "earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"}, 3 + "ex_doc": {:hex, :ex_doc, "0.40.1", "67542e4b6dde74811cfd580e2c0149b78010fd13001fda7cfeb2b2c2ffb1344d", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "bcef0e2d360d93ac19f01a85d58f91752d930c0a30e2681145feea6bd3516e00"}, 4 + "finch": {:hex, :finch, "0.21.0", "b1c3b2d48af02d0c66d2a9ebfb5622be5c5ecd62937cf79a88a7f98d48a8290c", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "87dc6e169794cb2570f75841a19da99cfde834249568f2a5b121b809588a4377"}, 5 + "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, 6 + "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, 7 + "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, 8 + "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, 9 + "makeup_erlang": {:hex, :makeup_erlang, "1.0.3", "4252d5d4098da7415c390e847c814bad3764c94a814a0b4245176215615e1035", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "953297c02582a33411ac6208f2c6e55f0e870df7f80da724ed613f10e6706afd"}, 10 + "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, 11 + "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"}, 12 + "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, 13 + "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, 14 + "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, 15 + "req": {:hex, :req, "0.5.17", "0096ddd5b0ed6f576a03dde4b158a0c727215b15d2795e59e0916c6971066ede", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0b8bc6ffdfebbc07968e59d3ff96d52f2202d0536f10fef4dc11dc02a2a43e39"}, 16 + "telemetry": {:hex, :telemetry, "1.4.1", "ab6de178e2b29b58e8256b92b382ea3f590a47152ca3651ea857a6cae05ac423", [:rebar3], [], "hexpm", "2172e05a27531d3d31dd9782841065c50dd5c3c7699d95266b2edd54c2dafa1c"}, 17 + }
+429
test/pocketenv_test.exs
··· 1 + defmodule PocketenvTest do 2 + use ExUnit.Case 3 + 4 + # --------------------------------------------------------------------------- 5 + # Pocketenv.Client 6 + # --------------------------------------------------------------------------- 7 + 8 + describe "Pocketenv.Client.base_url/0" do 9 + test "returns the default API URL when no config is present" do 10 + prev = Application.get_env(:pocketenv, :api_url) 11 + Application.delete_env(:pocketenv, :api_url) 12 + System.delete_env("POCKETENV_API_URL") 13 + on_exit(fn -> if prev, do: Application.put_env(:pocketenv, :api_url, prev) end) 14 + 15 + assert Pocketenv.Client.base_url() == "https://api.pocketenv.io" 16 + end 17 + 18 + test "respects the POCKETENV_API_URL environment variable" do 19 + prev = Application.get_env(:pocketenv, :api_url) 20 + Application.delete_env(:pocketenv, :api_url) 21 + on_exit(fn -> if prev, do: Application.put_env(:pocketenv, :api_url, prev) end) 22 + 23 + System.put_env("POCKETENV_API_URL", "https://custom.api.example.com") 24 + on_exit(fn -> System.delete_env("POCKETENV_API_URL") end) 25 + 26 + assert Pocketenv.Client.base_url() == "https://custom.api.example.com" 27 + end 28 + 29 + test "application config takes precedence over environment variable" do 30 + System.put_env("POCKETENV_API_URL", "https://env.example.com") 31 + on_exit(fn -> System.delete_env("POCKETENV_API_URL") end) 32 + 33 + Application.put_env(:pocketenv, :api_url, "https://config.example.com") 34 + on_exit(fn -> Application.delete_env(:pocketenv, :api_url) end) 35 + 36 + assert Pocketenv.Client.base_url() == "https://config.example.com" 37 + end 38 + end 39 + 40 + describe "Pocketenv.Client.token/0" do 41 + test "returns {:error, :not_logged_in} when no token source is available" do 42 + prev_app = Application.get_env(:pocketenv, :token) 43 + prev_env = System.get_env("POCKETENV_TOKEN") 44 + 45 + Application.delete_env(:pocketenv, :token) 46 + System.delete_env("POCKETENV_TOKEN") 47 + 48 + on_exit(fn -> 49 + if prev_app, do: Application.put_env(:pocketenv, :token, prev_app) 50 + if prev_env, do: System.put_env("POCKETENV_TOKEN", prev_env) 51 + end) 52 + 53 + token_path = Path.join([System.user_home!(), ".pocketenv", "token.json"]) 54 + 55 + if File.exists?(token_path) do 56 + # Token file is present (developer machine after pocketenv login) 57 + assert {:ok, t} = Pocketenv.Client.token() 58 + assert is_binary(t) 59 + else 60 + assert Pocketenv.Client.token() == {:error, :not_logged_in} 61 + end 62 + end 63 + 64 + test "returns {:ok, token} from the POCKETENV_TOKEN environment variable" do 65 + prev_app = Application.get_env(:pocketenv, :token) 66 + Application.delete_env(:pocketenv, :token) 67 + on_exit(fn -> if prev_app, do: Application.put_env(:pocketenv, :token, prev_app) end) 68 + 69 + System.put_env("POCKETENV_TOKEN", "env-token-abc") 70 + on_exit(fn -> System.delete_env("POCKETENV_TOKEN") end) 71 + 72 + assert Pocketenv.Client.token() == {:ok, "env-token-abc"} 73 + end 74 + 75 + test "application config takes precedence over environment variable" do 76 + System.put_env("POCKETENV_TOKEN", "env-token") 77 + on_exit(fn -> System.delete_env("POCKETENV_TOKEN") end) 78 + 79 + Application.put_env(:pocketenv, :token, "config-token") 80 + on_exit(fn -> Application.delete_env(:pocketenv, :token) end) 81 + 82 + assert Pocketenv.Client.token() == {:ok, "config-token"} 83 + end 84 + end 85 + 86 + # --------------------------------------------------------------------------- 87 + # Sandbox.Types.Profile 88 + # --------------------------------------------------------------------------- 89 + 90 + describe "Sandbox.Types.Profile.from_map/1" do 91 + test "parses a full profile map" do 92 + raw = %{ 93 + "id" => "user-1", 94 + "did" => "did:plc:abc123", 95 + "handle" => "alice.bsky.social", 96 + "displayName" => "Alice", 97 + "avatar" => "https://cdn.bsky.app/img/avatar/plain/did:plc:abc123/bafkreig@jpeg", 98 + "createdAt" => "2024-01-01T00:00:00Z", 99 + "updatedAt" => "2024-06-01T00:00:00Z" 100 + } 101 + 102 + profile = Sandbox.Types.Profile.from_map(raw) 103 + 104 + assert profile.id == "user-1" 105 + assert profile.did == "did:plc:abc123" 106 + assert profile.handle == "alice.bsky.social" 107 + assert profile.display_name == "Alice" 108 + assert profile.avatar =~ "cdn.bsky.app" 109 + assert profile.created_at == "2024-01-01T00:00:00Z" 110 + assert profile.updated_at == "2024-06-01T00:00:00Z" 111 + end 112 + 113 + test "handles missing optional fields gracefully" do 114 + raw = %{"did" => "did:plc:xyz", "handle" => "bob.bsky.social"} 115 + profile = Sandbox.Types.Profile.from_map(raw) 116 + 117 + assert profile.did == "did:plc:xyz" 118 + assert profile.handle == "bob.bsky.social" 119 + assert profile.display_name == nil 120 + assert profile.avatar == nil 121 + end 122 + end 123 + 124 + # --------------------------------------------------------------------------- 125 + # %Sandbox{} struct 126 + # --------------------------------------------------------------------------- 127 + 128 + describe "Sandbox.from_map/1" do 129 + test "parses a running sandbox" do 130 + raw = %{ 131 + "id" => "sbx-001", 132 + "name" => "my-sandbox", 133 + "provider" => "cloudflare", 134 + "baseSandbox" => "openclaw", 135 + "displayName" => "My Sandbox", 136 + "uri" => "at://did:plc:abc/io.pocketenv.sandbox/openclaw", 137 + "status" => "RUNNING", 138 + "installs" => 42, 139 + "createdAt" => "2024-01-01T00:00:00Z", 140 + "startedAt" => "2024-06-01T12:00:00Z" 141 + } 142 + 143 + sandbox = Sandbox.from_map(raw) 144 + 145 + assert sandbox.id == "sbx-001" 146 + assert sandbox.name == "my-sandbox" 147 + assert sandbox.provider == "cloudflare" 148 + assert sandbox.base_sandbox == "openclaw" 149 + assert sandbox.display_name == "My Sandbox" 150 + assert sandbox.status == :running 151 + assert sandbox.installs == 42 152 + assert sandbox.created_at == "2024-01-01T00:00:00Z" 153 + assert sandbox.started_at == "2024-06-01T12:00:00Z" 154 + end 155 + 156 + test "parses a stopped sandbox" do 157 + raw = %{"id" => "sbx-002", "name" => "idle", "status" => "STOPPED", "installs" => 0} 158 + sandbox = Sandbox.from_map(raw) 159 + assert sandbox.status == :stopped 160 + end 161 + 162 + test "treats unknown status values as :unknown" do 163 + raw = %{"id" => "sbx-003", "name" => "weird", "status" => "PENDING", "installs" => 0} 164 + sandbox = Sandbox.from_map(raw) 165 + assert sandbox.status == :unknown 166 + end 167 + 168 + test "defaults installs to 0 when absent" do 169 + raw = %{"id" => "sbx-004", "name" => "fresh", "status" => "STOPPED"} 170 + sandbox = Sandbox.from_map(raw) 171 + assert sandbox.installs == 0 172 + end 173 + 174 + test "parses nested owner profile" do 175 + raw = %{ 176 + "id" => "sbx-005", 177 + "name" => "owned", 178 + "status" => "STOPPED", 179 + "installs" => 1, 180 + "owner" => %{ 181 + "id" => "user-99", 182 + "did" => "did:plc:owner", 183 + "handle" => "owner.bsky.social" 184 + } 185 + } 186 + 187 + sandbox = Sandbox.from_map(raw) 188 + assert %Sandbox.Types.Profile{handle: "owner.bsky.social"} = sandbox.owner 189 + end 190 + 191 + test "owner is nil when not present in payload" do 192 + raw = %{"id" => "sbx-006", "name" => "no-owner", "status" => "STOPPED", "installs" => 0} 193 + sandbox = Sandbox.from_map(raw) 194 + assert sandbox.owner == nil 195 + end 196 + end 197 + 198 + # --------------------------------------------------------------------------- 199 + # Sandbox.Types.Port 200 + # --------------------------------------------------------------------------- 201 + 202 + describe "Sandbox.Types.Port.from_map/1" do 203 + test "parses a port with all fields" do 204 + raw = %{ 205 + "port" => 3000, 206 + "description" => "web server", 207 + "previewUrl" => "https://3000-my-sandbox.sbx.pocketenv.io" 208 + } 209 + 210 + port = Sandbox.Types.Port.from_map(raw) 211 + 212 + assert port.port == 3000 213 + assert port.description == "web server" 214 + assert port.preview_url == "https://3000-my-sandbox.sbx.pocketenv.io" 215 + end 216 + 217 + test "handles missing optional fields" do 218 + raw = %{"port" => 8080} 219 + port = Sandbox.Types.Port.from_map(raw) 220 + 221 + assert port.port == 8080 222 + assert port.description == nil 223 + assert port.preview_url == nil 224 + end 225 + end 226 + 227 + # --------------------------------------------------------------------------- 228 + # Sandbox.Types.ExecResult 229 + # --------------------------------------------------------------------------- 230 + 231 + describe "Sandbox.Types.ExecResult.from_map/1" do 232 + test "parses a successful exec result" do 233 + raw = %{"stdout" => "hello\n", "stderr" => "", "exitCode" => 0} 234 + result = Sandbox.Types.ExecResult.from_map(raw) 235 + 236 + assert result.stdout == "hello\n" 237 + assert result.stderr == "" 238 + assert result.exit_code == 0 239 + end 240 + 241 + test "parses a failed exec result" do 242 + raw = %{"stdout" => "", "stderr" => "command not found\n", "exitCode" => 127} 243 + result = Sandbox.Types.ExecResult.from_map(raw) 244 + 245 + assert result.stdout == "" 246 + assert result.stderr == "command not found\n" 247 + assert result.exit_code == 127 248 + end 249 + 250 + test "defaults stdout and stderr to empty strings when absent" do 251 + raw = %{"exitCode" => 0} 252 + result = Sandbox.Types.ExecResult.from_map(raw) 253 + 254 + assert result.stdout == "" 255 + assert result.stderr == "" 256 + end 257 + 258 + test "defaults exit_code to 0 when absent" do 259 + raw = %{"stdout" => "ok"} 260 + result = Sandbox.Types.ExecResult.from_map(raw) 261 + 262 + assert result.exit_code == 0 263 + end 264 + end 265 + 266 + # --------------------------------------------------------------------------- 267 + # Sandbox pipe methods — {:ok, struct} passthrough 268 + # --------------------------------------------------------------------------- 269 + 270 + describe "Sandbox pipe methods – {:ok, struct} passthrough" do 271 + setup do 272 + fns = Sandbox.__info__(:functions) 273 + {:ok, fns: fns} 274 + end 275 + 276 + test "start/2 is defined", %{fns: fns} do 277 + assert {:start, 1} in fns 278 + assert {:start, 2} in fns 279 + end 280 + 281 + test "stop/2 is defined", %{fns: fns} do 282 + assert {:stop, 1} in fns 283 + assert {:stop, 2} in fns 284 + end 285 + 286 + test "delete/2 is defined", %{fns: fns} do 287 + assert {:delete, 1} in fns 288 + assert {:delete, 2} in fns 289 + end 290 + 291 + test "wait_until_running/2 is defined", %{fns: fns} do 292 + assert {:wait_until_running, 1} in fns 293 + assert {:wait_until_running, 2} in fns 294 + end 295 + 296 + test "exec/4 is defined", %{fns: fns} do 297 + assert {:exec, 2} in fns 298 + assert {:exec, 3} in fns 299 + assert {:exec, 4} in fns 300 + end 301 + 302 + test "expose/3 is defined", %{fns: fns} do 303 + assert {:expose, 2} in fns 304 + assert {:expose, 3} in fns 305 + end 306 + 307 + test "unexpose/3 is defined", %{fns: fns} do 308 + assert {:unexpose, 2} in fns 309 + assert {:unexpose, 3} in fns 310 + end 311 + 312 + test "list_ports/2 is defined", %{fns: fns} do 313 + assert {:list_ports, 1} in fns 314 + assert {:list_ports, 2} in fns 315 + end 316 + 317 + test "vscode/2 is defined", %{fns: fns} do 318 + assert {:vscode, 1} in fns 319 + assert {:vscode, 2} in fns 320 + end 321 + end 322 + 323 + describe "Sandbox pipe methods – error propagation" do 324 + test "passing {:error, reason} to start/2 raises FunctionClauseError" do 325 + assert_raise FunctionClauseError, fn -> 326 + Sandbox.start({:error, :not_found}) 327 + end 328 + end 329 + 330 + test "passing {:error, reason} to stop/2 raises FunctionClauseError" do 331 + assert_raise FunctionClauseError, fn -> 332 + Sandbox.stop({:error, :not_found}) 333 + end 334 + end 335 + 336 + test "passing {:error, reason} to exec/4 raises FunctionClauseError" do 337 + assert_raise FunctionClauseError, fn -> 338 + Sandbox.exec({:error, :not_found}, "echo", []) 339 + end 340 + end 341 + 342 + test "passing {:error, reason} to expose/3 raises FunctionClauseError" do 343 + assert_raise FunctionClauseError, fn -> 344 + Sandbox.expose({:error, :not_found}, 3000) 345 + end 346 + end 347 + 348 + test "passing {:error, reason} to vscode/2 raises FunctionClauseError" do 349 + assert_raise FunctionClauseError, fn -> 350 + Sandbox.vscode({:error, :not_found}) 351 + end 352 + end 353 + end 354 + 355 + describe "Sandbox pipe methods – delete/2 returns last known state" do 356 + test "delete/2 returns the struct that was passed in on success" do 357 + sandbox = %Sandbox{id: "sbx-del", name: "to-delete", status: :stopped, installs: 0} 358 + assert is_struct(sandbox, Sandbox) 359 + 360 + fns = Sandbox.__info__(:functions) 361 + assert {:delete, 1} in fns 362 + assert {:delete, 2} in fns 363 + end 364 + end 365 + 366 + describe "Sandbox pipe methods – unexpose/3 returns sandbox" do 367 + test "unexpose/3 is exported with correct arities" do 368 + sandbox = %Sandbox{id: "sbx-unexp", name: "test-sandbox", status: :running, installs: 1} 369 + assert is_struct(sandbox, Sandbox) 370 + 371 + fns = Sandbox.__info__(:functions) 372 + assert {:unexpose, 2} in fns 373 + assert {:unexpose, 3} in fns 374 + end 375 + end 376 + 377 + # --------------------------------------------------------------------------- 378 + # Pocketenv public API surface 379 + # --------------------------------------------------------------------------- 380 + 381 + describe "Pocketenv public API" do 382 + setup do 383 + fns = Pocketenv.__info__(:functions) 384 + {:ok, fns: fns} 385 + end 386 + 387 + test "create_sandbox/2 is exported", %{fns: fns} do 388 + assert {:create_sandbox, 1} in fns 389 + assert {:create_sandbox, 2} in fns 390 + end 391 + 392 + test "get_sandbox/2 is exported", %{fns: fns} do 393 + assert {:get_sandbox, 1} in fns 394 + assert {:get_sandbox, 2} in fns 395 + end 396 + 397 + test "list_sandboxes/1 is exported", %{fns: fns} do 398 + assert {:list_sandboxes, 0} in fns 399 + assert {:list_sandboxes, 1} in fns 400 + end 401 + 402 + test "list_sandboxes_by_actor/2 is exported", %{fns: fns} do 403 + assert {:list_sandboxes_by_actor, 1} in fns 404 + assert {:list_sandboxes_by_actor, 2} in fns 405 + end 406 + 407 + test "me/1 is exported", %{fns: fns} do 408 + assert {:me, 0} in fns 409 + assert {:me, 1} in fns 410 + end 411 + 412 + test "get_profile/2 is exported", %{fns: fns} do 413 + assert {:get_profile, 1} in fns 414 + assert {:get_profile, 2} in fns 415 + end 416 + 417 + test "old generic names (create, start, stop, delete, exec, list) are NOT exported", 418 + %{fns: fns} do 419 + refute {:create, 1} in fns 420 + refute {:create, 2} in fns 421 + refute {:start, 1} in fns 422 + refute {:stop, 1} in fns 423 + refute {:delete, 1} in fns 424 + refute {:exec, 2} in fns 425 + refute {:list, 0} in fns 426 + refute {:list, 1} in fns 427 + end 428 + end 429 + end
+1
test/test_helper.exs
··· 1 + ExUnit.start()