Installer
NAME
App::Ariza::Installer - the four scripts a user actually runs
SYNOPSIS
use App::Ariza::Config;
use App::Ariza::Installer;
my $cfg = App::Ariza::Config.load('/path/to/App-Moneymoor'.IO);
my @written = App::Ariza::Installer.write(:out-dir($repo), :config($cfg));
say @written; # (ā¦/install.sh ā¦/install.ps1 ā¦/uninstall.sh ā¦/uninstall.ps1)
# Just the text, for a diff or a test:
say App::Ariza::Installer.render(
:template<install-posix.sh.j2>,
|App::Ariza::Installer.context(:config($cfg)),
);
DESCRIPTION
App::Ariza::Bundle produces an archive; this produces the thing that puts one on someone's machine. Four files, committed at the app's repository root beside the launcher-less bundles they install:
install.sh macOS and Linux, curl-pipeable and runnable as a file
install.ps1 Windows
uninstall.sh
uninstall.ps1
$ curl -fsSL https://raw.githubusercontent.com/<owner>/<repo>/HEAD/install.sh | sh
==> Moneymoor 0.2.0 for macos-arm64
==> downloading https://github.com/ā¦/moneymoor-0.2.0-macos-arm64.tar.gz
ok sha256 verified
ok added /home/you/.local/bin to PATH in /home/you/.zshrc
ok Moneymoor 0.2.0 installed
run it: moneymoor
installed in: /home/you/.local/share/moneymoor/versions/0.2.0
uninstall: curl -fsSL https://raw.githubusercontent.com/ā¦/uninstall.sh | sh
One POSIX script, not two
The installer scripts that came before a bundle shipped
install-macos.sh and install-linux.sh separately, because they did
genuinely different things: one drove Homebrew, the other drove five
package managers.
A bundle installer does none of that. The only per-platform decision
left is which asset to download, which is a uname call away at run
time ā so there is one script, it detects, and a user copying a
curl ⦠| sh line off a README does not have to know which of two
files applies to them.
What it does, and does not do
Downloads a prebuilt bundle and verifies its SHA-256 against the
.sha256asset published beside it. No digest, no install.Unpacks into
< $XDG_DATA_HOME/<exec>/versions/<version>/ >, beside whatever is already there, then flips acurrentsymlink and links< ~/.local/bin/<exec> >at it. A failed or interrupted download cannot damage a working install, because nothing that exists is touched until the new tree is complete.Puts
~/.local/binonPATHonly if it is not there already, through one marked block appended to each shell rc file that exists. Re-running never duplicates it; the uninstaller removes exactly that block and nothing else.Keeps the newly installed version and the exact physical version that was current before the switch, points
previousat that rollback target, and prunes anything else. Windows defers a locked directory safely and retries it on a later install.Needs no root, no compiler, no package manager and no Raku.
It does not create desktop entries, register file associations, install a terminal emulator, or write to any shared location. A bundle needs none of it, and the shared registry the older installer scripts kept existed only to refcount things this one never installs.
Re-running is a repair
Asking for a version that is already installed does not re-download it.
The existing tree is checked, the current symlink and the
~/.local/bin link are re-pointed, the PATH block is re-checked, and
the script exits 0 saying "already installed". That makes "run the
installer again" the correct advice for the most common breakage ā a
link someone deleted or a shell that never picked up PATH.
An existing version directory with no runnable launcher in it is not a version, and is replaced rather than trusted.
The escape hatch
--url (and the < <EXEC>_BUNDLE_URL > environment variable, e.g.
MONEYMOOR_BUNDLE_URL) installs from a source the user names, and
bypasses GitHub entirely. It accepts a plain file path as readily as
a URL, which is what makes an air-gapped install, a release candidate,
and this distribution's own end-to-end test possible without a network
or a published release.
--insecure-no-verify applies to that path only. A source someone
named themselves may legitimately have no .sha256 beside it, and the
script says so loudly before continuing. A published release always
has one, so a missing digest there means a tampered or half-uploaded
release and stays fatal however many flags are passed.
Version resolution without a JSON parser
The default is "latest", read from the location: header of
<https://github.com/<repo>/releases/latest> ā one HEAD request,
no API token, no jq on a machine that may have neither. --version
names a tag instead.
For an explicit --url, the version comes from the archive's own name.
That parse strips a known slug off the end rather than splitting on
dashes, because moneymoor-1.0-rc1-macos-arm64 has to mean version
1.0-rc1 and not 1.0. If the name does not parse, the unpacked
bundle's own directory name is tried, and only then does it give up.
Unknown platforms say so
Detection produces one of the slugs the app declares in
bundle.platforms or nothing at all. There is no nearest match: a
glibc bundle does not run on Alpine and an arm64 one does not run on an
Intel Mac, so a wrong guess is a download followed by an exec format
error. What a user gets instead names their machine and points at the
releases page:
error no prebuilt Moneymoor bundle for Linux ppc64le yet -- see
https://github.com/owner/repo/releases for what is published
The Linux branch probes for a ld-musl-*.so.1 loader before choosing
between -glibc and -musl, the same conclusive test
App::Ariza::Platform uses.
The install pays for the first launch
The last thing an install does before the parting message is run the
launcher it has just linked ā < <exec> --version > by default, or
whatever installer.warm names ā with its output suppressed and a
line on screen saying what is happening:
ok Moneymoor 0.3.0 installed
==> warming up -- the first launch does the work the rest never repeat
ok ready
Whatever a first launch has to do that later ones do not ā paging a few hundred megabytes off a cold disk, creating a per-user state directory ā happens there, while an installer is visibly working, rather than the first time somebody actually wants the program. Every path that reaches the parting message goes through it, including the re-run that found the version already installed: that re-run is what people try when the last one did not take.
A warm-up that fails warns and the install succeeds. No non-zero exit, no rollback, no "installation failed".
That is a deliberate divergence from how everything else here behaves,
and the difference is where the failure happens. ariza bundle and
ariza smoke run in our own pipeline, before anything is published,
where stopping is cheap and a false negative is expensive. This runs on
a stranger's machine, after the bundle has been downloaded,
checksummed and moved into place ā where the same signal much more
often means the machine (no terminal, a sandbox, a scanner holding a
file open) than the release, and where failing the install would delete
a working program from somebody who has one. So the message says what
did not complete, and that the app is installed and its download was
verified, so run it.
There is no timeout, on purpose: a mechanism that needed one would
imply a warm command that might not return, and the fix for that is the
command. Which is why an empty installer.warm is a load-time error
rather than "run it with no arguments" ā see App::Ariza::Config.
The arguments are the one place in these templates where an app's own
string becomes script syntax, so they are quoted here rather than
trusted there: sh-quote per word for sh, ps-quote per word for
PowerShell, splatted at the call site so an argument list cannot arrive
as one argument with spaces in it.
Curl-pipeable, and therefore silent
A script read from a pipe is executed as it arrives, so this one is
entirely function definitions with a single main "$@" at the end: a
truncated download cannot half-run it.
It also never reads standard input ā no confirmation prompts, no
read ā because when the script arrives on standard input there is
nothing to read from. That is not a limitation worth working around;
an installer that cannot be interrogated is one that can be run from a
CI job.
Windows
The same shape in PowerShell:
%LOCALAPPDATA%\<Display>\versions\<version>, a
current junction (a symlink would need administrator rights or
Developer Mode, which a per-user install has no business demanding),
and ...\current\bin added once to the user PATH in
HKCU\Environment. Because the PATH entry points through the junction,
an upgrade needs no PATH change at all.
Archives are unpacked with tar.exe, which has shipped in Windows
since 10 1803 and reads the .tar.gz ariza packages every platform's
bundle as; Expand-Archive handles a .zip if one is ever pointed at
with -Url.
Output is CRLF.
METHODS
write(:$out-dir!, :$config!, :$branch --> List)
Render every installer this app gets into :$out-dir and return the
paths, chmod 0755 for the POSIX pair. The directory must already
exist ā the default target is the app's own repository, and silently
creating one would turn a typo into a successful render nobody can find.
Dies when the app declares no bundle.platforms (there would be
nothing to detect or download) or no installer.repo (nowhere to
download it from).
render(:$template!, *%ctx --> Str)
One template, as text. Exposed separately because that is what a golden-file test compares, and an installer is a thing worth diffing.
context(:$config!, :$branch --> Hash)
The render context: the app's names, the repository, the declared slugs
for each family, the < <EXEC>_BUNDLE_URL > variable, the warm-up
(warm, and its arguments quoted for each shell) and the two inlined
runtime libraries.
sh-quote(Str $word --> Str) / ps-quote(Str $word --> Str)
One word, quoted for sh and for PowerShell respectively. The warm-up
arguments are the only part of a generated installer that comes from
the app rather than from ariza, and they are written into it as script
syntax, so they are quoted where they are rendered rather than trusted
where they land.
scripts-for(App::Ariza::Config $config --> List)
The { template, output, family, mode, crlf } entries this app gets,
in write order. An app that declares no Windows platform gets no
install.ps1: a script whose only possible answer is "there is no
bundle for your machine" is worse than its absence.
slugs-for(App::Ariza::Config $config, Str $family --> List) / family-of(Str $slug --> Str)
The declared slugs in one family, and the family of a slug ā
windows, or posix for everything else.
env-url(App::Ariza::Config $config --> Str)
The name of the source-override environment variable:
MONEYMOOR_BUNDLE_URL for moneymoor.
SEE ALSO
App::Ariza::Bundle, which builds what this installs; App::Ariza::Launcher, which writes what it links to.
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.