import argv import child_process as p import directories import filepath import gleam/bit_array import gleam/bool import gleam/crypto import gleam/dynamic/decode import gleam/http import gleam/http/request import gleam/httpc import gleam/int import gleam/io import gleam/list import gleam/option.{type Option, None, Some} import gleam/order import gleam/result.{try} import gleam/string import kludge/internal/manifest import kludge/internal/patch import platform import simplifile import snag import star import temporary import tom fn patch_plural(count: Int) { case count { 1 -> "patch" _ -> "patches" } } fn print_help() { io.println( "Kludge - patch a package in a pinch 🩹 gleam run -m kludge # Patches everything in ./patches gleam run -m kludge [package] # Writes your changes in ./patches for that package", ) } pub fn main() -> Nil { case argv.load().arguments { [] -> case apply_patches() { Ok(#([], [], _ignored)) -> { io.println("No patches left to apply") } Ok(#(success, failure, ignored)) -> { let success_count = success |> list.length let success_msg = case success_count { 0 -> "" _ -> { let applied = success |> list.map(fn(path) { "* " <> path }) |> string.join("\n") "Successfully applied " <> success_count |> int.to_string <> " " <> patch_plural(success_count) <> "\n" <> applied } } let failure_count = failure |> list.length let failure_msg = case failure_count { 0 -> "" _ -> { let failures = failure |> list.map(fn(path) { "* " <> path.0 <> " - " <> snag.line_print(path.1) }) |> string.join("\n") "Failed to apply " <> failure_count |> int.to_string <> " " <> patch_plural(failure_count) <> "\n" <> failures } } let ignored_count = ignored |> list.length let ignored_msg = case ignored_count { 0 -> "" 1 -> "There is 1 patch that was already applied" _ -> "There are " <> ignored_count |> int.to_string <> " " <> patch_plural(ignored_count) <> " that were already applied" } [success_msg, failure_msg, ignored_msg] |> list.filter(fn(it) { it |> string.is_empty |> bool.negate }) |> string.join("\n") |> io.println() } Error(err) -> io.println(snag.pretty_print(err)) } ["--help"] | ["-h"] | ["help"] -> print_help() [package] -> case find_patches(package) { Ok(diff) -> io.println("Wrote patch to " <> diff) Error(err) -> io.println(snag.pretty_print(err)) } _ -> print_help() } Nil } fn get_manifest() -> Result(manifest.Manifest, snag.Snag) { use toml_text <- try( simplifile.read("manifest.toml") |> snag.map_error(simplifile.describe_error) |> snag.context("Unable to read manifest file"), ) use toml <- try( tom.parse_to_dynamic(toml_text) |> snag.map_error(string.inspect) |> snag.context("Cannot parse manifest file's toml"), ) use manifest <- try( decode.run(toml, manifest.manifest_decoder()) |> snag.map_error(string.inspect) |> snag.context("Cannot decode manfiest file structure"), ) Ok(manifest) } fn apply_patches() -> Result( #(List(String), List(#(String, snag.Snag)), List(String)), snag.Snag, ) { use manifest <- try(get_manifest()) use patches <- try( simplifile.read_directory("./patches") |> snag.map_error(simplifile.describe_error) |> snag.context("Cannot read patches directory"), ) let sorted_patches = list.map(patches, fn(name) { patch.parse_patch_name("./patches/" <> name) |> result.replace_error(name) }) |> list.sort(fn(lhs, rhs) { case lhs { Ok(lhs) -> case rhs { Ok(rhs) -> patch.compare_patch(lhs, rhs) Error(_) -> order.Gt } Error(_) -> order.Lt } }) list.fold(sorted_patches, #([], [], []), fn(acc, patch) { let name = case patch { Ok(patch) -> patch.path Error(name) -> name } let #(successes, errors, ignored) = acc let result = { use patch <- try(patch |> result.replace_error(InvalidFileName)) let version = case list.find(manifest.packages, fn(it) { it.name == patch.package_name }) { Ok(manifest_package) -> case manifest_package { manifest.GitPackage(..) | manifest.PathPackage(..) | manifest.HexPackage(..) -> manifest_package.version |> Ok } Error(Nil) -> Error(DependencyDoesNotExist) } use version <- try(version) let actual_version = case version == patch.package_version { True -> None False -> Some(version) } use p.Output(code, _text) <- try( p.new_with_path("git") |> p.cwd(".") |> p.args([ "apply", "--directory=build/packages/" <> patch.package_name, patch.path, ]) |> p.run |> result.map_error(fn(err) { string.inspect(err) |> snag.new() |> snag.layer("Cannot start git") |> GitApplyError(actual_version) }), ) case code { 0 -> { Ok(Nil) } _ -> { use p.Output(code, text) <- try( p.new_with_path("git") |> p.cwd(".") |> p.args([ "apply", "--check", "--reverse", "--directory=build/packages/" <> patch.package_name, patch.path, ]) |> p.run |> result.map_error(fn(err) { string.inspect(err) |> snag.new() |> snag.layer("Cannot start git") |> GitApplyError(actual_version) }), ) use <- bool.guard( code != 0, Error(GitApplyError( snag.new("git error: " <> text) |> snag.layer("Cannot apply patch"), actual_version, )), ) Error(PatchAlreadyApplied) } } } case result { Ok(Nil) -> #([name, ..successes], errors, ignored) Error(err) -> case err { InvalidFileName -> #( successes, [#(name, snag.new("Cannot parse file name")), ..errors], ignored, ) GitApplyError(snag, expected_version) -> { let snag = case expected_version { Some(version) -> snag.layer( snag, "Package version mismatch, actual version is " <> version, ) None -> snag } #(successes, [#(name, snag), ..errors], ignored) } DependencyDoesNotExist -> #( successes, [ #(name, snag.new("Dependency does not exist in manifest.toml")), ..errors ], ignored, ) PatchAlreadyApplied -> #(successes, errors, [name, ..ignored]) } } }) |> Ok } /// Version of the package according to manifest.toml type ApplyPatchError { InvalidFileName DependencyDoesNotExist GitApplyError(msg: snag.Snag, expected_version: Option(String)) PatchAlreadyApplied } fn git(args: List(String), cwd: String) { use p.Output(code, text) as out <- try( p.new_with_path("git") |> p.cwd(cwd) |> p.args(args) |> p.run |> snag.map_error(string.inspect), ) case code { 0 -> Ok(out) _ -> snag.error( "Git returned an unexpected error with code " <> int.to_string(code) <> " and body " <> text, ) } } fn find_patches(package_name: String) { use manifest <- try(get_manifest()) use package <- try( list.find(manifest.packages, fn(it) { it.name == package_name }) |> snag.replace_error("Cannot find package " <> package_name), ) let patch_file_path = "./patches/" <> package.name <> "+" <> package.version <> ".patch" use <- bool.guard( { simplifile.is_file(patch_file_path) |> result.unwrap(False) }, snag.error( "Patch " <> patch_file_path <> " already exists, move or remove to make a new patch", ), ) let diff = { use temp_dir <- temporary.create(temporary.directory()) let git = git(_, temp_dir) use version <- try( git(["--version"]) |> snag.context("Cannot check version"), ) use <- bool.guard( !string.starts_with(version.output, "git version "), snag.error( "git does not appear to be git, the version control software", ) |> snag.context("Cannot check version"), ) use Nil <- try( clone_original_into(package, temp_dir) |> snag.context("Cannot getting original version of the package"), ) use _ <- try( p.new_with_path("git") |> p.cwd(temp_dir) |> p.args(["init"]) |> p.run |> snag.map_error(string.inspect) |> snag.context("Error initializing repository"), ) use _ <- try( git(["config", "--local", "user.name", "gleam-kludge"]) |> snag.context("Error setting git username"), ) use _ <- try( git(["config", "--local", "user.email", "gleam@kludge.run"]) |> snag.context("Error setting git email"), ) use _ <- try( git(["add", "-f", "."]) |> snag.context("Error adding original files to be tracked"), ) use _ <- try( git(["commit", "--allow-empty", "-m", "original"]) |> snag.context("Error commiting original files"), ) use entries <- try( simplifile.read_directory(temp_dir) |> snag.map_error(simplifile.describe_error) |> snag.context("Cannot list temp dir"), ) use _ <- try( list.try_each(list.filter(entries, fn(e) { e != ".git" }), fn(entry) { simplifile.delete(filepath.join(temp_dir, entry)) |> snag.map_error(simplifile.describe_error) }) |> snag.context("Cannot clear temp dir"), ) let original = "build/packages/" <> package.name use Nil <- try( simplifile.copy_directory(original, temp_dir) |> snag.map_error(simplifile.describe_error) |> snag.context( "Cannot copy modified files to git temp dir (" <> temp_dir <> ")", ), ) use _ <- try( git(["add", "-f", "."]) |> snag.context("Cannot stage modified files to repo"), ) use _ <- try( git(["reset", "--", "build", "manifest.toml"]) |> snag.context("Cannot reset modified in repo"), ) use p.Output(code, text) <- try( git([ "diff", "--cached", "--no-color", "--ignore-space-at-eol", "--no-ext-diff", "--src-prefix=a/", "--dst-prefix=b/", ]), ) case code { 0 -> Ok(text) _ -> case string.contains(text, "Unexpected file mode string: 120000") { True -> Error(snag.new( "These changes involve creating symlinks, kludge does not support symlinks.", )) False -> Error(snag.new("Cannot create diff" <> text)) } } } |> snag.map_error(simplifile.describe_error) |> result.flatten() |> snag.context("Cannot determine diff") use diff <- try(diff) use <- bool.guard( string.is_empty(diff), snag.error("No changes have been made"), ) use Nil <- try( simplifile.create_directory_all("./patches") |> snag.map_error(simplifile.describe_error) |> snag.context("Cannot make patches directory"), ) use Nil <- try( simplifile.write(patch_file_path, diff) |> snag.map_error(simplifile.describe_error) |> snag.context("Cannot write patch file"), ) Ok(patch_file_path) } fn clone_original_into( package: manifest.ManifestPackage, destination: String, ) -> snag.Result(Nil) { case package { manifest.HexPackage(..) -> { use cache <- try( get_gleam_cache() |> snag.context("Cannot find gleam hexpm cache directory"), ) let hexpkgs = filepath.join(cache, "hex/hexpm/packages/") let cached_package_path = filepath.join(hexpkgs, package.outer_checksum <> ".tar") let can_use_cached = result.unwrap(simplifile.is_file(cached_package_path), False) let outer_archive_find_result = case can_use_cached { True -> { use bits <- try( simplifile.read_bits(cached_package_path) |> snag.map_error(simplifile.describe_error), ) let checksum = bit_array.base16_encode(crypto.hash(crypto.Sha256, bits)) use <- bool.guard( checksum != package.outer_checksum, snag.error( "Hash validation error! Expected (manifest): " <> package.outer_checksum <> " Got (in cache): " <> checksum, ), ) Ok(star.FromData(bits)) } False -> { use response <- try( request.new() |> request.set_method(http.Get) |> request.set_scheme(http.Https) |> request.set_host("repo.hex.pm") |> request.set_path( "tarballs/" <> package.name <> "-" <> package.version <> ".tar", ) |> request.set_body(<<>>) |> httpc.send_bits() |> snag.map_error(string.inspect) |> snag.context("Cannot fetch package " <> package.name), ) use <- bool.guard( response.status != 200, snag.error( "Non 200 response (" <> int.to_string(response.status) <> ") was sent by hexpm", ), ) let checksum = bit_array.base16_encode(crypto.hash(crypto.Sha256, response.body)) use <- bool.guard( checksum != package.outer_checksum, snag.error( "Hash validation error! Expected (manifest): " <> package.outer_checksum <> " Got (downloaded): " <> checksum, ), ) Ok(star.FromData(response.body)) } } use outer_archive <- try( outer_archive_find_result |> snag.context("Cannot find outer archive source"), ) use outer_files <- try( outer_archive |> star.extract_memory(star.Uncompressed, star.AllEntries) |> snag.map_error(string.inspect) |> snag.context("Error when decompressing decompressing outer"), ) use contents <- try( list.find(outer_files, fn(file) { file.header.name == "contents.tar.gz" }) |> snag.replace_error("Cannot find contents.tar.gz in hex archive"), ) use Nil <- try( star.extract( star.FromData(contents.contents), destination, star.Gzip, star.AllEntries, star.Overwrite, ) |> snag.map_error(string.inspect) |> snag.context("Failed to extract hexpm contents.tar.gz"), ) Ok(Nil) } manifest.GitPackage(..) -> { let git = git(_, destination) use _ <- try( git(["init"]) |> snag.context("Failed to initialize git repository"), ) use _ <- try( git(["remote", "add", "origin", package.repo]) |> snag.context("Cannot add repo origin of " <> package.repo), ) use _ <- try( git(["fetch", "--depth=1", "origin", package.commit]) |> snag.context( "Error fetching git commit hash" <> package.commit <> ". Warning: server may not support shallow SHA fetches (uploadpack.allowReachableSHA1InWant)", ), ) use _ <- try( git(["checkout", "FETCH_HEAD"]) |> snag.context("Cannot check out FETCH_HEAD"), ) Ok(Nil) } manifest.PathPackage(..) -> { Error(snag.new("Path packages are not supported in kludge.")) } } } fn get_gleam_cache() -> Result(String, snag.Snag) { case platform.os() { platform.Darwin -> case directories.home_dir() { Ok(home) -> Ok(filepath.join(home, "Library/Caches/gleam")) Error(Nil) -> snag.error("Cannot find home directory") } platform.Win32 -> case directories.data_local_dir() { Ok(dir) -> Ok(filepath.join(dir, "gleam")) Error(Nil) -> snag.error("Cannot find local app data") } // everything is xdg here platform.Linux | platform.FreeBsd | platform.OpenBsd | platform.SunOs | platform.Aix | platform.OtherOs(_) -> case directories.cache_dir() { Ok(cache) -> filepath.join(cache, "gleam") |> Ok Error(Nil) -> snag.error("Cannot find cache directory") } } }