···55The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7788+## [1.2.0] - 2026-04-05
99+1010+### Added
1111+1212+- `pocketenv/copy` module with three file-transfer operations:
1313+ - `upload/3` — compress a local file or directory and extract it into a sandbox path
1414+ - `download/3` — push a sandbox path to storage and extract it locally
1515+ - `copy_to/4` — copy a path from one sandbox to another with no local I/O
1616+- Ignore-file support in `upload`: patterns from `.pocketenvignore`, `.gitignore`, `.npmignore`, and `.dockerignore` are applied during compression (supports `*`, `**`, `?`, negation via `!`, last-match-wins semantics)
1717+- `storage_url` field on `Client` (defaults to `https://sandbox.pocketenv.io`); configurable via `new_client_with_urls/3` for self-hosted or test setups
1818+819## [1.1.2] - 2026-04-02
9201021### Fixed
+31-2
README.md
···6677A Gleam client library for the [Pocketenv](https://pocketenv.io) API, providing
88access to sandboxes, environment variables, secrets, files, volumes, services,
99-ports, and networking.
99+ports, networking, and file transfers.
10101111```sh
1212-gleam add pocketenv@1
1212+gleam add pocketenv@1.2
1313```
14141515## Usage
···238238239239 // Configure Tailscale networking
240240 let assert Ok(Nil) = sb |> network.setup_tailscale("tskey-auth-xxxx")
241241+}
242242+```
243243+244244+### File transfers
245245+246246+Upload a local file or directory into a sandbox, download from a sandbox, or
247247+copy between two sandboxes. Files are transferred via a gzip'd tar archive.
248248+Ignore patterns from `.pocketenvignore`, `.gitignore`, `.npmignore`, and
249249+`.dockerignore` are respected during upload.
250250+251251+```gleam
252252+import pocketenv
253253+import pocketenv/sandbox
254254+import pocketenv/copy
255255+import gleam/option.{Some}
256256+257257+pub fn main() {
258258+ let client = pocketenv.new_client("your-token")
259259+ let assert Ok(Some(sandbox_data)) = sandbox.get(client, "sandbox-abc123")
260260+ let sb = sandbox_data |> sandbox.connect(client)
261261+262262+ // Upload a local directory into the sandbox
263263+ let assert Ok(Nil) = sb |> copy.upload("./dist", "/app/dist")
264264+265265+ // Download a path from the sandbox to a local directory
266266+ let assert Ok(Nil) = sb |> copy.download("/app/logs", "./logs")
267267+268268+ // Copy a path from this sandbox to another sandbox (no local I/O)
269269+ let assert Ok(Nil) = sb |> copy.copy_to("other-sandbox-id", "/app/data", "/app/data")
241270}
242271```
243272
···1212import gleam/result
1313import gleam/string
14141515-/// Holds the base URL and bearer token used for every API request.
1515+/// Holds the base URL, storage URL, and bearer token used for every API request.
1616pub type Client {
1717- Client(base_url: String, token: String)
1717+ Client(base_url: String, storage_url: String, token: String)
1818}
19192020/// Errors that can be returned by any API call.
···4545/// The default base URL for the Pocketenv API.
4646pub const default_base_url = "https://api.pocketenv.io"
47474848-/// Creates a new API client using the default base URL (`https://api.pocketenv.io`).
4848+/// The default base URL for the Pocketenv storage service.
4949+pub const default_storage_url = "https://sandbox.pocketenv.io"
5050+5151+/// Creates a new API client using the default URLs.
4952///
5053/// ## Example
5154///
···5356/// let client = pocketenv.new_client("your-token")
5457/// ```
5558pub fn new_client(token: String) -> Client {
5656- Client(base_url: default_base_url, token: token)
5959+ Client(
6060+ base_url: default_base_url,
6161+ storage_url: default_storage_url,
6262+ token: token,
6363+ )
5764}
58655959-/// Creates a new API client with a custom base URL.
6666+/// Creates a new API client with a custom API base URL (storage URL stays at the default).
6067///
6168/// ## Example
6269///
···6471/// let client = pocketenv.new_client_with_base_url("https://self-hosted.example.com", "your-token")
6572/// ```
6673pub fn new_client_with_base_url(base_url: String, token: String) -> Client {
6767- Client(base_url: base_url, token: token)
7474+ Client(base_url: base_url, storage_url: default_storage_url, token: token)
7575+}
7676+7777+/// Creates a new API client with custom API and storage URLs.
7878+///
7979+/// ## Example
8080+///
8181+/// ```gleam
8282+/// let client = pocketenv.new_client_with_urls("https://api.example.com", "https://storage.example.com", "your-token")
8383+/// ```
8484+pub fn new_client_with_urls(
8585+ base_url: String,
8686+ storage_url: String,
8787+ token: String,
8888+) -> Client {
8989+ Client(base_url: base_url, storage_url: storage_url, token: token)
6890}
69917092/// Fetches the profile of the authenticated actor.
+258
src/pocketenv/copy.gleam
···11+//// Transfer files between your local machine and a sandbox, or between two
22+//// sandboxes.
33+////
44+//// - `upload` — local path → sandbox path
55+//// - `download` — sandbox path → local directory
66+//// - `copy_to` — sandbox path → another sandbox path (no local I/O)
77+88+import gleam/bit_array
99+import gleam/dynamic/decode
1010+import gleam/http
1111+import gleam/http/request
1212+import gleam/httpc
1313+import gleam/json
1414+import gleam/result
1515+import gleam/string
1616+import pocketenv.{
1717+ type Client, type PocketenvError, ApiError, HttpError, JsonDecodeError,
1818+ RequestBuildError, do_post,
1919+}
2020+import pocketenv/sandbox.{type ConnectedSandbox}
2121+2222+// ---------------------------------------------------------------------------
2323+// Erlang FFI
2424+// ---------------------------------------------------------------------------
2525+2626+@external(erlang, "pocketenv_copy_ffi", "temp_path")
2727+fn temp_path() -> String
2828+2929+@external(erlang, "pocketenv_copy_ffi", "compress")
3030+fn compress_ffi(source_path: String) -> Result(String, String)
3131+3232+@external(erlang, "pocketenv_copy_ffi", "decompress")
3333+fn decompress_ffi(archive_path: String, dest_path: String) -> Result(Nil, String)
3434+3535+@external(erlang, "pocketenv_copy_ffi", "read_file")
3636+fn read_file(path: String) -> Result(BitArray, String)
3737+3838+@external(erlang, "pocketenv_copy_ffi", "write_file")
3939+fn write_file(path: String, data: BitArray) -> Result(Nil, String)
4040+4141+@external(erlang, "pocketenv_copy_ffi", "delete_file")
4242+fn delete_file(path: String) -> Nil
4343+4444+@external(erlang, "pocketenv_copy_ffi", "random_hex")
4545+fn random_hex(n: Int) -> String
4646+4747+// ---------------------------------------------------------------------------
4848+// Internal helpers
4949+// ---------------------------------------------------------------------------
5050+5151+/// Asks the sandbox to compress `directory_path` into storage.
5252+/// Returns the UUID of the stored archive.
5353+fn push_directory(
5454+ client: Client,
5555+ sandbox_id: String,
5656+ directory_path: String,
5757+) -> Result(String, PocketenvError) {
5858+ let body =
5959+ json.to_string(json.object([
6060+ #("sandboxId", json.string(sandbox_id)),
6161+ #("directoryPath", json.string(directory_path)),
6262+ ]))
6363+ use resp_body <- result.try(do_post(
6464+ client,
6565+ "/xrpc/io.pocketenv.sandbox.pushDirectory",
6666+ [],
6767+ body,
6868+ ))
6969+ json.parse(resp_body, {
7070+ use uuid <- decode.field("uuid", decode.string)
7171+ decode.success(uuid)
7272+ })
7373+ |> result.map_error(JsonDecodeError)
7474+}
7575+7676+/// Tells the sandbox to pull an archive (identified by `uuid`) into
7777+/// `directory_path`.
7878+fn pull_directory(
7979+ client: Client,
8080+ sandbox_id: String,
8181+ uuid: String,
8282+ directory_path: String,
8383+) -> Result(Nil, PocketenvError) {
8484+ let body =
8585+ json.to_string(json.object([
8686+ #("uuid", json.string(uuid)),
8787+ #("sandboxId", json.string(sandbox_id)),
8888+ #("directoryPath", json.string(directory_path)),
8989+ ]))
9090+ use _ <- result.try(do_post(
9191+ client,
9292+ "/xrpc/io.pocketenv.sandbox.pullDirectory",
9393+ [],
9494+ body,
9595+ ))
9696+ Ok(Nil)
9797+}
9898+9999+/// Uploads a local tar.gz archive to storage as multipart/form-data.
100100+/// Returns the UUID assigned to the stored archive.
101101+fn upload_to_storage(
102102+ archive_path: String,
103103+ token: String,
104104+ storage_url: String,
105105+) -> Result(String, PocketenvError) {
106106+ use content <- result.try(read_file(archive_path) |> result.map_error(HttpError))
107107+ let boundary = "FormBoundary" <> random_hex(8)
108108+ let crlf = "\r\n"
109109+ let body =
110110+ bit_array.concat([
111111+ bit_array.from_string(
112112+ "--"
113113+ <> boundary
114114+ <> crlf
115115+ <> "Content-Disposition: form-data; name=\"file\"; filename=\"archive.tar.gz\""
116116+ <> crlf
117117+ <> "Content-Type: application/gzip"
118118+ <> crlf
119119+ <> crlf,
120120+ ),
121121+ content,
122122+ bit_array.from_string(crlf <> "--" <> boundary <> "--" <> crlf),
123123+ ])
124124+ let url = storage_url <> "/cp"
125125+ use base_req <- result.try(
126126+ request.to(url) |> result.replace_error(RequestBuildError),
127127+ )
128128+ let req =
129129+ base_req
130130+ |> request.set_body(body)
131131+ |> request.set_method(http.Post)
132132+ |> request.prepend_header("authorization", "Bearer " <> token)
133133+ |> request.prepend_header(
134134+ "content-type",
135135+ "multipart/form-data; boundary=" <> boundary,
136136+ )
137137+ use resp <- result.try(
138138+ httpc.send_bits(req)
139139+ |> result.map_error(fn(e) { HttpError(string.inspect(e)) }),
140140+ )
141141+ case resp.status {
142142+ s if s >= 200 && s < 300 -> {
143143+ use body_str <- result.try(
144144+ bit_array.to_string(resp.body)
145145+ |> result.replace_error(HttpError("invalid UTF-8 in storage response")),
146146+ )
147147+ json.parse(body_str, {
148148+ use uuid <- decode.field("uuid", decode.string)
149149+ decode.success(uuid)
150150+ })
151151+ |> result.map_error(JsonDecodeError)
152152+ }
153153+ s -> Error(ApiError(s))
154154+ }
155155+}
156156+157157+/// Downloads an archive from storage by UUID, writing it to `dest_path`.
158158+fn download_from_storage(
159159+ uuid: String,
160160+ dest_path: String,
161161+ token: String,
162162+ storage_url: String,
163163+) -> Result(Nil, PocketenvError) {
164164+ let url = storage_url <> "/cp/" <> uuid
165165+ use base_req <- result.try(
166166+ request.to(url) |> result.replace_error(RequestBuildError),
167167+ )
168168+ let req =
169169+ base_req
170170+ |> request.set_body(<<>>)
171171+ |> request.set_method(http.Get)
172172+ |> request.prepend_header("authorization", "Bearer " <> token)
173173+ use resp <- result.try(
174174+ httpc.send_bits(req)
175175+ |> result.map_error(fn(e) { HttpError(string.inspect(e)) }),
176176+ )
177177+ case resp.status {
178178+ s if s >= 200 && s < 300 ->
179179+ write_file(dest_path, resp.body) |> result.map_error(HttpError)
180180+ s -> Error(ApiError(s))
181181+ }
182182+}
183183+184184+// ---------------------------------------------------------------------------
185185+// Public API
186186+// ---------------------------------------------------------------------------
187187+188188+/// Uploads a local file or directory to `sandbox_path` inside the sandbox.
189189+///
190190+/// The local path is compressed into a tar.gz archive, uploaded to storage,
191191+/// and then extracted by the sandbox at the specified destination path.
192192+///
193193+/// ## Example
194194+///
195195+/// ```gleam
196196+/// let assert Ok(Nil) = sb |> copy.upload("./dist", "/app/dist")
197197+/// ```
198198+pub fn upload(
199199+ sb: ConnectedSandbox,
200200+ local_path: String,
201201+ sandbox_path: String,
202202+) -> Result(Nil, PocketenvError) {
203203+ use archive <- result.try(
204204+ compress_ffi(local_path) |> result.map_error(HttpError),
205205+ )
206206+ let upload_result =
207207+ upload_to_storage(archive, sb.client.token, sb.client.storage_url)
208208+ let _ = delete_file(archive)
209209+ use uuid <- result.try(upload_result)
210210+ pull_directory(sb.client, sb.data.id, uuid, sandbox_path)
211211+}
212212+213213+/// Downloads `sandbox_path` from the sandbox to the local `local_path`
214214+/// directory.
215215+///
216216+/// The sandbox compresses the source path, which is then downloaded and
217217+/// extracted locally.
218218+///
219219+/// ## Example
220220+///
221221+/// ```gleam
222222+/// let assert Ok(Nil) = sb |> copy.download("/app/logs", "./logs")
223223+/// ```
224224+pub fn download(
225225+ sb: ConnectedSandbox,
226226+ sandbox_path: String,
227227+ local_path: String,
228228+) -> Result(Nil, PocketenvError) {
229229+ use uuid <- result.try(push_directory(sb.client, sb.data.id, sandbox_path))
230230+ let archive = temp_path()
231231+ let result = {
232232+ use _ <- result.try(
233233+ download_from_storage(uuid, archive, sb.client.token, sb.client.storage_url),
234234+ )
235235+ decompress_ffi(archive, local_path) |> result.map_error(HttpError)
236236+ }
237237+ let _ = delete_file(archive)
238238+ result
239239+}
240240+241241+/// Copies `src_path` from this sandbox into `dest_path` on `dest_sandbox_id`.
242242+///
243243+/// No local I/O is involved — the transfer goes directly through storage.
244244+///
245245+/// ## Example
246246+///
247247+/// ```gleam
248248+/// let assert Ok(Nil) = sb |> copy.copy_to(other_id, "/app/data", "/app/data")
249249+/// ```
250250+pub fn copy_to(
251251+ sb: ConnectedSandbox,
252252+ dest_sandbox_id: String,
253253+ src_path: String,
254254+ dest_path: String,
255255+) -> Result(Nil, PocketenvError) {
256256+ use uuid <- result.try(push_directory(sb.client, sb.data.id, src_path))
257257+ pull_directory(sb.client, dest_sandbox_id, uuid, dest_path)
258258+}
+199
src/pocketenv_copy_ffi.erl
···11+-module(pocketenv_copy_ffi).
22+-export([temp_path/0, compress/1, decompress/2, read_file/1, write_file/2,
33+ delete_file/1, random_hex/1, file_exists/1]).
44+55+%% Returns a unique temporary file path for a tar.gz archive.
66+temp_path() ->
77+ Hex = random_hex(16),
88+ TmpDir = case os:getenv("TMPDIR") of
99+ false -> "/tmp";
1010+ D -> string:trim(D, trailing, "/")
1111+ end,
1212+ list_to_binary(TmpDir ++ "/" ++ binary_to_list(Hex) ++ ".tar.gz").
1313+1414+%% Compresses a local file or directory into a tar.gz archive.
1515+%% When compressing a directory, patterns from .pocketenvignore, .gitignore,
1616+%% .npmignore, and .dockerignore are respected.
1717+%% Returns {ok, ArchivePath} | {error, Reason}.
1818+compress(SourcePath) ->
1919+ Archive = temp_path(),
2020+ SourceStr = binary_to_list(SourcePath),
2121+ case filelib:is_dir(SourceStr) of
2222+ true ->
2323+ Patterns = load_ignore_patterns(SourceStr),
2424+ BaseDir = case lists:last(SourceStr) of
2525+ $/ -> SourceStr;
2626+ _ -> SourceStr ++ "/"
2727+ end,
2828+ AllFiles = filelib:fold_files(SourceStr, ".*", true,
2929+ fun(F, Acc) -> [F | Acc] end, []),
3030+ Files = [F || F <- AllFiles,
3131+ not is_ignored(lists:nthtail(length(BaseDir), F), Patterns)],
3232+ Entries = [{lists:nthtail(length(BaseDir), F), F} || F <- Files],
3333+ create_tar(Archive, Entries);
3434+ false ->
3535+ Basename = binary_to_list(filename:basename(SourcePath)),
3636+ create_tar(Archive, [{Basename, SourceStr}])
3737+ end.
3838+3939+create_tar(Archive, Entries) ->
4040+ ArchiveStr = binary_to_list(Archive),
4141+ case erl_tar:create(ArchiveStr, Entries, [compressed]) of
4242+ ok -> {ok, Archive};
4343+ {error, Reason} -> {error, format_error(Reason)}
4444+ end.
4545+4646+%% Extracts a tar.gz archive into DestPath.
4747+%% Returns {ok, nil} | {error, Reason}.
4848+decompress(ArchivePath, DestPath) ->
4949+ DestStr = binary_to_list(DestPath),
5050+ case filelib:ensure_dir(DestStr ++ "/") of
5151+ ok ->
5252+ case erl_tar:extract(binary_to_list(ArchivePath),
5353+ [compressed, {cwd, DestStr}]) of
5454+ ok -> {ok, nil};
5555+ {error, Reason} -> {error, format_error(Reason)}
5656+ end;
5757+ {error, Reason} ->
5858+ {error, atom_to_binary(Reason, utf8)}
5959+ end.
6060+6161+%% Reads a file into a binary.
6262+%% Returns {ok, Binary} | {error, Reason}.
6363+read_file(Path) ->
6464+ case file:read_file(Path) of
6565+ {ok, Data} -> {ok, Data};
6666+ {error, Reason} -> {error, atom_to_binary(Reason, utf8)}
6767+ end.
6868+6969+%% Writes binary data to a file, creating parent directories as needed.
7070+%% Returns {ok, nil} | {error, Reason}.
7171+write_file(Path, Data) ->
7272+ PathStr = binary_to_list(Path),
7373+ case filelib:ensure_dir(PathStr) of
7474+ ok ->
7575+ case file:write_file(PathStr, Data) of
7676+ ok -> {ok, nil};
7777+ {error, Reason} -> {error, atom_to_binary(Reason, utf8)}
7878+ end;
7979+ {error, Reason} ->
8080+ {error, atom_to_binary(Reason, utf8)}
8181+ end.
8282+8383+%% Deletes a file, ignoring errors.
8484+delete_file(Path) ->
8585+ file:delete(Path),
8686+ nil.
8787+8888+%% Returns true if the path refers to an existing regular file.
8989+file_exists(Path) ->
9090+ filelib:is_regular(binary_to_list(Path)).
9191+9292+%% Returns a random lowercase hex string of 2*N characters.
9393+random_hex(N) ->
9494+ Bytes = crypto:strong_rand_bytes(N),
9595+ iolist_to_binary([io_lib:format("~2.16.0b", [B]) || <<B>> <= Bytes]).
9696+9797+%% ---------------------------------------------------------------------------
9898+%% Ignore file support
9999+%% ---------------------------------------------------------------------------
100100+101101+%% Loads patterns from .pocketenvignore, .gitignore, .npmignore, .dockerignore
102102+%% in Dir. Each pattern is {include, PatternStr} or {negate, PatternStr}.
103103+load_ignore_patterns(Dir) ->
104104+ IgnoreFiles = [".pocketenvignore", ".gitignore", ".npmignore", ".dockerignore"],
105105+ lists:flatmap(
106106+ fun(F) -> read_ignore_file(filename:join(Dir, F)) end,
107107+ IgnoreFiles
108108+ ).
109109+110110+read_ignore_file(Path) ->
111111+ case file:read_file(Path) of
112112+ {ok, Bin} ->
113113+ Lines = binary:split(Bin, [<<"\n">>, <<"\r\n">>], [global]),
114114+ lists:filtermap(fun parse_ignore_line/1, Lines);
115115+ _ ->
116116+ []
117117+ end.
118118+119119+parse_ignore_line(Line) ->
120120+ Str = string:trim(binary_to_list(Line)),
121121+ case Str of
122122+ [] -> false;
123123+ [$# | _] -> false;
124124+ [$! | Rest] -> {true, {negate, string:trim(Rest)}};
125125+ _ -> {true, {include, Str}}
126126+ end.
127127+128128+%% Returns true if RelPath should be excluded, processing patterns in order
129129+%% (last matching pattern wins, negations can re-include).
130130+is_ignored(RelPath, Patterns) ->
131131+ Basename = filename:basename(RelPath),
132132+ {Ignored, _LastPat} = lists:foldl(
133133+ fun({Type, Pat}, {_IsIgnored, _} = Acc) ->
134134+ case matches_pattern(RelPath, Basename, Pat) of
135135+ true ->
136136+ case Type of
137137+ include -> {true, Pat};
138138+ negate -> {false, Pat}
139139+ end;
140140+ false ->
141141+ Acc
142142+ end
143143+ end,
144144+ {false, none},
145145+ Patterns
146146+ ),
147147+ Ignored.
148148+149149+%% Matches RelPath against a single gitignore-style pattern.
150150+%% If the pattern contains no slash (after stripping a leading one), it is
151151+%% matched against both the full relative path and the basename.
152152+matches_pattern(RelPath, Basename, Pattern) ->
153153+ %% Strip optional leading slash (anchors to root — we already work with
154154+ %% relative paths, so the effect is the same).
155155+ Pat = case Pattern of
156156+ [$/ | Rest] -> Rest;
157157+ _ -> Pattern
158158+ end,
159159+ %% A trailing slash means "match directories only"; we skip that
160160+ %% distinction here and just strip it.
161161+ Pat2 = case lists:reverse(Pat) of
162162+ [$/ | RevRest] -> lists:reverse(RevRest);
163163+ _ -> Pat
164164+ end,
165165+ Regex = glob_to_regex(Pat2),
166166+ HasSlash = lists:member($/, Pat2),
167167+ case HasSlash of
168168+ true ->
169169+ re_match(RelPath, Regex);
170170+ false ->
171171+ %% Pattern without slash matches anywhere in the path tree.
172172+ re_match(RelPath, Regex) orelse re_match(Basename, Regex)
173173+ end.
174174+175175+re_match(String, Regex) ->
176176+ re:run(String, Regex, [{capture, none}]) =:= match.
177177+178178+%% Converts a gitignore-style glob to an Erlang regex string.
179179+glob_to_regex(Glob) ->
180180+ %% Escape regex metacharacters (all except *, ?, [ ] which we handle).
181181+ Esc = re:replace(Glob, "([.+^${}()|\\\\])", "\\\\\\1",
182182+ [global, {return, list}]),
183183+ %% Replace ** before *, so we don't double-process.
184184+ S1 = re:replace(Esc, "\\*\\*", "\x00DS\x00", [global, {return, list}]),
185185+ S2 = re:replace(S1, "\\*", "[^/]*", [global, {return, list}]),
186186+ S3 = re:replace(S2, "\x00DS\x00", ".*", [global, {return, list}]),
187187+ S4 = re:replace(S3, "\\?", "[^/]", [global, {return, list}]),
188188+ "^" ++ S4 ++ "(/.*)?$".
189189+190190+%% ---------------------------------------------------------------------------
191191+%% Error formatting
192192+%% ---------------------------------------------------------------------------
193193+194194+format_error({_Name, Reason}) ->
195195+ iolist_to_binary(erl_tar:format_error(Reason));
196196+format_error(Reason) when is_atom(Reason) ->
197197+ iolist_to_binary(erl_tar:format_error(Reason));
198198+format_error(Reason) ->
199199+ iolist_to_binary(io_lib:format("~p", [Reason])).
+232
test/pocketenv_test.gleam
···11+import gleam/bit_array
12import gleam/int
23import gleam/json
34import gleam/option.{None, Some}
45import gleeunit
56import mock_server
67import pocketenv
88+import pocketenv/copy
79import pocketenv/env
810import pocketenv/files
911import pocketenv/ports
···457459 mock_server.stop(pid)
458460 assert result == Error(pocketenv.ApiError(403))
459461}
462462+463463+// ---- copy FFI helpers -------------------------------------------------------
464464+465465+@external(erlang, "pocketenv_copy_ffi", "random_hex")
466466+fn random_hex(n: Int) -> String
467467+468468+@external(erlang, "pocketenv_copy_ffi", "write_file")
469469+fn write_file(path: String, data: BitArray) -> Result(Nil, String)
470470+471471+@external(erlang, "pocketenv_copy_ffi", "compress")
472472+fn compress(path: String) -> Result(String, String)
473473+474474+@external(erlang, "pocketenv_copy_ffi", "decompress")
475475+fn decompress(archive: String, dest: String) -> Result(Nil, String)
476476+477477+@external(erlang, "pocketenv_copy_ffi", "file_exists")
478478+fn file_exists(path: String) -> Bool
479479+480480+fn tmp_dir() -> String {
481481+ "/tmp/pocketenv_test_" <> random_hex(8)
482482+}
483483+484484+// ---- copy: compress / decompress roundtrip ----------------------------------
485485+486486+pub fn compress_decompress_single_file_test() {
487487+ let dir = tmp_dir()
488488+ let src = dir <> "/src/hello.txt"
489489+ let assert Ok(Nil) = write_file(src, bit_array.from_string("hello world"))
490490+ let assert Ok(archive) = compress(src)
491491+ let dest = dir <> "/out"
492492+ let assert Ok(Nil) = decompress(archive, dest)
493493+ assert file_exists(dest <> "/hello.txt")
494494+}
495495+496496+pub fn compress_decompress_directory_test() {
497497+ let dir = tmp_dir()
498498+ let assert Ok(Nil) =
499499+ write_file(dir <> "/src/a.txt", bit_array.from_string("a"))
500500+ let assert Ok(Nil) =
501501+ write_file(dir <> "/src/sub/b.txt", bit_array.from_string("b"))
502502+ let assert Ok(archive) = compress(dir <> "/src")
503503+ let dest = dir <> "/out"
504504+ let assert Ok(Nil) = decompress(archive, dest)
505505+ assert file_exists(dest <> "/a.txt")
506506+ assert file_exists(dest <> "/sub/b.txt")
507507+}
508508+509509+// ---- copy: ignore file support ----------------------------------------------
510510+511511+pub fn compress_gitignore_excludes_files_test() {
512512+ let dir = tmp_dir()
513513+ let assert Ok(Nil) =
514514+ write_file(dir <> "/src/keep.txt", bit_array.from_string("keep"))
515515+ let assert Ok(Nil) =
516516+ write_file(dir <> "/src/skip.log", bit_array.from_string("skip"))
517517+ let assert Ok(Nil) =
518518+ write_file(dir <> "/src/.gitignore", bit_array.from_string("*.log\n"))
519519+ let assert Ok(archive) = compress(dir <> "/src")
520520+ let dest = dir <> "/out"
521521+ let assert Ok(Nil) = decompress(archive, dest)
522522+ assert file_exists(dest <> "/keep.txt")
523523+ assert !file_exists(dest <> "/skip.log")
524524+}
525525+526526+pub fn compress_pocketenvignore_test() {
527527+ let dir = tmp_dir()
528528+ let assert Ok(Nil) =
529529+ write_file(dir <> "/src/main.go", bit_array.from_string("package main"))
530530+ let assert Ok(Nil) =
531531+ write_file(
532532+ dir <> "/src/build/output",
533533+ bit_array.from_string("compiled"),
534534+ )
535535+ let assert Ok(Nil) =
536536+ write_file(
537537+ dir <> "/src/.pocketenvignore",
538538+ bit_array.from_string("build/\n"),
539539+ )
540540+ let assert Ok(archive) = compress(dir <> "/src")
541541+ let dest = dir <> "/out"
542542+ let assert Ok(Nil) = decompress(archive, dest)
543543+ assert file_exists(dest <> "/main.go")
544544+ assert !file_exists(dest <> "/build/output")
545545+}
546546+547547+pub fn compress_negation_pattern_test() {
548548+ let dir = tmp_dir()
549549+ let assert Ok(Nil) =
550550+ write_file(dir <> "/src/a.log", bit_array.from_string("a"))
551551+ let assert Ok(Nil) =
552552+ write_file(dir <> "/src/keep.log", bit_array.from_string("keep"))
553553+ // Ignore all .log files, but re-include keep.log
554554+ let assert Ok(Nil) =
555555+ write_file(
556556+ dir <> "/src/.gitignore",
557557+ bit_array.from_string("*.log\n!keep.log\n"),
558558+ )
559559+ let assert Ok(archive) = compress(dir <> "/src")
560560+ let dest = dir <> "/out"
561561+ let assert Ok(Nil) = decompress(archive, dest)
562562+ assert !file_exists(dest <> "/a.log")
563563+ assert file_exists(dest <> "/keep.log")
564564+}
565565+566566+// ---- copy: HTTP tests -------------------------------------------------------
567567+568568+fn stub_connected_with_storage(
569569+ id: String,
570570+ client: pocketenv.Client,
571571+) -> sandbox.ConnectedSandbox {
572572+ sandbox.connect(
573573+ sandbox.Sandbox(
574574+ id: id,
575575+ name: "stub",
576576+ provider: None,
577577+ base_sandbox: None,
578578+ display_name: None,
579579+ uri: None,
580580+ description: None,
581581+ topics: None,
582582+ logo: None,
583583+ readme: None,
584584+ repo: None,
585585+ vcpus: None,
586586+ memory: None,
587587+ disk: None,
588588+ installs: None,
589589+ status: None,
590590+ started_at: None,
591591+ created_at: "2024-01-01",
592592+ ),
593593+ client,
594594+ )
595595+}
596596+597597+pub fn copy_to_ok_test() {
598598+ let #(port, pid) = mock_server.start()
599599+ mock_server.set_response(
600600+ "/xrpc/io.pocketenv.sandbox.pushDirectory",
601601+ 200,
602602+ "{\"uuid\":\"test-uuid\"}",
603603+ )
604604+ mock_server.set_response(
605605+ "/xrpc/io.pocketenv.sandbox.pullDirectory",
606606+ 200,
607607+ "{}",
608608+ )
609609+ let client =
610610+ pocketenv.new_client_with_urls(base_url(port), base_url(port), "tok")
611611+ let result =
612612+ stub_connected_with_storage("src-sb", client)
613613+ |> copy.copy_to("dst-sb", "/src", "/dst")
614614+ mock_server.stop(pid)
615615+ assert result == Ok(Nil)
616616+}
617617+618618+pub fn copy_to_push_error_test() {
619619+ let #(port, pid) = mock_server.start()
620620+ mock_server.set_response(
621621+ "/xrpc/io.pocketenv.sandbox.pushDirectory",
622622+ 500,
623623+ "{}",
624624+ )
625625+ let client =
626626+ pocketenv.new_client_with_urls(base_url(port), base_url(port), "tok")
627627+ let result =
628628+ stub_connected_with_storage("sb", client)
629629+ |> copy.copy_to("dst", "/a", "/b")
630630+ mock_server.stop(pid)
631631+ assert result == Error(pocketenv.ApiError(500))
632632+}
633633+634634+pub fn copy_to_pull_error_test() {
635635+ let #(port, pid) = mock_server.start()
636636+ mock_server.set_response(
637637+ "/xrpc/io.pocketenv.sandbox.pushDirectory",
638638+ 200,
639639+ "{\"uuid\":\"u1\"}",
640640+ )
641641+ mock_server.set_response(
642642+ "/xrpc/io.pocketenv.sandbox.pullDirectory",
643643+ 403,
644644+ "{}",
645645+ )
646646+ let client =
647647+ pocketenv.new_client_with_urls(base_url(port), base_url(port), "tok")
648648+ let result =
649649+ stub_connected_with_storage("sb", client)
650650+ |> copy.copy_to("dst", "/a", "/b")
651651+ mock_server.stop(pid)
652652+ assert result == Error(pocketenv.ApiError(403))
653653+}
654654+655655+pub fn download_push_error_test() {
656656+ let #(port, pid) = mock_server.start()
657657+ mock_server.set_response(
658658+ "/xrpc/io.pocketenv.sandbox.pushDirectory",
659659+ 401,
660660+ "{}",
661661+ )
662662+ let client =
663663+ pocketenv.new_client_with_urls(base_url(port), base_url(port), "tok")
664664+ let result =
665665+ stub_connected_with_storage("sb", client)
666666+ |> copy.download("/remote", tmp_dir())
667667+ mock_server.stop(pid)
668668+ assert result == Error(pocketenv.ApiError(401))
669669+}
670670+671671+pub fn upload_pull_error_test() {
672672+ let dir = tmp_dir()
673673+ let src = dir <> "/src/file.txt"
674674+ let assert Ok(Nil) = write_file(src, bit_array.from_string("content"))
675675+ let #(port, pid) = mock_server.start()
676676+ // Storage upload succeeds
677677+ mock_server.set_response("/cp", 200, "{\"uuid\":\"u1\"}")
678678+ // Pull fails
679679+ mock_server.set_response(
680680+ "/xrpc/io.pocketenv.sandbox.pullDirectory",
681681+ 500,
682682+ "{}",
683683+ )
684684+ let client =
685685+ pocketenv.new_client_with_urls(base_url(port), base_url(port), "tok")
686686+ let result =
687687+ stub_connected_with_storage("sb", client)
688688+ |> copy.upload(src, "/remote")
689689+ mock_server.stop(pid)
690690+ assert result == Error(pocketenv.ApiError(500))
691691+}