Skip to content

fix(quickstart): make curl-install and af-stack dev work from a fresh clone - #215

Merged
AbirAbbas merged 8 commits into
mainfrom
fix/install-script-pipefail
Sep 2, 2026
Merged

fix(quickstart): make curl-install and af-stack dev work from a fresh clone#215
AbirAbbas merged 8 commits into
mainfrom
fix/install-script-pipefail

Conversation

@AbirAbbas

Copy link
Copy Markdown
Contributor

Summary

The README quickstart — curl … install.sh | bash then af-stack dev — was broken on a fresh clone in three independent ways. All three were invisible from a long-lived maintainer checkout, which is why they shipped.

1. install.sh died before downloading anything. It piped the GitHub API response into grep -m1, which exits on its first match; curl then fails its remaining write (curl: (23) Failed writing body) and, under set -o pipefail, the whole $(...) is treated as failed even though the tag parsed. Reproduced 5/5 from a file, from stdin, and via curl | bash. The script now resolves the tag from the github.com/<repo>/releases/latest 302 redirect (which also avoids the 60 req/hour unauthenticated API limit that CI runners hit) and falls back to the API with the body buffered before parsing.

2. Even when the install succeeded, af-stack dev could be "command not found". For any non-root user /usr/local/bin is not writable, so the binary landed in ~/.local/bin, which macOS never puts on PATH and Ubuntu only adds at the next login. The script printed a version banner via the absolute path and exited 0. It now prefers a usable directory that is already on PATH and, when the chosen one is not, prints the exact export PATH=… line plus the rc-file line for the user's shell. It also flags an older copy shadowing the new one.

3. A fresh clone crash-looped the runtime. .env.example set AF_STACK_KMS_KEY=change-me-to-a-real-key. Since the KMS boot gate (#141) a key that is set but cannot be loaded is fatal by design, so af-stack dev, af-stack mode, and cp .env.example .env && docker compose up all seeded that placeholder and the runtime refused to start. The example now ships the runtime's dev sentinel dev-secret-change-me (the same value docker-compose.yml defaults to), so it boots with the deterministic dev key and a warning. The docs-site quickstart also told readers to write AF_STACK_KMS_KEY=$(openssl rand -hex 32) into .env; compose passes that through literally, same crash. Fixed.

Installer hardening (from an adversarial review, each reproduced first)

  • Checksum fetch failed open: any transport error on checksums.txt was reported as "not found on the release" and the install continued unverified. It now branches on the HTTP status, refuses to install otherwise with the status and curl's error, and AF_STACK_SKIP_CHECKSUM=1 is the explicit override.
  • The post-install probe was silenced and ran after the success banner; it now gates the banner. install -m 0755 replaces mv so a sudo install is root-owned. mkdir gets the same sudo fallback and actionable error as the copy, and directory resolution runs before the download so a bad target fails in under a second.
  • AF_STACK_VERSION=0.12.4 (bare, the spelling release.yml and the prod compose use) 404'd and blamed repo visibility; the other prefix is tried once.
  • AF_STACK_DOWNLOAD_BASE points both fetches at a mirror, which is also what lets CI drive the real script against a local fake release.

Gates added so this cannot regress silently

  • install-script CI job, required by ci-success (path-filtered, so unrelated PRs skip it): bash -n + shellcheck; the installer run against the real latest release piped from stdin and from a file with an exact af-stack <version> assertion (tags resolved via gh, independently of the script's own redirect); the previous release pinned both v-prefixed and bare, so a no-op pin can never pass; and a local http.server serving the real archive as a fake release, against which the script must reject a tampered checksums.txt and install nothing, refuse a missing one with HTTP 404 unless overridden, and install with the correct one.
  • secrets.TestEnvExampleBootsKMS: reads the committed .env.example the way compose does, loads the KMS cipher from it, and runs the runtime's boot preflight. It fails against the old placeholder with the exact crash-loop reason and passes with the sentinel. .env.example is added to CI's Go path filter so the test runs when the example changes.

Verification

  • All ten steps of the new CI job run green locally. Beyond them: the PATH warning prints the exact export line when the dir is off PATH and nothing when it is on PATH; trailing slashes are trimmed; an uncreatable dir fails before any download; unknown repo, bad pin, and missing release each fail with a specific message.
  • Fresh clone of main + released af-stack v0.12.4 + af-stack dev with no manual edits: /ready reports ready, POST /api/v1/agents/supportdesk.echo echoes back, dashboard and customer app serve their sign-in pages, and the runtime logs exactly one dev-key warning with zero restarts.
  • Contract test proven red on the old example, green on the new. Full go build, go vet, and go test ./... pass; golangci-lint v2.13.1 in CI's new-issues mode reports 0 new issues. docs-lint, build-docs-site (47 pages), and validate-deploy-targets pass locally.

Not in this PR

  • The docs-site references port 38080 in several pages (quickstart, swap-defaults, the Scalar API browser proxy, launch posts) and wrong operator credentials on the quickstart page; that port was never a default. Separate reconciliation sweep with its own CI assertion.
  • af-stack dev silently skips port auto-allocation when Node is missing or older than 18; the README now says Node is optional, but porting the preflight into the Go binary would remove the dependency.
  • The af-stack dev banner could print the seeded operator login, which the README never gives.
  • af-stack dev could generate a unique random KMS key when seeding a new .env instead of the shared dev sentinel.

🤖 Generated with Claude Code

AbirAbbas and others added 8 commits September 2, 2026 09:20
`curl … install.sh | bash` died at "Resolving latest release" with
`curl: (23) Failed writing body`. The script piped the GitHub API
response into `grep -m1`, which exits as soon as it has its match; curl
then fails its remaining write and, under `set -o pipefail`, the whole
`$(...)` is treated as failed even though the tag parsed correctly. On
WSL2/bash this reproduced 5/5 from a file, from stdin, and via curl|bash.

Resolve the tag from the github.com/<repo>/releases/latest 302 redirect
instead, which also sidesteps the 60 req/hour unauthenticated API limit
that CI runners and office NATs hit, and keep the API as a fallback with
the body buffered before parsing. While here, make checksum verification
a plain function whose failure the caller turns into a hard error, since
a `die` inside the old `( cd … )` subshell only exited the subshell.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Abir Abbas <abirabbas1998@gmail.com>
The first line of the README quickstart had no gate: nothing in CI ever
executed scripts/install.sh, so it could ship broken. Add an
`install-script` job, path-filtered to the script and this workflow,
that lints it (bash -n + shellcheck) and runs it in the three shapes
users hit — piped from stdin like the README one-liner, from a file,
and pinned with AF_STACK_VERSION — asserting `af-stack version` prints a
semver each time, and that a tampered checksums.txt is rejected.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Abir Abbas <abirabbas1998@gmail.com>
A fresh clone could not boot. `.env.example` set
`AF_STACK_KMS_KEY=change-me-to-a-real-key`, and since the KMS boot gate
(#141) a key that is set but cannot be loaded is fatal by design — so
`af-stack dev`, `af-stack mode`, and `cp .env.example .env && docker
compose up` all seeded that placeholder and the runtime crash-looped with
"refusing to start: KMS is configured but the key could not be loaded".
Maintainers never saw it because their local .env files already carried
real keys, and the release-smoke compose hardcodes a hex key.

Use the runtime's well-known dev sentinel `dev-secret-change-me` instead,
which is also the docker-compose.yml default, so the example boots with
the deterministic dev KEK and a warning exactly like a missing key does.
Fix the deploy skill doc that named the auth-secret default as the KMS
sentinel; setting that value would trip the same fatal.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Abir Abbas <abirabbas1998@gmail.com>
TestEnvExampleBootsKMS reads the repo's .env.example the way compose and
the CLI's .env seeding do, loads the KMS cipher from it, and runs the
same preflight the runtime does at boot. It fails against the previous
placeholder with the exact reason a fresh clone crash-looped, and passes
with the dev sentinel. Add .env.example to CI's Go path filter so the
test runs whenever the example changes, not only when Go code does.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Abir Abbas <abirabbas1998@gmail.com>
The docs-site quickstart had readers write
`AF_STACK_KMS_KEY=$(openssl rand -hex 32)` into .env. A .env file is not
a shell: docker compose hands the runtime the literal string, which is
not hex, and the runtime refuses to start. Show the dev sentinel as the
value and say to paste the command's output for a real key.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Abir Abbas <abirabbas1998@gmail.com>
…y runs

Hardening from an adversarial review of the installer, each item
reproduced before it was fixed:

- Checksum fetch failed open: any transport error on checksums.txt was
  reported as "not found on the release" and the install continued
  unverified. Every release publishes that file, so branch on the HTTP
  status, refuse to install otherwise, and print the status and curl's
  error. AF_STACK_SKIP_CHECKSUM=1 is the explicit override.
- Install dir off PATH: for any non-root user /usr/local/bin is not
  writable, the binary landed in ~/.local/bin, and the script printed a
  version banner and exited 0 — followed by `af-stack dev: command not
  found`. Prefer a usable candidate that is already on PATH, and when
  the chosen dir is not, print the exact export line and the rc-file
  line for the user's shell instead of a bare banner. Also flag an older
  copy shadowing the new one.
- The post-install probe was silenced and ran after the success banner;
  it now gates the banner, so a noexec mount or truncated archive fails
  loudly. `install -m 0755` replaces `mv` so a sudo install is
  root-owned. mkdir gets the same sudo fallback and actionable error as
  the copy, and dir resolution moves before the download so a bad target
  fails in under a second.
- AF_STACK_VERSION=0.12.4 (bare, the spelling release.yml and the prod
  compose use) 404'd and blamed repo visibility; try the other prefix
  once, keeping the checksums fetch on the tag that resolved.
- AF_STACK_DOWNLOAD_BASE points both fetches at a mirror, which is what
  lets CI drive the real script against a local fake release.
- Trailing slashes on AF_STACK_INSTALL_DIR no longer defeat the PATH
  check or print a doubled slash.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Abir Abbas <abirabbas1998@gmail.com>
…lock

The first cut of this job could not catch the regressions it exists
for: it was not in ci-success's needs (so a red run never blocked a
merge), its version assertions only checked the semver shape (a
resolver returning the wrong tag passed), the pinned step pinned the
latest release (a no-op pin passed), and the tamper check sourced a
sed-extracted copy of verify_checksum instead of running install.sh
(a fail-open at the call site passed).

Now: ci-success requires the job (path-filtered skips still count as
success, as for every other job); the two newest release tags come
from gh, independently of the redirect the script uses, and every
assertion is an exact `af-stack <version>` match; the pin uses the
previous release, both v-prefixed and bare, so ignoring it can never
pass; and a local http.server serves the real archive as a fake release
so the script itself is run against a tampered checksums.txt (must exit
non-zero with the checksum message and install nothing), a missing one
(must refuse with HTTP 404 unless AF_STACK_SKIP_CHECKSUM=1), and the
correct one (must install).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Abir Abbas <abirabbas1998@gmail.com>
cli-distribution.md now lists bare pins, the redirect-based resolution,
fail-closed checksums with AF_STACK_SKIP_CHECKSUM, the
AF_STACK_DOWNLOAD_BASE mirror, and the PATH hint. README's quickstart
said the only prerequisite is Docker; `af-stack dev` also uses Node for
port auto-allocation and silently falls back to the defaults without
it, so say so in one line.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Abir Abbas <abirabbas1998@gmail.com>
@AbirAbbas
AbirAbbas merged commit 76f8e2d into main Sep 2, 2026
30 checks passed
@AbirAbbas
AbirAbbas deleted the fix/install-script-pipefail branch September 2, 2026 14:29
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.

1 participant