Gleam SDK for Pocketenv
1
fork

Configure Feed

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

Add initial Pocketenv Gleam client library

+2278
+23
.github/workflows/test.yml
··· 1 + name: test 2 + 3 + on: 4 + push: 5 + branches: 6 + - master 7 + - main 8 + pull_request: 9 + 10 + jobs: 11 + test: 12 + runs-on: ubuntu-latest 13 + steps: 14 + - uses: actions/checkout@v4 15 + - uses: erlef/setup-beam@v1 16 + with: 17 + otp-version: "28" 18 + gleam-version: "1.14.0" 19 + rebar3-version: "3" 20 + # elixir-version: "1" 21 + - run: gleam deps download 22 + - run: gleam test 23 + - run: gleam format --check src test
+4
.gitignore
··· 1 + *.beam 2 + *.ez 3 + /build 4 + erl_crash.dump
+21
LICENSE
··· 1 + MIT License 2 + 3 + Copyright (c) 2026 tsiry.sndr@pocketenv.io 4 + 5 + Permission is hereby granted, free of charge, to any person obtaining a copy 6 + of this software and associated documentation files (the "Software"), to deal 7 + in the Software without restriction, including without limitation the rights 8 + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 + copies of the Software, and to permit persons to whom the Software is 10 + furnished to do so, subject to the following conditions: 11 + 12 + The above copyright notice and this permission notice shall be included in all 13 + copies or substantial portions of the Software. 14 + 15 + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 + SOFTWARE.
+269
README.md
··· 1 + # pocketenv 2 + 3 + [![Package Version](https://img.shields.io/hexpm/v/pocketenv)](https://hex.pm/packages/pocketenv) 4 + [![Hex Docs](https://img.shields.io/badge/hex-docs-ffaff3)](https://hexdocs.pm/pocketenv/) 5 + 6 + A Gleam client library for the [Pocketenv](https://pocketenv.io) API, providing 7 + access to sandboxes, environment variables, secrets, files, volumes, services, 8 + ports, and networking. 9 + 10 + ```sh 11 + gleam add pocketenv@1 12 + ``` 13 + 14 + ## Usage 15 + 16 + ### Creating a client 17 + 18 + ```gleam 19 + import pocketenv 20 + 21 + pub fn main() { 22 + let client = pocketenv.new_client("your-api-token") 23 + // use client with any of the sub-modules below 24 + } 25 + ``` 26 + 27 + ### Sandboxes 28 + 29 + ```gleam 30 + import pocketenv 31 + import pocketenv/sandbox 32 + import gleam/option.{None, Some} 33 + import gleam/io 34 + 35 + pub fn main() { 36 + let client = pocketenv.new_client("https://pocketenv.io", "your-token") 37 + 38 + let assert Ok(sb) = 39 + client 40 + |> sandbox.new("my-sandbox", "openclaw", "cloudflare") 41 + |> sandbox.with_description("My app sandbox") 42 + |> sandbox.create() 43 + io.println("Created: " <> sb.data.id) 44 + 45 + // List all sandboxes 46 + let assert Ok(#(sandboxes, _total)) = sandbox.list(client, None, None) 47 + io.debug(sandboxes) 48 + 49 + // Start, exec, then stop the sandbox 50 + let assert Ok(Nil) = sb |> sandbox.start(None, None) 51 + let assert Ok(result) = sb |> sandbox.exec("echo hello") 52 + io.println(result.stdout) 53 + let assert Ok(Nil) = sb |> sandbox.stop() 54 + 55 + // Delete when done 56 + let assert Ok(Nil) = sb |> sandbox.delete() 57 + } 58 + ``` 59 + 60 + ### Environment variables 61 + 62 + ```gleam 63 + import pocketenv 64 + import pocketenv/env 65 + import gleam/option.{None} 66 + 67 + pub fn main() { 68 + let client = pocketenv.new_client("https://pocketenv.io", "your-token") 69 + let sandbox_id = "sandbox-abc123" 70 + 71 + // Set a variable 72 + let assert Ok(Nil) = env.put(client, sandbox_id, "DATABASE_URL", "postgres://localhost/mydb") 73 + 74 + // List variables 75 + let assert Ok(vars) = env.list(client, sandbox_id, None, None) 76 + 77 + // Delete a variable by its id 78 + case vars { 79 + [first, ..] -> { 80 + let assert Ok(Nil) = env.delete(client, first.id) 81 + } 82 + [] -> Nil 83 + } 84 + } 85 + ``` 86 + 87 + ### Secrets 88 + 89 + ```gleam 90 + import pocketenv 91 + import pocketenv/secrets 92 + import gleam/option.{None} 93 + 94 + pub fn main() { 95 + let client = pocketenv.new_client("https://pocketenv.io", "your-token") 96 + let sandbox_id = "sandbox-abc123" 97 + 98 + // Store a secret 99 + let assert Ok(Nil) = secrets.put(client, sandbox_id, "API_KEY", "super-secret-value") 100 + 101 + // List secret names (values are never returned) 102 + let assert Ok(all) = secrets.list(client, sandbox_id, None, None) 103 + 104 + // Delete a secret 105 + case all { 106 + [first, ..] -> { 107 + let assert Ok(Nil) = secrets.delete(client, first.id) 108 + } 109 + [] -> Nil 110 + } 111 + } 112 + ``` 113 + 114 + ### Files 115 + 116 + ```gleam 117 + import pocketenv 118 + import pocketenv/files 119 + 120 + pub fn main() { 121 + let client = pocketenv.new_client("https://pocketenv.io", "your-token") 122 + let sandbox_id = "sandbox-abc123" 123 + 124 + // Write a file into the sandbox 125 + let assert Ok(Nil) = 126 + files.write(client, sandbox_id, "/app/config.json", "{\"debug\": true}") 127 + 128 + // List files 129 + let assert Ok(all) = files.list(client, sandbox_id) 130 + 131 + // Delete a file 132 + case all { 133 + [first, ..] -> { 134 + let assert Ok(Nil) = files.delete(client, first.id) 135 + } 136 + [] -> Nil 137 + } 138 + } 139 + ``` 140 + 141 + ### Volumes 142 + 143 + ```gleam 144 + import pocketenv 145 + import pocketenv/volume 146 + 147 + pub fn main() { 148 + let client = pocketenv.new_client("https://pocketenv.io", "your-token") 149 + let sandbox_id = "sandbox-abc123" 150 + 151 + // Mount a persistent volume 152 + let assert Ok(Nil) = volume.create(client, sandbox_id, "data-vol", "/mnt/data") 153 + 154 + // List volumes 155 + let assert Ok(vols) = volume.list(client, sandbox_id) 156 + 157 + // Delete a volume 158 + case vols { 159 + [first, ..] -> { 160 + let assert Ok(Nil) = volume.delete(client, first.id) 161 + } 162 + [] -> Nil 163 + } 164 + } 165 + ``` 166 + 167 + ### Services 168 + 169 + ```gleam 170 + import pocketenv 171 + import pocketenv/services 172 + import gleam/option.{None, Some} 173 + 174 + pub fn main() { 175 + let client = pocketenv.new_client("https://pocketenv.io", "your-token") 176 + let sandbox_id = "sandbox-abc123" 177 + 178 + // Register and start a web server service 179 + let assert Ok(Nil) = 180 + services.create( 181 + client, 182 + sandbox_id, 183 + "web", 184 + "python -m http.server 8080", 185 + Some([8080]), 186 + Some("Simple HTTP server"), 187 + ) 188 + 189 + let assert Ok(svcs) = services.list(client, sandbox_id) 190 + case svcs { 191 + [svc, ..] -> { 192 + let assert Ok(Nil) = services.start(client, svc.id) 193 + let assert Ok(Nil) = services.restart(client, svc.id) 194 + let assert Ok(Nil) = services.stop(client, svc.id) 195 + let assert Ok(Nil) = services.delete(client, svc.id) 196 + } 197 + [] -> Nil 198 + } 199 + } 200 + ``` 201 + 202 + ### Ports & Networking 203 + 204 + ```gleam 205 + import pocketenv 206 + import pocketenv/network 207 + import pocketenv/ports 208 + import gleam/io 209 + import gleam/option.{None, Some} 210 + 211 + pub fn main() { 212 + let client = pocketenv.new_client("https://pocketenv.io", "your-token") 213 + let sandbox_id = "sandbox-abc123" 214 + 215 + // Expose a port and get a preview URL 216 + let assert Ok(preview_url) = 217 + network.expose(client, sandbox_id, 3000, Some("Dev server")) 218 + io.debug(preview_url) 219 + 220 + // List currently exposed ports 221 + let assert Ok(exposed) = ports.list(client, sandbox_id) 222 + io.debug(exposed) 223 + 224 + // Unexpose the port 225 + let assert Ok(Nil) = network.unexpose(client, sandbox_id, 3000) 226 + 227 + // Configure Tailscale networking 228 + let assert Ok(Nil) = 229 + network.setup_tailscale(client, sandbox_id, "tskey-auth-xxxx") 230 + } 231 + ``` 232 + 233 + ### Profile 234 + 235 + ```gleam 236 + import pocketenv 237 + import gleam/io 238 + 239 + pub fn main() { 240 + let client = pocketenv.new_client("https://pocketenv.io", "your-token") 241 + let assert Ok(profile) = pocketenv.get_profile(client) 242 + io.println("Logged in as: " <> profile.handle) 243 + } 244 + ``` 245 + 246 + ## Error handling 247 + 248 + All API functions return `Result(_, PocketenvError)`. The error variants are: 249 + 250 + ```gleam 251 + import pocketenv.{ApiError, HttpError, JsonDecodeError, RequestBuildError} 252 + 253 + case pocketenv.get_profile(client) { 254 + Ok(profile) -> io.println(profile.handle) 255 + Error(ApiError(status)) -> io.println("API error: " <> int.to_string(status)) 256 + Error(HttpError(msg)) -> io.println("HTTP error: " <> msg) 257 + Error(JsonDecodeError(_)) -> io.println("Failed to decode response") 258 + Error(RequestBuildError) -> io.println("Could not build request URL") 259 + } 260 + ``` 261 + 262 + Further documentation can be found at <https://hexdocs.pm/pocketenv>. 263 + 264 + ## Development 265 + 266 + ```sh 267 + gleam run # Run the project 268 + gleam test # Run the tests 269 + ```
+22
gleam.toml
··· 1 + name = "pocketenv" 2 + version = "1.0.0" 3 + 4 + # Fill out these fields if you intend to generate HTML documentation or publish 5 + # your project to the Hex package manager. 6 + # 7 + # description = "" 8 + # licences = ["Apache-2.0"] 9 + # repository = { type = "github", user = "", repo = "" } 10 + # links = [{ title = "Website", href = "" }] 11 + # 12 + # For a full reference of all the available options, you can have a look at 13 + # https://gleam.run/writing-gleam/gleam-toml/. 14 + 15 + [dependencies] 16 + gleam_stdlib = ">= 0.44.0 and < 2.0.0" 17 + gleam_http = ">= 4.0.0 and < 5.0.0" 18 + gleam_httpc = ">= 5.0.0 and < 6.0.0" 19 + gleam_json = ">= 3.0.0 and < 4.0.0" 20 + 21 + [dev-dependencies] 22 + gleeunit = ">= 1.0.0 and < 2.0.0"
+18
manifest.toml
··· 1 + # This file was generated by Gleam 2 + # You typically do not need to edit this file 3 + 4 + packages = [ 5 + { name = "gleam_erlang", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "1124AD3AA21143E5AF0FC5CF3D9529F6DB8CA03E43A55711B60B6B7B3874375C" }, 6 + { name = "gleam_http", version = "4.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "82EA6A717C842456188C190AFB372665EA56CE13D8559BF3B1DD9E40F619EE0C" }, 7 + { name = "gleam_httpc", version = "5.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gleam_httpc", source = "hex", outer_checksum = "C545172618D07811494E97AAA4A0FB34DA6F6D0061FDC8041C2F8E3BE2B2E48F" }, 8 + { name = "gleam_json", version = "3.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "44FDAA8847BE8FC48CA7A1C089706BD54BADCC4C45B237A992EDDF9F2CDB2836" }, 9 + { name = "gleam_stdlib", version = "0.70.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "86949BF5D1F0E4AC0AB5B06F235D8A5CC11A2DFC33BF22F752156ED61CA7D0FF" }, 10 + { name = "gleeunit", version = "1.9.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "DA9553CE58B67924B3C631F96FE3370C49EB6D6DC6B384EC4862CC4AAA718F3C" }, 11 + ] 12 + 13 + [requirements] 14 + gleam_http = { version = ">= 4.0.0 and < 5.0.0" } 15 + gleam_httpc = { version = ">= 5.0.0 and < 6.0.0" } 16 + gleam_json = { version = ">= 3.0.0 and < 4.0.0" } 17 + gleam_stdlib = { version = ">= 0.44.0 and < 2.0.0" } 18 + gleeunit = { version = ">= 1.0.0 and < 2.0.0" }
+183
src/pocketenv.gleam
··· 1 + //// Core types and HTTP helpers for the Pocketenv API client. 2 + //// 3 + //// Start by creating a `Client` with `new_client/2`, then pass it to any 4 + //// of the sub-module functions (`sandbox`, `env`, `secrets`, `files`, etc.). 5 + 6 + import gleam/dynamic/decode 7 + import gleam/http 8 + import gleam/http/request 9 + import gleam/httpc 10 + import gleam/json 11 + import gleam/option.{type Option, None} 12 + import gleam/result 13 + import gleam/string 14 + 15 + /// Holds the base URL and bearer token used for every API request. 16 + pub type Client { 17 + Client(base_url: String, token: String) 18 + } 19 + 20 + /// Errors that can be returned by any API call. 21 + pub type PocketenvError { 22 + /// An HTTP-level error (connection failure, timeout, etc.). 23 + HttpError(String) 24 + /// The response body could not be decoded as expected JSON. 25 + JsonDecodeError(json.DecodeError) 26 + /// The server returned a non-2xx status code. 27 + ApiError(Int) 28 + /// The request URL could not be constructed (malformed base URL). 29 + RequestBuildError 30 + } 31 + 32 + /// Information about the authenticated actor (user/bot). 33 + pub type Profile { 34 + Profile( 35 + id: Option(String), 36 + did: String, 37 + handle: String, 38 + display_name: Option(String), 39 + avatar: Option(String), 40 + created_at: Option(String), 41 + updated_at: Option(String), 42 + ) 43 + } 44 + 45 + /// The default base URL for the Pocketenv API. 46 + pub const default_base_url = "https://api.pocketenv.io" 47 + 48 + /// Creates a new API client using the default base URL (`https://api.pocketenv.io`). 49 + /// 50 + /// ## Example 51 + /// 52 + /// ```gleam 53 + /// let client = pocketenv.new_client("your-token") 54 + /// ``` 55 + pub fn new_client(token: String) -> Client { 56 + Client(base_url: default_base_url, token: token) 57 + } 58 + 59 + /// Creates a new API client with a custom base URL. 60 + /// 61 + /// ## Example 62 + /// 63 + /// ```gleam 64 + /// let client = pocketenv.new_client_with_base_url("https://self-hosted.example.com", "your-token") 65 + /// ``` 66 + pub fn new_client_with_base_url(base_url: String, token: String) -> Client { 67 + Client(base_url: base_url, token: token) 68 + } 69 + 70 + /// Fetches the profile of the authenticated actor. 71 + /// 72 + /// ## Example 73 + /// 74 + /// ```gleam 75 + /// let assert Ok(profile) = pocketenv.get_profile(client) 76 + /// io.println(profile.handle) 77 + /// ``` 78 + pub fn get_profile(client: Client) -> Result(Profile, PocketenvError) { 79 + use body <- result.try(do_get( 80 + client, 81 + "/xrpc/io.pocketenv.actor.getProfile", 82 + [], 83 + )) 84 + json.parse(body, profile_decoder()) 85 + |> result.map_error(JsonDecodeError) 86 + } 87 + 88 + /// JSON decoder for `Profile`. Useful when embedding profile data in custom decoders. 89 + pub fn profile_decoder() -> decode.Decoder(Profile) { 90 + use id <- decode.optional_field("id", None, decode.optional(decode.string)) 91 + use did <- decode.field("did", decode.string) 92 + use handle <- decode.field("handle", decode.string) 93 + use display_name <- decode.optional_field( 94 + "displayName", 95 + None, 96 + decode.optional(decode.string), 97 + ) 98 + use avatar <- decode.optional_field( 99 + "avatar", 100 + None, 101 + decode.optional(decode.string), 102 + ) 103 + use created_at <- decode.optional_field( 104 + "createdAt", 105 + None, 106 + decode.optional(decode.string), 107 + ) 108 + use updated_at <- decode.optional_field( 109 + "updatedAt", 110 + None, 111 + decode.optional(decode.string), 112 + ) 113 + decode.success(Profile( 114 + id: id, 115 + did: did, 116 + handle: handle, 117 + display_name: display_name, 118 + avatar: avatar, 119 + created_at: created_at, 120 + updated_at: updated_at, 121 + )) 122 + } 123 + 124 + /// Sends an authenticated GET request to `path` with optional query params. 125 + /// Returns the raw response body on success. 126 + pub fn do_get( 127 + client: Client, 128 + path: String, 129 + query: List(#(String, String)), 130 + ) -> Result(String, PocketenvError) { 131 + let url = client.base_url <> path 132 + use req <- result.try( 133 + request.to(url) |> result.replace_error(RequestBuildError), 134 + ) 135 + let req = 136 + req 137 + |> request.set_method(http.Get) 138 + |> request.prepend_header("authorization", "Bearer " <> client.token) 139 + let req = case query { 140 + [] -> req 141 + q -> request.set_query(req, q) 142 + } 143 + use resp <- result.try( 144 + httpc.send(req) 145 + |> result.map_error(fn(e) { HttpError(string.inspect(e)) }), 146 + ) 147 + case resp.status { 148 + s if s >= 200 && s < 300 -> Ok(resp.body) 149 + s -> Error(ApiError(s)) 150 + } 151 + } 152 + 153 + /// Sends an authenticated POST request to `path` with an optional query and a 154 + /// JSON `body`. Returns the raw response body on success. 155 + pub fn do_post( 156 + client: Client, 157 + path: String, 158 + query: List(#(String, String)), 159 + body: String, 160 + ) -> Result(String, PocketenvError) { 161 + let url = client.base_url <> path 162 + use req <- result.try( 163 + request.to(url) |> result.replace_error(RequestBuildError), 164 + ) 165 + let req = 166 + req 167 + |> request.set_method(http.Post) 168 + |> request.prepend_header("authorization", "Bearer " <> client.token) 169 + |> request.prepend_header("content-type", "application/json") 170 + |> request.set_body(body) 171 + let req = case query { 172 + [] -> req 173 + q -> request.set_query(req, q) 174 + } 175 + use resp <- result.try( 176 + httpc.send(req) 177 + |> result.map_error(fn(e) { HttpError(string.inspect(e)) }), 178 + ) 179 + case resp.status { 180 + s if s >= 200 && s < 300 -> Ok(resp.body) 181 + s -> Error(ApiError(s)) 182 + } 183 + }
+107
src/pocketenv/env.gleam
··· 1 + //// Manage environment variables attached to a sandbox. 2 + //// 3 + //// Variables are plain-text key/value pairs injected into the sandbox at 4 + //// runtime. For sensitive data prefer `pocketenv/secrets`. 5 + 6 + import gleam/dynamic/decode 7 + import gleam/int 8 + import gleam/json 9 + import gleam/list 10 + import gleam/option.{type Option, None, Some} 11 + import gleam/result 12 + import pocketenv.{ 13 + type Client, type PocketenvError, JsonDecodeError, do_get, do_post, 14 + } 15 + 16 + /// A single environment variable stored in a sandbox. 17 + pub type Variable { 18 + Variable(id: String, name: String, value: String, created_at: String) 19 + } 20 + 21 + /// Lists environment variables for `sandbox_id`. 22 + /// Optionally paginate with `limit` and `offset`. 23 + /// 24 + /// ## Example 25 + /// 26 + /// ```gleam 27 + /// let assert Ok(vars) = env.list(client, sandbox_id, None, None) 28 + /// ``` 29 + pub fn list( 30 + client: Client, 31 + sandbox_id: String, 32 + limit: Option(Int), 33 + offset: Option(Int), 34 + ) -> Result(List(Variable), PocketenvError) { 35 + let query = [#("sandboxId", sandbox_id)] 36 + let query = case limit { 37 + Some(l) -> list.append(query, [#("limit", int.to_string(l))]) 38 + None -> query 39 + } 40 + let query = case offset { 41 + Some(o) -> list.append(query, [#("offset", int.to_string(o))]) 42 + None -> query 43 + } 44 + use body <- result.try(do_get( 45 + client, 46 + "/xrpc/io.pocketenv.variable.getVariables", 47 + query, 48 + )) 49 + json.parse(body, { 50 + use variables <- decode.field("variables", decode.list(variable_decoder())) 51 + decode.success(variables) 52 + }) 53 + |> result.map_error(JsonDecodeError) 54 + } 55 + 56 + /// Creates or updates an environment variable named `name` with `value` in `sandbox_id`. 57 + /// 58 + /// ## Example 59 + /// 60 + /// ```gleam 61 + /// let assert Ok(Nil) = env.put(client, sandbox_id, "PORT", "8080") 62 + /// ``` 63 + pub fn put( 64 + client: Client, 65 + sandbox_id: String, 66 + name: String, 67 + value: String, 68 + ) -> Result(Nil, PocketenvError) { 69 + let body = 70 + json.to_string(json.object([ 71 + #( 72 + "variable", 73 + json.object([ 74 + #("sandboxId", json.string(sandbox_id)), 75 + #("name", json.string(name)), 76 + #("value", json.string(value)), 77 + ]), 78 + ), 79 + ])) 80 + use _ <- result.try(do_post( 81 + client, 82 + "/xrpc/io.pocketenv.variable.addVariable", 83 + [], 84 + body, 85 + )) 86 + Ok(Nil) 87 + } 88 + 89 + /// Deletes the environment variable identified by `id`. 90 + pub fn delete(client: Client, id: String) -> Result(Nil, PocketenvError) { 91 + use _ <- result.try(do_post( 92 + client, 93 + "/xrpc/io.pocketenv.variable.deleteVariable", 94 + [#("id", id)], 95 + "{}", 96 + )) 97 + Ok(Nil) 98 + } 99 + 100 + /// JSON decoder for `Variable`. 101 + pub fn variable_decoder() -> decode.Decoder(Variable) { 102 + use id <- decode.field("id", decode.string) 103 + use name <- decode.field("name", decode.string) 104 + use value <- decode.field("value", decode.string) 105 + use created_at <- decode.field("createdAt", decode.string) 106 + decode.success(Variable(id: id, name: name, value: value, created_at: created_at)) 107 + }
+90
src/pocketenv/files.gleam
··· 1 + //// Manage files stored inside a sandbox. 2 + //// 3 + //// Files are written into the sandbox filesystem and can be listed or deleted. 4 + 5 + import gleam/dynamic/decode 6 + import gleam/json 7 + import gleam/result 8 + import pocketenv.{ 9 + type Client, type PocketenvError, JsonDecodeError, do_get, do_post, 10 + } 11 + 12 + /// Metadata for a file stored in a sandbox. 13 + pub type File { 14 + File(id: String, path: String, created_at: String) 15 + } 16 + 17 + /// Lists all files in `sandbox_id`. 18 + /// 19 + /// ## Example 20 + /// 21 + /// ```gleam 22 + /// let assert Ok(files) = files.list(client, sandbox_id) 23 + /// ``` 24 + pub fn list( 25 + client: Client, 26 + sandbox_id: String, 27 + ) -> Result(List(File), PocketenvError) { 28 + use body <- result.try(do_get( 29 + client, 30 + "/xrpc/io.pocketenv.file.getFiles", 31 + [#("sandboxId", sandbox_id)], 32 + )) 33 + json.parse(body, { 34 + use files <- decode.field("files", decode.list(file_decoder())) 35 + decode.success(files) 36 + }) 37 + |> result.map_error(JsonDecodeError) 38 + } 39 + 40 + /// Writes (or overwrites) a file at `path` with `content` in `sandbox_id`. 41 + /// 42 + /// ## Example 43 + /// 44 + /// ```gleam 45 + /// let assert Ok(Nil) = files.write(client, sandbox_id, "/app/.env", "PORT=8080\n") 46 + /// ``` 47 + pub fn write( 48 + client: Client, 49 + sandbox_id: String, 50 + path: String, 51 + content: String, 52 + ) -> Result(Nil, PocketenvError) { 53 + let body = 54 + json.to_string(json.object([ 55 + #( 56 + "file", 57 + json.object([ 58 + #("sandboxId", json.string(sandbox_id)), 59 + #("path", json.string(path)), 60 + #("content", json.string(content)), 61 + ]), 62 + ), 63 + ])) 64 + use _ <- result.try(do_post( 65 + client, 66 + "/xrpc/io.pocketenv.file.addFile", 67 + [], 68 + body, 69 + )) 70 + Ok(Nil) 71 + } 72 + 73 + /// Deletes the file identified by `id`. 74 + pub fn delete(client: Client, id: String) -> Result(Nil, PocketenvError) { 75 + use _ <- result.try(do_post( 76 + client, 77 + "/xrpc/io.pocketenv.file.deleteFile", 78 + [#("id", id)], 79 + "{}", 80 + )) 81 + Ok(Nil) 82 + } 83 + 84 + /// JSON decoder for `File`. 85 + pub fn file_decoder() -> decode.Decoder(File) { 86 + use id <- decode.field("id", decode.string) 87 + use path <- decode.field("path", decode.string) 88 + use created_at <- decode.field("createdAt", decode.string) 89 + decode.success(File(id: id, path: path, created_at: created_at)) 90 + }
+116
src/pocketenv/network.gleam
··· 1 + //// Manage network access for a sandbox. 2 + //// 3 + //// Expose or unexpose ports to the internet and configure Tailscale for 4 + //// private network connectivity. 5 + 6 + import gleam/dynamic/decode 7 + import gleam/json 8 + import gleam/option.{type Option, None, Some} 9 + import gleam/result 10 + import pocketenv.{ 11 + type Client, type PocketenvError, JsonDecodeError, do_get, do_post, 12 + } 13 + 14 + /// Exposes `port` on `sandbox_id` to the internet. 15 + /// Returns a preview URL if one is generated by the platform. 16 + /// 17 + /// ## Example 18 + /// 19 + /// ```gleam 20 + /// let assert Ok(url) = network.expose(client, sandbox_id, 3000, Some("Dev server")) 21 + /// io.debug(url) // Some("https://...") 22 + /// ``` 23 + pub fn expose( 24 + client: Client, 25 + sandbox_id: String, 26 + port: Int, 27 + description: Option(String), 28 + ) -> Result(Option(String), PocketenvError) { 29 + let fields = [#("port", json.int(port))] 30 + let fields = case description { 31 + Some(d) -> [#("description", json.string(d)), ..fields] 32 + None -> fields 33 + } 34 + let body = json.to_string(json.object(fields)) 35 + use resp_body <- result.try(do_post( 36 + client, 37 + "/xrpc/io.pocketenv.sandbox.exposePort", 38 + [#("id", sandbox_id)], 39 + body, 40 + )) 41 + json.parse(resp_body, { 42 + use preview_url <- decode.optional_field( 43 + "previewUrl", 44 + None, 45 + decode.optional(decode.string), 46 + ) 47 + decode.success(preview_url) 48 + }) 49 + |> result.map_error(JsonDecodeError) 50 + } 51 + 52 + /// Removes the public exposure of `port` on `sandbox_id`. 53 + /// 54 + /// ## Example 55 + /// 56 + /// ```gleam 57 + /// let assert Ok(Nil) = network.unexpose(client, sandbox_id, 3000) 58 + /// ``` 59 + pub fn unexpose( 60 + client: Client, 61 + sandbox_id: String, 62 + port: Int, 63 + ) -> Result(Nil, PocketenvError) { 64 + let body = json.to_string(json.object([#("port", json.int(port))])) 65 + use _ <- result.try(do_post( 66 + client, 67 + "/xrpc/io.pocketenv.sandbox.unexposePort", 68 + [#("id", sandbox_id)], 69 + body, 70 + )) 71 + Ok(Nil) 72 + } 73 + 74 + /// Stores a Tailscale auth key for `sandbox_id`, enabling private network access. 75 + /// 76 + /// ## Example 77 + /// 78 + /// ```gleam 79 + /// let assert Ok(Nil) = network.setup_tailscale(client, sandbox_id, "tskey-auth-xxxx") 80 + /// ``` 81 + pub fn setup_tailscale( 82 + client: Client, 83 + sandbox_id: String, 84 + auth_key: String, 85 + ) -> Result(Nil, PocketenvError) { 86 + let body = 87 + json.to_string(json.object([#("tailscaleAuthKey", json.string(auth_key))])) 88 + use _ <- result.try(do_post( 89 + client, 90 + "/xrpc/io.pocketenv.sandbox.putTailscaleAuthKey", 91 + [#("id", sandbox_id)], 92 + body, 93 + )) 94 + Ok(Nil) 95 + } 96 + 97 + /// Retrieves the stored Tailscale auth key for `sandbox_id`, if any. 98 + pub fn get_tailscale_auth_key( 99 + client: Client, 100 + sandbox_id: String, 101 + ) -> Result(Option(String), PocketenvError) { 102 + use body <- result.try(do_get( 103 + client, 104 + "/xrpc/io.pocketenv.sandbox.getTailscaleAuthKey", 105 + [#("id", sandbox_id)], 106 + )) 107 + json.parse(body, { 108 + use key <- decode.optional_field( 109 + "tailscaleAuthKey", 110 + None, 111 + decode.optional(decode.string), 112 + ) 113 + decode.success(key) 114 + }) 115 + |> result.map_error(JsonDecodeError) 116 + }
+53
src/pocketenv/ports.gleam
··· 1 + //// Query exposed ports of a sandbox. 2 + //// 3 + //// To expose or unexpose ports use `pocketenv/network`. 4 + 5 + import gleam/dynamic/decode 6 + import gleam/json 7 + import gleam/option.{type Option, None} 8 + import gleam/result 9 + import pocketenv.{type Client, type PocketenvError, JsonDecodeError, do_get} 10 + 11 + /// An exposed port on a sandbox. 12 + pub type Port { 13 + Port(port: Int, description: Option(String), preview_url: Option(String)) 14 + } 15 + 16 + /// Lists all currently exposed ports for `sandbox_id`. 17 + /// 18 + /// ## Example 19 + /// 20 + /// ```gleam 21 + /// let assert Ok(ports) = ports.list(client, sandbox_id) 22 + /// ``` 23 + pub fn list( 24 + client: Client, 25 + sandbox_id: String, 26 + ) -> Result(List(Port), PocketenvError) { 27 + use body <- result.try(do_get( 28 + client, 29 + "/xrpc/io.pocketenv.sandbox.getExposedPorts", 30 + [#("id", sandbox_id)], 31 + )) 32 + json.parse(body, { 33 + use ports <- decode.field("ports", decode.list(port_decoder())) 34 + decode.success(ports) 35 + }) 36 + |> result.map_error(JsonDecodeError) 37 + } 38 + 39 + /// JSON decoder for `Port`. 40 + pub fn port_decoder() -> decode.Decoder(Port) { 41 + use port <- decode.field("port", decode.int) 42 + use description <- decode.optional_field( 43 + "description", 44 + None, 45 + decode.optional(decode.string), 46 + ) 47 + use preview_url <- decode.optional_field( 48 + "previewUrl", 49 + None, 50 + decode.optional(decode.string), 51 + ) 52 + decode.success(Port(port: port, description: description, preview_url: preview_url)) 53 + }
+463
src/pocketenv/sandbox.gleam
··· 1 + //// Create, manage, and interact with Pocketenv sandboxes. 2 + //// 3 + //// A sandbox is an isolated cloud environment that can run commands, expose 4 + //// ports, and host services. All other resources (env vars, secrets, files, 5 + //// volumes, services) are attached to a sandbox. 6 + 7 + import gleam/dynamic/decode 8 + import gleam/int 9 + import gleam/json 10 + import gleam/list 11 + import gleam/option.{type Option, None, Some} 12 + import gleam/result 13 + import pocketenv.{ 14 + type Client, type PocketenvError, JsonDecodeError, do_get, do_post, 15 + } 16 + 17 + /// Full details of a Pocketenv sandbox. 18 + pub type Sandbox { 19 + Sandbox( 20 + id: String, 21 + name: String, 22 + provider: Option(String), 23 + base_sandbox: Option(String), 24 + display_name: Option(String), 25 + uri: Option(String), 26 + description: Option(String), 27 + topics: Option(List(String)), 28 + logo: Option(String), 29 + readme: Option(String), 30 + repo: Option(String), 31 + vcpus: Option(Int), 32 + memory: Option(Int), 33 + disk: Option(Int), 34 + installs: Option(Int), 35 + status: Option(String), 36 + started_at: Option(String), 37 + created_at: String, 38 + ) 39 + } 40 + 41 + /// A sandbox bundled with its client — returned by `create` and `connect`. 42 + /// All operations (`start`, `stop`, `exec`, …) work directly on this type. 43 + pub type ConnectedSandbox { 44 + ConnectedSandbox(data: Sandbox, client: Client) 45 + } 46 + 47 + /// The output of a command executed inside a sandbox. 48 + pub type ExecResult { 49 + ExecResult(stdout: String, stderr: String, exit_code: Int) 50 + } 51 + 52 + /// A builder for configuring a new sandbox before calling `create`. 53 + pub opaque type SandboxBuilder { 54 + SandboxBuilder( 55 + client: Client, 56 + name: String, 57 + base: String, 58 + provider: String, 59 + repo: Option(String), 60 + description: Option(String), 61 + ) 62 + } 63 + 64 + /// Starts a sandbox builder with the three required fields. 65 + /// Chain optional `with_*` setters then call `create()`. 66 + /// 67 + /// ## Example 68 + /// 69 + /// ```gleam 70 + /// let assert Ok(sb) = 71 + /// client 72 + /// |> sandbox.new("my-app", "openclaw", "cloudflare") 73 + /// |> sandbox.with_description("My app sandbox") 74 + /// |> sandbox.create() 75 + /// ``` 76 + pub fn new( 77 + client: Client, 78 + name: String, 79 + base: String, 80 + provider: String, 81 + ) -> SandboxBuilder { 82 + SandboxBuilder( 83 + client: client, 84 + name: name, 85 + base: base, 86 + provider: provider, 87 + repo: None, 88 + description: None, 89 + ) 90 + } 91 + 92 + /// Sets the Git repo URL to clone when the sandbox starts. 93 + pub fn with_repo(builder: SandboxBuilder, repo: String) -> SandboxBuilder { 94 + SandboxBuilder(..builder, repo: Some(repo)) 95 + } 96 + 97 + /// Sets a human-readable description for the sandbox. 98 + pub fn with_description( 99 + builder: SandboxBuilder, 100 + description: String, 101 + ) -> SandboxBuilder { 102 + SandboxBuilder(..builder, description: Some(description)) 103 + } 104 + 105 + /// Creates the sandbox and returns a `ConnectedSandbox` ready for use. 106 + /// 107 + /// ## Example 108 + /// 109 + /// ```gleam 110 + /// let assert Ok(sb) = 111 + /// client 112 + /// |> sandbox.new("my-app", "openclaw", "cloudflare") 113 + /// |> sandbox.create() 114 + /// 115 + /// sb |> sandbox.start(None, None) 116 + /// sb |> sandbox.exec("echo hello") 117 + /// sb |> sandbox.stop() 118 + /// ``` 119 + pub fn create( 120 + builder: SandboxBuilder, 121 + ) -> Result(ConnectedSandbox, PocketenvError) { 122 + let fields = [ 123 + #("name", json.string(builder.name)), 124 + #("base", json.string(builder.base)), 125 + #("provider", json.string(builder.provider)), 126 + ] 127 + let fields = case builder.repo { 128 + Some(r) -> list.append(fields, [#("repo", json.string(r))]) 129 + None -> fields 130 + } 131 + let fields = case builder.description { 132 + Some(d) -> list.append(fields, [#("description", json.string(d))]) 133 + None -> fields 134 + } 135 + let body = json.to_string(json.object(fields)) 136 + use resp_body <- result.try(do_post( 137 + builder.client, 138 + "/xrpc/io.pocketenv.sandbox.createSandbox", 139 + [], 140 + body, 141 + )) 142 + use sb <- result.try( 143 + json.parse(resp_body, sandbox_decoder()) 144 + |> result.map_error(JsonDecodeError), 145 + ) 146 + Ok(ConnectedSandbox(data: sb, client: builder.client)) 147 + } 148 + 149 + /// Wraps a `Sandbox` obtained from `get`/`list` with a client, producing a 150 + /// `ConnectedSandbox` that can be passed to `start`, `stop`, `exec`, etc. 151 + /// 152 + /// ## Example 153 + /// 154 + /// ```gleam 155 + /// let assert Ok(#(sandboxes, _)) = sandbox.list(client, None, None) 156 + /// let assert [first, ..] = sandboxes 157 + /// let sb = first |> sandbox.connect(client) 158 + /// sb |> sandbox.exec("ls") 159 + /// ``` 160 + pub fn connect(sandbox: Sandbox, client: Client) -> ConnectedSandbox { 161 + ConnectedSandbox(data: sandbox, client: client) 162 + } 163 + 164 + /// Fetches a single sandbox by `id`. Returns `None` if not found. 165 + /// 166 + /// ## Example 167 + /// 168 + /// ```gleam 169 + /// let assert Ok(Some(sb)) = sandbox.get(client, "sandbox-abc123") 170 + /// ``` 171 + pub fn get( 172 + client: Client, 173 + id: String, 174 + ) -> Result(Option(Sandbox), PocketenvError) { 175 + use body <- result.try( 176 + do_get(client, "/xrpc/io.pocketenv.sandbox.getSandbox", [#("id", id)]), 177 + ) 178 + json.parse(body, { 179 + use sandbox <- decode.optional_field( 180 + "sandbox", 181 + None, 182 + decode.optional(sandbox_decoder()), 183 + ) 184 + decode.success(sandbox) 185 + }) 186 + |> result.map_error(JsonDecodeError) 187 + } 188 + 189 + /// Lists sandboxes visible to the authenticated actor. 190 + /// Returns a tuple of `#(sandboxes, total_count)`. 191 + /// Optionally paginate with `limit` and `offset`. 192 + /// 193 + /// ## Example 194 + /// 195 + /// ```gleam 196 + /// let assert Ok(#(sandboxes, total)) = sandbox.list(client, Some(10), None) 197 + /// ``` 198 + pub fn list( 199 + client: Client, 200 + limit: Option(Int), 201 + offset: Option(Int), 202 + ) -> Result(#(List(Sandbox), Int), PocketenvError) { 203 + let query = [] 204 + let query = case limit { 205 + Some(l) -> list.append(query, [#("limit", int.to_string(l))]) 206 + None -> query 207 + } 208 + let query = case offset { 209 + Some(o) -> list.append(query, [#("offset", int.to_string(o))]) 210 + None -> query 211 + } 212 + use body <- result.try(do_get( 213 + client, 214 + "/xrpc/io.pocketenv.sandbox.getSandboxes", 215 + query, 216 + )) 217 + json.parse(body, { 218 + use sandboxes <- decode.field("sandboxes", decode.list(sandbox_decoder())) 219 + use total <- decode.optional_field( 220 + "total", 221 + None, 222 + decode.optional(decode.int), 223 + ) 224 + decode.success(#(sandboxes, option.unwrap(total, 0))) 225 + }) 226 + |> result.map_error(JsonDecodeError) 227 + } 228 + 229 + /// Lists sandboxes owned by the actor identified by `did`. 230 + pub fn get_actor_sandboxes( 231 + client: Client, 232 + did: String, 233 + limit: Option(Int), 234 + offset: Option(Int), 235 + ) -> Result(List(Sandbox), PocketenvError) { 236 + let query = [#("did", did)] 237 + let query = case limit { 238 + Some(l) -> list.append(query, [#("limit", int.to_string(l))]) 239 + None -> query 240 + } 241 + let query = case offset { 242 + Some(o) -> list.append(query, [#("offset", int.to_string(o))]) 243 + None -> query 244 + } 245 + use body <- result.try(do_get( 246 + client, 247 + "/xrpc/io.pocketenv.actor.getActorSandboxes", 248 + query, 249 + )) 250 + json.parse(body, { 251 + use sandboxes <- decode.field("sandboxes", decode.list(sandbox_decoder())) 252 + decode.success(sandboxes) 253 + }) 254 + |> result.map_error(JsonDecodeError) 255 + } 256 + 257 + /// Starts the sandbox. 258 + /// Optionally clone a `repo` on start and keep the sandbox alive with `keep_alive`. 259 + /// 260 + /// ## Example 261 + /// 262 + /// ```gleam 263 + /// sb |> sandbox.start(None, Some(True)) 264 + /// ``` 265 + pub fn start( 266 + sb: ConnectedSandbox, 267 + repo: Option(String), 268 + keep_alive: Option(Bool), 269 + ) -> Result(Nil, PocketenvError) { 270 + let fields = case repo { 271 + Some(r) -> [#("repo", json.string(r))] 272 + None -> [] 273 + } 274 + let fields = case keep_alive { 275 + Some(k) -> list.append(fields, [#("keepAlive", json.bool(k))]) 276 + None -> fields 277 + } 278 + let body = json.to_string(json.object(fields)) 279 + use _ <- result.try(do_post( 280 + sb.client, 281 + "/xrpc/io.pocketenv.sandbox.startSandbox", 282 + [#("id", sb.data.id)], 283 + body, 284 + )) 285 + Ok(Nil) 286 + } 287 + 288 + /// Stops the sandbox. 289 + /// 290 + /// ## Example 291 + /// 292 + /// ```gleam 293 + /// sb |> sandbox.stop() 294 + /// ``` 295 + pub fn stop(sb: ConnectedSandbox) -> Result(Nil, PocketenvError) { 296 + use _ <- result.try(do_post( 297 + sb.client, 298 + "/xrpc/io.pocketenv.sandbox.stopSandbox", 299 + [#("id", sb.data.id)], 300 + "{}", 301 + )) 302 + Ok(Nil) 303 + } 304 + 305 + /// Permanently deletes the sandbox. 306 + /// 307 + /// ## Example 308 + /// 309 + /// ```gleam 310 + /// sb |> sandbox.delete() 311 + /// ``` 312 + pub fn delete(sb: ConnectedSandbox) -> Result(Nil, PocketenvError) { 313 + use _ <- result.try(do_post( 314 + sb.client, 315 + "/xrpc/io.pocketenv.sandbox.deleteSandbox", 316 + [#("id", sb.data.id)], 317 + "{}", 318 + )) 319 + Ok(Nil) 320 + } 321 + 322 + /// Executes a shell `command` inside the running sandbox. 323 + /// Returns stdout, stderr, and the exit code. 324 + /// 325 + /// ## Example 326 + /// 327 + /// ```gleam 328 + /// let assert Ok(res) = sb |> sandbox.exec("ls /app") 329 + /// io.println(res.stdout) 330 + /// ``` 331 + pub fn exec( 332 + sb: ConnectedSandbox, 333 + command: String, 334 + ) -> Result(ExecResult, PocketenvError) { 335 + let body = json.to_string(json.object([#("command", json.string(command))])) 336 + use resp_body <- result.try(do_post( 337 + sb.client, 338 + "/xrpc/io.pocketenv.sandbox.exec", 339 + [#("id", sb.data.id)], 340 + body, 341 + )) 342 + json.parse(resp_body, exec_decoder()) 343 + |> result.map_error(JsonDecodeError) 344 + } 345 + 346 + /// Exposes a VS Code server on the sandbox. 347 + /// 348 + /// ## Example 349 + /// 350 + /// ```gleam 351 + /// sb |> sandbox.expose_vscode() 352 + /// ``` 353 + pub fn expose_vscode(sb: ConnectedSandbox) -> Result(Nil, PocketenvError) { 354 + use _ <- result.try(do_post( 355 + sb.client, 356 + "/xrpc/io.pocketenv.sandbox.exposeVscode", 357 + [#("id", sb.data.id)], 358 + "{}", 359 + )) 360 + Ok(Nil) 361 + } 362 + 363 + /// JSON decoder for `Sandbox`. 364 + pub fn sandbox_decoder() -> decode.Decoder(Sandbox) { 365 + use id <- decode.field("id", decode.string) 366 + use name <- decode.field("name", decode.string) 367 + use provider <- decode.optional_field( 368 + "provider", 369 + None, 370 + decode.optional(decode.string), 371 + ) 372 + use base_sandbox <- decode.optional_field( 373 + "baseSandbox", 374 + None, 375 + decode.optional(decode.string), 376 + ) 377 + use display_name <- decode.optional_field( 378 + "displayName", 379 + None, 380 + decode.optional(decode.string), 381 + ) 382 + use uri <- decode.optional_field("uri", None, decode.optional(decode.string)) 383 + use description <- decode.optional_field( 384 + "description", 385 + None, 386 + decode.optional(decode.string), 387 + ) 388 + use topics <- decode.optional_field( 389 + "topics", 390 + None, 391 + decode.optional(decode.list(decode.string)), 392 + ) 393 + use logo <- decode.optional_field( 394 + "logo", 395 + None, 396 + decode.optional(decode.string), 397 + ) 398 + use readme <- decode.optional_field( 399 + "readme", 400 + None, 401 + decode.optional(decode.string), 402 + ) 403 + use repo <- decode.optional_field( 404 + "repo", 405 + None, 406 + decode.optional(decode.string), 407 + ) 408 + use vcpus <- decode.optional_field("vcpus", None, decode.optional(decode.int)) 409 + use memory <- decode.optional_field( 410 + "memory", 411 + None, 412 + decode.optional(decode.int), 413 + ) 414 + use disk <- decode.optional_field("disk", None, decode.optional(decode.int)) 415 + use installs <- decode.optional_field( 416 + "installs", 417 + None, 418 + decode.optional(decode.int), 419 + ) 420 + use status <- decode.optional_field( 421 + "status", 422 + None, 423 + decode.optional(decode.string), 424 + ) 425 + use started_at <- decode.optional_field( 426 + "startedAt", 427 + None, 428 + decode.optional(decode.string), 429 + ) 430 + use created_at <- decode.field("createdAt", decode.string) 431 + decode.success(Sandbox( 432 + id: id, 433 + name: name, 434 + provider: provider, 435 + base_sandbox: base_sandbox, 436 + display_name: display_name, 437 + uri: uri, 438 + description: description, 439 + topics: topics, 440 + logo: logo, 441 + readme: readme, 442 + repo: repo, 443 + vcpus: vcpus, 444 + memory: memory, 445 + disk: disk, 446 + installs: installs, 447 + status: status, 448 + started_at: started_at, 449 + created_at: created_at, 450 + )) 451 + } 452 + 453 + /// JSON decoder for `ExecResult`. 454 + pub fn exec_decoder() -> decode.Decoder(ExecResult) { 455 + use stdout <- decode.field("stdout", decode.string) 456 + use stderr <- decode.field("stderr", decode.string) 457 + use exit_code <- decode.field("exitCode", decode.int) 458 + decode.success(ExecResult( 459 + stdout: stdout, 460 + stderr: stderr, 461 + exit_code: exit_code, 462 + )) 463 + }
+106
src/pocketenv/secrets.gleam
··· 1 + //// Manage encrypted secrets attached to a sandbox. 2 + //// 3 + //// Secrets are similar to environment variables but their values are write-only: 4 + //// the API never returns the stored value. Use them for API keys, passwords, and 5 + //// other sensitive data. 6 + 7 + import gleam/dynamic/decode 8 + import gleam/int 9 + import gleam/json 10 + import gleam/list 11 + import gleam/option.{type Option, None, Some} 12 + import gleam/result 13 + import pocketenv.{ 14 + type Client, type PocketenvError, JsonDecodeError, do_get, do_post, 15 + } 16 + 17 + /// A secret stored in a sandbox. Only the `name` is exposed; the value is never returned. 18 + pub type Secret { 19 + Secret(id: String, name: String, created_at: String) 20 + } 21 + 22 + /// Lists secret names for `sandbox_id`. Optionally paginate with `limit` and `offset`. 23 + /// 24 + /// ## Example 25 + /// 26 + /// ```gleam 27 + /// let assert Ok(secrets) = secrets.list(client, sandbox_id, None, None) 28 + /// ``` 29 + pub fn list( 30 + client: Client, 31 + sandbox_id: String, 32 + limit: Option(Int), 33 + offset: Option(Int), 34 + ) -> Result(List(Secret), PocketenvError) { 35 + let query = [#("sandboxId", sandbox_id)] 36 + let query = case limit { 37 + Some(l) -> list.append(query, [#("limit", int.to_string(l))]) 38 + None -> query 39 + } 40 + let query = case offset { 41 + Some(o) -> list.append(query, [#("offset", int.to_string(o))]) 42 + None -> query 43 + } 44 + use body <- result.try(do_get( 45 + client, 46 + "/xrpc/io.pocketenv.secret.getSecrets", 47 + query, 48 + )) 49 + json.parse(body, { 50 + use secrets <- decode.field("secrets", decode.list(secret_decoder())) 51 + decode.success(secrets) 52 + }) 53 + |> result.map_error(JsonDecodeError) 54 + } 55 + 56 + /// Creates or updates a secret named `name` with `value` in `sandbox_id`. 57 + /// 58 + /// ## Example 59 + /// 60 + /// ```gleam 61 + /// let assert Ok(Nil) = secrets.put(client, sandbox_id, "DB_PASSWORD", "s3cr3t") 62 + /// ``` 63 + pub fn put( 64 + client: Client, 65 + sandbox_id: String, 66 + name: String, 67 + value: String, 68 + ) -> Result(Nil, PocketenvError) { 69 + let body = 70 + json.to_string(json.object([ 71 + #( 72 + "secret", 73 + json.object([ 74 + #("sandboxId", json.string(sandbox_id)), 75 + #("name", json.string(name)), 76 + #("value", json.string(value)), 77 + ]), 78 + ), 79 + ])) 80 + use _ <- result.try(do_post( 81 + client, 82 + "/xrpc/io.pocketenv.secret.addSecret", 83 + [], 84 + body, 85 + )) 86 + Ok(Nil) 87 + } 88 + 89 + /// Deletes the secret identified by `id`. 90 + pub fn delete(client: Client, id: String) -> Result(Nil, PocketenvError) { 91 + use _ <- result.try(do_post( 92 + client, 93 + "/xrpc/io.pocketenv.secret.deleteSecret", 94 + [#("id", id)], 95 + "{}", 96 + )) 97 + Ok(Nil) 98 + } 99 + 100 + /// JSON decoder for `Secret`. 101 + pub fn secret_decoder() -> decode.Decoder(Secret) { 102 + use id <- decode.field("id", decode.string) 103 + use name <- decode.field("name", decode.string) 104 + use created_at <- decode.field("createdAt", decode.string) 105 + decode.success(Secret(id: id, name: name, created_at: created_at)) 106 + }
+168
src/pocketenv/services.gleam
··· 1 + //// Manage long-running services inside a sandbox. 2 + //// 3 + //// A service is a named process (e.g. a web server or background worker) that 4 + //// the platform can start, stop, and restart independently. 5 + 6 + import gleam/dynamic/decode 7 + import gleam/json 8 + import gleam/list 9 + import gleam/option.{type Option, None, Some} 10 + import gleam/result 11 + import pocketenv.{ 12 + type Client, type PocketenvError, JsonDecodeError, do_get, do_post, 13 + } 14 + 15 + /// A service running (or registered to run) inside a sandbox. 16 + pub type Service { 17 + Service( 18 + id: String, 19 + name: String, 20 + command: String, 21 + ports: Option(List(Int)), 22 + description: Option(String), 23 + status: String, 24 + created_at: String, 25 + ) 26 + } 27 + 28 + /// Lists all services registered in `sandbox_id`. 29 + /// 30 + /// ## Example 31 + /// 32 + /// ```gleam 33 + /// let assert Ok(svcs) = services.list(client, sandbox_id) 34 + /// ``` 35 + pub fn list( 36 + client: Client, 37 + sandbox_id: String, 38 + ) -> Result(List(Service), PocketenvError) { 39 + use body <- result.try(do_get( 40 + client, 41 + "/xrpc/io.pocketenv.service.getServices", 42 + [#("sandboxId", sandbox_id)], 43 + )) 44 + json.parse(body, { 45 + use services <- decode.field("services", decode.list(service_decoder())) 46 + decode.success(services) 47 + }) 48 + |> result.map_error(JsonDecodeError) 49 + } 50 + 51 + /// Registers a new service in `sandbox_id`. 52 + /// 53 + /// - `name` — unique name for the service 54 + /// - `command` — shell command to run 55 + /// - `ports` — optional list of ports the service listens on 56 + /// - `description` — optional human-readable description 57 + /// 58 + /// ## Example 59 + /// 60 + /// ```gleam 61 + /// let assert Ok(Nil) = 62 + /// services.create(client, sandbox_id, "api", "node server.js", Some([3000]), None) 63 + /// ``` 64 + pub fn create( 65 + client: Client, 66 + sandbox_id: String, 67 + name: String, 68 + command: String, 69 + ports: Option(List(Int)), 70 + description: Option(String), 71 + ) -> Result(Nil, PocketenvError) { 72 + let service_fields = [ 73 + #("name", json.string(name)), 74 + #("command", json.string(command)), 75 + ] 76 + let service_fields = case ports { 77 + Some(ps) -> 78 + list.append(service_fields, [#("ports", json.array(ps, json.int))]) 79 + None -> service_fields 80 + } 81 + let service_fields = case description { 82 + Some(d) -> 83 + list.append(service_fields, [#("description", json.string(d))]) 84 + None -> service_fields 85 + } 86 + let body = 87 + json.to_string(json.object([#("service", json.object(service_fields))])) 88 + use _ <- result.try(do_post( 89 + client, 90 + "/xrpc/io.pocketenv.service.addService", 91 + [#("sandboxId", sandbox_id)], 92 + body, 93 + )) 94 + Ok(Nil) 95 + } 96 + 97 + /// Starts the service identified by `service_id`. 98 + pub fn start( 99 + client: Client, 100 + service_id: String, 101 + ) -> Result(Nil, PocketenvError) { 102 + use _ <- result.try(do_post( 103 + client, 104 + "/xrpc/io.pocketenv.service.startService", 105 + [#("serviceId", service_id)], 106 + "{}", 107 + )) 108 + Ok(Nil) 109 + } 110 + 111 + /// Stops the service identified by `service_id`. 112 + pub fn stop(client: Client, service_id: String) -> Result(Nil, PocketenvError) { 113 + use _ <- result.try(do_post( 114 + client, 115 + "/xrpc/io.pocketenv.service.stopService", 116 + [#("serviceId", service_id)], 117 + "{}", 118 + )) 119 + Ok(Nil) 120 + } 121 + 122 + /// Restarts the service identified by `service_id`. 123 + pub fn restart( 124 + client: Client, 125 + service_id: String, 126 + ) -> Result(Nil, PocketenvError) { 127 + use _ <- result.try(do_post( 128 + client, 129 + "/xrpc/io.pocketenv.service.restartService", 130 + [#("serviceId", service_id)], 131 + "{}", 132 + )) 133 + Ok(Nil) 134 + } 135 + 136 + /// Deletes the service identified by `service_id`. 137 + pub fn delete( 138 + client: Client, 139 + service_id: String, 140 + ) -> Result(Nil, PocketenvError) { 141 + use _ <- result.try(do_post( 142 + client, 143 + "/xrpc/io.pocketenv.service.deleteService", 144 + [#("serviceId", service_id)], 145 + "{}", 146 + )) 147 + Ok(Nil) 148 + } 149 + 150 + /// JSON decoder for `Service`. 151 + pub fn service_decoder() -> decode.Decoder(Service) { 152 + use id <- decode.field("id", decode.string) 153 + use name <- decode.field("name", decode.string) 154 + use command <- decode.field("command", decode.string) 155 + use ports <- decode.optional_field("ports", None, decode.optional(decode.list(decode.int))) 156 + use description <- decode.optional_field("description", None, decode.optional(decode.string)) 157 + use status <- decode.field("status", decode.string) 158 + use created_at <- decode.field("createdAt", decode.string) 159 + decode.success(Service( 160 + id: id, 161 + name: name, 162 + command: command, 163 + ports: ports, 164 + description: description, 165 + status: status, 166 + created_at: created_at, 167 + )) 168 + }
+91
src/pocketenv/volume.gleam
··· 1 + //// Manage persistent volumes mounted in a sandbox. 2 + //// 3 + //// Volumes provide durable storage that survives sandbox restarts. 4 + 5 + import gleam/dynamic/decode 6 + import gleam/json 7 + import gleam/result 8 + import pocketenv.{ 9 + type Client, type PocketenvError, JsonDecodeError, do_get, do_post, 10 + } 11 + 12 + /// A persistent volume mounted in a sandbox. 13 + pub type Volume { 14 + Volume(id: String, name: String, path: String, created_at: String) 15 + } 16 + 17 + /// Lists all volumes attached to `sandbox_id`. 18 + /// 19 + /// ## Example 20 + /// 21 + /// ```gleam 22 + /// let assert Ok(vols) = volume.list(client, sandbox_id) 23 + /// ``` 24 + pub fn list( 25 + client: Client, 26 + sandbox_id: String, 27 + ) -> Result(List(Volume), PocketenvError) { 28 + use body <- result.try(do_get( 29 + client, 30 + "/xrpc/io.pocketenv.volume.getVolumes", 31 + [#("sandboxId", sandbox_id)], 32 + )) 33 + json.parse(body, { 34 + use volumes <- decode.field("volumes", decode.list(volume_decoder())) 35 + decode.success(volumes) 36 + }) 37 + |> result.map_error(JsonDecodeError) 38 + } 39 + 40 + /// Creates a volume named `name` mounted at `path` in `sandbox_id`. 41 + /// 42 + /// ## Example 43 + /// 44 + /// ```gleam 45 + /// let assert Ok(Nil) = volume.create(client, sandbox_id, "data", "/mnt/data") 46 + /// ``` 47 + pub fn create( 48 + client: Client, 49 + sandbox_id: String, 50 + name: String, 51 + path: String, 52 + ) -> Result(Nil, PocketenvError) { 53 + let body = 54 + json.to_string(json.object([ 55 + #( 56 + "volume", 57 + json.object([ 58 + #("sandboxId", json.string(sandbox_id)), 59 + #("name", json.string(name)), 60 + #("path", json.string(path)), 61 + ]), 62 + ), 63 + ])) 64 + use _ <- result.try(do_post( 65 + client, 66 + "/xrpc/io.pocketenv.volume.addVolume", 67 + [], 68 + body, 69 + )) 70 + Ok(Nil) 71 + } 72 + 73 + /// Deletes the volume identified by `id`. 74 + pub fn delete(client: Client, id: String) -> Result(Nil, PocketenvError) { 75 + use _ <- result.try(do_post( 76 + client, 77 + "/xrpc/io.pocketenv.volume.deleteVolume", 78 + [#("id", id)], 79 + "{}", 80 + )) 81 + Ok(Nil) 82 + } 83 + 84 + /// JSON decoder for `Volume`. 85 + pub fn volume_decoder() -> decode.Decoder(Volume) { 86 + use id <- decode.field("id", decode.string) 87 + use name <- decode.field("name", decode.string) 88 + use path <- decode.field("path", decode.string) 89 + use created_at <- decode.field("createdAt", decode.string) 90 + decode.success(Volume(id: id, name: name, path: path, created_at: created_at)) 91 + }
+10
test/mock_server.gleam
··· 1 + import gleam/dynamic.{type Dynamic} 2 + 3 + @external(erlang, "mock_server_ffi", "start") 4 + pub fn start() -> #(Int, Dynamic) 5 + 6 + @external(erlang, "mock_server_ffi", "stop") 7 + pub fn stop(pid: Dynamic) -> Nil 8 + 9 + @external(erlang, "mock_server_ffi", "set_response") 10 + pub fn set_response(path: String, status: Int, body: String) -> Nil
+75
test/mock_server_ffi.erl
··· 1 + -module(mock_server_ffi). 2 + -export([start/0, stop/1, set_response/3]). 3 + 4 + start() -> 5 + case ets:whereis(mock_http_responses) of 6 + undefined -> ets:new(mock_http_responses, [named_table, public, set]); 7 + _ -> ets:delete_all_objects(mock_http_responses) 8 + end, 9 + Self = self(), 10 + Pid = spawn(fun() -> 11 + {ok, LSock} = gen_tcp:listen(0, [binary, {packet, raw}, {active, false}, {reuseaddr, true}]), 12 + {ok, Port} = inet:port(LSock), 13 + Self ! {ready, Port}, 14 + accept_loop(LSock) 15 + end), 16 + receive {ready, Port} -> {Port, Pid} end. 17 + 18 + stop(Pid) -> 19 + exit(Pid, kill). 20 + 21 + set_response(Path, Status, Body) -> 22 + ets:insert(mock_http_responses, { 23 + unicode:characters_to_list(Path), 24 + Status, 25 + unicode:characters_to_list(Body) 26 + }). 27 + 28 + accept_loop(LSock) -> 29 + case gen_tcp:accept(LSock, 5000) of 30 + {ok, CSock} -> 31 + spawn(fun() -> handle(CSock) end), 32 + accept_loop(LSock); 33 + _ -> 34 + ok 35 + end. 36 + 37 + handle(Sock) -> 38 + case recv_request_line(Sock, <<>>) of 39 + {ok, RequestLine} -> 40 + Parts = string:split(RequestLine, " ", all), 41 + Path = case Parts of 42 + [_, P | _] -> 43 + [Base | _] = string:split(P, "?"), 44 + Base; 45 + _ -> 46 + "/" 47 + end, 48 + {Status, RespBody} = case ets:lookup(mock_http_responses, Path) of 49 + [{_, S, B}] -> {S, B}; 50 + [] -> {200, "{}"} 51 + end, 52 + Resp = io_lib:format( 53 + "HTTP/1.1 ~w OK\r\nContent-Type: application/json\r\nContent-Length: ~w\r\nConnection: close\r\n\r\n~s", 54 + [Status, length(RespBody), RespBody] 55 + ), 56 + gen_tcp:send(Sock, Resp); 57 + _ -> 58 + ok 59 + end, 60 + gen_tcp:close(Sock). 61 + 62 + recv_request_line(Sock, Acc) -> 63 + case gen_tcp:recv(Sock, 0, 5000) of 64 + {ok, Data} -> 65 + Full = <<Acc/binary, Data/binary>>, 66 + case binary:match(Full, <<"\r\n">>) of 67 + {Pos, _} -> 68 + Line = binary:part(Full, 0, Pos), 69 + {ok, binary_to_list(Line)}; 70 + nomatch -> 71 + recv_request_line(Sock, Full) 72 + end; 73 + {error, Reason} -> 74 + {error, Reason} 75 + end.
+459
test/pocketenv_test.gleam
··· 1 + import gleam/int 2 + import gleam/json 3 + import gleam/option.{None, Some} 4 + import gleeunit 5 + import mock_server 6 + import pocketenv 7 + import pocketenv/env 8 + import pocketenv/files 9 + import pocketenv/ports 10 + import pocketenv/sandbox 11 + import pocketenv/secrets 12 + import pocketenv/services 13 + import pocketenv/volume 14 + 15 + pub fn main() -> Nil { 16 + gleeunit.main() 17 + } 18 + 19 + // ---- profile ---------------------------------------------------------------- 20 + 21 + pub fn profile_decoder_full_test() { 22 + let json_str = 23 + "{\"id\":\"abc\",\"did\":\"did:plc:123\",\"handle\":\"alice.bsky.social\",\"displayName\":\"Alice\",\"avatar\":\"https://example.com/avatar.jpg\",\"createdAt\":\"2024-01-01\",\"updatedAt\":\"2024-06-01\"}" 24 + let result = json.parse(json_str, pocketenv.profile_decoder()) 25 + assert result 26 + == Ok(pocketenv.Profile( 27 + id: Some("abc"), 28 + did: "did:plc:123", 29 + handle: "alice.bsky.social", 30 + display_name: Some("Alice"), 31 + avatar: Some("https://example.com/avatar.jpg"), 32 + created_at: Some("2024-01-01"), 33 + updated_at: Some("2024-06-01"), 34 + )) 35 + } 36 + 37 + pub fn profile_decoder_minimal_test() { 38 + let json_str = "{\"did\":\"did:plc:456\",\"handle\":\"bob.bsky.social\"}" 39 + let result = json.parse(json_str, pocketenv.profile_decoder()) 40 + assert result 41 + == Ok(pocketenv.Profile( 42 + id: None, 43 + did: "did:plc:456", 44 + handle: "bob.bsky.social", 45 + display_name: None, 46 + avatar: None, 47 + created_at: None, 48 + updated_at: None, 49 + )) 50 + } 51 + 52 + pub fn profile_decoder_missing_required_field_test() { 53 + let json_str = "{\"did\":\"did:plc:789\"}" 54 + let result = json.parse(json_str, pocketenv.profile_decoder()) 55 + assert result |> is_error 56 + } 57 + 58 + // ---- sandbox ---------------------------------------------------------------- 59 + 60 + pub fn sandbox_decoder_full_test() { 61 + let json_str = 62 + "{\"id\":\"s1\",\"name\":\"my-sandbox\",\"provider\":\"cloudflare\",\"baseSandbox\":\"base\",\"displayName\":\"My Sandbox\",\"uri\":\"https://sandbox.example.com\",\"description\":\"A test sandbox\",\"topics\":[\"gleam\",\"elixir\"],\"logo\":\"https://example.com/logo.png\",\"readme\":\"# Hello\",\"repo\":\"https://github.com/example/repo\",\"vcpus\":2,\"memory\":512,\"disk\":10,\"installs\":5,\"status\":\"running\",\"startedAt\":\"2024-01-02\",\"createdAt\":\"2024-01-01\"}" 63 + let result = json.parse(json_str, sandbox.sandbox_decoder()) 64 + assert result 65 + == Ok(sandbox.Sandbox( 66 + id: "s1", 67 + name: "my-sandbox", 68 + provider: Some("cloudflare"), 69 + base_sandbox: Some("base"), 70 + display_name: Some("My Sandbox"), 71 + uri: Some("https://sandbox.example.com"), 72 + description: Some("A test sandbox"), 73 + topics: Some(["gleam", "elixir"]), 74 + logo: Some("https://example.com/logo.png"), 75 + readme: Some("# Hello"), 76 + repo: Some("https://github.com/example/repo"), 77 + vcpus: Some(2), 78 + memory: Some(512), 79 + disk: Some(10), 80 + installs: Some(5), 81 + status: Some("running"), 82 + started_at: Some("2024-01-02"), 83 + created_at: "2024-01-01", 84 + )) 85 + } 86 + 87 + pub fn sandbox_decoder_minimal_test() { 88 + let json_str = 89 + "{\"id\":\"s2\",\"name\":\"bare\",\"createdAt\":\"2024-03-01\"}" 90 + let result = json.parse(json_str, sandbox.sandbox_decoder()) 91 + assert result 92 + == Ok(sandbox.Sandbox( 93 + id: "s2", 94 + name: "bare", 95 + provider: None, 96 + base_sandbox: None, 97 + display_name: None, 98 + uri: None, 99 + description: None, 100 + topics: None, 101 + logo: None, 102 + readme: None, 103 + repo: None, 104 + vcpus: None, 105 + memory: None, 106 + disk: None, 107 + installs: None, 108 + status: None, 109 + started_at: None, 110 + created_at: "2024-03-01", 111 + )) 112 + } 113 + 114 + pub fn sandbox_decoder_missing_required_test() { 115 + let json_str = "{\"name\":\"no-id\",\"createdAt\":\"2024-03-01\"}" 116 + let result = json.parse(json_str, sandbox.sandbox_decoder()) 117 + assert result |> is_error 118 + } 119 + 120 + pub fn exec_decoder_test() { 121 + let json_str = "{\"stdout\":\"hello\\n\",\"stderr\":\"\",\"exitCode\":0}" 122 + let result = json.parse(json_str, sandbox.exec_decoder()) 123 + assert result 124 + == Ok(sandbox.ExecResult(stdout: "hello\n", stderr: "", exit_code: 0)) 125 + } 126 + 127 + pub fn exec_decoder_nonzero_exit_test() { 128 + let json_str = 129 + "{\"stdout\":\"\",\"stderr\":\"error: command not found\",\"exitCode\":127}" 130 + let result = json.parse(json_str, sandbox.exec_decoder()) 131 + assert result 132 + == Ok(sandbox.ExecResult( 133 + stdout: "", 134 + stderr: "error: command not found", 135 + exit_code: 127, 136 + )) 137 + } 138 + 139 + // ---- env (variables) -------------------------------------------------------- 140 + 141 + pub fn variable_decoder_test() { 142 + let json_str = 143 + "{\"id\":\"v1\",\"name\":\"DATABASE_URL\",\"value\":\"postgres://localhost/db\",\"createdAt\":\"2024-02-01\"}" 144 + let result = json.parse(json_str, env.variable_decoder()) 145 + assert result 146 + == Ok(env.Variable( 147 + id: "v1", 148 + name: "DATABASE_URL", 149 + value: "postgres://localhost/db", 150 + created_at: "2024-02-01", 151 + )) 152 + } 153 + 154 + pub fn variable_decoder_missing_field_test() { 155 + let json_str = 156 + "{\"id\":\"v2\",\"name\":\"MISSING_VALUE\",\"createdAt\":\"2024-02-01\"}" 157 + let result = json.parse(json_str, env.variable_decoder()) 158 + assert result |> is_error 159 + } 160 + 161 + // ---- files ------------------------------------------------------------------ 162 + 163 + pub fn file_decoder_test() { 164 + let json_str = 165 + "{\"id\":\"f1\",\"path\":\"/etc/config.yaml\",\"createdAt\":\"2024-04-01\"}" 166 + let result = json.parse(json_str, files.file_decoder()) 167 + assert result 168 + == Ok(files.File( 169 + id: "f1", 170 + path: "/etc/config.yaml", 171 + created_at: "2024-04-01", 172 + )) 173 + } 174 + 175 + pub fn file_decoder_missing_path_test() { 176 + let json_str = "{\"id\":\"f2\",\"createdAt\":\"2024-04-01\"}" 177 + let result = json.parse(json_str, files.file_decoder()) 178 + assert result |> is_error 179 + } 180 + 181 + // ---- ports ------------------------------------------------------------------ 182 + 183 + pub fn port_decoder_full_test() { 184 + let json_str = 185 + "{\"port\":8080,\"description\":\"HTTP\",\"previewUrl\":\"https://preview.example.com\"}" 186 + let result = json.parse(json_str, ports.port_decoder()) 187 + assert result 188 + == Ok(ports.Port( 189 + port: 8080, 190 + description: Some("HTTP"), 191 + preview_url: Some("https://preview.example.com"), 192 + )) 193 + } 194 + 195 + pub fn port_decoder_minimal_test() { 196 + let json_str = "{\"port\":3000}" 197 + let result = json.parse(json_str, ports.port_decoder()) 198 + assert result 199 + == Ok(ports.Port(port: 3000, description: None, preview_url: None)) 200 + } 201 + 202 + pub fn port_decoder_missing_port_test() { 203 + let json_str = "{\"description\":\"no port\"}" 204 + let result = json.parse(json_str, ports.port_decoder()) 205 + assert result |> is_error 206 + } 207 + 208 + // ---- services --------------------------------------------------------------- 209 + 210 + pub fn service_decoder_full_test() { 211 + let json_str = 212 + "{\"id\":\"svc1\",\"name\":\"web\",\"command\":\"npm start\",\"ports\":[3000,8080],\"description\":\"Web server\",\"status\":\"running\",\"createdAt\":\"2024-05-01\"}" 213 + let result = json.parse(json_str, services.service_decoder()) 214 + assert result 215 + == Ok(services.Service( 216 + id: "svc1", 217 + name: "web", 218 + command: "npm start", 219 + ports: Some([3000, 8080]), 220 + description: Some("Web server"), 221 + status: "running", 222 + created_at: "2024-05-01", 223 + )) 224 + } 225 + 226 + pub fn service_decoder_minimal_test() { 227 + let json_str = 228 + "{\"id\":\"svc2\",\"name\":\"worker\",\"command\":\"./run.sh\",\"status\":\"stopped\",\"createdAt\":\"2024-05-02\"}" 229 + let result = json.parse(json_str, services.service_decoder()) 230 + assert result 231 + == Ok(services.Service( 232 + id: "svc2", 233 + name: "worker", 234 + command: "./run.sh", 235 + ports: None, 236 + description: None, 237 + status: "stopped", 238 + created_at: "2024-05-02", 239 + )) 240 + } 241 + 242 + // ---- volume ----------------------------------------------------------------- 243 + 244 + pub fn volume_decoder_test() { 245 + let json_str = 246 + "{\"id\":\"vol1\",\"name\":\"data\",\"path\":\"/mnt/data\",\"createdAt\":\"2024-06-01\"}" 247 + let result = json.parse(json_str, volume.volume_decoder()) 248 + assert result 249 + == Ok(volume.Volume( 250 + id: "vol1", 251 + name: "data", 252 + path: "/mnt/data", 253 + created_at: "2024-06-01", 254 + )) 255 + } 256 + 257 + pub fn volume_decoder_missing_path_test() { 258 + let json_str = 259 + "{\"id\":\"vol2\",\"name\":\"tmp\",\"createdAt\":\"2024-06-02\"}" 260 + let result = json.parse(json_str, volume.volume_decoder()) 261 + assert result |> is_error 262 + } 263 + 264 + // ---- secrets ---------------------------------------------------------------- 265 + 266 + pub fn secret_decoder_test() { 267 + let json_str = 268 + "{\"id\":\"sec1\",\"name\":\"API_KEY\",\"createdAt\":\"2024-07-01\"}" 269 + let result = json.parse(json_str, secrets.secret_decoder()) 270 + assert result 271 + == Ok(secrets.Secret(id: "sec1", name: "API_KEY", created_at: "2024-07-01")) 272 + } 273 + 274 + pub fn secret_decoder_missing_name_test() { 275 + let json_str = "{\"id\":\"sec2\",\"createdAt\":\"2024-07-02\"}" 276 + let result = json.parse(json_str, secrets.secret_decoder()) 277 + assert result |> is_error 278 + } 279 + 280 + // ---- helpers ---------------------------------------------------------------- 281 + 282 + fn is_error(result: Result(a, b)) -> Bool { 283 + case result { 284 + Ok(_) -> False 285 + Error(_) -> True 286 + } 287 + } 288 + 289 + fn base_url(port: Int) -> String { 290 + "http://localhost:" <> int.to_string(port) 291 + } 292 + 293 + // ---- sandbox HTTP ----------------------------------------------------------- 294 + 295 + pub fn sandbox_create_ok_test() { 296 + let resp = 297 + "{\"id\":\"s1\",\"name\":\"my-box\",\"provider\":\"cloudflare\",\"createdAt\":\"2024-01-01\"}" 298 + let #(port, pid) = mock_server.start() 299 + mock_server.set_response( 300 + "/xrpc/io.pocketenv.sandbox.createSandbox", 301 + 200, 302 + resp, 303 + ) 304 + let client = pocketenv.new_client_with_base_url(base_url(port), "tok") 305 + let assert Ok(sb) = 306 + client 307 + |> sandbox.new("my-box", "base", "cloudflare") 308 + |> sandbox.create() 309 + mock_server.stop(pid) 310 + assert sb.data 311 + == sandbox.Sandbox( 312 + id: "s1", 313 + name: "my-box", 314 + provider: Some("cloudflare"), 315 + base_sandbox: None, 316 + display_name: None, 317 + uri: None, 318 + description: None, 319 + topics: None, 320 + logo: None, 321 + readme: None, 322 + repo: None, 323 + vcpus: None, 324 + memory: None, 325 + disk: None, 326 + installs: None, 327 + status: None, 328 + started_at: None, 329 + created_at: "2024-01-01", 330 + ) 331 + } 332 + 333 + pub fn sandbox_create_api_error_test() { 334 + let #(port, pid) = mock_server.start() 335 + mock_server.set_response( 336 + "/xrpc/io.pocketenv.sandbox.createSandbox", 337 + 401, 338 + "{\"error\":\"Unauthorized\"}", 339 + ) 340 + let client = pocketenv.new_client_with_base_url(base_url(port), "bad-token") 341 + let result = 342 + client 343 + |> sandbox.new("x", "base", "cloudflare") 344 + |> sandbox.create() 345 + mock_server.stop(pid) 346 + assert result == Error(pocketenv.ApiError(401)) 347 + } 348 + 349 + pub fn sandbox_create_invalid_json_test() { 350 + let #(port, pid) = mock_server.start() 351 + mock_server.set_response( 352 + "/xrpc/io.pocketenv.sandbox.createSandbox", 353 + 200, 354 + "not-json", 355 + ) 356 + let client = pocketenv.new_client_with_base_url(base_url(port), "tok") 357 + let result = 358 + client 359 + |> sandbox.new("x", "base", "cloudflare") 360 + |> sandbox.create() 361 + mock_server.stop(pid) 362 + assert result |> is_error 363 + } 364 + 365 + fn stub_connected( 366 + id: String, 367 + client: pocketenv.Client, 368 + ) -> sandbox.ConnectedSandbox { 369 + sandbox.connect( 370 + sandbox.Sandbox( 371 + id: id, 372 + name: "stub", 373 + provider: None, 374 + base_sandbox: None, 375 + display_name: None, 376 + uri: None, 377 + description: None, 378 + topics: None, 379 + logo: None, 380 + readme: None, 381 + repo: None, 382 + vcpus: None, 383 + memory: None, 384 + disk: None, 385 + installs: None, 386 + status: None, 387 + started_at: None, 388 + created_at: "2024-01-01", 389 + ), 390 + client, 391 + ) 392 + } 393 + 394 + pub fn sandbox_start_ok_test() { 395 + let #(port, pid) = mock_server.start() 396 + mock_server.set_response("/xrpc/io.pocketenv.sandbox.startSandbox", 200, "{}") 397 + let client = pocketenv.new_client_with_base_url(base_url(port), "tok") 398 + let result = stub_connected("s1", client) |> sandbox.start(None, None) 399 + mock_server.stop(pid) 400 + assert result == Ok(Nil) 401 + } 402 + 403 + pub fn sandbox_start_error_test() { 404 + let #(port, pid) = mock_server.start() 405 + mock_server.set_response("/xrpc/io.pocketenv.sandbox.startSandbox", 500, "{}") 406 + let client = pocketenv.new_client_with_base_url(base_url(port), "tok") 407 + let result = stub_connected("s1", client) |> sandbox.start(None, None) 408 + mock_server.stop(pid) 409 + assert result == Error(pocketenv.ApiError(500)) 410 + } 411 + 412 + pub fn sandbox_stop_ok_test() { 413 + let #(port, pid) = mock_server.start() 414 + mock_server.set_response("/xrpc/io.pocketenv.sandbox.stopSandbox", 200, "{}") 415 + let client = pocketenv.new_client_with_base_url(base_url(port), "tok") 416 + let result = stub_connected("s1", client) |> sandbox.stop() 417 + mock_server.stop(pid) 418 + assert result == Ok(Nil) 419 + } 420 + 421 + pub fn sandbox_stop_error_test() { 422 + let #(port, pid) = mock_server.start() 423 + mock_server.set_response("/xrpc/io.pocketenv.sandbox.stopSandbox", 404, "{}") 424 + let client = pocketenv.new_client_with_base_url(base_url(port), "tok") 425 + let result = stub_connected("missing", client) |> sandbox.stop() 426 + mock_server.stop(pid) 427 + assert result == Error(pocketenv.ApiError(404)) 428 + } 429 + 430 + pub fn sandbox_exec_ok_test() { 431 + let resp = "{\"stdout\":\"hello\\n\",\"stderr\":\"\",\"exitCode\":0}" 432 + let #(port, pid) = mock_server.start() 433 + mock_server.set_response("/xrpc/io.pocketenv.sandbox.exec", 200, resp) 434 + let client = pocketenv.new_client_with_base_url(base_url(port), "tok") 435 + let result = stub_connected("s1", client) |> sandbox.exec("echo hello") 436 + mock_server.stop(pid) 437 + assert result 438 + == Ok(sandbox.ExecResult(stdout: "hello\n", stderr: "", exit_code: 0)) 439 + } 440 + 441 + pub fn sandbox_exec_nonzero_exit_test() { 442 + let resp = "{\"stdout\":\"\",\"stderr\":\"not found\",\"exitCode\":127}" 443 + let #(port, pid) = mock_server.start() 444 + mock_server.set_response("/xrpc/io.pocketenv.sandbox.exec", 200, resp) 445 + let client = pocketenv.new_client_with_base_url(base_url(port), "tok") 446 + let result = stub_connected("s1", client) |> sandbox.exec("badcmd") 447 + mock_server.stop(pid) 448 + assert result 449 + == Ok(sandbox.ExecResult(stdout: "", stderr: "not found", exit_code: 127)) 450 + } 451 + 452 + pub fn sandbox_exec_api_error_test() { 453 + let #(port, pid) = mock_server.start() 454 + mock_server.set_response("/xrpc/io.pocketenv.sandbox.exec", 403, "{}") 455 + let client = pocketenv.new_client_with_base_url(base_url(port), "tok") 456 + let result = stub_connected("s1", client) |> sandbox.exec("ls") 457 + mock_server.stop(pid) 458 + assert result == Error(pocketenv.ApiError(403)) 459 + }