Gleam SDK for Pocketenv
1
fork

Configure Feed

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

Add copy module and storage_url support

+760 -9
+11
CHANGELOG.md
··· 5 5 The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), 6 6 and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 7 8 + ## [1.2.0] - 2026-04-05 9 + 10 + ### Added 11 + 12 + - `pocketenv/copy` module with three file-transfer operations: 13 + - `upload/3` — compress a local file or directory and extract it into a sandbox path 14 + - `download/3` — push a sandbox path to storage and extract it locally 15 + - `copy_to/4` — copy a path from one sandbox to another with no local I/O 16 + - Ignore-file support in `upload`: patterns from `.pocketenvignore`, `.gitignore`, `.npmignore`, and `.dockerignore` are applied during compression (supports `*`, `**`, `?`, negation via `!`, last-match-wins semantics) 17 + - `storage_url` field on `Client` (defaults to `https://sandbox.pocketenv.io`); configurable via `new_client_with_urls/3` for self-hosted or test setups 18 + 8 19 ## [1.1.2] - 2026-04-02 9 20 10 21 ### Fixed
+31 -2
README.md
··· 6 6 7 7 A Gleam client library for the [Pocketenv](https://pocketenv.io) API, providing 8 8 access to sandboxes, environment variables, secrets, files, volumes, services, 9 - ports, and networking. 9 + ports, networking, and file transfers. 10 10 11 11 ```sh 12 - gleam add pocketenv@1 12 + gleam add pocketenv@1.2 13 13 ``` 14 14 15 15 ## Usage ··· 238 238 239 239 // Configure Tailscale networking 240 240 let assert Ok(Nil) = sb |> network.setup_tailscale("tskey-auth-xxxx") 241 + } 242 + ``` 243 + 244 + ### File transfers 245 + 246 + Upload a local file or directory into a sandbox, download from a sandbox, or 247 + copy between two sandboxes. Files are transferred via a gzip'd tar archive. 248 + Ignore patterns from `.pocketenvignore`, `.gitignore`, `.npmignore`, and 249 + `.dockerignore` are respected during upload. 250 + 251 + ```gleam 252 + import pocketenv 253 + import pocketenv/sandbox 254 + import pocketenv/copy 255 + import gleam/option.{Some} 256 + 257 + pub fn main() { 258 + let client = pocketenv.new_client("your-token") 259 + let assert Ok(Some(sandbox_data)) = sandbox.get(client, "sandbox-abc123") 260 + let sb = sandbox_data |> sandbox.connect(client) 261 + 262 + // Upload a local directory into the sandbox 263 + let assert Ok(Nil) = sb |> copy.upload("./dist", "/app/dist") 264 + 265 + // Download a path from the sandbox to a local directory 266 + let assert Ok(Nil) = sb |> copy.download("/app/logs", "./logs") 267 + 268 + // Copy a path from this sandbox to another sandbox (no local I/O) 269 + let assert Ok(Nil) = sb |> copy.copy_to("other-sandbox-id", "/app/data", "/app/data") 241 270 } 242 271 ``` 243 272
+1 -1
gleam.toml
··· 1 1 name = "pocketenv" 2 - version = "1.1.2" 2 + version = "1.2.0" 3 3 4 4 description = "Gleam SDK for Pocketenv" 5 5 licences = ["MIT"]
+28 -6
src/pocketenv.gleam
··· 12 12 import gleam/result 13 13 import gleam/string 14 14 15 - /// Holds the base URL and bearer token used for every API request. 15 + /// Holds the base URL, storage URL, and bearer token used for every API request. 16 16 pub type Client { 17 - Client(base_url: String, token: String) 17 + Client(base_url: String, storage_url: String, token: String) 18 18 } 19 19 20 20 /// Errors that can be returned by any API call. ··· 45 45 /// The default base URL for the Pocketenv API. 46 46 pub const default_base_url = "https://api.pocketenv.io" 47 47 48 - /// Creates a new API client using the default base URL (`https://api.pocketenv.io`). 48 + /// The default base URL for the Pocketenv storage service. 49 + pub const default_storage_url = "https://sandbox.pocketenv.io" 50 + 51 + /// Creates a new API client using the default URLs. 49 52 /// 50 53 /// ## Example 51 54 /// ··· 53 56 /// let client = pocketenv.new_client("your-token") 54 57 /// ``` 55 58 pub fn new_client(token: String) -> Client { 56 - Client(base_url: default_base_url, token: token) 59 + Client( 60 + base_url: default_base_url, 61 + storage_url: default_storage_url, 62 + token: token, 63 + ) 57 64 } 58 65 59 - /// Creates a new API client with a custom base URL. 66 + /// Creates a new API client with a custom API base URL (storage URL stays at the default). 60 67 /// 61 68 /// ## Example 62 69 /// ··· 64 71 /// let client = pocketenv.new_client_with_base_url("https://self-hosted.example.com", "your-token") 65 72 /// ``` 66 73 pub fn new_client_with_base_url(base_url: String, token: String) -> Client { 67 - Client(base_url: base_url, token: token) 74 + Client(base_url: base_url, storage_url: default_storage_url, token: token) 75 + } 76 + 77 + /// Creates a new API client with custom API and storage URLs. 78 + /// 79 + /// ## Example 80 + /// 81 + /// ```gleam 82 + /// let client = pocketenv.new_client_with_urls("https://api.example.com", "https://storage.example.com", "your-token") 83 + /// ``` 84 + pub fn new_client_with_urls( 85 + base_url: String, 86 + storage_url: String, 87 + token: String, 88 + ) -> Client { 89 + Client(base_url: base_url, storage_url: storage_url, token: token) 68 90 } 69 91 70 92 /// Fetches the profile of the authenticated actor.
+258
src/pocketenv/copy.gleam
··· 1 + //// Transfer files between your local machine and a sandbox, or between two 2 + //// sandboxes. 3 + //// 4 + //// - `upload` — local path → sandbox path 5 + //// - `download` — sandbox path → local directory 6 + //// - `copy_to` — sandbox path → another sandbox path (no local I/O) 7 + 8 + import gleam/bit_array 9 + import gleam/dynamic/decode 10 + import gleam/http 11 + import gleam/http/request 12 + import gleam/httpc 13 + import gleam/json 14 + import gleam/result 15 + import gleam/string 16 + import pocketenv.{ 17 + type Client, type PocketenvError, ApiError, HttpError, JsonDecodeError, 18 + RequestBuildError, do_post, 19 + } 20 + import pocketenv/sandbox.{type ConnectedSandbox} 21 + 22 + // --------------------------------------------------------------------------- 23 + // Erlang FFI 24 + // --------------------------------------------------------------------------- 25 + 26 + @external(erlang, "pocketenv_copy_ffi", "temp_path") 27 + fn temp_path() -> String 28 + 29 + @external(erlang, "pocketenv_copy_ffi", "compress") 30 + fn compress_ffi(source_path: String) -> Result(String, String) 31 + 32 + @external(erlang, "pocketenv_copy_ffi", "decompress") 33 + fn decompress_ffi(archive_path: String, dest_path: String) -> Result(Nil, String) 34 + 35 + @external(erlang, "pocketenv_copy_ffi", "read_file") 36 + fn read_file(path: String) -> Result(BitArray, String) 37 + 38 + @external(erlang, "pocketenv_copy_ffi", "write_file") 39 + fn write_file(path: String, data: BitArray) -> Result(Nil, String) 40 + 41 + @external(erlang, "pocketenv_copy_ffi", "delete_file") 42 + fn delete_file(path: String) -> Nil 43 + 44 + @external(erlang, "pocketenv_copy_ffi", "random_hex") 45 + fn random_hex(n: Int) -> String 46 + 47 + // --------------------------------------------------------------------------- 48 + // Internal helpers 49 + // --------------------------------------------------------------------------- 50 + 51 + /// Asks the sandbox to compress `directory_path` into storage. 52 + /// Returns the UUID of the stored archive. 53 + fn push_directory( 54 + client: Client, 55 + sandbox_id: String, 56 + directory_path: String, 57 + ) -> Result(String, PocketenvError) { 58 + let body = 59 + json.to_string(json.object([ 60 + #("sandboxId", json.string(sandbox_id)), 61 + #("directoryPath", json.string(directory_path)), 62 + ])) 63 + use resp_body <- result.try(do_post( 64 + client, 65 + "/xrpc/io.pocketenv.sandbox.pushDirectory", 66 + [], 67 + body, 68 + )) 69 + json.parse(resp_body, { 70 + use uuid <- decode.field("uuid", decode.string) 71 + decode.success(uuid) 72 + }) 73 + |> result.map_error(JsonDecodeError) 74 + } 75 + 76 + /// Tells the sandbox to pull an archive (identified by `uuid`) into 77 + /// `directory_path`. 78 + fn pull_directory( 79 + client: Client, 80 + sandbox_id: String, 81 + uuid: String, 82 + directory_path: String, 83 + ) -> Result(Nil, PocketenvError) { 84 + let body = 85 + json.to_string(json.object([ 86 + #("uuid", json.string(uuid)), 87 + #("sandboxId", json.string(sandbox_id)), 88 + #("directoryPath", json.string(directory_path)), 89 + ])) 90 + use _ <- result.try(do_post( 91 + client, 92 + "/xrpc/io.pocketenv.sandbox.pullDirectory", 93 + [], 94 + body, 95 + )) 96 + Ok(Nil) 97 + } 98 + 99 + /// Uploads a local tar.gz archive to storage as multipart/form-data. 100 + /// Returns the UUID assigned to the stored archive. 101 + fn upload_to_storage( 102 + archive_path: String, 103 + token: String, 104 + storage_url: String, 105 + ) -> Result(String, PocketenvError) { 106 + use content <- result.try(read_file(archive_path) |> result.map_error(HttpError)) 107 + let boundary = "FormBoundary" <> random_hex(8) 108 + let crlf = "\r\n" 109 + let body = 110 + bit_array.concat([ 111 + bit_array.from_string( 112 + "--" 113 + <> boundary 114 + <> crlf 115 + <> "Content-Disposition: form-data; name=\"file\"; filename=\"archive.tar.gz\"" 116 + <> crlf 117 + <> "Content-Type: application/gzip" 118 + <> crlf 119 + <> crlf, 120 + ), 121 + content, 122 + bit_array.from_string(crlf <> "--" <> boundary <> "--" <> crlf), 123 + ]) 124 + let url = storage_url <> "/cp" 125 + use base_req <- result.try( 126 + request.to(url) |> result.replace_error(RequestBuildError), 127 + ) 128 + let req = 129 + base_req 130 + |> request.set_body(body) 131 + |> request.set_method(http.Post) 132 + |> request.prepend_header("authorization", "Bearer " <> token) 133 + |> request.prepend_header( 134 + "content-type", 135 + "multipart/form-data; boundary=" <> boundary, 136 + ) 137 + use resp <- result.try( 138 + httpc.send_bits(req) 139 + |> result.map_error(fn(e) { HttpError(string.inspect(e)) }), 140 + ) 141 + case resp.status { 142 + s if s >= 200 && s < 300 -> { 143 + use body_str <- result.try( 144 + bit_array.to_string(resp.body) 145 + |> result.replace_error(HttpError("invalid UTF-8 in storage response")), 146 + ) 147 + json.parse(body_str, { 148 + use uuid <- decode.field("uuid", decode.string) 149 + decode.success(uuid) 150 + }) 151 + |> result.map_error(JsonDecodeError) 152 + } 153 + s -> Error(ApiError(s)) 154 + } 155 + } 156 + 157 + /// Downloads an archive from storage by UUID, writing it to `dest_path`. 158 + fn download_from_storage( 159 + uuid: String, 160 + dest_path: String, 161 + token: String, 162 + storage_url: String, 163 + ) -> Result(Nil, PocketenvError) { 164 + let url = storage_url <> "/cp/" <> uuid 165 + use base_req <- result.try( 166 + request.to(url) |> result.replace_error(RequestBuildError), 167 + ) 168 + let req = 169 + base_req 170 + |> request.set_body(<<>>) 171 + |> request.set_method(http.Get) 172 + |> request.prepend_header("authorization", "Bearer " <> token) 173 + use resp <- result.try( 174 + httpc.send_bits(req) 175 + |> result.map_error(fn(e) { HttpError(string.inspect(e)) }), 176 + ) 177 + case resp.status { 178 + s if s >= 200 && s < 300 -> 179 + write_file(dest_path, resp.body) |> result.map_error(HttpError) 180 + s -> Error(ApiError(s)) 181 + } 182 + } 183 + 184 + // --------------------------------------------------------------------------- 185 + // Public API 186 + // --------------------------------------------------------------------------- 187 + 188 + /// Uploads a local file or directory to `sandbox_path` inside the sandbox. 189 + /// 190 + /// The local path is compressed into a tar.gz archive, uploaded to storage, 191 + /// and then extracted by the sandbox at the specified destination path. 192 + /// 193 + /// ## Example 194 + /// 195 + /// ```gleam 196 + /// let assert Ok(Nil) = sb |> copy.upload("./dist", "/app/dist") 197 + /// ``` 198 + pub fn upload( 199 + sb: ConnectedSandbox, 200 + local_path: String, 201 + sandbox_path: String, 202 + ) -> Result(Nil, PocketenvError) { 203 + use archive <- result.try( 204 + compress_ffi(local_path) |> result.map_error(HttpError), 205 + ) 206 + let upload_result = 207 + upload_to_storage(archive, sb.client.token, sb.client.storage_url) 208 + let _ = delete_file(archive) 209 + use uuid <- result.try(upload_result) 210 + pull_directory(sb.client, sb.data.id, uuid, sandbox_path) 211 + } 212 + 213 + /// Downloads `sandbox_path` from the sandbox to the local `local_path` 214 + /// directory. 215 + /// 216 + /// The sandbox compresses the source path, which is then downloaded and 217 + /// extracted locally. 218 + /// 219 + /// ## Example 220 + /// 221 + /// ```gleam 222 + /// let assert Ok(Nil) = sb |> copy.download("/app/logs", "./logs") 223 + /// ``` 224 + pub fn download( 225 + sb: ConnectedSandbox, 226 + sandbox_path: String, 227 + local_path: String, 228 + ) -> Result(Nil, PocketenvError) { 229 + use uuid <- result.try(push_directory(sb.client, sb.data.id, sandbox_path)) 230 + let archive = temp_path() 231 + let result = { 232 + use _ <- result.try( 233 + download_from_storage(uuid, archive, sb.client.token, sb.client.storage_url), 234 + ) 235 + decompress_ffi(archive, local_path) |> result.map_error(HttpError) 236 + } 237 + let _ = delete_file(archive) 238 + result 239 + } 240 + 241 + /// Copies `src_path` from this sandbox into `dest_path` on `dest_sandbox_id`. 242 + /// 243 + /// No local I/O is involved — the transfer goes directly through storage. 244 + /// 245 + /// ## Example 246 + /// 247 + /// ```gleam 248 + /// let assert Ok(Nil) = sb |> copy.copy_to(other_id, "/app/data", "/app/data") 249 + /// ``` 250 + pub fn copy_to( 251 + sb: ConnectedSandbox, 252 + dest_sandbox_id: String, 253 + src_path: String, 254 + dest_path: String, 255 + ) -> Result(Nil, PocketenvError) { 256 + use uuid <- result.try(push_directory(sb.client, sb.data.id, src_path)) 257 + pull_directory(sb.client, dest_sandbox_id, uuid, dest_path) 258 + }
+199
src/pocketenv_copy_ffi.erl
··· 1 + -module(pocketenv_copy_ffi). 2 + -export([temp_path/0, compress/1, decompress/2, read_file/1, write_file/2, 3 + delete_file/1, random_hex/1, file_exists/1]). 4 + 5 + %% Returns a unique temporary file path for a tar.gz archive. 6 + temp_path() -> 7 + Hex = random_hex(16), 8 + TmpDir = case os:getenv("TMPDIR") of 9 + false -> "/tmp"; 10 + D -> string:trim(D, trailing, "/") 11 + end, 12 + list_to_binary(TmpDir ++ "/" ++ binary_to_list(Hex) ++ ".tar.gz"). 13 + 14 + %% Compresses a local file or directory into a tar.gz archive. 15 + %% When compressing a directory, patterns from .pocketenvignore, .gitignore, 16 + %% .npmignore, and .dockerignore are respected. 17 + %% Returns {ok, ArchivePath} | {error, Reason}. 18 + compress(SourcePath) -> 19 + Archive = temp_path(), 20 + SourceStr = binary_to_list(SourcePath), 21 + case filelib:is_dir(SourceStr) of 22 + true -> 23 + Patterns = load_ignore_patterns(SourceStr), 24 + BaseDir = case lists:last(SourceStr) of 25 + $/ -> SourceStr; 26 + _ -> SourceStr ++ "/" 27 + end, 28 + AllFiles = filelib:fold_files(SourceStr, ".*", true, 29 + fun(F, Acc) -> [F | Acc] end, []), 30 + Files = [F || F <- AllFiles, 31 + not is_ignored(lists:nthtail(length(BaseDir), F), Patterns)], 32 + Entries = [{lists:nthtail(length(BaseDir), F), F} || F <- Files], 33 + create_tar(Archive, Entries); 34 + false -> 35 + Basename = binary_to_list(filename:basename(SourcePath)), 36 + create_tar(Archive, [{Basename, SourceStr}]) 37 + end. 38 + 39 + create_tar(Archive, Entries) -> 40 + ArchiveStr = binary_to_list(Archive), 41 + case erl_tar:create(ArchiveStr, Entries, [compressed]) of 42 + ok -> {ok, Archive}; 43 + {error, Reason} -> {error, format_error(Reason)} 44 + end. 45 + 46 + %% Extracts a tar.gz archive into DestPath. 47 + %% Returns {ok, nil} | {error, Reason}. 48 + decompress(ArchivePath, DestPath) -> 49 + DestStr = binary_to_list(DestPath), 50 + case filelib:ensure_dir(DestStr ++ "/") of 51 + ok -> 52 + case erl_tar:extract(binary_to_list(ArchivePath), 53 + [compressed, {cwd, DestStr}]) of 54 + ok -> {ok, nil}; 55 + {error, Reason} -> {error, format_error(Reason)} 56 + end; 57 + {error, Reason} -> 58 + {error, atom_to_binary(Reason, utf8)} 59 + end. 60 + 61 + %% Reads a file into a binary. 62 + %% Returns {ok, Binary} | {error, Reason}. 63 + read_file(Path) -> 64 + case file:read_file(Path) of 65 + {ok, Data} -> {ok, Data}; 66 + {error, Reason} -> {error, atom_to_binary(Reason, utf8)} 67 + end. 68 + 69 + %% Writes binary data to a file, creating parent directories as needed. 70 + %% Returns {ok, nil} | {error, Reason}. 71 + write_file(Path, Data) -> 72 + PathStr = binary_to_list(Path), 73 + case filelib:ensure_dir(PathStr) of 74 + ok -> 75 + case file:write_file(PathStr, Data) of 76 + ok -> {ok, nil}; 77 + {error, Reason} -> {error, atom_to_binary(Reason, utf8)} 78 + end; 79 + {error, Reason} -> 80 + {error, atom_to_binary(Reason, utf8)} 81 + end. 82 + 83 + %% Deletes a file, ignoring errors. 84 + delete_file(Path) -> 85 + file:delete(Path), 86 + nil. 87 + 88 + %% Returns true if the path refers to an existing regular file. 89 + file_exists(Path) -> 90 + filelib:is_regular(binary_to_list(Path)). 91 + 92 + %% Returns a random lowercase hex string of 2*N characters. 93 + random_hex(N) -> 94 + Bytes = crypto:strong_rand_bytes(N), 95 + iolist_to_binary([io_lib:format("~2.16.0b", [B]) || <<B>> <= Bytes]). 96 + 97 + %% --------------------------------------------------------------------------- 98 + %% Ignore file support 99 + %% --------------------------------------------------------------------------- 100 + 101 + %% Loads patterns from .pocketenvignore, .gitignore, .npmignore, .dockerignore 102 + %% in Dir. Each pattern is {include, PatternStr} or {negate, PatternStr}. 103 + load_ignore_patterns(Dir) -> 104 + IgnoreFiles = [".pocketenvignore", ".gitignore", ".npmignore", ".dockerignore"], 105 + lists:flatmap( 106 + fun(F) -> read_ignore_file(filename:join(Dir, F)) end, 107 + IgnoreFiles 108 + ). 109 + 110 + read_ignore_file(Path) -> 111 + case file:read_file(Path) of 112 + {ok, Bin} -> 113 + Lines = binary:split(Bin, [<<"\n">>, <<"\r\n">>], [global]), 114 + lists:filtermap(fun parse_ignore_line/1, Lines); 115 + _ -> 116 + [] 117 + end. 118 + 119 + parse_ignore_line(Line) -> 120 + Str = string:trim(binary_to_list(Line)), 121 + case Str of 122 + [] -> false; 123 + [$# | _] -> false; 124 + [$! | Rest] -> {true, {negate, string:trim(Rest)}}; 125 + _ -> {true, {include, Str}} 126 + end. 127 + 128 + %% Returns true if RelPath should be excluded, processing patterns in order 129 + %% (last matching pattern wins, negations can re-include). 130 + is_ignored(RelPath, Patterns) -> 131 + Basename = filename:basename(RelPath), 132 + {Ignored, _LastPat} = lists:foldl( 133 + fun({Type, Pat}, {_IsIgnored, _} = Acc) -> 134 + case matches_pattern(RelPath, Basename, Pat) of 135 + true -> 136 + case Type of 137 + include -> {true, Pat}; 138 + negate -> {false, Pat} 139 + end; 140 + false -> 141 + Acc 142 + end 143 + end, 144 + {false, none}, 145 + Patterns 146 + ), 147 + Ignored. 148 + 149 + %% Matches RelPath against a single gitignore-style pattern. 150 + %% If the pattern contains no slash (after stripping a leading one), it is 151 + %% matched against both the full relative path and the basename. 152 + matches_pattern(RelPath, Basename, Pattern) -> 153 + %% Strip optional leading slash (anchors to root — we already work with 154 + %% relative paths, so the effect is the same). 155 + Pat = case Pattern of 156 + [$/ | Rest] -> Rest; 157 + _ -> Pattern 158 + end, 159 + %% A trailing slash means "match directories only"; we skip that 160 + %% distinction here and just strip it. 161 + Pat2 = case lists:reverse(Pat) of 162 + [$/ | RevRest] -> lists:reverse(RevRest); 163 + _ -> Pat 164 + end, 165 + Regex = glob_to_regex(Pat2), 166 + HasSlash = lists:member($/, Pat2), 167 + case HasSlash of 168 + true -> 169 + re_match(RelPath, Regex); 170 + false -> 171 + %% Pattern without slash matches anywhere in the path tree. 172 + re_match(RelPath, Regex) orelse re_match(Basename, Regex) 173 + end. 174 + 175 + re_match(String, Regex) -> 176 + re:run(String, Regex, [{capture, none}]) =:= match. 177 + 178 + %% Converts a gitignore-style glob to an Erlang regex string. 179 + glob_to_regex(Glob) -> 180 + %% Escape regex metacharacters (all except *, ?, [ ] which we handle). 181 + Esc = re:replace(Glob, "([.+^${}()|\\\\])", "\\\\\\1", 182 + [global, {return, list}]), 183 + %% Replace ** before *, so we don't double-process. 184 + S1 = re:replace(Esc, "\\*\\*", "\x00DS\x00", [global, {return, list}]), 185 + S2 = re:replace(S1, "\\*", "[^/]*", [global, {return, list}]), 186 + S3 = re:replace(S2, "\x00DS\x00", ".*", [global, {return, list}]), 187 + S4 = re:replace(S3, "\\?", "[^/]", [global, {return, list}]), 188 + "^" ++ S4 ++ "(/.*)?$". 189 + 190 + %% --------------------------------------------------------------------------- 191 + %% Error formatting 192 + %% --------------------------------------------------------------------------- 193 + 194 + format_error({_Name, Reason}) -> 195 + iolist_to_binary(erl_tar:format_error(Reason)); 196 + format_error(Reason) when is_atom(Reason) -> 197 + iolist_to_binary(erl_tar:format_error(Reason)); 198 + format_error(Reason) -> 199 + iolist_to_binary(io_lib:format("~p", [Reason])).
+232
test/pocketenv_test.gleam
··· 1 + import gleam/bit_array 1 2 import gleam/int 2 3 import gleam/json 3 4 import gleam/option.{None, Some} 4 5 import gleeunit 5 6 import mock_server 6 7 import pocketenv 8 + import pocketenv/copy 7 9 import pocketenv/env 8 10 import pocketenv/files 9 11 import pocketenv/ports ··· 457 459 mock_server.stop(pid) 458 460 assert result == Error(pocketenv.ApiError(403)) 459 461 } 462 + 463 + // ---- copy FFI helpers ------------------------------------------------------- 464 + 465 + @external(erlang, "pocketenv_copy_ffi", "random_hex") 466 + fn random_hex(n: Int) -> String 467 + 468 + @external(erlang, "pocketenv_copy_ffi", "write_file") 469 + fn write_file(path: String, data: BitArray) -> Result(Nil, String) 470 + 471 + @external(erlang, "pocketenv_copy_ffi", "compress") 472 + fn compress(path: String) -> Result(String, String) 473 + 474 + @external(erlang, "pocketenv_copy_ffi", "decompress") 475 + fn decompress(archive: String, dest: String) -> Result(Nil, String) 476 + 477 + @external(erlang, "pocketenv_copy_ffi", "file_exists") 478 + fn file_exists(path: String) -> Bool 479 + 480 + fn tmp_dir() -> String { 481 + "/tmp/pocketenv_test_" <> random_hex(8) 482 + } 483 + 484 + // ---- copy: compress / decompress roundtrip ---------------------------------- 485 + 486 + pub fn compress_decompress_single_file_test() { 487 + let dir = tmp_dir() 488 + let src = dir <> "/src/hello.txt" 489 + let assert Ok(Nil) = write_file(src, bit_array.from_string("hello world")) 490 + let assert Ok(archive) = compress(src) 491 + let dest = dir <> "/out" 492 + let assert Ok(Nil) = decompress(archive, dest) 493 + assert file_exists(dest <> "/hello.txt") 494 + } 495 + 496 + pub fn compress_decompress_directory_test() { 497 + let dir = tmp_dir() 498 + let assert Ok(Nil) = 499 + write_file(dir <> "/src/a.txt", bit_array.from_string("a")) 500 + let assert Ok(Nil) = 501 + write_file(dir <> "/src/sub/b.txt", bit_array.from_string("b")) 502 + let assert Ok(archive) = compress(dir <> "/src") 503 + let dest = dir <> "/out" 504 + let assert Ok(Nil) = decompress(archive, dest) 505 + assert file_exists(dest <> "/a.txt") 506 + assert file_exists(dest <> "/sub/b.txt") 507 + } 508 + 509 + // ---- copy: ignore file support ---------------------------------------------- 510 + 511 + pub fn compress_gitignore_excludes_files_test() { 512 + let dir = tmp_dir() 513 + let assert Ok(Nil) = 514 + write_file(dir <> "/src/keep.txt", bit_array.from_string("keep")) 515 + let assert Ok(Nil) = 516 + write_file(dir <> "/src/skip.log", bit_array.from_string("skip")) 517 + let assert Ok(Nil) = 518 + write_file(dir <> "/src/.gitignore", bit_array.from_string("*.log\n")) 519 + let assert Ok(archive) = compress(dir <> "/src") 520 + let dest = dir <> "/out" 521 + let assert Ok(Nil) = decompress(archive, dest) 522 + assert file_exists(dest <> "/keep.txt") 523 + assert !file_exists(dest <> "/skip.log") 524 + } 525 + 526 + pub fn compress_pocketenvignore_test() { 527 + let dir = tmp_dir() 528 + let assert Ok(Nil) = 529 + write_file(dir <> "/src/main.go", bit_array.from_string("package main")) 530 + let assert Ok(Nil) = 531 + write_file( 532 + dir <> "/src/build/output", 533 + bit_array.from_string("compiled"), 534 + ) 535 + let assert Ok(Nil) = 536 + write_file( 537 + dir <> "/src/.pocketenvignore", 538 + bit_array.from_string("build/\n"), 539 + ) 540 + let assert Ok(archive) = compress(dir <> "/src") 541 + let dest = dir <> "/out" 542 + let assert Ok(Nil) = decompress(archive, dest) 543 + assert file_exists(dest <> "/main.go") 544 + assert !file_exists(dest <> "/build/output") 545 + } 546 + 547 + pub fn compress_negation_pattern_test() { 548 + let dir = tmp_dir() 549 + let assert Ok(Nil) = 550 + write_file(dir <> "/src/a.log", bit_array.from_string("a")) 551 + let assert Ok(Nil) = 552 + write_file(dir <> "/src/keep.log", bit_array.from_string("keep")) 553 + // Ignore all .log files, but re-include keep.log 554 + let assert Ok(Nil) = 555 + write_file( 556 + dir <> "/src/.gitignore", 557 + bit_array.from_string("*.log\n!keep.log\n"), 558 + ) 559 + let assert Ok(archive) = compress(dir <> "/src") 560 + let dest = dir <> "/out" 561 + let assert Ok(Nil) = decompress(archive, dest) 562 + assert !file_exists(dest <> "/a.log") 563 + assert file_exists(dest <> "/keep.log") 564 + } 565 + 566 + // ---- copy: HTTP tests ------------------------------------------------------- 567 + 568 + fn stub_connected_with_storage( 569 + id: String, 570 + client: pocketenv.Client, 571 + ) -> sandbox.ConnectedSandbox { 572 + sandbox.connect( 573 + sandbox.Sandbox( 574 + id: id, 575 + name: "stub", 576 + provider: None, 577 + base_sandbox: None, 578 + display_name: None, 579 + uri: None, 580 + description: None, 581 + topics: None, 582 + logo: None, 583 + readme: None, 584 + repo: None, 585 + vcpus: None, 586 + memory: None, 587 + disk: None, 588 + installs: None, 589 + status: None, 590 + started_at: None, 591 + created_at: "2024-01-01", 592 + ), 593 + client, 594 + ) 595 + } 596 + 597 + pub fn copy_to_ok_test() { 598 + let #(port, pid) = mock_server.start() 599 + mock_server.set_response( 600 + "/xrpc/io.pocketenv.sandbox.pushDirectory", 601 + 200, 602 + "{\"uuid\":\"test-uuid\"}", 603 + ) 604 + mock_server.set_response( 605 + "/xrpc/io.pocketenv.sandbox.pullDirectory", 606 + 200, 607 + "{}", 608 + ) 609 + let client = 610 + pocketenv.new_client_with_urls(base_url(port), base_url(port), "tok") 611 + let result = 612 + stub_connected_with_storage("src-sb", client) 613 + |> copy.copy_to("dst-sb", "/src", "/dst") 614 + mock_server.stop(pid) 615 + assert result == Ok(Nil) 616 + } 617 + 618 + pub fn copy_to_push_error_test() { 619 + let #(port, pid) = mock_server.start() 620 + mock_server.set_response( 621 + "/xrpc/io.pocketenv.sandbox.pushDirectory", 622 + 500, 623 + "{}", 624 + ) 625 + let client = 626 + pocketenv.new_client_with_urls(base_url(port), base_url(port), "tok") 627 + let result = 628 + stub_connected_with_storage("sb", client) 629 + |> copy.copy_to("dst", "/a", "/b") 630 + mock_server.stop(pid) 631 + assert result == Error(pocketenv.ApiError(500)) 632 + } 633 + 634 + pub fn copy_to_pull_error_test() { 635 + let #(port, pid) = mock_server.start() 636 + mock_server.set_response( 637 + "/xrpc/io.pocketenv.sandbox.pushDirectory", 638 + 200, 639 + "{\"uuid\":\"u1\"}", 640 + ) 641 + mock_server.set_response( 642 + "/xrpc/io.pocketenv.sandbox.pullDirectory", 643 + 403, 644 + "{}", 645 + ) 646 + let client = 647 + pocketenv.new_client_with_urls(base_url(port), base_url(port), "tok") 648 + let result = 649 + stub_connected_with_storage("sb", client) 650 + |> copy.copy_to("dst", "/a", "/b") 651 + mock_server.stop(pid) 652 + assert result == Error(pocketenv.ApiError(403)) 653 + } 654 + 655 + pub fn download_push_error_test() { 656 + let #(port, pid) = mock_server.start() 657 + mock_server.set_response( 658 + "/xrpc/io.pocketenv.sandbox.pushDirectory", 659 + 401, 660 + "{}", 661 + ) 662 + let client = 663 + pocketenv.new_client_with_urls(base_url(port), base_url(port), "tok") 664 + let result = 665 + stub_connected_with_storage("sb", client) 666 + |> copy.download("/remote", tmp_dir()) 667 + mock_server.stop(pid) 668 + assert result == Error(pocketenv.ApiError(401)) 669 + } 670 + 671 + pub fn upload_pull_error_test() { 672 + let dir = tmp_dir() 673 + let src = dir <> "/src/file.txt" 674 + let assert Ok(Nil) = write_file(src, bit_array.from_string("content")) 675 + let #(port, pid) = mock_server.start() 676 + // Storage upload succeeds 677 + mock_server.set_response("/cp", 200, "{\"uuid\":\"u1\"}") 678 + // Pull fails 679 + mock_server.set_response( 680 + "/xrpc/io.pocketenv.sandbox.pullDirectory", 681 + 500, 682 + "{}", 683 + ) 684 + let client = 685 + pocketenv.new_client_with_urls(base_url(port), base_url(port), "tok") 686 + let result = 687 + stub_connected_with_storage("sb", client) 688 + |> copy.upload(src, "/remote") 689 + mock_server.stop(pid) 690 + assert result == Error(pocketenv.ApiError(500)) 691 + }