Skip to content

feat(install): add auto-selecting curl|sh installer + streamline README - #387

Merged
mogul merged 1 commit into
mainfrom
docs/npm-install-fresh-macos
Aug 27, 2026
Merged

feat(install): add auto-selecting curl|sh installer + streamline README#387
mogul merged 1 commit into
mainfrom
docs/npm-install-fresh-macos

Conversation

@mogul

@mogul mogul commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Ready for review. Rebased on current main; the ADR is numbered (0026, accepted) and the npm path is now wired. Please still weigh in on the ADR design, not just the code.

Context

Onboarding surfaced two recurring pains for non-technical users (see the ADR's "Observed evidence" section):

  • The ./acq-from-the-wrong-folder trap — running ./acq from a project/home dir yields "no such file", with no obvious recovery. Multiple users hit it.
  • No gentle "just install it" front door — Homebrew needs admin (managed GFE accounts often lack it), and npm/brew dead-end on a bare Mac.

This PR gives acq a real install path that puts it on PATH.

What's here

  • install.sh — one hardened curl | sh front door that auto-selects the best method already on the host: Homebrew → npm → managed git clone.
    • npm branch is FUNCTIONAL (wired via package.json bin/files; installs place acq on PATH and the launcher resolves its acq.backends/ tree through the npm symlink).
    • brew branch is STUBBED (detects brew, explains the tap isn't published yet, falls back to clone) pending a published Homebrew tap (deferred below).
    • clone fallback is fully functional: shallow clone → ~/.local/share/acq, symlink → ~/.local/bin/acq, idempotent, no sudo. Fails closed if a requested --ref can't be checked out, cleaning up any partial clone.
    • Optional commit-SHA pinning (--sha / ACQ_INSTALL_SHA): checks out a full 40-char commit and verifies HEAD matches it, failing closed (and cleaning up) on a missing/mismatched SHA — git objects are content-addressed, so a matching SHA is itself an integrity check.
    • Proactive Command Line Tools setup: probes git by running it, triggers xcode-select --install (no admin), guides the user to the dialog (incl. "look in your Dock"), and waits until git is usable (5s poll, 30-min cap → actionable timeout).
    • PATH is never changed without consent; declining prints the exact line.
    • Flags: --method brew|npm|clone, --ref, --sha, --no-msb, --dry-run, --yes/-y.
  • release-please-config.json — an extra-files ($.version) entry so release-please bumps package.json's version on release; package.json now carries the real manifest version (2.0.0) instead of a placeholder.
  • README — rewritten to a 3-step quickstart (open terminal → install → run) for a non-technical audience; prerequisites/manual-clone/tagged-release/brew/npm moved into <details>; new troubleshooting for the wrong-folder trap and CLT/Dock.
  • ADR (docs/adr/0026-installation-and-distribution.md, status: accepted) — the design rationale, incl. why the hash-confirmed tarball was rejected (git/CLT is needed at runtime anyway; a full commit-SHA checkout is itself content-addressed integrity), and a carve-out noting the msb leg is an unpinned upstream curl | sh outside our control.

Deferred (to be filed as issues after ADR sign-off)

  • Create GSA-TTS/homebrew-tap + acq formula → un-stub brew branch. (External repo — cannot be created from this repo.)
  • Release automation to publish SHA256SUMS and a canonical commit SHA per release; then swap README URL main → pinned tag, and default the installer to that SHA (the --sha mechanism is already in place).

Verification transcript

Run in the acq sandbox (Linux; macOS-specific CLT path exercised via stubs + --dry-run):

Check Result
shellcheck --severity=warning install.sh clean
markdownlint-cli2 (our files) 0 issues
scripts/test-acq-bats (bats-core, per ADR-0025) 368 passed, 0 failed
gitleaks protect --staged no leaks
npm pack --dry-run ships exactly acq + acq.backends/ (7) + package.json/README/LICENSE (11 files)
npm install -g (local) places acq, launcher resolves through symlink
auto-select (host has npm, no brew) picks npm
--method clone --yes (real, local repo) launcher linked, PATH consent line added, idempotent
bad --ref (clone) fails closed (exit 1), partial clone removed
--sha <matching> (real, local repo) checks out + prints "verified HEAD matches pinned commit"
--sha <well-formed but absent> fails closed (exit 1), partial clone removed
malformed --sha / --sha with --method npm rejected at parse with clear error
release-please version sync npm pack reports manifest version 2.0.0 (no placeholder)
invalid --method bogus exits 1 with clear error
no-usable-git on "Darwin" (stubbed, dry-run) shows CLT trigger step, no hang
non-macOS no-git fails closed with guidance

Rollback

Revert this branch/PR. install.sh is additive and the README/ADR are docs; nothing in acq's runtime dispatch changed.

Security impact

No auth/authz changes. Installer uses no sudo, writes only under \$HOME, gates PATH edits and msb install on consent, fails closed on an unresolvable --ref, and (once releases publish it) will verify integrity via a pinned commit SHA. The msb leg delegates to msb's own upstream installer, which is unpinned and outside our control (documented in the ADR).

AI-assisted (OpenCode). Human owner: @mogul.

Testing this PR before merge

The README one-liner points at main, which won't have install.sh until this merges. To try it now, run it from a checkout of this branch. Safe (non-destructive) checks that never touch your real ~/.local or PATH:

git fetch origin docs/npm-install-fresh-macos
git checkout docs/npm-install-fresh-macos

# 1. See what it would do — makes no changes, no network mutations:
sh install.sh --help
sh install.sh --dry-run
sh install.sh --method clone --dry-run
sh install.sh --method npm   --dry-run

# 2. Real clone install into a throwaway HOME (does NOT touch your real dotfiles),
#    using this local checkout as the source so it's offline:
TMP=$(mktemp -d)
HOME="$TMP" ACQ_INSTALL_REPO_URL="$PWD" ACQ_INSTALL_REF=docs/npm-install-fresh-macos \
  sh install.sh --method clone --no-msb --yes
ls -l "$TMP/.local/bin/acq"                    # launcher symlink
git -C "$TMP/.local/share/acq" rev-parse HEAD  # pinned to the ref
rm -rf "$TMP"

# 3. Fail-closed check: a bad --ref must exit non-zero and leave nothing behind:
TMP=$(mktemp -d)
HOME="$TMP" ACQ_INSTALL_REPO_URL="$PWD" ACQ_INSTALL_REF=does-not-exist \
  sh install.sh --method clone --no-msb --yes ; echo "exit=$?"   # expect exit=1
ls "$TMP/.local/share/acq" 2>&1 || echo "clone absent (good)"
rm -rf "$TMP"

For a real, non-throwaway install on macOS, just run sh install.sh (auto-selects a method, asks before editing PATH or installing msb). On a bare Mac it will trigger the Command Line Tools dialog — accept it (look in your Dock if you don't see the window).

@wz-gsa wz-gsa left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adversarial review — one blocking finding, two non-blocking

Blocking: the default install is unpinned, contradicting the script's and ADR's own security claims

install.sh:

# Pin to a release tag by default so `curl | sh` never runs a moving target.
# Override with --ref for testing a branch or specific tag.
REF="${ACQ_INSTALL_REF:-main}"

The comment says "pin to a release tag by default." The code's default is main — a moving target, the opposite of what the comment claims.

This isn't a stale comment on a minor path — it's the headline, README-recommended install method this PR is built around:

curl -fsSL https://raw.githubusercontent.com/GSA-TTS/agentic-coding-quickstart/main/install.sh | sh

Note the URL itself already fetches the script from main; the script then clones the repo at main too (or, on the npm path, npm install -g github:GSA-TTS/agentic-coding-quickstart#mainREF feeds spec unconditionally whenever non-empty, so the same unpinned default reaches all three install methods, not just clone). Nothing in the README's quickstart flow tells a user to pass --ref <tag> or --sha <commit> — those exist, but they're opt-in, not the default.

The ADR states the opposite of what the code does:

"Our own install.sh and the acq clone are pinned; this carve-out applies only to the third-party msb bootstrap."

That's only true if a user overrides REF. As shipped, install.sh's own default is exactly as unpinned as the third-party msb installer it explicitly calls out as the exception — the Control Mapping cites SR-3 (Supply Chain Controls) and SR-11 (Component Authenticity) for a property the default path doesn't provide. release-please-config.json's change doesn't rewrite REF's default at release time either, so there's no mechanism that flips this to a real tag automatically.

Suggested fix: either (a) default REF to a real pinned tag (bump it as part of the release process, the way release-please-config.json's new extra-files entry already keeps package.json's version in sync — the same mechanism could point at this script or a generated constant), or (b) if main is genuinely intended as the default (rolling-release philosophy), fix the comment and the ADR to say so honestly rather than claim a pinning guarantee that isn't there. Either is fine; the current mismatch between claim and code is not.

Non-blocking: ensure_git's Command Line Tools wait has no non-interactive bypass

ensure_git unconditionally runs xcode-select --install and polls for up to 30 minutes when git isn't usable — with no ASSUME_YES/--yes check. --yes is documented as "Intended for non-interactive/CI use," but on a git-less macOS runner this launches a GUI dialog nothing can click and then blocks the job for up to 30 minutes before finally dying with a timeout message, rather than failing fast. Worth gating the wait (or skipping straight to the actionable die message) when ASSUME_YES=1 or stdin isn't a TTY.

Verified as sound (not flagged)

  • The --sha integrity check is real: content-addressed git rev-parse HEAD comparison, rm -rf + fail-closed on mismatch, correctly restricted to the clone method (npm/brew reject --sha).
  • confirm() fails closed (no way to ask → decline) and correctly never edits PATH without consent.
  • git_is_usable probes by running git, not just command -v — correctly catches the macOS shim-without-CLT-installed case.
  • No sudo, no writes outside $HOME, npm PATH-detection via command -v after npm install -g is not a race (POSIX sh doesn't hash between commands the way an interactive login shell does).
  • The Homebrew stub path is honestly labeled and safely falls through to the functional clone method rather than silently no-op'ing.

All 4 CI checks green; this is a review-blocker on the security-claim mismatch, not the implementation quality, which is otherwise careful.

AI-assisted (OpenCode).

@mogul
mogul force-pushed the docs/npm-install-fresh-macos branch from 41e79df to 486e911 Compare August 27, 2026 18:19
@mogul

mogul commented Aug 27, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @wz-gsa — thorough review. Both findings addressed in 486e911.

Blocking: default install unpinned vs. the pinning claim

You're right: the default REF is main (a moving target), and the comment + ADR claimed a pin the default path didn't provide. Fixed by making the claim honest rather than faking a pin we can't yet back (option (b) from your review), because there's a genuine chicken/egg — no release tag contains install.sh yet, and the README curl URL fetches the script from main too, so hardcoding a tag default now would point clone/npm at a version predating this installer.

  • install.sh: the REF comment now states plainly this is a rolling-release default — main is a moving target, not a pinned/content-addressed reference — and directs users to --ref/--sha for a verifiable install today (--sha is integrity-checked against HEAD, fail-closed).
  • ADR-0026: the Security-posture section is rewritten from "Pinned." to "Pinning is available today, but not yet the default," and the Context no longer claims "no unpinned remote code." It now lists two unpinned legs honestly (the main default until release automation lands, and the third-party msb bootstrap), and notes the clone/npm supply-chain guarantee is exactly as strong as the REF the user chooses.
  • Flipping the default to a pinned tag + canonical SHA at release time (the mechanism you suggested, analogous to the extra-files version sync) is now explicitly owned by Release automation: publish SHA256SUMS + canonical commit SHA; pin installer/README to a release #408, which moves the README URL and the REF default together.

Non-blocking: ensure_git had no non-interactive bypass

Fixed. ensure_git now fails fast instead of launching a GUI dialog and polling 30 minutes when it can't be interactive: if --yes is set, or stdin isn't a TTY and /dev/tty can't be opened, it dies immediately with the actionable xcode-select --install guidance. Verified live — --yes and piped-stdin both exit in ~0s; the usable-git and --dry-run paths are unchanged.

Thanks also for confirming the --sha integrity check, confirm() fail-closed behavior, and git_is_usable probing-by-running as sound — that matches the intent.

Verification on the updated branch: shellcheck clean, dash -n OK, markdown lint 0 issues, scripts/test-acq-bats 368/0, gitleaks protect --staged clean, plus live CLT-fail-fast and clone/--sha checks.

AI-assisted (OpenCode).

Add install.sh: a no-sudo, no-admin front door that auto-selects an
install method (brew -> npm -> git clone) so non-technical users need not
choose. The clone fallback is fully functional (shallow clone to
~/.local/share/acq, consent-gated PATH edit, idempotent); the npm path is
wired via package.json bin/files; the brew path is stubbed pending a
published homebrew tap.

Support optional commit-SHA pinning (--sha / ACQ_INSTALL_SHA): the clone
method checks out the pinned commit and verifies HEAD matches it, failing
closed and cleaning up on mismatch. Git objects are content-addressed, so
a matching SHA is itself an integrity check.

Be honest about the default ref: the default REF is main (a moving
target) until release automation ships a pinned tag + canonical SHA. The
comment and ADR no longer claim a pinning guarantee the default path does
not provide; --ref/--sha are the opt-in hardening available today.
Default-pinning is tracked as release-automation follow-up work.

Fail fast when the macOS Command Line Tools are missing but we are running
non-interactively (--yes, or piped stdin with no reachable terminal):
launching the GUI installer and polling for 30 minutes is useless when no
one can click the dialog, so die immediately with actionable guidance.

Wire release-please (extra-files, $.version) to bump package.json's
version on release so the npm package tracks the release manifest instead
of a hand-maintained placeholder.

Proactively trigger and explain the macOS Command Line Tools install
(the git dependency is unavoidable at acq runtime), noting the pop-up may
hide in the Dock. Fail closed when a requested --ref cannot be checked
out, cleaning up any partial clone.

Streamline the README quickstart to three steps for a non-technical
audience and record the decision in docs/adr/0026-installation-and-distribution.md.

Co-authored-by: OpenCode [claude-opus-4] <bret.mogilefsky@gsa.gov>
@mogul
mogul force-pushed the docs/npm-install-fresh-macos branch from 486e911 to 912618c Compare August 27, 2026 18:23
@mogul
mogul merged commit 4d67551 into main Aug 27, 2026
4 checks passed
@mogul
mogul deleted the docs/npm-install-fresh-macos branch August 27, 2026 18:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants