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 .sha256 asset published beside it. No digest, no install.

  • Unpacks into < $XDG_DATA_HOME/<exec>/versions/<version>/ >, beside whatever is already there, then flips a current symlink 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/bin on PATH only 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 previous at 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.

App::Ariza v0.2.4

bundler and distribution tool for Raku terminal apps

Authors

  • Matt Doughty

License

Artistic-2.0

Dependencies

Config::TOML:ver<0.1.3+>:auth<zef:raku-community-modules>JSON::Fast:ver<0.19+>:auth<cpan:TIMOTIMO>Template::Jinja2:ver<0.3.0+>:auth<zef:apogee>

Test Dependencies

Provides

  • App::Ariza
  • App::Ariza::Bundle
  • App::Ariza::CI
  • App::Ariza::Config
  • App::Ariza::Installer
  • App::Ariza::Launcher
  • App::Ariza::Licensing
  • App::Ariza::Native
  • App::Ariza::Platform
  • App::Ariza::Rakudo
  • App::Ariza::Resources
  • App::Ariza::Runner
  • App::Ariza::Site
  • App::Ariza::Smoke
  • App::Ariza::Tools
  • App::Ariza::Update
  • App::Ariza::Versions

The Camelia image is copyright 2009 by Larry Wall. "Raku" is a trademark of the Yet Another Society. All rights reserved.

Built with Podlite — the markup and publishing tools behind this site.