Tools
NAME
App::Ariza::Tools - the shell-out layer: run, fetch, digest, extract
SYNOPSIS
use App::Ariza::Tools;
# Processes
my ($code, $out, $err) = try-run(<otool -L /bin/ls>); # never dies
my $text = run-checked(<git rev-parse HEAD>, :what<git>); # dies on failure
my %env = child-env(:PERL6LIB(Str), :NOTCURSES_NATIVE_DATA_DIR($dir));
# Network
my $json = http-get('https://rakudo.org/dl/rakudo');
http-download($url, $cache.add('rakudo.tar.gz'));
# Files
ensure-dir($bundle.add('native'));
my $sha = sha256-file($archive); # lowercase hex, 64 chars
extract-archive($archive, $staging);
my $top = sole-child($staging); # the one wrapper directory
copy-writable($src, $dest);
rm-rf($staging);
DESCRIPTION
Building a bundle is mostly driving other programs: curl, tar,
zef, otool, install_name_tool, codesign. This module is the
one place that knows how to do that safely, so no other module in ariza
contains a bare run and every failure reads the same way.
There are no new distribution dependencies here on purpose. The
Build.rakumod of every native-wrapping distribution these apps
depend on already downloads with curl and unpacks with tar;
ariza fetches three artefacts from static hosts,
and pulling in a TLS stack to do it would be a much larger surface than
the feature justifies.
RUNNING PROCESSES
try-run(@cmd, :%env, :$cwd --> List)
(exitcode, stdout, stderr). Never throws. A command that cannot be
spawned at all ā no such binary ā comes back as exit code -1 with the
reason on stderr, so callers need one error path, not two.
run-checked(@cmd, :%env, :$cwd, :$what --> Str)
stdout, or a die naming :$what (defaulting to the command),
the exit code, and the child's stderr indented beneath. Captured
stderr that nobody prints is the reason build tools are hated; this
one prints it.
have-command(Str, :&run --> Bool)
Whether a command can be spawned. Used to choose between
interchangeable tools ā sha256sum or shasum ā and to say
"install patchelf" before a build gets half way through, never to
skip work. :&run replaces the probe, so a test can assert on the
"there is no patchelf" branch without uninstalling one.
child-env(*%overrides --> Hash)
%*ENV plus %overrides, with any key whose override is undefined
removed.
Removal matters more than it looks. Setting PERL6LIB="" does not unset
it: Rakudo sees a set-but-empty variable and prints a v6.e deprecation
warning across the entire child run, which is exactly what happens when
you try to keep a stray PERL6LIB out of a zef invocation the
obvious way. child-env(:PERL6LIB(Str)) deletes the key.
NETWORK
http-get(Str $url --> Str)
The response body as text, via curl --fail or wget. Dies on any
HTTP error, which is what --fail buys over a 404 page silently
becoming your JSON.
http-download(Str $url, IO() $dest --> IO::Path)
Download to $dest. The transfer lands in a .part sibling and is
renamed into place only on success, so an interrupted download cannot be
picked up as a cache hit by the next run. A partial file is removed on
failure.
FILES
sha256-file(IO(), :&run --> Str)
Lowercase hex digest. sha256sum, shasum -a 256 and
certutil -hashfile are tried in that order until one of them returns
64 hex characters.
Every kind of failure falls through to the next tool: absent from
PATH, unspawnable, non-zero exit, or output that is not a digest.
Only an exhausted list dies, and it names what each attempt did. That is
not defensiveness for its own sake ā it is what makes Windows work. A
GitHub runner has a shasum on PATH that is a Perl script with no
interpreter association, so where finds it and CreateProcess
refuses it; stopping there took down every digest on the machine while
certutil, which Windows has always had, went untried.
certutil is last for the opposite reason: Linux distributions ship an
unrelated NSS tool under that name, and being last means it is only
reached when nothing else answered, where its refusal is simply one more
failed attempt.
There is no "skip verification" fallback. Every digest here is either gating a cache reuse or being written into a manifest a user may check, and both are worse than useless if they can silently be absent.
An empty file never reaches any of the three tools: its digest is a
constant, and both certutil (which cannot map a zero-byte file ā
ERROR_FILE_INVALID, not a transient) and sha256sum (which, given a
backslash-separated Windows path, switches to GNU's "escaped output"
format and fuses a leading backslash onto the digest) mishandle it in
ways worth not asking them about.
extract-archive(IO() $archive, IO() $into --> IO::Path)
Unpack a .tar.gz, .tgz or .zip. Zip prefers unzip and falls
back to tar -xf (bsdtar reads zip; GNU tar does not), so both a Linux
box with unzip and a Windows box with only tar work.
sole-child(IO() $dir --> IO::Path)
The single entry inside a directory, for archives that wrap everything in one top-level folder. Dies naming what it found otherwise, rather than guessing at the first alphabetically.
ensure-dir(IO() --> IO::Path) / rm-rf(IO()) / copy-writable(IO(), IO() --> IO::Path)
Create a directory tree; remove one; copy a file and make the copy writable.
rm-rf unlinks symlinks rather than descending through them: a bundle
contains symlinked libraries, and following one out of the tree during a
cleanup is how a delete becomes an incident. A missing path is not an
error, so it is safe in LEAVE.
copy-writable exists because Homebrew ships libraries mode 444. A
bundler that preserves that mode cannot run install_name_tool over
its own copy, and the failure surfaces several steps later as an
inexplicable "permission denied".
AUTHOR
Matt Doughty
COPYRIGHT AND LICENSE
Copyright 2026 Matt Doughty
This library is free software; you can redistribute it and/or modify it under the Artistic License 2.0.