Elixir SDK for Pocketenv
1
fork

Configure Feed

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

Add sandbox file copy and ignore support

+831 -1
+9
CHANGELOG.md
··· 5 5 The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 6 and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 7 8 + ## [0.1.8] - 2026-04-05 9 + 10 + ### Added 11 + - Copy interface: `Sandbox.upload/4`, `Sandbox.download/4`, `Sandbox.copy_to/5` for transferring files between local paths and sandboxes, or between two sandboxes 12 + - `Pocketenv.Copy` module handling tar.gz compression/decompression (via `:erl_tar`), multipart upload to storage, and binary download from storage 13 + - `Pocketenv.Ignore` module: gitignore-style filtering applied during directory compression, respecting `.pocketenvignore`, `.gitignore`, `.npmignore`, and `.dockerignore` files at any depth; supports `*`, `**`, `?`, `[...]` globs, trailing `/` directory patterns, and `!` negation 14 + - `POCKETENV_STORAGE_URL` environment variable and `:storage_url` app config key for overriding the storage endpoint (default: `https://sandbox.pocketenv.io`) 15 + 8 16 ## [0.1.7] - 2026-04-02 9 17 10 18 ### Fixed ··· 55 63 - MIT License 56 64 - Package description in `mix.exs` 57 65 66 + [0.1.8]: https://github.com/pocketenv-io/pocketenv-elixir/compare/v0.1.7...v0.1.8 58 67 [0.1.7]: https://github.com/pocketenv-io/pocketenv-elixir/compare/v0.1.6...v0.1.7 59 68 [0.1.6]: https://github.com/pocketenv-io/pocketenv-elixir/compare/v0.1.5...v0.1.6 60 69 [0.1.5]: https://github.com/pocketenv-io/pocketenv-elixir/compare/v0.1.4...v0.1.5
+23
lib/pocketenv/api.ex
··· 308 308 end 309 309 310 310 # --------------------------------------------------------------------------- 311 + # Copy 312 + # --------------------------------------------------------------------------- 313 + 314 + def push_directory(sandbox_id, directory_path, opts \\ []) do 315 + case Client.post( 316 + "/xrpc/io.pocketenv.sandbox.pushDirectory", 317 + %{"sandboxId" => sandbox_id, "directoryPath" => directory_path}, 318 + take_token(opts) 319 + ) do 320 + {:ok, %{"uuid" => uuid}} -> {:ok, uuid} 321 + {:error, _} = err -> err 322 + end 323 + end 324 + 325 + def pull_directory(sandbox_id, uuid, directory_path, opts \\ []) do 326 + Client.post( 327 + "/xrpc/io.pocketenv.sandbox.pullDirectory", 328 + %{"uuid" => uuid, "sandboxId" => sandbox_id, "directoryPath" => directory_path}, 329 + take_token(opts) 330 + ) 331 + end 332 + 333 + # --------------------------------------------------------------------------- 311 334 # Private helpers 312 335 # --------------------------------------------------------------------------- 313 336
+217
lib/pocketenv/copy.ex
··· 1 + defmodule Pocketenv.Copy do 2 + @moduledoc false 3 + # Handles file and directory transfers between local paths and sandboxes, 4 + # and between sandboxes. Not part of the public API — use Sandbox.upload/4, 5 + # Sandbox.download/4, and Sandbox.copy_to/5 instead. 6 + 7 + alias Pocketenv.{API, Client} 8 + 9 + @default_storage_url "https://sandbox.pocketenv.io" 10 + 11 + def storage_url do 12 + Application.get_env(:pocketenv_ex, :storage_url) || 13 + System.get_env("POCKETENV_STORAGE_URL") || 14 + @default_storage_url 15 + end 16 + 17 + # --------------------------------------------------------------------------- 18 + # Public operations 19 + # --------------------------------------------------------------------------- 20 + 21 + @doc """ 22 + Compress `local_path` and upload it to `sandbox_path` inside the sandbox. 23 + """ 24 + def upload(sandbox_id, local_path, sandbox_path, opts \\ []) do 25 + case compress(local_path) do 26 + {:ok, archive} -> 27 + result = 28 + with {:ok, uuid} <- upload_to_storage(archive, opts), 29 + {:ok, _} <- API.pull_directory(sandbox_id, uuid, sandbox_path, opts) do 30 + :ok 31 + end 32 + 33 + File.rm(archive) 34 + result 35 + 36 + {:error, _} = err -> 37 + err 38 + end 39 + end 40 + 41 + @doc """ 42 + Push `sandbox_path` from the sandbox to storage, download it, and extract to 43 + `local_path`. 44 + """ 45 + def download(sandbox_id, sandbox_path, local_path, opts \\ []) do 46 + archive = temp_path() 47 + 48 + result = 49 + with {:ok, uuid} <- API.push_directory(sandbox_id, sandbox_path, opts), 50 + :ok <- download_from_storage(uuid, archive, opts), 51 + :ok <- decompress(archive, local_path) do 52 + :ok 53 + end 54 + 55 + File.rm(archive) 56 + result 57 + end 58 + 59 + @doc """ 60 + Push `src_path` from `src_sandbox_id` to storage, then pull it into 61 + `dest_path` inside `dest_sandbox_id`. No local I/O involved. 62 + """ 63 + def to(src_sandbox_id, dest_sandbox_id, src_path, dest_path, opts \\ []) do 64 + with {:ok, uuid} <- API.push_directory(src_sandbox_id, src_path, opts), 65 + {:ok, _} <- API.pull_directory(dest_sandbox_id, uuid, dest_path, opts) do 66 + :ok 67 + end 68 + end 69 + 70 + # --------------------------------------------------------------------------- 71 + # Compression helpers 72 + # --------------------------------------------------------------------------- 73 + 74 + defp compress(source) do 75 + hash = :crypto.hash(:sha256, source) |> Base.encode16(case: :lower) 76 + archive = Path.join(System.tmp_dir!(), "#{hash}.tar.gz") 77 + 78 + entries = 79 + case File.lstat!(source) do 80 + %{type: :regular} -> 81 + [{String.to_charlist(Path.basename(source)), File.read!(source)}] 82 + 83 + %{type: :directory} -> 84 + contexts = Pocketenv.Ignore.load(source) 85 + 86 + walk_dir(source, "") 87 + |> Enum.reject(fn rel -> Pocketenv.Ignore.ignored?(contexts, rel) end) 88 + |> Enum.map(fn rel -> 89 + {String.to_charlist(rel), File.read!(Path.join(source, rel))} 90 + end) 91 + end 92 + 93 + case :erl_tar.create(String.to_charlist(archive), entries, [:compressed]) do 94 + :ok -> {:ok, archive} 95 + {:error, reason} -> {:error, reason} 96 + end 97 + end 98 + 99 + # Recursively lists all regular files under `base`, returning paths relative 100 + # to `base`. Symbolic links and other special files are skipped. 101 + defp walk_dir(base, prefix) do 102 + dir = if prefix == "", do: base, else: Path.join(base, prefix) 103 + 104 + dir 105 + |> File.ls!() 106 + |> Enum.flat_map(fn entry -> 107 + rel = if prefix == "", do: entry, else: Path.join(prefix, entry) 108 + full = Path.join(base, rel) 109 + 110 + case File.lstat!(full).type do 111 + :regular -> [rel] 112 + :directory -> walk_dir(base, rel) 113 + _ -> [] 114 + end 115 + end) 116 + end 117 + 118 + defp decompress(archive, dest) do 119 + File.mkdir_p!(dest) 120 + 121 + case :erl_tar.extract(String.to_charlist(archive), [ 122 + :compressed, 123 + {:cwd, String.to_charlist(dest)} 124 + ]) do 125 + :ok -> :ok 126 + {:error, reason} -> {:error, reason} 127 + end 128 + end 129 + 130 + defp temp_path do 131 + hex = :crypto.strong_rand_bytes(16) |> Base.encode16(case: :lower) 132 + Path.join(System.tmp_dir!(), "#{hex}.tar.gz") 133 + end 134 + 135 + # --------------------------------------------------------------------------- 136 + # Storage HTTP helpers 137 + # --------------------------------------------------------------------------- 138 + 139 + defp upload_to_storage(file_path, opts) do 140 + with {:ok, token} <- resolve_token(opts), 141 + {:ok, binary} <- File.read(file_path) do 142 + {body, content_type} = build_multipart("file", binary, "archive.tar.gz", "application/gzip") 143 + url = storage_url() <> "/cp" 144 + 145 + case Req.post(url, 146 + body: body, 147 + headers: [ 148 + {"authorization", "Bearer #{token}"}, 149 + {"content-type", content_type} 150 + ] 151 + ) do 152 + {:ok, %{status: status, body: %{"uuid" => uuid}}} when status in 200..299 -> 153 + {:ok, uuid} 154 + 155 + {:ok, %{status: status, body: body}} -> 156 + {:error, %{status: status, body: body}} 157 + 158 + {:error, reason} -> 159 + {:error, reason} 160 + end 161 + end 162 + end 163 + 164 + defp download_from_storage(uuid, dest_file, opts) do 165 + with {:ok, token} <- resolve_token(opts) do 166 + url = storage_url() <> "/cp/#{uuid}" 167 + 168 + case Req.get(url, 169 + decode_body: false, 170 + headers: [{"authorization", "Bearer #{token}"}] 171 + ) do 172 + {:ok, %{status: status, body: body}} when status in 200..299 -> 173 + File.write(dest_file, body) 174 + 175 + {:ok, %{status: status, body: body}} -> 176 + {:error, %{status: status, body: body}} 177 + 178 + {:error, reason} -> 179 + {:error, reason} 180 + end 181 + end 182 + end 183 + 184 + defp resolve_token(opts) do 185 + case Keyword.fetch(opts, :token) do 186 + {:ok, token} -> {:ok, token} 187 + :error -> Client.token() 188 + end 189 + end 190 + 191 + defp build_multipart(name, binary, filename, content_type) do 192 + boundary = "FormBoundary" <> Base.encode16(:crypto.strong_rand_bytes(8)) 193 + 194 + body = 195 + IO.iodata_to_binary([ 196 + "--", 197 + boundary, 198 + "\r\n", 199 + "Content-Disposition: form-data; name=\"", 200 + name, 201 + "\"; filename=\"", 202 + filename, 203 + "\"\r\n", 204 + "Content-Type: ", 205 + content_type, 206 + "\r\n", 207 + "\r\n", 208 + binary, 209 + "\r\n", 210 + "--", 211 + boundary, 212 + "--\r\n" 213 + ]) 214 + 215 + {body, "multipart/form-data; boundary=#{boundary}"} 216 + end 217 + end
+215
lib/pocketenv/ignore.ex
··· 1 + defmodule Pocketenv.Ignore do 2 + @moduledoc false 3 + # Gitignore-style file filtering for copy operations. 4 + # 5 + # Mirrors the TypeScript SDK's ignore.ts: loads `.pocketenvignore`, 6 + # `.gitignore`, `.npmignore`, and `.dockerignore` files found anywhere 7 + # under the source directory, then exposes `ignored?/2` to check whether 8 + # a relative path should be excluded from an archive. 9 + # 10 + # Matching semantics follow the gitignore spec subset that covers 11 + # real-world usage: 12 + # - `*` matches anything except `/` 13 + # - `**` matches anything including `/` 14 + # - `?` matches any single character except `/` 15 + # - `[abc]` character classes 16 + # - Leading `/` anchors to the ignore file's directory 17 + # - Trailing `/` means directory (also matches all contents) 18 + # - `!` prefix negates (un-ignores) a previously matched path 19 + # - Last matching pattern wins 20 + 21 + @ignore_filenames MapSet.new([ 22 + ".pocketenvignore", 23 + ".gitignore", 24 + ".npmignore", 25 + ".dockerignore" 26 + ]) 27 + 28 + @type context :: {dir :: String.t(), patterns :: [pattern()]} 29 + @type pattern :: {:ignore | :keep, Regex.t(), Regex.t()} 30 + 31 + # --------------------------------------------------------------------------- 32 + # Public API 33 + # --------------------------------------------------------------------------- 34 + 35 + @doc """ 36 + Scans `root` recursively for ignore files and returns a list of contexts, 37 + each binding a directory (relative to `root`) to its compiled patterns. 38 + """ 39 + @spec load(String.t()) :: [context()] 40 + def load(root) do 41 + walk(root, "") 42 + |> Enum.filter(fn rel -> MapSet.member?(@ignore_filenames, Path.basename(rel)) end) 43 + |> Enum.flat_map(fn rel -> 44 + full = Path.join(root, rel) 45 + dir = case Path.dirname(rel) do 46 + "." -> "" 47 + d -> d 48 + end 49 + 50 + case File.read(full) do 51 + {:ok, content} -> [{dir, parse(content)}] 52 + {:error, _} -> [] 53 + end 54 + end) 55 + end 56 + 57 + @doc false 58 + # Builds a context list directly from a pattern string. Used in tests to 59 + # avoid filesystem access. 60 + @spec load_from_string(String.t(), String.t()) :: [context()] 61 + def load_from_string(dir, content), do: [{dir, parse(content)}] 62 + 63 + @doc """ 64 + Returns `true` if `path` (relative to the source root) should be excluded 65 + based on the given ignore `contexts`. 66 + 67 + Implements the same suffix-checking strategy as the TypeScript SDK: 68 + each sub-path suffix of `path` is tested so that un-anchored patterns 69 + (e.g. `*.log`, `node_modules`) match at any depth. 70 + """ 71 + @spec ignored?([context()], String.t()) :: boolean() 72 + def ignored?(contexts, path) do 73 + Enum.any?(contexts, fn {dir, patterns} -> 74 + scoped = 75 + cond do 76 + dir == "" -> 77 + path 78 + 79 + String.starts_with?(path, dir <> "/") -> 80 + String.slice(path, byte_size(dir) + 1, byte_size(path)) 81 + 82 + true -> 83 + nil 84 + end 85 + 86 + if scoped == nil do 87 + false 88 + else 89 + parts = String.split(scoped, "/") 90 + n = length(parts) 91 + 92 + Enum.any?(0..(n - 1), fn i -> 93 + sub = parts |> Enum.drop(i) |> Enum.join("/") 94 + match_any?(patterns, sub) 95 + end) 96 + end 97 + end) 98 + end 99 + 100 + # --------------------------------------------------------------------------- 101 + # Pattern parsing 102 + # --------------------------------------------------------------------------- 103 + 104 + defp parse(content) do 105 + content 106 + |> String.split(["\r\n", "\n"]) 107 + |> Enum.map(&String.trim/1) 108 + |> Enum.reject(fn line -> line == "" or String.starts_with?(line, "#") end) 109 + |> Enum.map(&parse_line/1) 110 + end 111 + 112 + defp parse_line("!" <> rest) do 113 + {exact, prefix} = compile(rest) 114 + {:keep, exact, prefix} 115 + end 116 + 117 + defp parse_line(line) do 118 + {exact, prefix} = compile(line) 119 + {:ignore, exact, prefix} 120 + end 121 + 122 + # Compiles a single gitignore pattern into two regexes: 123 + # - `exact` — matches the path itself 124 + # - `prefix` — matches anything *inside* a matching directory 125 + defp compile(pattern) do 126 + pattern = String.trim_trailing(pattern, "/") 127 + pattern = String.trim_leading(pattern, "/") 128 + rs = to_regex_str(pattern) 129 + exact = Regex.compile!("^#{rs}$") 130 + prefix = Regex.compile!("^#{rs}/") 131 + {exact, prefix} 132 + end 133 + 134 + # --------------------------------------------------------------------------- 135 + # Glob → regex conversion 136 + # --------------------------------------------------------------------------- 137 + 138 + defp to_regex_str(pattern), do: pattern |> do_to_regex([]) |> Enum.join() 139 + 140 + # Base case 141 + defp do_to_regex("", acc), do: Enum.reverse(acc) 142 + 143 + # `**/` — zero or more leading path components 144 + defp do_to_regex("**/" <> rest, acc), 145 + do: do_to_regex(rest, ["(?:.+/)?" | acc]) 146 + 147 + # `**` — anything (including slashes) 148 + defp do_to_regex("**" <> rest, acc), 149 + do: do_to_regex(rest, [".*" | acc]) 150 + 151 + # `*` — anything except `/` 152 + defp do_to_regex("*" <> rest, acc), 153 + do: do_to_regex(rest, ["[^/]*" | acc]) 154 + 155 + # `?` — any single character except `/` 156 + defp do_to_regex("?" <> rest, acc), 157 + do: do_to_regex(rest, ["[^/]" | acc]) 158 + 159 + # `[...]` character class — pass through verbatim 160 + defp do_to_regex("[" <> rest, acc) do 161 + case String.split(rest, "]", parts: 2) do 162 + [chars, remainder] -> do_to_regex(remainder, ["[#{chars}]" | acc]) 163 + _ -> do_to_regex(rest, ["\\[" | acc]) 164 + end 165 + end 166 + 167 + # Escape regex metacharacters that aren't glob specials 168 + defp do_to_regex(<<c, rest::binary>>, acc) when c in ~c[.+^${}()|\\] do 169 + do_to_regex(rest, ["\\#{<<c>>}" | acc]) 170 + end 171 + 172 + # Literal character 173 + defp do_to_regex(<<c, rest::binary>>, acc), 174 + do: do_to_regex(rest, [<<c>> | acc]) 175 + 176 + # --------------------------------------------------------------------------- 177 + # Pattern matching 178 + # --------------------------------------------------------------------------- 179 + 180 + # Returns `true` if the last matching pattern says `:ignore`. 181 + defp match_any?(patterns, sub) do 182 + patterns 183 + |> Enum.reduce(nil, fn {type, exact, prefix}, acc -> 184 + if Regex.match?(exact, sub) or Regex.match?(prefix, sub), do: type, else: acc 185 + end) 186 + |> case do 187 + :ignore -> true 188 + _ -> false 189 + end 190 + end 191 + 192 + # --------------------------------------------------------------------------- 193 + # Filesystem walk helper 194 + # --------------------------------------------------------------------------- 195 + 196 + defp walk(base, prefix) do 197 + dir = if prefix == "", do: base, else: Path.join(base, prefix) 198 + 199 + case File.ls(dir) do 200 + {:ok, entries} -> 201 + Enum.flat_map(entries, fn entry -> 202 + rel = if prefix == "", do: entry, else: Path.join(prefix, entry) 203 + 204 + case File.lstat(Path.join(base, rel)) do 205 + {:ok, %{type: :regular}} -> [rel] 206 + {:ok, %{type: :directory}} -> walk(base, rel) 207 + _ -> [] 208 + end 209 + end) 210 + 211 + {:error, _} -> 212 + [] 213 + end 214 + end 215 + end
+77
lib/sandbox.ex
··· 513 513 end 514 514 515 515 # --------------------------------------------------------------------------- 516 + # Copy 517 + # --------------------------------------------------------------------------- 518 + 519 + @doc """ 520 + Uploads a local file or directory to a path inside this sandbox. 521 + 522 + The local path is compressed into a tar.gz archive, uploaded to storage, 523 + and then extracted by the sandbox at `sandbox_path`. 524 + 525 + ## Options 526 + 527 + - `:token` — bearer token override. 528 + 529 + ## Example 530 + 531 + sandbox |> Sandbox.upload("./my-project", "/workspace") 532 + sandbox |> Sandbox.upload("./config.json", "/app/config.json") 533 + """ 534 + @spec upload(t() | {:ok, t()}, String.t(), String.t(), keyword()) :: :ok | {:error, term()} 535 + def upload(sandbox_or_result, local_path, sandbox_path, opts \\ []) 536 + 537 + def upload({:ok, %__MODULE__{} = sandbox}, local_path, sandbox_path, opts), 538 + do: upload(sandbox, local_path, sandbox_path, opts) 539 + 540 + def upload(%__MODULE__{} = sandbox, local_path, sandbox_path, opts) do 541 + Pocketenv.Copy.upload(sandbox.id, local_path, sandbox_path, opts) 542 + end 543 + 544 + @doc """ 545 + Downloads a path from inside this sandbox to a local directory. 546 + 547 + The sandbox compresses `sandbox_path` into a tar.gz archive, which is then 548 + downloaded and extracted to `local_path`. 549 + 550 + ## Options 551 + 552 + - `:token` — bearer token override. 553 + 554 + ## Example 555 + 556 + sandbox |> Sandbox.download("/workspace", "./output") 557 + """ 558 + @spec download(t() | {:ok, t()}, String.t(), String.t(), keyword()) :: :ok | {:error, term()} 559 + def download(sandbox_or_result, sandbox_path, local_path, opts \\ []) 560 + 561 + def download({:ok, %__MODULE__{} = sandbox}, sandbox_path, local_path, opts), 562 + do: download(sandbox, sandbox_path, local_path, opts) 563 + 564 + def download(%__MODULE__{} = sandbox, sandbox_path, local_path, opts) do 565 + Pocketenv.Copy.download(sandbox.id, sandbox_path, local_path, opts) 566 + end 567 + 568 + @doc """ 569 + Copies a path from this sandbox to a path inside another sandbox. 570 + 571 + No local I/O is involved — the transfer goes directly through storage. 572 + 573 + ## Options 574 + 575 + - `:token` — bearer token override. 576 + 577 + ## Example 578 + 579 + sandbox |> Sandbox.copy_to(other_sandbox_id, "/src", "/dest") 580 + """ 581 + @spec copy_to(t() | {:ok, t()}, String.t(), String.t(), String.t(), keyword()) :: 582 + :ok | {:error, term()} 583 + def copy_to(sandbox_or_result, dest_sandbox_id, src_path, dest_path, opts \\ []) 584 + 585 + def copy_to({:ok, %__MODULE__{} = sandbox}, dest_sandbox_id, src_path, dest_path, opts), 586 + do: copy_to(sandbox, dest_sandbox_id, src_path, dest_path, opts) 587 + 588 + def copy_to(%__MODULE__{} = sandbox, dest_sandbox_id, src_path, dest_path, opts) do 589 + Pocketenv.Copy.to(sandbox.id, dest_sandbox_id, src_path, dest_path, opts) 590 + end 591 + 592 + # --------------------------------------------------------------------------- 516 593 # Private 517 594 # --------------------------------------------------------------------------- 518 595
+1 -1
mix.exs
··· 4 4 def project do 5 5 [ 6 6 app: :pocketenv_ex, 7 - version: "0.1.7", 7 + version: "0.1.8", 8 8 elixir: "~> 1.15", 9 9 start_permanent: Mix.env() == :prod, 10 10 deps: deps(),
+289
test/pocketenv_test.exs
··· 375 375 end 376 376 377 377 # --------------------------------------------------------------------------- 378 + # Pocketenv.Ignore 379 + # --------------------------------------------------------------------------- 380 + 381 + describe "Pocketenv.Ignore.ignored?/2 – simple name patterns" do 382 + test "ignores an exact filename match" do 383 + ctx = build_contexts("", ".DS_Store") 384 + assert Pocketenv.Ignore.ignored?(ctx, ".DS_Store") 385 + end 386 + 387 + test "ignores the filename at any depth" do 388 + ctx = build_contexts("", ".DS_Store") 389 + assert Pocketenv.Ignore.ignored?(ctx, "a/b/.DS_Store") 390 + end 391 + 392 + test "does not ignore a non-matching file" do 393 + ctx = build_contexts("", ".DS_Store") 394 + refute Pocketenv.Ignore.ignored?(ctx, "README.md") 395 + end 396 + end 397 + 398 + describe "Pocketenv.Ignore.ignored?/2 – glob patterns" do 399 + test "*.log matches a log file at root" do 400 + ctx = build_contexts("", "*.log") 401 + assert Pocketenv.Ignore.ignored?(ctx, "debug.log") 402 + end 403 + 404 + test "*.log matches a log file at any depth via suffix check" do 405 + ctx = build_contexts("", "*.log") 406 + assert Pocketenv.Ignore.ignored?(ctx, "logs/debug.log") 407 + assert Pocketenv.Ignore.ignored?(ctx, "a/b/c/server.log") 408 + end 409 + 410 + test "*.log does not match unrelated extensions" do 411 + ctx = build_contexts("", "*.log") 412 + refute Pocketenv.Ignore.ignored?(ctx, "app.ex") 413 + refute Pocketenv.Ignore.ignored?(ctx, "logs/README.md") 414 + end 415 + 416 + test "? matches exactly one non-slash character" do 417 + ctx = build_contexts("", "foo?.txt") 418 + assert Pocketenv.Ignore.ignored?(ctx, "fooa.txt") 419 + refute Pocketenv.Ignore.ignored?(ctx, "foo.txt") 420 + refute Pocketenv.Ignore.ignored?(ctx, "fooab.txt") 421 + end 422 + end 423 + 424 + describe "Pocketenv.Ignore.ignored?/2 – directory patterns" do 425 + test "directory name ignores the directory itself" do 426 + ctx = build_contexts("", "node_modules") 427 + assert Pocketenv.Ignore.ignored?(ctx, "node_modules") 428 + end 429 + 430 + test "directory name ignores all contents" do 431 + ctx = build_contexts("", "node_modules") 432 + assert Pocketenv.Ignore.ignored?(ctx, "node_modules/index.js") 433 + assert Pocketenv.Ignore.ignored?(ctx, "node_modules/lodash/index.js") 434 + end 435 + 436 + test "directory name with trailing slash ignores all contents" do 437 + ctx = build_contexts("", "dist/") 438 + assert Pocketenv.Ignore.ignored?(ctx, "dist/bundle.js") 439 + assert Pocketenv.Ignore.ignored?(ctx, "dist/a/b.js") 440 + end 441 + 442 + test "directory pattern does not match unrelated names" do 443 + ctx = build_contexts("", "node_modules") 444 + refute Pocketenv.Ignore.ignored?(ctx, "src/index.js") 445 + refute Pocketenv.Ignore.ignored?(ctx, "not_node_modules/foo.js") 446 + end 447 + end 448 + 449 + describe "Pocketenv.Ignore.ignored?/2 – double-star patterns" do 450 + test "**/.DS_Store matches at any depth" do 451 + ctx = build_contexts("", "**/.DS_Store") 452 + assert Pocketenv.Ignore.ignored?(ctx, ".DS_Store") 453 + assert Pocketenv.Ignore.ignored?(ctx, "a/.DS_Store") 454 + assert Pocketenv.Ignore.ignored?(ctx, "a/b/c/.DS_Store") 455 + end 456 + 457 + test "build/** ignores everything under build/" do 458 + ctx = build_contexts("", "build/**") 459 + assert Pocketenv.Ignore.ignored?(ctx, "build/app.js") 460 + assert Pocketenv.Ignore.ignored?(ctx, "build/nested/app.js") 461 + end 462 + 463 + test "build/** does not match sibling directories" do 464 + ctx = build_contexts("", "build/**") 465 + refute Pocketenv.Ignore.ignored?(ctx, "src/app.js") 466 + end 467 + end 468 + 469 + describe "Pocketenv.Ignore.ignored?/2 – negation" do 470 + test "! un-ignores a previously matched path" do 471 + ctx = build_contexts("", "*.log\n!important.log") 472 + assert Pocketenv.Ignore.ignored?(ctx, "debug.log") 473 + refute Pocketenv.Ignore.ignored?(ctx, "important.log") 474 + end 475 + 476 + test "last matching pattern wins" do 477 + # ignore all, then keep .env, then ignore .env again 478 + ctx = build_contexts("", "*.env\n!.env\n.env") 479 + assert Pocketenv.Ignore.ignored?(ctx, ".env") 480 + end 481 + end 482 + 483 + describe "Pocketenv.Ignore.ignored?/2 – sub-directory context" do 484 + test "a context scoped to a subdirectory only applies within that directory" do 485 + ctx = build_contexts("src", "*.log") 486 + assert Pocketenv.Ignore.ignored?(ctx, "src/debug.log") 487 + refute Pocketenv.Ignore.ignored?(ctx, "lib/debug.log") 488 + end 489 + end 490 + 491 + describe "Pocketenv.Ignore.ignored?/2 – comments and blank lines" do 492 + test "lines starting with # are ignored" do 493 + ctx = build_contexts("", "# this is a comment\n*.log") 494 + assert Pocketenv.Ignore.ignored?(ctx, "app.log") 495 + end 496 + 497 + test "blank lines are ignored" do 498 + ctx = build_contexts("", "\n\n*.log\n\n") 499 + assert Pocketenv.Ignore.ignored?(ctx, "app.log") 500 + end 501 + end 502 + 503 + describe "Pocketenv.Ignore.load/1" do 504 + test "loads patterns from .gitignore in a temp directory" do 505 + dir = System.tmp_dir!() |> Path.join("pocketenv_ignore_test_#{System.unique_integer([:positive])}") 506 + File.mkdir_p!(dir) 507 + File.write!(Path.join(dir, ".gitignore"), "node_modules\n*.log\n") 508 + on_exit(fn -> File.rm_rf(dir) end) 509 + 510 + contexts = Pocketenv.Ignore.load(dir) 511 + assert length(contexts) == 1 512 + 513 + assert Pocketenv.Ignore.ignored?(contexts, "node_modules/index.js") 514 + assert Pocketenv.Ignore.ignored?(contexts, "app.log") 515 + refute Pocketenv.Ignore.ignored?(contexts, "src/app.ex") 516 + end 517 + 518 + test "loads patterns from nested ignore files with correct scope" do 519 + dir = System.tmp_dir!() |> Path.join("pocketenv_ignore_nested_#{System.unique_integer([:positive])}") 520 + sub = Path.join(dir, "packages/ui") 521 + File.mkdir_p!(sub) 522 + File.write!(Path.join(dir, ".gitignore"), "*.log\n") 523 + File.write!(Path.join(sub, ".gitignore"), "dist\n") 524 + on_exit(fn -> File.rm_rf(dir) end) 525 + 526 + contexts = Pocketenv.Ignore.load(dir) 527 + assert length(contexts) == 2 528 + 529 + # Root .gitignore applies everywhere 530 + assert Pocketenv.Ignore.ignored?(contexts, "app.log") 531 + assert Pocketenv.Ignore.ignored?(contexts, "packages/ui/app.log") 532 + 533 + # Nested .gitignore only applies within its directory 534 + assert Pocketenv.Ignore.ignored?(contexts, "packages/ui/dist/bundle.js") 535 + refute Pocketenv.Ignore.ignored?(contexts, "dist/bundle.js") 536 + end 537 + 538 + test "returns empty list for a directory with no ignore files" do 539 + dir = System.tmp_dir!() |> Path.join("pocketenv_no_ignore_#{System.unique_integer([:positive])}") 540 + File.mkdir_p!(dir) 541 + File.write!(Path.join(dir, "README.md"), "hello") 542 + on_exit(fn -> File.rm_rf(dir) end) 543 + 544 + assert Pocketenv.Ignore.load(dir) == [] 545 + end 546 + 547 + test "skips unreadable ignore files gracefully" do 548 + # An empty context list is acceptable when no files can be read 549 + contexts = Pocketenv.Ignore.load("/nonexistent/path") 550 + assert contexts == [] 551 + end 552 + end 553 + 554 + # Builds a single ignore context directly from a pattern string, 555 + # bypassing filesystem access. 556 + defp build_contexts(dir, pattern_content) do 557 + contexts = Pocketenv.Ignore.load_from_string(dir, pattern_content) 558 + contexts 559 + end 560 + 561 + # --------------------------------------------------------------------------- 562 + # Pocketenv.Copy.storage_url/0 563 + # --------------------------------------------------------------------------- 564 + 565 + describe "Pocketenv.Copy.storage_url/0" do 566 + test "returns the default storage URL when no config is present" do 567 + prev = Application.get_env(:pocketenv_ex, :storage_url) 568 + Application.delete_env(:pocketenv_ex, :storage_url) 569 + System.delete_env("POCKETENV_STORAGE_URL") 570 + on_exit(fn -> if prev, do: Application.put_env(:pocketenv_ex, :storage_url, prev) end) 571 + 572 + assert Pocketenv.Copy.storage_url() == "https://sandbox.pocketenv.io" 573 + end 574 + 575 + test "respects the POCKETENV_STORAGE_URL environment variable" do 576 + prev = Application.get_env(:pocketenv_ex, :storage_url) 577 + Application.delete_env(:pocketenv_ex, :storage_url) 578 + on_exit(fn -> if prev, do: Application.put_env(:pocketenv_ex, :storage_url, prev) end) 579 + 580 + System.put_env("POCKETENV_STORAGE_URL", "https://custom.storage.example.com") 581 + on_exit(fn -> System.delete_env("POCKETENV_STORAGE_URL") end) 582 + 583 + assert Pocketenv.Copy.storage_url() == "https://custom.storage.example.com" 584 + end 585 + 586 + test "application config takes precedence over environment variable" do 587 + System.put_env("POCKETENV_STORAGE_URL", "https://env.storage.example.com") 588 + on_exit(fn -> System.delete_env("POCKETENV_STORAGE_URL") end) 589 + 590 + Application.put_env(:pocketenv_ex, :storage_url, "https://config.storage.example.com") 591 + on_exit(fn -> Application.delete_env(:pocketenv_ex, :storage_url) end) 592 + 593 + assert Pocketenv.Copy.storage_url() == "https://config.storage.example.com" 594 + end 595 + end 596 + 597 + # --------------------------------------------------------------------------- 598 + # Sandbox copy methods — API surface 599 + # --------------------------------------------------------------------------- 600 + 601 + describe "Sandbox copy methods – arities" do 602 + setup do 603 + fns = Sandbox.__info__(:functions) 604 + {:ok, fns: fns} 605 + end 606 + 607 + test "upload/4 is defined", %{fns: fns} do 608 + assert {:upload, 3} in fns 609 + assert {:upload, 4} in fns 610 + end 611 + 612 + test "download/4 is defined", %{fns: fns} do 613 + assert {:download, 3} in fns 614 + assert {:download, 4} in fns 615 + end 616 + 617 + test "copy_to/5 is defined", %{fns: fns} do 618 + assert {:copy_to, 4} in fns 619 + assert {:copy_to, 5} in fns 620 + end 621 + end 622 + 623 + describe "Sandbox copy methods – {:ok, struct} passthrough" do 624 + test "upload/4 accepts {:ok, %Sandbox{}} and does not raise FunctionClauseError" do 625 + sandbox = %Sandbox{id: "sbx-1", name: "test", status: :running, installs: 0} 626 + tmp = Path.join(System.tmp_dir!(), "pocketenv_test_upload_#{:erlang.unique_integer([:positive])}.txt") 627 + File.write!(tmp, "hello") 628 + on_exit(fn -> File.rm(tmp) end) 629 + 630 + # {:ok, struct} must not raise FunctionClauseError; it will fail at the HTTP layer 631 + assert match?({:error, _}, Sandbox.upload({:ok, sandbox}, tmp, "/remote")) 632 + end 633 + 634 + test "download/4 accepts {:ok, %Sandbox{}} and does not raise FunctionClauseError" do 635 + sandbox = %Sandbox{id: "sbx-1", name: "test", status: :running, installs: 0} 636 + # Fails at HTTP layer (no token / no network), not at pattern match 637 + assert match?({:error, _}, Sandbox.download({:ok, sandbox}, "/remote", System.tmp_dir!())) 638 + end 639 + 640 + test "copy_to/5 accepts {:ok, %Sandbox{}} and does not raise FunctionClauseError" do 641 + sandbox = %Sandbox{id: "sbx-1", name: "test", status: :running, installs: 0} 642 + assert match?({:error, _}, Sandbox.copy_to({:ok, sandbox}, "sbx-2", "/src", "/dest")) 643 + end 644 + end 645 + 646 + describe "Sandbox copy methods – error propagation" do 647 + test "upload/4 raises FunctionClauseError on {:error, reason}" do 648 + assert_raise FunctionClauseError, fn -> 649 + Sandbox.upload({:error, :not_found}, "/local", "/remote") 650 + end 651 + end 652 + 653 + test "download/4 raises FunctionClauseError on {:error, reason}" do 654 + assert_raise FunctionClauseError, fn -> 655 + Sandbox.download({:error, :not_found}, "/remote", "/local") 656 + end 657 + end 658 + 659 + test "copy_to/5 raises FunctionClauseError on {:error, reason}" do 660 + assert_raise FunctionClauseError, fn -> 661 + Sandbox.copy_to({:error, :not_found}, "sbx-2", "/src", "/dest") 662 + end 663 + end 664 + end 665 + 666 + # --------------------------------------------------------------------------- 378 667 # Pocketenv public API surface 379 668 # --------------------------------------------------------------------------- 380 669