ADR 0005: install + uninstall architecture
Date: 2026-06-28 Status: Accepted
Context
Section titled “Context”Install and uninstall had grown organically into a set of surfaces that worked but didn’t read like a mature project’s:
npm install -g harnerygave you the CLI, but nothing told you to then runharn initto wire a project, so the README left users with an unwired engine.- A repo-root
install.shdid clone-time setup (deps, build,harn init, PATH symlinks), but its name implied “this is THE installer” even though it required agit clonefirst and wasn’t served anywhere. It blurred end-user install with contributor/dashboard setup. harn uninstallreversed the project wiring; a repo-rootuninstall.shmirroredinstall.sh. Neither was reachable as a one-liner, and the shell scripts had no CI coverage.
The shape was sound; the packaging and positioning weren’t.
Decision
Section titled “Decision”Four surfaces, one per intent, each following the conventions of the tool class it belongs to.
-
Get the CLI — a hosted one-liner.
curl -fsSL https://harnery.com/install.sh | bash, served fromdocs/public/install.sh(GitHub Pages copiespublic/to the site root). It installs the published npm package globally with whatever package manager is present (npm preferred for a predictable global bin, Bun otherwise), PATH-checks, verifiesharn --version, and points atharn init. A mirroruninstall.shremoves it. Equivalent manual path:npm install -g harnery. -
Wire a project —
harn init. Unchanged. -
Unwire a project —
harn deinit. Renamed fromharn uninstall(see the naming note below). The inverse ofharn init; on a TTY it now asks before deleting.harnery/and prints how to remove the CLI itself. -
Contribute / run the dashboard —
scripts/setup.sh+scripts/teardown.sh. The former repo-rootinstall.sh/uninstall.sh, moved underscripts/and reframed as clone setup. This frees the nameinstall.shfor the real hosted installer and signals “this is for a checkout, not for end users.”
Rules the scripts follow, and why:
- The project-unwire verb is
deinit, notuninstall. Once a hosteduninstall.shexisted to remove the CLI, “uninstall” named two different scopes: unwire a project vs remove the CLI.deinit(the inverse ofinit, mirroringgit submodule deinit) keeps the project layer’s vocabulary distinct from the CLI layer’s install/uninstall, so “uninstall” now means one thing only. Pre-1.0, so the rename is a clean break with no alias. - The hosted scripts are non-interactive.
curl … | bashpipes the script into bash’s stdin, so there is no terminal to read an answer from and no[ -t 0 ]. Every choice is therefore a flag or env var; nothing prompts. - They are wrapped in a
main()called on the last line. If the download is truncated mid-flight, bash refuses to run a half-written function rather than executing a partial install. Standard curl-pipe-bash safety. - No auto-
init. Installing the CLI and wiring a project are separate steps (mirrors husky/pre-commit/tailwind). The one-liner isn’t necessarily run from inside a target project, and silently wiring the current directory would be surprising. - Missing runtime: instruct and exit. If neither Bun nor Node is present the
installer prints how to get one and exits non-zero.
--install-runtimeopts into installing Bun. We don’t install a runtime onto someone’s machine by default, even though rustup/bun’s own installers do; predictability wins for a tool that’s usually added next to an existing Node/Bun setup. - The
harn deinitprompt + the “remove the CLI” hint are gated to standaloneharn. An embedding host (createHarneryProgram({ binName })) routes output through its own emit and owns its install lifecycle, so<host> deinitstays strictly flag-driven and says nothing about removing “the package”. Resolved via thebinNamepassed toregisterDeinitCommand, the same valueharn initalready receives.
Alternatives considered
Section titled “Alternatives considered”- Keep a single root
install.shand just host it. Rejected: a hosted installer thatgit clones is unusual, and a clone-bootstrap script that also end-users install muddies both jobs. Splitting by audience is clearer. - Keep
harn uninstallfor the project-unwire command. Rejected: it collides with the hosteduninstall.sh(remove the CLI). Same word, two scopes.harn deinitresolves the ambiguity (see the naming rule above). The raw discoverability of “uninstall” is a liability here, not a feature: someone who typesharn uninstallexpecting “remove harnery from my machine” gets the surprising answer (it only unwires the current project). harn install/harn uninstallas a symmetric project pair. Rejected:harn installwould collide head-on with installing the CLI (the one-liner /npm i -g), which is the worse ambiguity of the two.- A
harn installsubcommand. Rejected: bootstrapping the CLI onto PATH is inherently the package manager’s job (or the one-liner’s); a subcommand can’t install the binary that runs it.
Consequences
Section titled “Consequences”- The one-liner URL only resolves after the docs site deploys from
main(docs.ymlruns onmain). Work lands onnext, so the URL is live at the next release, not the moment the file is committed. - A CI
installersjob exercises the hosted scripts against a freshly-packed tarball (via theHARNERY_INSTALL_SOURCEseam) on both Node and Bun, plus a bash integration test forscripts/setup.sh/teardown.sh, so the shell surface can’t silently rot. - Teardown is two layers: unwire a project (
harn deinit) vs remove the CLI (uninstall.sh/npm rm -g harnery). The docs cross-link them so the split is explicit rather than confusing. - Not done yet: checksum / signature verification of the curl-piped script. Acceptable pre-1.0 (the script is short, served over HTTPS from our own domain, and the docs link to it so it can be read first); revisit before 1.0.