cast/install.sh

438 lines
21 KiB
Bash
Raw Normal View History

feat: cast — the Coolify executor, extracted from the infra state repo Public tool, private state. cast holds no hostnames, no bindings, no secrets: it joins a product repo's .infra/ manifest with a state directory you point it at, and makes Coolify match. Extracted from heavy-duty/infra, which was half tool and half state — the inconsistency that made it impossible to say whether "infra" named a CLI or a runbook. rig builds the boxes; cast fills them; infra is what they are filled with. Two changes were required to make it genuinely stateless and publishable: - The implicit cwd contract (environments.yaml / secrets/ / .coolify.env resolved against the working directory, silently reading the wrong file from the wrong place) is now an explicit --state <dir> / $CAST_STATE. - BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the only product knowledge in the executor — becomes the generic, operator- owned environments.<env>.forbidden_var_patterns. The guard now lives in private state, so a product-side change cannot lower its own guard, and it is a pattern rather than a list, so it catches unforeseen siblings. Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then ~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended apply mechanism, with no environment names known to the tool. Instance identity (org names, the GitHub App name, founder domains) is out of the fixtures and out of register-github-app.sh, which took APP_NAME and ORG as arguments rather than baking them in. 69 tests green; bin/cast + curl installer mirror rig's shape. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:25:44 +00:00
#!/usr/bin/env bash
set -euo pipefail
# cast installer — intended for: curl -fsSL .../install.sh | bash
#
feat: release flow — tagged releases with a prebuilt dist asset (#96) The cast half of the flow designed in heavy-duty/box#83, aligned with box#90 and rig#40, plus the piece unique to cast: a prebuilt release asset, because cast is the one repo where the source tarball is not the package. - CHANGELOG.md (box's format) with this PR's entry under Unreleased; feature PRs land their entry as part of the PR. - `cast --version` / `-V` answers with package.json's version, read relative to the compiled module so a source checkout and an installed prebuilt tree agree. - release.yml, on EVERY tag push (no shape filter — a mismatched tag must fail the assert loudly, not be pattern-skipped): asserts tag == package.json version FIRST, extracts that version's changelog section (.github/scripts/release-notes.sh, shared with the tests; missing or empty refuses), builds once (npm ci && npm run build && npm prune --omit=dev), stages bin/ dist/ node_modules/ package.json as cast-X.Y.Z/ and attaches cast-X.Y.Z.tgz to `gh release create --verify-tag`. No tests here — ci.yml gated the merge commit, and the suite needs age. - install.sh grows the three channels: default = the latest release's asset (tag resolved off the releases/latest redirect Location — no API, no token; failure dies loudly naming CAST_REF=main, never a silent fallback), CAST_REF=<tag> = pinned (asset first, source fallback), CAST_REF=main = dev build-from-source. npm is required only on the source path, and a prebuilt tree is sanity-checked (dist/, node_modules/) before $DEST is replaced. - test/release.test.ts drives it all offline: --version, the extraction against fixtures (0.7.0 never matches 0.7.0-rc1) and the real changelog, and REAL install.sh runs through all three channels with a stub curl and a poisoned npm — including the loud no-releases refusal with no $DEST side effects. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 21:29:53 +00:00
# Fetches a cast tree and installs it into the VERSIONED layout under $DEST
# (box#79's layout, ported the way rig#36 ported it):
feat: cast — the Coolify executor, extracted from the infra state repo Public tool, private state. cast holds no hostnames, no bindings, no secrets: it joins a product repo's .infra/ manifest with a state directory you point it at, and makes Coolify match. Extracted from heavy-duty/infra, which was half tool and half state — the inconsistency that made it impossible to say whether "infra" named a CLI or a runbook. rig builds the boxes; cast fills them; infra is what they are filled with. Two changes were required to make it genuinely stateless and publishable: - The implicit cwd contract (environments.yaml / secrets/ / .coolify.env resolved against the working directory, silently reading the wrong file from the wrong place) is now an explicit --state <dir> / $CAST_STATE. - BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the only product knowledge in the executor — becomes the generic, operator- owned environments.<env>.forbidden_var_patterns. The guard now lives in private state, so a product-side change cannot lower its own guard, and it is a pattern rather than a list, so it catches unforeseen siblings. Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then ~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended apply mechanism, with no environment names known to the tool. Instance identity (org names, the GitHub App name, founder domains) is out of the fixtures and out of register-github-app.sh, which took APP_NAME and ORG as arguments rather than baking them in. 69 tests green; bin/cast + curl installer mirror rig's shape. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:25:44 +00:00
#
refactor: rescope to versioned installations — the release flow moves out Maintainer direction: this PR's one goal is the versioned layout, the same one box#79 built and rig#36 ported — the release flow (tags, release.yml, prebuilt assets, CHANGELOG) is its own PR later, the shape rig#40 has. So: release.yml, changelog-section.sh, CHANGELOG.md and the asset-aware installer channels leave this branch, and in their place cast gets the family layout for real: - install.sh lands each build at $DEST/versions/<package.json version>, 'current' names the default (atomic rename flips), $BINDIR/cast points through it. Converging no-op on an installed version (nothing rebuilt), CAST_REINSTALL=1 replaces, a new version installs beside and becomes default. Pre-versioning flat installs migrate in place, bit for bit. CAST_INSTALL_SOURCE=<dir|tarball> installs locally (CI/tests, rig's RIG_INSTALL_SOURCE precedent). No flip gate: box refuses under live boxes, rig warns on a converged host — cast is an API client, a flip strands nothing, 'cast use <old>' is one command away. - bin/cast grows the layout verbs in bash (they must work when dist/ is broken): versions (marks current+running), use (atomic flip, then asserts the chain ANSWERS the new version), uninstall (consent gate, CURRENT guard, dangling-current guard, ends with the absence assert). valid_version/pkg_version are byte-identical copies in both files; a test diffs them so the gates cannot drift. - cast --version stays: package.json is the single source of truth, printed with the install root, rig-style. - ci.yml gains the install job: the real installer, from this checkout, layout asserted, converge no-op asserted, uninstall --all asserted absent — the box CI precedent. - Tests drive the REAL install.sh and bin/cast offline (npm shim, local source): the layout, the chain answering end to end, no-op/reinstall/ side-by-side/migration semantics, the hostile-version gates, refs/heads download, every uninstall refusal. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 21:17:59 +00:00
# $DEST/versions/<version>/ one full tree per installed version
# $DEST/current -> versions/<version> the default version
# $BINDIR/cast -> $DEST/current/bin/cast the PATH entry
#
refactor: rescope to versioned installations — the release flow moves out Maintainer direction: this PR's one goal is the versioned layout, the same one box#79 built and rig#36 ported — the release flow (tags, release.yml, prebuilt assets, CHANGELOG) is its own PR later, the shape rig#40 has. So: release.yml, changelog-section.sh, CHANGELOG.md and the asset-aware installer channels leave this branch, and in their place cast gets the family layout for real: - install.sh lands each build at $DEST/versions/<package.json version>, 'current' names the default (atomic rename flips), $BINDIR/cast points through it. Converging no-op on an installed version (nothing rebuilt), CAST_REINSTALL=1 replaces, a new version installs beside and becomes default. Pre-versioning flat installs migrate in place, bit for bit. CAST_INSTALL_SOURCE=<dir|tarball> installs locally (CI/tests, rig's RIG_INSTALL_SOURCE precedent). No flip gate: box refuses under live boxes, rig warns on a converged host — cast is an API client, a flip strands nothing, 'cast use <old>' is one command away. - bin/cast grows the layout verbs in bash (they must work when dist/ is broken): versions (marks current+running), use (atomic flip, then asserts the chain ANSWERS the new version), uninstall (consent gate, CURRENT guard, dangling-current guard, ends with the absence assert). valid_version/pkg_version are byte-identical copies in both files; a test diffs them so the gates cannot drift. - cast --version stays: package.json is the single source of truth, printed with the install root, rig-style. - ci.yml gains the install job: the real installer, from this checkout, layout asserted, converge no-op asserted, uninstall --all asserted absent — the box CI precedent. - Tests drive the REAL install.sh and bin/cast offline (npm shim, local source): the layout, the chain answering end to end, no-op/reinstall/ side-by-side/migration semantics, the hostile-version gates, refs/heads download, every uninstall refusal. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 21:17:59 +00:00
# Versions install side by side: `cast versions` lists them, `cast use <v>`
# switches the default, `cast uninstall` removes them. Re-running with an
# already-installed version is a converging no-op (CAST_REINSTALL=1 replaces
# that version's tree); a NEW version installs beside the old one and becomes
# the default. cast neither refuses nor warns where box refuses and rig
# warns: box protects live boxes and rig a converged host, but cast is an
# API client — flipping its version strands nothing on this machine, and
# `cast use <old>` is always one command away. A pre-versioning flat tree is
# migrated in place, so upgrading is seamless.
#
# The version IS the tree's package.json version — cast's single source of
# truth (deliberately no separate VERSION file).
#
feat: release flow — tagged releases with a prebuilt dist asset (#96) The cast half of the flow designed in heavy-duty/box#83, aligned with box#90 and rig#40, plus the piece unique to cast: a prebuilt release asset, because cast is the one repo where the source tarball is not the package. - CHANGELOG.md (box's format) with this PR's entry under Unreleased; feature PRs land their entry as part of the PR. - `cast --version` / `-V` answers with package.json's version, read relative to the compiled module so a source checkout and an installed prebuilt tree agree. - release.yml, on EVERY tag push (no shape filter — a mismatched tag must fail the assert loudly, not be pattern-skipped): asserts tag == package.json version FIRST, extracts that version's changelog section (.github/scripts/release-notes.sh, shared with the tests; missing or empty refuses), builds once (npm ci && npm run build && npm prune --omit=dev), stages bin/ dist/ node_modules/ package.json as cast-X.Y.Z/ and attaches cast-X.Y.Z.tgz to `gh release create --verify-tag`. No tests here — ci.yml gated the merge commit, and the suite needs age. - install.sh grows the three channels: default = the latest release's asset (tag resolved off the releases/latest redirect Location — no API, no token; failure dies loudly naming CAST_REF=main, never a silent fallback), CAST_REF=<tag> = pinned (asset first, source fallback), CAST_REF=main = dev build-from-source. npm is required only on the source path, and a prebuilt tree is sanity-checked (dist/, node_modules/) before $DEST is replaced. - test/release.test.ts drives it all offline: --version, the extraction against fixtures (0.7.0 never matches 0.7.0-rc1) and the real changelog, and REAL install.sh runs through all three channels with a stub curl and a poisoned npm — including the loud no-releases refusal with no $DEST side effects. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 21:29:53 +00:00
# WHICH tree, and whether it is built here, is the channel's business — three
# channels from this one script (#96; box#83's design, plus the piece unique
# to cast: the PREBUILT release asset, because cast is the one repo where the
# source tarball is not the package):
#
# CAST_REF unset the latest RELEASE — the tag is resolved from the
# releases/latest redirect, the download is that
# release's prebuilt cast-<tag>.tgz asset (bin/,
# compiled dist/, production node_modules/): no npm,
# no tsc, no devDependencies on this machine
# CAST_REF=<tag> that release, pinned — its asset first, source as
# the fallback for a ref that has none
# CAST_REF=<branch> the development tree, built from source here —
# CAST_REF=main is the dev channel, and (with local
# source installs) the one place npm is required
#
# Whatever the channel fetched still lands the same way: in versions/<its
# package.json version>, with the current symlink flipped atomically.
#
refactor: rescope to versioned installations — the release flow moves out Maintainer direction: this PR's one goal is the versioned layout, the same one box#79 built and rig#36 ported — the release flow (tags, release.yml, prebuilt assets, CHANGELOG) is its own PR later, the shape rig#40 has. So: release.yml, changelog-section.sh, CHANGELOG.md and the asset-aware installer channels leave this branch, and in their place cast gets the family layout for real: - install.sh lands each build at $DEST/versions/<package.json version>, 'current' names the default (atomic rename flips), $BINDIR/cast points through it. Converging no-op on an installed version (nothing rebuilt), CAST_REINSTALL=1 replaces, a new version installs beside and becomes default. Pre-versioning flat installs migrate in place, bit for bit. CAST_INSTALL_SOURCE=<dir|tarball> installs locally (CI/tests, rig's RIG_INSTALL_SOURCE precedent). No flip gate: box refuses under live boxes, rig warns on a converged host — cast is an API client, a flip strands nothing, 'cast use <old>' is one command away. - bin/cast grows the layout verbs in bash (they must work when dist/ is broken): versions (marks current+running), use (atomic flip, then asserts the chain ANSWERS the new version), uninstall (consent gate, CURRENT guard, dangling-current guard, ends with the absence assert). valid_version/pkg_version are byte-identical copies in both files; a test diffs them so the gates cannot drift. - cast --version stays: package.json is the single source of truth, printed with the install root, rig-style. - ci.yml gains the install job: the real installer, from this checkout, layout asserted, converge no-op asserted, uninstall --all asserted absent — the box CI precedent. - Tests drive the REAL install.sh and bin/cast offline (npm shim, local source): the layout, the chain answering end to end, no-op/reinstall/ side-by-side/migration semantics, the hostile-version gates, refs/heads download, every uninstall refusal. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 21:17:59 +00:00
# CAST_INSTALL_SOURCE=<dir-or-tarball> installs from a local tree instead of
# downloading — for CI and the test suite, so what lands is the code under
# review.
#
# Unlike rig (pure bash, runs on bare boxes), cast runs on YOUR machine and
# needs node — it is an API client, never something a server installs.
feat: cast — the Coolify executor, extracted from the infra state repo Public tool, private state. cast holds no hostnames, no bindings, no secrets: it joins a product repo's .infra/ manifest with a state directory you point it at, and makes Coolify match. Extracted from heavy-duty/infra, which was half tool and half state — the inconsistency that made it impossible to say whether "infra" named a CLI or a runbook. rig builds the boxes; cast fills them; infra is what they are filled with. Two changes were required to make it genuinely stateless and publishable: - The implicit cwd contract (environments.yaml / secrets/ / .coolify.env resolved against the working directory, silently reading the wrong file from the wrong place) is now an explicit --state <dir> / $CAST_STATE. - BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the only product knowledge in the executor — becomes the generic, operator- owned environments.<env>.forbidden_var_patterns. The guard now lives in private state, so a product-side change cannot lower its own guard, and it is a pattern rather than a list, so it catches unforeseen siblings. Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then ~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended apply mechanism, with no environment names known to the tool. Instance identity (org names, the GitHub App name, founder domains) is out of the fixtures and out of register-github-app.sh, which took APP_NAME and ORG as arguments rather than baking them in. 69 tests green; bin/cast + curl installer mirror rig's shape. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:25:44 +00:00
REPO="${CAST_REPO:-heavy-duty/cast}"
feat: release flow — tagged releases with a prebuilt dist asset (#96) The cast half of the flow designed in heavy-duty/box#83, aligned with box#90 and rig#40, plus the piece unique to cast: a prebuilt release asset, because cast is the one repo where the source tarball is not the package. - CHANGELOG.md (box's format) with this PR's entry under Unreleased; feature PRs land their entry as part of the PR. - `cast --version` / `-V` answers with package.json's version, read relative to the compiled module so a source checkout and an installed prebuilt tree agree. - release.yml, on EVERY tag push (no shape filter — a mismatched tag must fail the assert loudly, not be pattern-skipped): asserts tag == package.json version FIRST, extracts that version's changelog section (.github/scripts/release-notes.sh, shared with the tests; missing or empty refuses), builds once (npm ci && npm run build && npm prune --omit=dev), stages bin/ dist/ node_modules/ package.json as cast-X.Y.Z/ and attaches cast-X.Y.Z.tgz to `gh release create --verify-tag`. No tests here — ci.yml gated the merge commit, and the suite needs age. - install.sh grows the three channels: default = the latest release's asset (tag resolved off the releases/latest redirect Location — no API, no token; failure dies loudly naming CAST_REF=main, never a silent fallback), CAST_REF=<tag> = pinned (asset first, source fallback), CAST_REF=main = dev build-from-source. npm is required only on the source path, and a prebuilt tree is sanity-checked (dist/, node_modules/) before $DEST is replaced. - test/release.test.ts drives it all offline: --version, the extraction against fixtures (0.7.0 never matches 0.7.0-rc1) and the real changelog, and REAL install.sh runs through all three channels with a stub curl and a poisoned npm — including the loud no-releases refusal with no $DEST side effects. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 21:29:53 +00:00
REF="${CAST_REF:-}" # empty = the latest release, resolved below
feat: cast — the Coolify executor, extracted from the infra state repo Public tool, private state. cast holds no hostnames, no bindings, no secrets: it joins a product repo's .infra/ manifest with a state directory you point it at, and makes Coolify match. Extracted from heavy-duty/infra, which was half tool and half state — the inconsistency that made it impossible to say whether "infra" named a CLI or a runbook. rig builds the boxes; cast fills them; infra is what they are filled with. Two changes were required to make it genuinely stateless and publishable: - The implicit cwd contract (environments.yaml / secrets/ / .coolify.env resolved against the working directory, silently reading the wrong file from the wrong place) is now an explicit --state <dir> / $CAST_STATE. - BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the only product knowledge in the executor — becomes the generic, operator- owned environments.<env>.forbidden_var_patterns. The guard now lives in private state, so a product-side change cannot lower its own guard, and it is a pattern rather than a list, so it catches unforeseen siblings. Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then ~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended apply mechanism, with no environment names known to the tool. Instance identity (org names, the GitHub App name, founder domains) is out of the fixtures and out of register-github-app.sh, which took APP_NAME and ORG as arguments rather than baking them in. 69 tests green; bin/cast + curl installer mirror rig's shape. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:25:44 +00:00
DEST="${CAST_HOME:-$HOME/.local/share/cast}"
if [ "$(id -u)" -eq 0 ]; then
BINDIR="${CAST_BIN:-/usr/local/bin}"
else
BINDIR="${CAST_BIN:-$HOME/.local/bin}"
fi
log() { printf 'cast-install: %s\n' "$*"; }
warn() { printf 'cast-install: WARNING: %s\n' "$*" >&2; }
die() { printf 'cast-install: ERROR: %s\n' "$*" >&2; exit 1; }
refactor: rescope to versioned installations — the release flow moves out Maintainer direction: this PR's one goal is the versioned layout, the same one box#79 built and rig#36 ported — the release flow (tags, release.yml, prebuilt assets, CHANGELOG) is its own PR later, the shape rig#40 has. So: release.yml, changelog-section.sh, CHANGELOG.md and the asset-aware installer channels leave this branch, and in their place cast gets the family layout for real: - install.sh lands each build at $DEST/versions/<package.json version>, 'current' names the default (atomic rename flips), $BINDIR/cast points through it. Converging no-op on an installed version (nothing rebuilt), CAST_REINSTALL=1 replaces, a new version installs beside and becomes default. Pre-versioning flat installs migrate in place, bit for bit. CAST_INSTALL_SOURCE=<dir|tarball> installs locally (CI/tests, rig's RIG_INSTALL_SOURCE precedent). No flip gate: box refuses under live boxes, rig warns on a converged host — cast is an API client, a flip strands nothing, 'cast use <old>' is one command away. - bin/cast grows the layout verbs in bash (they must work when dist/ is broken): versions (marks current+running), use (atomic flip, then asserts the chain ANSWERS the new version), uninstall (consent gate, CURRENT guard, dangling-current guard, ends with the absence assert). valid_version/pkg_version are byte-identical copies in both files; a test diffs them so the gates cannot drift. - cast --version stays: package.json is the single source of truth, printed with the install root, rig-style. - ci.yml gains the install job: the real installer, from this checkout, layout asserted, converge no-op asserted, uninstall --all asserted absent — the box CI precedent. - Tests drive the REAL install.sh and bin/cast offline (npm shim, local source): the layout, the chain answering end to end, no-op/reinstall/ side-by-side/migration semantics, the hostile-version gates, refs/heads download, every uninstall refusal. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 21:17:59 +00:00
# A version is a DIRECTORY NAME under versions/ — nothing else. One strict
# gate for every caller that builds a path from one (the installer's new_ver,
# migration's flat_ver, and bin/cast's 'use'/single-version uninstall): only
# [A-Za-z0-9._+-], no leading '.' or '-'. That forbids '/', '..'-escapes,
# spaces and option-lookalikes by construction — a crafted version dies HERE,
# never in an rm -rf or an ln. bin/cast carries a byte-identical copy;
# test/install-sh.test.ts diffs the two so the gates cannot drift.
valid_version() {
case "$1" in
''|.*|-*) return 1 ;;
*[!A-Za-z0-9._+-]*) return 1 ;;
esac
return 0
}
# The tree's package.json version, or empty. Read via node (a prerequisite
# anyway) — never by regexing JSON. The path travels by env var, not by
# splicing it into the expression, so no filename can break the quoting.
pkg_version() {
CAST_PKG_PATH="$1/package.json" node -p 'require(process.env.CAST_PKG_PATH).version' 2>/dev/null || true
}
feat: release flow — tagged releases with a prebuilt dist asset (#96) The cast half of the flow designed in heavy-duty/box#83, aligned with box#90 and rig#40, plus the piece unique to cast: a prebuilt release asset, because cast is the one repo where the source tarball is not the package. - CHANGELOG.md (box's format) with this PR's entry under Unreleased; feature PRs land their entry as part of the PR. - `cast --version` / `-V` answers with package.json's version, read relative to the compiled module so a source checkout and an installed prebuilt tree agree. - release.yml, on EVERY tag push (no shape filter — a mismatched tag must fail the assert loudly, not be pattern-skipped): asserts tag == package.json version FIRST, extracts that version's changelog section (.github/scripts/release-notes.sh, shared with the tests; missing or empty refuses), builds once (npm ci && npm run build && npm prune --omit=dev), stages bin/ dist/ node_modules/ package.json as cast-X.Y.Z/ and attaches cast-X.Y.Z.tgz to `gh release create --verify-tag`. No tests here — ci.yml gated the merge commit, and the suite needs age. - install.sh grows the three channels: default = the latest release's asset (tag resolved off the releases/latest redirect Location — no API, no token; failure dies loudly naming CAST_REF=main, never a silent fallback), CAST_REF=<tag> = pinned (asset first, source fallback), CAST_REF=main = dev build-from-source. npm is required only on the source path, and a prebuilt tree is sanity-checked (dist/, node_modules/) before $DEST is replaced. - test/release.test.ts drives it all offline: --version, the extraction against fixtures (0.7.0 never matches 0.7.0-rc1) and the real changelog, and REAL install.sh runs through all three channels with a stub curl and a poisoned npm — including the loud no-releases refusal with no $DEST side effects. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 21:29:53 +00:00
# --- the release channels (#96; box#83's design, near-verbatim) --------------
# resolve_latest_tag <owner/repo> — print the latest RELEASE tag, resolved by
# following the releases/latest redirect and reading the Location header
# (curl's %{redirect_url} is that header, parsed): no API, no token, no
# rate-limit pain. A repo with no releases redirects to /releases — not to
# /releases/tag/<tag> — so this returns 1 there instead of inventing a ref,
# and the CALLER owns the loud story. test/release.test.ts drives the whole
# installer, this function included, against a stubbed curl.
resolve_latest_tag() {
local loc
loc="$(curl -fsSI -o /dev/null -w '%{redirect_url}' "https://github.com/$1/releases/latest")" || return 1
case "$loc" in
*/releases/tag/?*) printf '%s\n' "${loc##*/releases/tag/}" ;;
*) return 1 ;;
esac
}
feat: cast — the Coolify executor, extracted from the infra state repo Public tool, private state. cast holds no hostnames, no bindings, no secrets: it joins a product repo's .infra/ manifest with a state directory you point it at, and makes Coolify match. Extracted from heavy-duty/infra, which was half tool and half state — the inconsistency that made it impossible to say whether "infra" named a CLI or a runbook. rig builds the boxes; cast fills them; infra is what they are filled with. Two changes were required to make it genuinely stateless and publishable: - The implicit cwd contract (environments.yaml / secrets/ / .coolify.env resolved against the working directory, silently reading the wrong file from the wrong place) is now an explicit --state <dir> / $CAST_STATE. - BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the only product knowledge in the executor — becomes the generic, operator- owned environments.<env>.forbidden_var_patterns. The guard now lives in private state, so a product-side change cannot lower its own guard, and it is a pattern rather than a list, so it catches unforeseen siblings. Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then ~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended apply mechanism, with no environment names known to the tool. Instance identity (org names, the GitHub App name, founder domains) is out of the fixtures and out of register-github-app.sh, which took APP_NAME and ORG as arguments rather than baking them in. 69 tests green; bin/cast + curl installer mirror rig's shape. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:25:44 +00:00
# --- prerequisites -----------------------------------------------------------
refactor: rescope to versioned installations — the release flow moves out Maintainer direction: this PR's one goal is the versioned layout, the same one box#79 built and rig#36 ported — the release flow (tags, release.yml, prebuilt assets, CHANGELOG) is its own PR later, the shape rig#40 has. So: release.yml, changelog-section.sh, CHANGELOG.md and the asset-aware installer channels leave this branch, and in their place cast gets the family layout for real: - install.sh lands each build at $DEST/versions/<package.json version>, 'current' names the default (atomic rename flips), $BINDIR/cast points through it. Converging no-op on an installed version (nothing rebuilt), CAST_REINSTALL=1 replaces, a new version installs beside and becomes default. Pre-versioning flat installs migrate in place, bit for bit. CAST_INSTALL_SOURCE=<dir|tarball> installs locally (CI/tests, rig's RIG_INSTALL_SOURCE precedent). No flip gate: box refuses under live boxes, rig warns on a converged host — cast is an API client, a flip strands nothing, 'cast use <old>' is one command away. - bin/cast grows the layout verbs in bash (they must work when dist/ is broken): versions (marks current+running), use (atomic flip, then asserts the chain ANSWERS the new version), uninstall (consent gate, CURRENT guard, dangling-current guard, ends with the absence assert). valid_version/pkg_version are byte-identical copies in both files; a test diffs them so the gates cannot drift. - cast --version stays: package.json is the single source of truth, printed with the install root, rig-style. - ci.yml gains the install job: the real installer, from this checkout, layout asserted, converge no-op asserted, uninstall --all asserted absent — the box CI precedent. - Tests drive the REAL install.sh and bin/cast offline (npm shim, local source): the layout, the chain answering end to end, no-op/reinstall/ side-by-side/migration semantics, the hostile-version gates, refs/heads download, every uninstall refusal. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 21:17:59 +00:00
# curl only when something must be downloaded — a local CAST_INSTALL_SOURCE
# needs none, which is what lets the test suite drive REAL installs offline.
feat: release flow — tagged releases with a prebuilt dist asset (#96) The cast half of the flow designed in heavy-duty/box#83, aligned with box#90 and rig#40, plus the piece unique to cast: a prebuilt release asset, because cast is the one repo where the source tarball is not the package. - CHANGELOG.md (box's format) with this PR's entry under Unreleased; feature PRs land their entry as part of the PR. - `cast --version` / `-V` answers with package.json's version, read relative to the compiled module so a source checkout and an installed prebuilt tree agree. - release.yml, on EVERY tag push (no shape filter — a mismatched tag must fail the assert loudly, not be pattern-skipped): asserts tag == package.json version FIRST, extracts that version's changelog section (.github/scripts/release-notes.sh, shared with the tests; missing or empty refuses), builds once (npm ci && npm run build && npm prune --omit=dev), stages bin/ dist/ node_modules/ package.json as cast-X.Y.Z/ and attaches cast-X.Y.Z.tgz to `gh release create --verify-tag`. No tests here — ci.yml gated the merge commit, and the suite needs age. - install.sh grows the three channels: default = the latest release's asset (tag resolved off the releases/latest redirect Location — no API, no token; failure dies loudly naming CAST_REF=main, never a silent fallback), CAST_REF=<tag> = pinned (asset first, source fallback), CAST_REF=main = dev build-from-source. npm is required only on the source path, and a prebuilt tree is sanity-checked (dist/, node_modules/) before $DEST is replaced. - test/release.test.ts drives it all offline: --version, the extraction against fixtures (0.7.0 never matches 0.7.0-rc1) and the real changelog, and REAL install.sh runs through all three channels with a stub curl and a poisoned npm — including the loud no-releases refusal with no $DEST side effects. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 21:29:53 +00:00
# npm is deliberately NOT required here: the release-asset channel installs a
# tree that was built once, in CI. build_tree checks for it, in the only
# paths that build (local source, and source downloads).
refactor: rescope to versioned installations — the release flow moves out Maintainer direction: this PR's one goal is the versioned layout, the same one box#79 built and rig#36 ported — the release flow (tags, release.yml, prebuilt assets, CHANGELOG) is its own PR later, the shape rig#40 has. So: release.yml, changelog-section.sh, CHANGELOG.md and the asset-aware installer channels leave this branch, and in their place cast gets the family layout for real: - install.sh lands each build at $DEST/versions/<package.json version>, 'current' names the default (atomic rename flips), $BINDIR/cast points through it. Converging no-op on an installed version (nothing rebuilt), CAST_REINSTALL=1 replaces, a new version installs beside and becomes default. Pre-versioning flat installs migrate in place, bit for bit. CAST_INSTALL_SOURCE=<dir|tarball> installs locally (CI/tests, rig's RIG_INSTALL_SOURCE precedent). No flip gate: box refuses under live boxes, rig warns on a converged host — cast is an API client, a flip strands nothing, 'cast use <old>' is one command away. - bin/cast grows the layout verbs in bash (they must work when dist/ is broken): versions (marks current+running), use (atomic flip, then asserts the chain ANSWERS the new version), uninstall (consent gate, CURRENT guard, dangling-current guard, ends with the absence assert). valid_version/pkg_version are byte-identical copies in both files; a test diffs them so the gates cannot drift. - cast --version stays: package.json is the single source of truth, printed with the install root, rig-style. - ci.yml gains the install job: the real installer, from this checkout, layout asserted, converge no-op asserted, uninstall --all asserted absent — the box CI precedent. - Tests drive the REAL install.sh and bin/cast offline (npm shim, local source): the layout, the chain answering end to end, no-op/reinstall/ side-by-side/migration semantics, the hostile-version gates, refs/heads download, every uninstall refusal. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 21:17:59 +00:00
if [ -z "${CAST_INSTALL_SOURCE:-}" ]; then
command -v curl >/dev/null 2>&1 || die "curl is required but was not found."
fi
feat: cast — the Coolify executor, extracted from the infra state repo Public tool, private state. cast holds no hostnames, no bindings, no secrets: it joins a product repo's .infra/ manifest with a state directory you point it at, and makes Coolify match. Extracted from heavy-duty/infra, which was half tool and half state — the inconsistency that made it impossible to say whether "infra" named a CLI or a runbook. rig builds the boxes; cast fills them; infra is what they are filled with. Two changes were required to make it genuinely stateless and publishable: - The implicit cwd contract (environments.yaml / secrets/ / .coolify.env resolved against the working directory, silently reading the wrong file from the wrong place) is now an explicit --state <dir> / $CAST_STATE. - BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the only product knowledge in the executor — becomes the generic, operator- owned environments.<env>.forbidden_var_patterns. The guard now lives in private state, so a product-side change cannot lower its own guard, and it is a pattern rather than a list, so it catches unforeseen siblings. Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then ~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended apply mechanism, with no environment names known to the tool. Instance identity (org names, the GitHub App name, founder domains) is out of the fixtures and out of register-github-app.sh, which took APP_NAME and ORG as arguments rather than baking them in. 69 tests green; bin/cast + curl installer mirror rig's shape. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:25:44 +00:00
command -v tar >/dev/null 2>&1 || die "tar is required but was not found."
command -v node >/dev/null 2>&1 || die "node >=22.12 is required but was not found."
NODE_MAJOR="$(node -p 'process.versions.node.split(".")[0]')"
[ "$NODE_MAJOR" -ge 22 ] || die "node >=22.12 is required (found $(node -v))."
# readlink -f is load-bearing across the layout (the launcher and every verb
# resolve the symlink chain with it). GNU always has it; Apple's readlink
# grew -f in macOS 12.3 (March 2022). Probe once and refuse loudly on the
# museum pieces, instead of failing weirdly mid-flip later.
readlink -f / >/dev/null 2>&1 \
|| die "this system's readlink does not support -f (macOS older than 12.3?) — upgrade, or 'brew install coreutils'."
feat: cast — the Coolify executor, extracted from the infra state repo Public tool, private state. cast holds no hostnames, no bindings, no secrets: it joins a product repo's .infra/ manifest with a state directory you point it at, and makes Coolify match. Extracted from heavy-duty/infra, which was half tool and half state — the inconsistency that made it impossible to say whether "infra" named a CLI or a runbook. rig builds the boxes; cast fills them; infra is what they are filled with. Two changes were required to make it genuinely stateless and publishable: - The implicit cwd contract (environments.yaml / secrets/ / .coolify.env resolved against the working directory, silently reading the wrong file from the wrong place) is now an explicit --state <dir> / $CAST_STATE. - BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the only product knowledge in the executor — becomes the generic, operator- owned environments.<env>.forbidden_var_patterns. The guard now lives in private state, so a product-side change cannot lower its own guard, and it is a pattern rather than a list, so it catches unforeseen siblings. Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then ~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended apply mechanism, with no environment names known to the tool. Instance identity (org names, the GitHub App name, founder domains) is out of the fixtures and out of register-github-app.sh, which took APP_NAME and ORG as arguments rather than baking them in. 69 tests green; bin/cast + curl installer mirror rig's shape. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:25:44 +00:00
# age is what decrypts the state repo's secrets — apply/diff shell out to it.
if ! command -v age >/dev/null 2>&1; then
warn "age not found — 'cast apply' and 'cast diff' will fail until it is installed."
warn " Debian/Ubuntu: sudo apt install age | Arch: sudo pacman -S age"
warn " Fedora: sudo dnf install age | macOS: brew install age"
fi
feat: cast — the Coolify executor, extracted from the infra state repo Public tool, private state. cast holds no hostnames, no bindings, no secrets: it joins a product repo's .infra/ manifest with a state directory you point it at, and makes Coolify match. Extracted from heavy-duty/infra, which was half tool and half state — the inconsistency that made it impossible to say whether "infra" named a CLI or a runbook. rig builds the boxes; cast fills them; infra is what they are filled with. Two changes were required to make it genuinely stateless and publishable: - The implicit cwd contract (environments.yaml / secrets/ / .coolify.env resolved against the working directory, silently reading the wrong file from the wrong place) is now an explicit --state <dir> / $CAST_STATE. - BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the only product knowledge in the executor — becomes the generic, operator- owned environments.<env>.forbidden_var_patterns. The guard now lives in private state, so a product-side change cannot lower its own guard, and it is a pattern rather than a list, so it catches unforeseen siblings. Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then ~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended apply mechanism, with no environment names known to the tool. Instance identity (org names, the GitHub App name, founder domains) is out of the fixtures and out of register-github-app.sh, which took APP_NAME and ORG as arguments rather than baking them in. 69 tests green; bin/cast + curl installer mirror rig's shape. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:25:44 +00:00
feat: release flow — tagged releases with a prebuilt dist asset (#96) The cast half of the flow designed in heavy-duty/box#83, aligned with box#90 and rig#40, plus the piece unique to cast: a prebuilt release asset, because cast is the one repo where the source tarball is not the package. - CHANGELOG.md (box's format) with this PR's entry under Unreleased; feature PRs land their entry as part of the PR. - `cast --version` / `-V` answers with package.json's version, read relative to the compiled module so a source checkout and an installed prebuilt tree agree. - release.yml, on EVERY tag push (no shape filter — a mismatched tag must fail the assert loudly, not be pattern-skipped): asserts tag == package.json version FIRST, extracts that version's changelog section (.github/scripts/release-notes.sh, shared with the tests; missing or empty refuses), builds once (npm ci && npm run build && npm prune --omit=dev), stages bin/ dist/ node_modules/ package.json as cast-X.Y.Z/ and attaches cast-X.Y.Z.tgz to `gh release create --verify-tag`. No tests here — ci.yml gated the merge commit, and the suite needs age. - install.sh grows the three channels: default = the latest release's asset (tag resolved off the releases/latest redirect Location — no API, no token; failure dies loudly naming CAST_REF=main, never a silent fallback), CAST_REF=<tag> = pinned (asset first, source fallback), CAST_REF=main = dev build-from-source. npm is required only on the source path, and a prebuilt tree is sanity-checked (dist/, node_modules/) before $DEST is replaced. - test/release.test.ts drives it all offline: --version, the extraction against fixtures (0.7.0 never matches 0.7.0-rc1) and the real changelog, and REAL install.sh runs through all three channels with a stub curl and a poisoned npm — including the loud no-releases refusal with no $DEST side effects. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 21:29:53 +00:00
# --- pick the channel --------------------------------------------------------
# No CAST_REF → the latest release. While no release exists (cast cuts its
# first, 0.1.0, right after #96 lands), this channel must FAIL, loudly and
# with the way out — never silently fall back to main: "I installed the
# latest release" must not quietly mean "I installed whatever main was that
# second". Resolved before anything on disk is touched (the flat-install
# migration included), so a refusal here has zero side effects.
refactor: rescope to versioned installations — the release flow moves out Maintainer direction: this PR's one goal is the versioned layout, the same one box#79 built and rig#36 ported — the release flow (tags, release.yml, prebuilt assets, CHANGELOG) is its own PR later, the shape rig#40 has. So: release.yml, changelog-section.sh, CHANGELOG.md and the asset-aware installer channels leave this branch, and in their place cast gets the family layout for real: - install.sh lands each build at $DEST/versions/<package.json version>, 'current' names the default (atomic rename flips), $BINDIR/cast points through it. Converging no-op on an installed version (nothing rebuilt), CAST_REINSTALL=1 replaces, a new version installs beside and becomes default. Pre-versioning flat installs migrate in place, bit for bit. CAST_INSTALL_SOURCE=<dir|tarball> installs locally (CI/tests, rig's RIG_INSTALL_SOURCE precedent). No flip gate: box refuses under live boxes, rig warns on a converged host — cast is an API client, a flip strands nothing, 'cast use <old>' is one command away. - bin/cast grows the layout verbs in bash (they must work when dist/ is broken): versions (marks current+running), use (atomic flip, then asserts the chain ANSWERS the new version), uninstall (consent gate, CURRENT guard, dangling-current guard, ends with the absence assert). valid_version/pkg_version are byte-identical copies in both files; a test diffs them so the gates cannot drift. - cast --version stays: package.json is the single source of truth, printed with the install root, rig-style. - ci.yml gains the install job: the real installer, from this checkout, layout asserted, converge no-op asserted, uninstall --all asserted absent — the box CI precedent. - Tests drive the REAL install.sh and bin/cast offline (npm shim, local source): the layout, the chain answering end to end, no-op/reinstall/ side-by-side/migration semantics, the hostile-version gates, refs/heads download, every uninstall refusal. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 21:17:59 +00:00
if [ -n "${CAST_INSTALL_SOURCE:-}" ]; then
SRCDESC="local source $CAST_INSTALL_SOURCE"
else
feat: release flow — tagged releases with a prebuilt dist asset (#96) The cast half of the flow designed in heavy-duty/box#83, aligned with box#90 and rig#40, plus the piece unique to cast: a prebuilt release asset, because cast is the one repo where the source tarball is not the package. - CHANGELOG.md (box's format) with this PR's entry under Unreleased; feature PRs land their entry as part of the PR. - `cast --version` / `-V` answers with package.json's version, read relative to the compiled module so a source checkout and an installed prebuilt tree agree. - release.yml, on EVERY tag push (no shape filter — a mismatched tag must fail the assert loudly, not be pattern-skipped): asserts tag == package.json version FIRST, extracts that version's changelog section (.github/scripts/release-notes.sh, shared with the tests; missing or empty refuses), builds once (npm ci && npm run build && npm prune --omit=dev), stages bin/ dist/ node_modules/ package.json as cast-X.Y.Z/ and attaches cast-X.Y.Z.tgz to `gh release create --verify-tag`. No tests here — ci.yml gated the merge commit, and the suite needs age. - install.sh grows the three channels: default = the latest release's asset (tag resolved off the releases/latest redirect Location — no API, no token; failure dies loudly naming CAST_REF=main, never a silent fallback), CAST_REF=<tag> = pinned (asset first, source fallback), CAST_REF=main = dev build-from-source. npm is required only on the source path, and a prebuilt tree is sanity-checked (dist/, node_modules/) before $DEST is replaced. - test/release.test.ts drives it all offline: --version, the extraction against fixtures (0.7.0 never matches 0.7.0-rc1) and the real changelog, and REAL install.sh runs through all three channels with a stub curl and a poisoned npm — including the loud no-releases refusal with no $DEST side effects. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 21:29:53 +00:00
if [ -z "$REF" ]; then
log "resolving the latest release of $REPO"
if ! REF="$(resolve_latest_tag "$REPO")"; then
warn "could not resolve the latest release of $REPO — either no release exists yet, or GitHub was unreachable."
warn "(cast has no release until 0.1.0 is cut — heavy-duty/cast#96. Until then, install the development tree explicitly.)"
die "set CAST_REF: e.g. curl -fsSL https://raw.githubusercontent.com/$REPO/main/install.sh | CAST_REF=main bash"
fi
log "latest release: $REF"
fi
refactor: rescope to versioned installations — the release flow moves out Maintainer direction: this PR's one goal is the versioned layout, the same one box#79 built and rig#36 ported — the release flow (tags, release.yml, prebuilt assets, CHANGELOG) is its own PR later, the shape rig#40 has. So: release.yml, changelog-section.sh, CHANGELOG.md and the asset-aware installer channels leave this branch, and in their place cast gets the family layout for real: - install.sh lands each build at $DEST/versions/<package.json version>, 'current' names the default (atomic rename flips), $BINDIR/cast points through it. Converging no-op on an installed version (nothing rebuilt), CAST_REINSTALL=1 replaces, a new version installs beside and becomes default. Pre-versioning flat installs migrate in place, bit for bit. CAST_INSTALL_SOURCE=<dir|tarball> installs locally (CI/tests, rig's RIG_INSTALL_SOURCE precedent). No flip gate: box refuses under live boxes, rig warns on a converged host — cast is an API client, a flip strands nothing, 'cast use <old>' is one command away. - bin/cast grows the layout verbs in bash (they must work when dist/ is broken): versions (marks current+running), use (atomic flip, then asserts the chain ANSWERS the new version), uninstall (consent gate, CURRENT guard, dangling-current guard, ends with the absence assert). valid_version/pkg_version are byte-identical copies in both files; a test diffs them so the gates cannot drift. - cast --version stays: package.json is the single source of truth, printed with the install root, rig-style. - ci.yml gains the install job: the real installer, from this checkout, layout asserted, converge no-op asserted, uninstall --all asserted absent — the box CI precedent. - Tests drive the REAL install.sh and bin/cast offline (npm shim, local source): the layout, the chain answering end to end, no-op/reinstall/ side-by-side/migration semantics, the hostile-version gates, refs/heads download, every uninstall refusal. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 21:17:59 +00:00
SRCDESC="$REPO@$REF"
fi
# Flip <root>/current to versions/<v> atomically: build the new link beside
# it, rename(2) over. Plain ln -sfn is unlink+create — a window where current
# names nothing and a concurrent 'cast' invocation dies mid-chain. The rename
# rides node's fs.renameSync because the coreutils spelling is not portable —
# GNU mv says "replace, don't descend" with -T, BSD/macOS says -h — while
# rename(2) itself is POSIX and node is a cast prerequisite on every
# platform. bin/cast carries a byte-identical copy; test/install-sh.test.ts
# diffs the two so they cannot drift.
flip_current() { # $1 = install root, $2 = version
ln -sfn "versions/$2" "$1/current.new.$$"
CAST_FLIP_NEW="$1/current.new.$$" CAST_FLIP_CUR="$1/current" \
node -e 'const fs = require("node:fs"); fs.renameSync(process.env.CAST_FLIP_NEW, process.env.CAST_FLIP_CUR);'
refactor: rescope to versioned installations — the release flow moves out Maintainer direction: this PR's one goal is the versioned layout, the same one box#79 built and rig#36 ported — the release flow (tags, release.yml, prebuilt assets, CHANGELOG) is its own PR later, the shape rig#40 has. So: release.yml, changelog-section.sh, CHANGELOG.md and the asset-aware installer channels leave this branch, and in their place cast gets the family layout for real: - install.sh lands each build at $DEST/versions/<package.json version>, 'current' names the default (atomic rename flips), $BINDIR/cast points through it. Converging no-op on an installed version (nothing rebuilt), CAST_REINSTALL=1 replaces, a new version installs beside and becomes default. Pre-versioning flat installs migrate in place, bit for bit. CAST_INSTALL_SOURCE=<dir|tarball> installs locally (CI/tests, rig's RIG_INSTALL_SOURCE precedent). No flip gate: box refuses under live boxes, rig warns on a converged host — cast is an API client, a flip strands nothing, 'cast use <old>' is one command away. - bin/cast grows the layout verbs in bash (they must work when dist/ is broken): versions (marks current+running), use (atomic flip, then asserts the chain ANSWERS the new version), uninstall (consent gate, CURRENT guard, dangling-current guard, ends with the absence assert). valid_version/pkg_version are byte-identical copies in both files; a test diffs them so the gates cannot drift. - cast --version stays: package.json is the single source of truth, printed with the install root, rig-style. - ci.yml gains the install job: the real installer, from this checkout, layout asserted, converge no-op asserted, uninstall --all asserted absent — the box CI precedent. - Tests drive the REAL install.sh and bin/cast offline (npm shim, local source): the layout, the chain answering end to end, no-op/reinstall/ side-by-side/migration semantics, the hostile-version gates, refs/heads download, every uninstall refusal. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 21:17:59 +00:00
}
# --- migrate a pre-versioning flat install -----------------------------------
# The old installer put the tree FLAT at $DEST (bin/cast directly under it).
# Move such a tree to versions/<its-version> BEFORE anything else, so the
# upgrade is seamless and the version comparison below sees the truth. The
# move is two renames inside one parent directory — no copying, no window with
# no install — and the operator's tree is preserved bit for bit.
if [ -e "$DEST/bin/cast" ] && [ ! -d "$DEST/versions" ]; then
flat_ver="$(pkg_version "$DEST")"
[ -n "$flat_ver" ] || flat_ver="0.0.0-unknown"
# The flat tree's version is data from disk, not from this installer — the
# same trust boundary as the new_ver check, so the same gate: a corrupted
# (or hostile) package.json must not steer the mv/ln below out of versions/.
valid_version "$flat_ver" || die "the flat install's package.json version is not a sane directory name: '$flat_ver' — fix $DEST/package.json, then re-run"
log "found a pre-versioning flat install at $DEST (version $flat_ver) — migrating it into the versioned layout"
staging="$DEST.migrating.$$"
mv "$DEST" "$staging"
mkdir -p "$DEST/versions"
mv "$staging" "$DEST/versions/$flat_ver"
flip_current "$DEST" "$flat_ver"
refactor: rescope to versioned installations — the release flow moves out Maintainer direction: this PR's one goal is the versioned layout, the same one box#79 built and rig#36 ported — the release flow (tags, release.yml, prebuilt assets, CHANGELOG) is its own PR later, the shape rig#40 has. So: release.yml, changelog-section.sh, CHANGELOG.md and the asset-aware installer channels leave this branch, and in their place cast gets the family layout for real: - install.sh lands each build at $DEST/versions/<package.json version>, 'current' names the default (atomic rename flips), $BINDIR/cast points through it. Converging no-op on an installed version (nothing rebuilt), CAST_REINSTALL=1 replaces, a new version installs beside and becomes default. Pre-versioning flat installs migrate in place, bit for bit. CAST_INSTALL_SOURCE=<dir|tarball> installs locally (CI/tests, rig's RIG_INSTALL_SOURCE precedent). No flip gate: box refuses under live boxes, rig warns on a converged host — cast is an API client, a flip strands nothing, 'cast use <old>' is one command away. - bin/cast grows the layout verbs in bash (they must work when dist/ is broken): versions (marks current+running), use (atomic flip, then asserts the chain ANSWERS the new version), uninstall (consent gate, CURRENT guard, dangling-current guard, ends with the absence assert). valid_version/pkg_version are byte-identical copies in both files; a test diffs them so the gates cannot drift. - cast --version stays: package.json is the single source of truth, printed with the install root, rig-style. - ci.yml gains the install job: the real installer, from this checkout, layout asserted, converge no-op asserted, uninstall --all asserted absent — the box CI precedent. - Tests drive the REAL install.sh and bin/cast offline (npm shim, local source): the layout, the chain answering end to end, no-op/reinstall/ side-by-side/migration semantics, the hostile-version gates, refs/heads download, every uninstall refusal. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 21:17:59 +00:00
mkdir -p "$BINDIR"
ln -sfn "$DEST/current/bin/cast" "$BINDIR/cast"
log "migrated: it now lives at $DEST/versions/$flat_ver (still current)"
fi
feat: cast — the Coolify executor, extracted from the infra state repo Public tool, private state. cast holds no hostnames, no bindings, no secrets: it joins a product repo's .infra/ manifest with a state directory you point it at, and makes Coolify match. Extracted from heavy-duty/infra, which was half tool and half state — the inconsistency that made it impossible to say whether "infra" named a CLI or a runbook. rig builds the boxes; cast fills them; infra is what they are filled with. Two changes were required to make it genuinely stateless and publishable: - The implicit cwd contract (environments.yaml / secrets/ / .coolify.env resolved against the working directory, silently reading the wrong file from the wrong place) is now an explicit --state <dir> / $CAST_STATE. - BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the only product knowledge in the executor — becomes the generic, operator- owned environments.<env>.forbidden_var_patterns. The guard now lives in private state, so a product-side change cannot lower its own guard, and it is a pattern rather than a list, so it catches unforeseen siblings. Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then ~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended apply mechanism, with no environment names known to the tool. Instance identity (org names, the GitHub App name, founder domains) is out of the fixtures and out of register-github-app.sh, which took APP_NAME and ORG as arguments rather than baking them in. 69 tests green; bin/cast + curl installer mirror rig's shape. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:25:44 +00:00
# --- temp workspace ----------------------------------------------------------
TMPDIR="$(mktemp -d)"
cleanup() { rm -rf "$TMPDIR"; }
trap cleanup EXIT
# --- acquire the tree --------------------------------------------------------
feat: release flow — tagged releases with a prebuilt dist asset (#96) The cast half of the flow designed in heavy-duty/box#83, aligned with box#90 and rig#40, plus the piece unique to cast: a prebuilt release asset, because cast is the one repo where the source tarball is not the package. - CHANGELOG.md (box's format) with this PR's entry under Unreleased; feature PRs land their entry as part of the PR. - `cast --version` / `-V` answers with package.json's version, read relative to the compiled module so a source checkout and an installed prebuilt tree agree. - release.yml, on EVERY tag push (no shape filter — a mismatched tag must fail the assert loudly, not be pattern-skipped): asserts tag == package.json version FIRST, extracts that version's changelog section (.github/scripts/release-notes.sh, shared with the tests; missing or empty refuses), builds once (npm ci && npm run build && npm prune --omit=dev), stages bin/ dist/ node_modules/ package.json as cast-X.Y.Z/ and attaches cast-X.Y.Z.tgz to `gh release create --verify-tag`. No tests here — ci.yml gated the merge commit, and the suite needs age. - install.sh grows the three channels: default = the latest release's asset (tag resolved off the releases/latest redirect Location — no API, no token; failure dies loudly naming CAST_REF=main, never a silent fallback), CAST_REF=<tag> = pinned (asset first, source fallback), CAST_REF=main = dev build-from-source. npm is required only on the source path, and a prebuilt tree is sanity-checked (dist/, node_modules/) before $DEST is replaced. - test/release.test.ts drives it all offline: --version, the extraction against fixtures (0.7.0 never matches 0.7.0-rc1) and the real changelog, and REAL install.sh runs through all three channels with a stub curl and a poisoned npm — including the loud no-releases refusal with no $DEST side effects. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 21:29:53 +00:00
PREBUILT="" # set when the tree came from a release asset (already built)
refactor: rescope to versioned installations — the release flow moves out Maintainer direction: this PR's one goal is the versioned layout, the same one box#79 built and rig#36 ported — the release flow (tags, release.yml, prebuilt assets, CHANGELOG) is its own PR later, the shape rig#40 has. So: release.yml, changelog-section.sh, CHANGELOG.md and the asset-aware installer channels leave this branch, and in their place cast gets the family layout for real: - install.sh lands each build at $DEST/versions/<package.json version>, 'current' names the default (atomic rename flips), $BINDIR/cast points through it. Converging no-op on an installed version (nothing rebuilt), CAST_REINSTALL=1 replaces, a new version installs beside and becomes default. Pre-versioning flat installs migrate in place, bit for bit. CAST_INSTALL_SOURCE=<dir|tarball> installs locally (CI/tests, rig's RIG_INSTALL_SOURCE precedent). No flip gate: box refuses under live boxes, rig warns on a converged host — cast is an API client, a flip strands nothing, 'cast use <old>' is one command away. - bin/cast grows the layout verbs in bash (they must work when dist/ is broken): versions (marks current+running), use (atomic flip, then asserts the chain ANSWERS the new version), uninstall (consent gate, CURRENT guard, dangling-current guard, ends with the absence assert). valid_version/pkg_version are byte-identical copies in both files; a test diffs them so the gates cannot drift. - cast --version stays: package.json is the single source of truth, printed with the install root, rig-style. - ci.yml gains the install job: the real installer, from this checkout, layout asserted, converge no-op asserted, uninstall --all asserted absent — the box CI precedent. - Tests drive the REAL install.sh and bin/cast offline (npm shim, local source): the layout, the chain answering end to end, no-op/reinstall/ side-by-side/migration semantics, the hostile-version gates, refs/heads download, every uninstall refusal. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 21:17:59 +00:00
if [ -n "${CAST_INSTALL_SOURCE:-}" ]; then
SRC="$CAST_INSTALL_SOURCE"
INSTALLED_FROM="local:$SRC"
if [ -d "$SRC" ]; then
log "copying local tree $SRC"
mkdir -p "$TMPDIR/tree"
# tar, not cp -a: --exclude=.git so a working checkout never carries its
# VCS state into the install tree, --exclude=./node_modules (top level
# only — nested ones are npm's own business) because npm ci below builds
# dependencies fresh from the lockfile anyway.
tar -C "$SRC" --exclude=.git --exclude=./node_modules -cf - . | tar -xf - -C "$TMPDIR/tree"
EXTRACTED="$TMPDIR/tree"
elif [ -f "$SRC" ]; then
log "extracting local tarball $SRC"
tar -xzf "$SRC" -C "$TMPDIR" || die "failed to extract $SRC"
EXTRACTED="$(find "$TMPDIR" -mindepth 1 -maxdepth 1 -type d | head -n1)"
else
refactor: rescope to versioned installations — the release flow moves out Maintainer direction: this PR's one goal is the versioned layout, the same one box#79 built and rig#36 ported — the release flow (tags, release.yml, prebuilt assets, CHANGELOG) is its own PR later, the shape rig#40 has. So: release.yml, changelog-section.sh, CHANGELOG.md and the asset-aware installer channels leave this branch, and in their place cast gets the family layout for real: - install.sh lands each build at $DEST/versions/<package.json version>, 'current' names the default (atomic rename flips), $BINDIR/cast points through it. Converging no-op on an installed version (nothing rebuilt), CAST_REINSTALL=1 replaces, a new version installs beside and becomes default. Pre-versioning flat installs migrate in place, bit for bit. CAST_INSTALL_SOURCE=<dir|tarball> installs locally (CI/tests, rig's RIG_INSTALL_SOURCE precedent). No flip gate: box refuses under live boxes, rig warns on a converged host — cast is an API client, a flip strands nothing, 'cast use <old>' is one command away. - bin/cast grows the layout verbs in bash (they must work when dist/ is broken): versions (marks current+running), use (atomic flip, then asserts the chain ANSWERS the new version), uninstall (consent gate, CURRENT guard, dangling-current guard, ends with the absence assert). valid_version/pkg_version are byte-identical copies in both files; a test diffs them so the gates cannot drift. - cast --version stays: package.json is the single source of truth, printed with the install root, rig-style. - ci.yml gains the install job: the real installer, from this checkout, layout asserted, converge no-op asserted, uninstall --all asserted absent — the box CI precedent. - Tests drive the REAL install.sh and bin/cast offline (npm shim, local source): the layout, the chain answering end to end, no-op/reinstall/ side-by-side/migration semantics, the hostile-version gates, refs/heads download, every uninstall refusal. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 21:17:59 +00:00
die "CAST_INSTALL_SOURCE is set but is neither a directory nor a tarball: $SRC"
fi
refactor: rescope to versioned installations — the release flow moves out Maintainer direction: this PR's one goal is the versioned layout, the same one box#79 built and rig#36 ported — the release flow (tags, release.yml, prebuilt assets, CHANGELOG) is its own PR later, the shape rig#40 has. So: release.yml, changelog-section.sh, CHANGELOG.md and the asset-aware installer channels leave this branch, and in their place cast gets the family layout for real: - install.sh lands each build at $DEST/versions/<package.json version>, 'current' names the default (atomic rename flips), $BINDIR/cast points through it. Converging no-op on an installed version (nothing rebuilt), CAST_REINSTALL=1 replaces, a new version installs beside and becomes default. Pre-versioning flat installs migrate in place, bit for bit. CAST_INSTALL_SOURCE=<dir|tarball> installs locally (CI/tests, rig's RIG_INSTALL_SOURCE precedent). No flip gate: box refuses under live boxes, rig warns on a converged host — cast is an API client, a flip strands nothing, 'cast use <old>' is one command away. - bin/cast grows the layout verbs in bash (they must work when dist/ is broken): versions (marks current+running), use (atomic flip, then asserts the chain ANSWERS the new version), uninstall (consent gate, CURRENT guard, dangling-current guard, ends with the absence assert). valid_version/pkg_version are byte-identical copies in both files; a test diffs them so the gates cannot drift. - cast --version stays: package.json is the single source of truth, printed with the install root, rig-style. - ci.yml gains the install job: the real installer, from this checkout, layout asserted, converge no-op asserted, uninstall --all asserted absent — the box CI precedent. - Tests drive the REAL install.sh and bin/cast offline (npm shim, local source): the layout, the chain answering end to end, no-op/reinstall/ side-by-side/migration semantics, the hostile-version gates, refs/heads download, every uninstall refusal. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 21:17:59 +00:00
else
log "installing cast ($REPO@$REF)"
feat: release flow — tagged releases with a prebuilt dist asset (#96) The cast half of the flow designed in heavy-duty/box#83, aligned with box#90 and rig#40, plus the piece unique to cast: a prebuilt release asset, because cast is the one repo where the source tarball is not the package. - CHANGELOG.md (box's format) with this PR's entry under Unreleased; feature PRs land their entry as part of the PR. - `cast --version` / `-V` answers with package.json's version, read relative to the compiled module so a source checkout and an installed prebuilt tree agree. - release.yml, on EVERY tag push (no shape filter — a mismatched tag must fail the assert loudly, not be pattern-skipped): asserts tag == package.json version FIRST, extracts that version's changelog section (.github/scripts/release-notes.sh, shared with the tests; missing or empty refuses), builds once (npm ci && npm run build && npm prune --omit=dev), stages bin/ dist/ node_modules/ package.json as cast-X.Y.Z/ and attaches cast-X.Y.Z.tgz to `gh release create --verify-tag`. No tests here — ci.yml gated the merge commit, and the suite needs age. - install.sh grows the three channels: default = the latest release's asset (tag resolved off the releases/latest redirect Location — no API, no token; failure dies loudly naming CAST_REF=main, never a silent fallback), CAST_REF=<tag> = pinned (asset first, source fallback), CAST_REF=main = dev build-from-source. npm is required only on the source path, and a prebuilt tree is sanity-checked (dist/, node_modules/) before $DEST is replaced. - test/release.test.ts drives it all offline: --version, the extraction against fixtures (0.7.0 never matches 0.7.0-rc1) and the real changelog, and REAL install.sh runs through all three channels with a stub curl and a poisoned npm — including the loud no-releases refusal with no $DEST side effects. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 21:29:53 +00:00
# A release's package is its PREBUILT asset (#96) — bin/, compiled dist/,
# production node_modules/, package.json, built once by release.yml — so
# the asset is tried first for every ref. Only a ref the OPERATOR named may
# fall back to source: a resolved latest release without its asset is a
# broken release, not a reason to start compiling here.
ASSET_URL="https://github.com/$REPO/releases/download/$REF/cast-$REF.tgz"
log "downloading $ASSET_URL"
if curl -fsSL "$ASSET_URL" -o "$TMPDIR/cast.tgz"; then
PREBUILT=1
INSTALLED_FROM="$REPO@$REF (release asset)"
log "extracting release asset"
tar -xzf "$TMPDIR/cast.tgz" -C "$TMPDIR" \
|| die "failed to extract $ASSET_URL"
else
[ -n "${CAST_REF:-}" ] \
|| die "release $REF has no cast-$REF.tgz asset (or GitHub was unreachable) — refusing to build the release from source. Report the broken release, or pick a ref yourself: CAST_REF=<tag> pins one, CAST_REF=main builds the development tree."
# The source fallback, tag first (the pin must win over a branch that
# happens to share its name), branch second — which keeps CAST_REF=main
# the dev channel. Source needs a build (build_tree below, the only
# place npm is used).
INSTALLED_FROM="$REPO@$REF"
got=""
for URL in "https://github.com/$REPO/archive/refs/tags/$REF.tar.gz" \
"https://github.com/$REPO/archive/refs/heads/$REF.tar.gz"; do
log "no prebuilt asset for '$REF' — trying source: $URL"
if curl -fsSL "$URL" -o "$TMPDIR/cast.tar.gz"; then
got="$URL"
break
fi
done
[ -n "$got" ] || die "failed to download $REPO@$REF — no release asset, and '$REF' is neither a tag nor a branch."
log "extracting archive"
tar -xzf "$TMPDIR/cast.tar.gz" -C "$TMPDIR" \
|| die "failed to extract archive"
fi
# GitHub names a source archive's top dir <repo>-<ref> and release.yml
# stages the asset's as cast-<tag> — deriving either name is guesswork (it
# broke for real at box's repo rename). Both tarball shapes have exactly
# ONE top-level directory: take the directory, whatever it is called, and
# let the bin/cast check below judge whether it is the right tree.
refactor: rescope to versioned installations — the release flow moves out Maintainer direction: this PR's one goal is the versioned layout, the same one box#79 built and rig#36 ported — the release flow (tags, release.yml, prebuilt assets, CHANGELOG) is its own PR later, the shape rig#40 has. So: release.yml, changelog-section.sh, CHANGELOG.md and the asset-aware installer channels leave this branch, and in their place cast gets the family layout for real: - install.sh lands each build at $DEST/versions/<package.json version>, 'current' names the default (atomic rename flips), $BINDIR/cast points through it. Converging no-op on an installed version (nothing rebuilt), CAST_REINSTALL=1 replaces, a new version installs beside and becomes default. Pre-versioning flat installs migrate in place, bit for bit. CAST_INSTALL_SOURCE=<dir|tarball> installs locally (CI/tests, rig's RIG_INSTALL_SOURCE precedent). No flip gate: box refuses under live boxes, rig warns on a converged host — cast is an API client, a flip strands nothing, 'cast use <old>' is one command away. - bin/cast grows the layout verbs in bash (they must work when dist/ is broken): versions (marks current+running), use (atomic flip, then asserts the chain ANSWERS the new version), uninstall (consent gate, CURRENT guard, dangling-current guard, ends with the absence assert). valid_version/pkg_version are byte-identical copies in both files; a test diffs them so the gates cannot drift. - cast --version stays: package.json is the single source of truth, printed with the install root, rig-style. - ci.yml gains the install job: the real installer, from this checkout, layout asserted, converge no-op asserted, uninstall --all asserted absent — the box CI precedent. - Tests drive the REAL install.sh and bin/cast offline (npm shim, local source): the layout, the chain answering end to end, no-op/reinstall/ side-by-side/migration semantics, the hostile-version gates, refs/heads download, every uninstall refusal. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 21:17:59 +00:00
EXTRACTED="$(find "$TMPDIR" -mindepth 1 -maxdepth 1 -type d | head -n1)"
fi
refactor: rescope to versioned installations — the release flow moves out Maintainer direction: this PR's one goal is the versioned layout, the same one box#79 built and rig#36 ported — the release flow (tags, release.yml, prebuilt assets, CHANGELOG) is its own PR later, the shape rig#40 has. So: release.yml, changelog-section.sh, CHANGELOG.md and the asset-aware installer channels leave this branch, and in their place cast gets the family layout for real: - install.sh lands each build at $DEST/versions/<package.json version>, 'current' names the default (atomic rename flips), $BINDIR/cast points through it. Converging no-op on an installed version (nothing rebuilt), CAST_REINSTALL=1 replaces, a new version installs beside and becomes default. Pre-versioning flat installs migrate in place, bit for bit. CAST_INSTALL_SOURCE=<dir|tarball> installs locally (CI/tests, rig's RIG_INSTALL_SOURCE precedent). No flip gate: box refuses under live boxes, rig warns on a converged host — cast is an API client, a flip strands nothing, 'cast use <old>' is one command away. - bin/cast grows the layout verbs in bash (they must work when dist/ is broken): versions (marks current+running), use (atomic flip, then asserts the chain ANSWERS the new version), uninstall (consent gate, CURRENT guard, dangling-current guard, ends with the absence assert). valid_version/pkg_version are byte-identical copies in both files; a test diffs them so the gates cannot drift. - cast --version stays: package.json is the single source of truth, printed with the install root, rig-style. - ci.yml gains the install job: the real installer, from this checkout, layout asserted, converge no-op asserted, uninstall --all asserted absent — the box CI precedent. - Tests drive the REAL install.sh and bin/cast offline (npm shim, local source): the layout, the chain answering end to end, no-op/reinstall/ side-by-side/migration semantics, the hostile-version gates, refs/heads download, every uninstall refusal. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 21:17:59 +00:00
[ -n "${EXTRACTED:-}" ] || die "could not find the source tree in $SRCDESC"
[ -f "$EXTRACTED/bin/cast" ] || die "source does not contain bin/cast — is $SRCDESC correct?"
feat: cast — the Coolify executor, extracted from the infra state repo Public tool, private state. cast holds no hostnames, no bindings, no secrets: it joins a product repo's .infra/ manifest with a state directory you point it at, and makes Coolify match. Extracted from heavy-duty/infra, which was half tool and half state — the inconsistency that made it impossible to say whether "infra" named a CLI or a runbook. rig builds the boxes; cast fills them; infra is what they are filled with. Two changes were required to make it genuinely stateless and publishable: - The implicit cwd contract (environments.yaml / secrets/ / .coolify.env resolved against the working directory, silently reading the wrong file from the wrong place) is now an explicit --state <dir> / $CAST_STATE. - BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the only product knowledge in the executor — becomes the generic, operator- owned environments.<env>.forbidden_var_patterns. The guard now lives in private state, so a product-side change cannot lower its own guard, and it is a pattern rather than a list, so it catches unforeseen siblings. Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then ~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended apply mechanism, with no environment names known to the tool. Instance identity (org names, the GitHub App name, founder domains) is out of the fixtures and out of register-github-app.sh, which took APP_NAME and ORG as arguments rather than baking them in. 69 tests green; bin/cast + curl installer mirror rig's shape. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:25:44 +00:00
refactor: rescope to versioned installations — the release flow moves out Maintainer direction: this PR's one goal is the versioned layout, the same one box#79 built and rig#36 ported — the release flow (tags, release.yml, prebuilt assets, CHANGELOG) is its own PR later, the shape rig#40 has. So: release.yml, changelog-section.sh, CHANGELOG.md and the asset-aware installer channels leave this branch, and in their place cast gets the family layout for real: - install.sh lands each build at $DEST/versions/<package.json version>, 'current' names the default (atomic rename flips), $BINDIR/cast points through it. Converging no-op on an installed version (nothing rebuilt), CAST_REINSTALL=1 replaces, a new version installs beside and becomes default. Pre-versioning flat installs migrate in place, bit for bit. CAST_INSTALL_SOURCE=<dir|tarball> installs locally (CI/tests, rig's RIG_INSTALL_SOURCE precedent). No flip gate: box refuses under live boxes, rig warns on a converged host — cast is an API client, a flip strands nothing, 'cast use <old>' is one command away. - bin/cast grows the layout verbs in bash (they must work when dist/ is broken): versions (marks current+running), use (atomic flip, then asserts the chain ANSWERS the new version), uninstall (consent gate, CURRENT guard, dangling-current guard, ends with the absence assert). valid_version/pkg_version are byte-identical copies in both files; a test diffs them so the gates cannot drift. - cast --version stays: package.json is the single source of truth, printed with the install root, rig-style. - ci.yml gains the install job: the real installer, from this checkout, layout asserted, converge no-op asserted, uninstall --all asserted absent — the box CI precedent. - Tests drive the REAL install.sh and bin/cast offline (npm shim, local source): the layout, the chain answering end to end, no-op/reinstall/ side-by-side/migration semantics, the hostile-version gates, refs/heads download, every uninstall refusal. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 21:17:59 +00:00
# The tree's own package.json names the directory it lands in — the version
# IS the identity of what is being installed, and 'cast versions' lists
# these names.
new_ver="$(pkg_version "$EXTRACTED")"
[ -n "$new_ver" ] || die "source has no package.json version — cannot install it as a version"
valid_version "$new_ver" || die "the source's package.json version is not a sane directory name: '$new_ver'"
feat: release flow — tagged releases with a prebuilt dist asset (#96) The cast half of the flow designed in heavy-duty/box#83, aligned with box#90 and rig#40, plus the piece unique to cast: a prebuilt release asset, because cast is the one repo where the source tarball is not the package. - CHANGELOG.md (box's format) with this PR's entry under Unreleased; feature PRs land their entry as part of the PR. - `cast --version` / `-V` answers with package.json's version, read relative to the compiled module so a source checkout and an installed prebuilt tree agree. - release.yml, on EVERY tag push (no shape filter — a mismatched tag must fail the assert loudly, not be pattern-skipped): asserts tag == package.json version FIRST, extracts that version's changelog section (.github/scripts/release-notes.sh, shared with the tests; missing or empty refuses), builds once (npm ci && npm run build && npm prune --omit=dev), stages bin/ dist/ node_modules/ package.json as cast-X.Y.Z/ and attaches cast-X.Y.Z.tgz to `gh release create --verify-tag`. No tests here — ci.yml gated the merge commit, and the suite needs age. - install.sh grows the three channels: default = the latest release's asset (tag resolved off the releases/latest redirect Location — no API, no token; failure dies loudly naming CAST_REF=main, never a silent fallback), CAST_REF=<tag> = pinned (asset first, source fallback), CAST_REF=main = dev build-from-source. npm is required only on the source path, and a prebuilt tree is sanity-checked (dist/, node_modules/) before $DEST is replaced. - test/release.test.ts drives it all offline: --version, the extraction against fixtures (0.7.0 never matches 0.7.0-rc1) and the real changelog, and REAL install.sh runs through all three channels with a stub curl and a poisoned npm — including the loud no-releases refusal with no $DEST side effects. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 21:29:53 +00:00
# Sanity-check a prebuilt asset BEFORE anything lands in $DEST: a runnable
# tree carries the compiled dist/ and its production node_modules/. Refusing
# here leaves whatever is already installed exactly as it was.
if [ -n "$PREBUILT" ]; then
{ [ -f "$EXTRACTED/dist/cli.js" ] && [ -d "$EXTRACTED/node_modules" ]; } \
|| die "the release asset is not a runnable cast tree (missing dist/ or node_modules/) — refusing to install it. Report the broken release, or build from source: CAST_REF=main."
fi
refactor: rescope to versioned installations — the release flow moves out Maintainer direction: this PR's one goal is the versioned layout, the same one box#79 built and rig#36 ported — the release flow (tags, release.yml, prebuilt assets, CHANGELOG) is its own PR later, the shape rig#40 has. So: release.yml, changelog-section.sh, CHANGELOG.md and the asset-aware installer channels leave this branch, and in their place cast gets the family layout for real: - install.sh lands each build at $DEST/versions/<package.json version>, 'current' names the default (atomic rename flips), $BINDIR/cast points through it. Converging no-op on an installed version (nothing rebuilt), CAST_REINSTALL=1 replaces, a new version installs beside and becomes default. Pre-versioning flat installs migrate in place, bit for bit. CAST_INSTALL_SOURCE=<dir|tarball> installs locally (CI/tests, rig's RIG_INSTALL_SOURCE precedent). No flip gate: box refuses under live boxes, rig warns on a converged host — cast is an API client, a flip strands nothing, 'cast use <old>' is one command away. - bin/cast grows the layout verbs in bash (they must work when dist/ is broken): versions (marks current+running), use (atomic flip, then asserts the chain ANSWERS the new version), uninstall (consent gate, CURRENT guard, dangling-current guard, ends with the absence assert). valid_version/pkg_version are byte-identical copies in both files; a test diffs them so the gates cannot drift. - cast --version stays: package.json is the single source of truth, printed with the install root, rig-style. - ci.yml gains the install job: the real installer, from this checkout, layout asserted, converge no-op asserted, uninstall --all asserted absent — the box CI precedent. - Tests drive the REAL install.sh and bin/cast offline (npm shim, local source): the layout, the chain answering end to end, no-op/reinstall/ side-by-side/migration semantics, the hostile-version gates, refs/heads download, every uninstall refusal. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 21:17:59 +00:00
set_exec() { # $1 = a cast tree: the executable bits install.sh owns
chmod +x "$1/bin/cast"
if [ -d "$1/scripts" ]; then
find "$1/scripts" -name '*.sh' -exec chmod +x {} +
fi
}
# Build the tree IN PLACE, in the temp workspace — deps + tsc, then drop the
# dev deps. Landing in versions/ happens after, by rename: a half-built tree
feat: release flow — tagged releases with a prebuilt dist asset (#96) The cast half of the flow designed in heavy-duty/box#83, aligned with box#90 and rig#40, plus the piece unique to cast: a prebuilt release asset, because cast is the one repo where the source tarball is not the package. - CHANGELOG.md (box's format) with this PR's entry under Unreleased; feature PRs land their entry as part of the PR. - `cast --version` / `-V` answers with package.json's version, read relative to the compiled module so a source checkout and an installed prebuilt tree agree. - release.yml, on EVERY tag push (no shape filter — a mismatched tag must fail the assert loudly, not be pattern-skipped): asserts tag == package.json version FIRST, extracts that version's changelog section (.github/scripts/release-notes.sh, shared with the tests; missing or empty refuses), builds once (npm ci && npm run build && npm prune --omit=dev), stages bin/ dist/ node_modules/ package.json as cast-X.Y.Z/ and attaches cast-X.Y.Z.tgz to `gh release create --verify-tag`. No tests here — ci.yml gated the merge commit, and the suite needs age. - install.sh grows the three channels: default = the latest release's asset (tag resolved off the releases/latest redirect Location — no API, no token; failure dies loudly naming CAST_REF=main, never a silent fallback), CAST_REF=<tag> = pinned (asset first, source fallback), CAST_REF=main = dev build-from-source. npm is required only on the source path, and a prebuilt tree is sanity-checked (dist/, node_modules/) before $DEST is replaced. - test/release.test.ts drives it all offline: --version, the extraction against fixtures (0.7.0 never matches 0.7.0-rc1) and the real changelog, and REAL install.sh runs through all three channels with a stub curl and a poisoned npm — including the loud no-releases refusal with no $DEST side effects. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 21:29:53 +00:00
# never sits where the version chain can resolve to it. A PREBUILT release
# asset skips this whole step — that build already happened, once, in CI —
# which is also why npm is checked here and not with the prerequisites: the
# source paths are the only ones that need it.
refactor: rescope to versioned installations — the release flow moves out Maintainer direction: this PR's one goal is the versioned layout, the same one box#79 built and rig#36 ported — the release flow (tags, release.yml, prebuilt assets, CHANGELOG) is its own PR later, the shape rig#40 has. So: release.yml, changelog-section.sh, CHANGELOG.md and the asset-aware installer channels leave this branch, and in their place cast gets the family layout for real: - install.sh lands each build at $DEST/versions/<package.json version>, 'current' names the default (atomic rename flips), $BINDIR/cast points through it. Converging no-op on an installed version (nothing rebuilt), CAST_REINSTALL=1 replaces, a new version installs beside and becomes default. Pre-versioning flat installs migrate in place, bit for bit. CAST_INSTALL_SOURCE=<dir|tarball> installs locally (CI/tests, rig's RIG_INSTALL_SOURCE precedent). No flip gate: box refuses under live boxes, rig warns on a converged host — cast is an API client, a flip strands nothing, 'cast use <old>' is one command away. - bin/cast grows the layout verbs in bash (they must work when dist/ is broken): versions (marks current+running), use (atomic flip, then asserts the chain ANSWERS the new version), uninstall (consent gate, CURRENT guard, dangling-current guard, ends with the absence assert). valid_version/pkg_version are byte-identical copies in both files; a test diffs them so the gates cannot drift. - cast --version stays: package.json is the single source of truth, printed with the install root, rig-style. - ci.yml gains the install job: the real installer, from this checkout, layout asserted, converge no-op asserted, uninstall --all asserted absent — the box CI precedent. - Tests drive the REAL install.sh and bin/cast offline (npm shim, local source): the layout, the chain answering end to end, no-op/reinstall/ side-by-side/migration semantics, the hostile-version gates, refs/heads download, every uninstall refusal. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 21:17:59 +00:00
build_tree() { # $1 = the tree to build
feat: release flow — tagged releases with a prebuilt dist asset (#96) The cast half of the flow designed in heavy-duty/box#83, aligned with box#90 and rig#40, plus the piece unique to cast: a prebuilt release asset, because cast is the one repo where the source tarball is not the package. - CHANGELOG.md (box's format) with this PR's entry under Unreleased; feature PRs land their entry as part of the PR. - `cast --version` / `-V` answers with package.json's version, read relative to the compiled module so a source checkout and an installed prebuilt tree agree. - release.yml, on EVERY tag push (no shape filter — a mismatched tag must fail the assert loudly, not be pattern-skipped): asserts tag == package.json version FIRST, extracts that version's changelog section (.github/scripts/release-notes.sh, shared with the tests; missing or empty refuses), builds once (npm ci && npm run build && npm prune --omit=dev), stages bin/ dist/ node_modules/ package.json as cast-X.Y.Z/ and attaches cast-X.Y.Z.tgz to `gh release create --verify-tag`. No tests here — ci.yml gated the merge commit, and the suite needs age. - install.sh grows the three channels: default = the latest release's asset (tag resolved off the releases/latest redirect Location — no API, no token; failure dies loudly naming CAST_REF=main, never a silent fallback), CAST_REF=<tag> = pinned (asset first, source fallback), CAST_REF=main = dev build-from-source. npm is required only on the source path, and a prebuilt tree is sanity-checked (dist/, node_modules/) before $DEST is replaced. - test/release.test.ts drives it all offline: --version, the extraction against fixtures (0.7.0 never matches 0.7.0-rc1) and the real changelog, and REAL install.sh runs through all three channels with a stub curl and a poisoned npm — including the loud no-releases refusal with no $DEST side effects. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 21:29:53 +00:00
if [ -n "$PREBUILT" ]; then
log "prebuilt release asset — nothing to build here"
return 0
fi
command -v npm >/dev/null 2>&1 || die "npm is required to build cast from source (only the release-asset channel installs without it)."
log "building (npm ci && npm run build)"
refactor: rescope to versioned installations — the release flow moves out Maintainer direction: this PR's one goal is the versioned layout, the same one box#79 built and rig#36 ported — the release flow (tags, release.yml, prebuilt assets, CHANGELOG) is its own PR later, the shape rig#40 has. So: release.yml, changelog-section.sh, CHANGELOG.md and the asset-aware installer channels leave this branch, and in their place cast gets the family layout for real: - install.sh lands each build at $DEST/versions/<package.json version>, 'current' names the default (atomic rename flips), $BINDIR/cast points through it. Converging no-op on an installed version (nothing rebuilt), CAST_REINSTALL=1 replaces, a new version installs beside and becomes default. Pre-versioning flat installs migrate in place, bit for bit. CAST_INSTALL_SOURCE=<dir|tarball> installs locally (CI/tests, rig's RIG_INSTALL_SOURCE precedent). No flip gate: box refuses under live boxes, rig warns on a converged host — cast is an API client, a flip strands nothing, 'cast use <old>' is one command away. - bin/cast grows the layout verbs in bash (they must work when dist/ is broken): versions (marks current+running), use (atomic flip, then asserts the chain ANSWERS the new version), uninstall (consent gate, CURRENT guard, dangling-current guard, ends with the absence assert). valid_version/pkg_version are byte-identical copies in both files; a test diffs them so the gates cannot drift. - cast --version stays: package.json is the single source of truth, printed with the install root, rig-style. - ci.yml gains the install job: the real installer, from this checkout, layout asserted, converge no-op asserted, uninstall --all asserted absent — the box CI precedent. - Tests drive the REAL install.sh and bin/cast offline (npm shim, local source): the layout, the chain answering end to end, no-op/reinstall/ side-by-side/migration semantics, the hostile-version gates, refs/heads download, every uninstall refusal. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 21:17:59 +00:00
( cd "$1" && npm ci --silent && npm run build --silent ) \
|| die "build failed"
refactor: rescope to versioned installations — the release flow moves out Maintainer direction: this PR's one goal is the versioned layout, the same one box#79 built and rig#36 ported — the release flow (tags, release.yml, prebuilt assets, CHANGELOG) is its own PR later, the shape rig#40 has. So: release.yml, changelog-section.sh, CHANGELOG.md and the asset-aware installer channels leave this branch, and in their place cast gets the family layout for real: - install.sh lands each build at $DEST/versions/<package.json version>, 'current' names the default (atomic rename flips), $BINDIR/cast points through it. Converging no-op on an installed version (nothing rebuilt), CAST_REINSTALL=1 replaces, a new version installs beside and becomes default. Pre-versioning flat installs migrate in place, bit for bit. CAST_INSTALL_SOURCE=<dir|tarball> installs locally (CI/tests, rig's RIG_INSTALL_SOURCE precedent). No flip gate: box refuses under live boxes, rig warns on a converged host — cast is an API client, a flip strands nothing, 'cast use <old>' is one command away. - bin/cast grows the layout verbs in bash (they must work when dist/ is broken): versions (marks current+running), use (atomic flip, then asserts the chain ANSWERS the new version), uninstall (consent gate, CURRENT guard, dangling-current guard, ends with the absence assert). valid_version/pkg_version are byte-identical copies in both files; a test diffs them so the gates cannot drift. - cast --version stays: package.json is the single source of truth, printed with the install root, rig-style. - ci.yml gains the install job: the real installer, from this checkout, layout asserted, converge no-op asserted, uninstall --all asserted absent — the box CI precedent. - Tests drive the REAL install.sh and bin/cast offline (npm shim, local source): the layout, the chain answering end to end, no-op/reinstall/ side-by-side/migration semantics, the hostile-version gates, refs/heads download, every uninstall refusal. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 21:17:59 +00:00
( cd "$1" && npm prune --omit=dev --silent ) || warn "could not prune dev dependencies"
}
feat: cast — the Coolify executor, extracted from the infra state repo Public tool, private state. cast holds no hostnames, no bindings, no secrets: it joins a product repo's .infra/ manifest with a state directory you point it at, and makes Coolify match. Extracted from heavy-duty/infra, which was half tool and half state — the inconsistency that made it impossible to say whether "infra" named a CLI or a runbook. rig builds the boxes; cast fills them; infra is what they are filled with. Two changes were required to make it genuinely stateless and publishable: - The implicit cwd contract (environments.yaml / secrets/ / .coolify.env resolved against the working directory, silently reading the wrong file from the wrong place) is now an explicit --state <dir> / $CAST_STATE. - BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the only product knowledge in the executor — becomes the generic, operator- owned environments.<env>.forbidden_var_patterns. The guard now lives in private state, so a product-side change cannot lower its own guard, and it is a pattern rather than a list, so it catches unforeseen siblings. Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then ~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended apply mechanism, with no environment names known to the tool. Instance identity (org names, the GitHub App name, founder domains) is out of the fixtures and out of register-github-app.sh, which took APP_NAME and ORG as arguments rather than baking them in. 69 tests green; bin/cast + curl installer mirror rig's shape. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:25:44 +00:00
refactor: rescope to versioned installations — the release flow moves out Maintainer direction: this PR's one goal is the versioned layout, the same one box#79 built and rig#36 ported — the release flow (tags, release.yml, prebuilt assets, CHANGELOG) is its own PR later, the shape rig#40 has. So: release.yml, changelog-section.sh, CHANGELOG.md and the asset-aware installer channels leave this branch, and in their place cast gets the family layout for real: - install.sh lands each build at $DEST/versions/<package.json version>, 'current' names the default (atomic rename flips), $BINDIR/cast points through it. Converging no-op on an installed version (nothing rebuilt), CAST_REINSTALL=1 replaces, a new version installs beside and becomes default. Pre-versioning flat installs migrate in place, bit for bit. CAST_INSTALL_SOURCE=<dir|tarball> installs locally (CI/tests, rig's RIG_INSTALL_SOURCE precedent). No flip gate: box refuses under live boxes, rig warns on a converged host — cast is an API client, a flip strands nothing, 'cast use <old>' is one command away. - bin/cast grows the layout verbs in bash (they must work when dist/ is broken): versions (marks current+running), use (atomic flip, then asserts the chain ANSWERS the new version), uninstall (consent gate, CURRENT guard, dangling-current guard, ends with the absence assert). valid_version/pkg_version are byte-identical copies in both files; a test diffs them so the gates cannot drift. - cast --version stays: package.json is the single source of truth, printed with the install root, rig-style. - ci.yml gains the install job: the real installer, from this checkout, layout asserted, converge no-op asserted, uninstall --all asserted absent — the box CI precedent. - Tests drive the REAL install.sh and bin/cast offline (npm shim, local source): the layout, the chain answering end to end, no-op/reinstall/ side-by-side/migration semantics, the hostile-version gates, refs/heads download, every uninstall refusal. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 21:17:59 +00:00
# --- install into $DEST/versions/<version> -----------------------------------
VDIR="$DEST/versions/$new_ver"
newly_installed=0
if [ -d "$VDIR" ]; then
if [ -n "${CAST_REINSTALL:-}" ]; then
# Replace THIS version's tree, as atomically as two renames allow — never
# a partial overlay of new files onto an old tree.
log "CAST_REINSTALL=1 — replacing the installed $new_ver tree"
build_tree "$EXTRACTED"
stage="$VDIR.new.$$"; old="$VDIR.old.$$"
rm -rf "$stage" "$old"
set_exec "$EXTRACTED"
mv "$EXTRACTED" "$stage"
# Swap by renames, delete LAST: rm-then-move leaves a hole the whole
# length of the delete where current -> this version resolves to nothing.
mv "$VDIR" "$old"
mv "$stage" "$VDIR"
rm -rf "$old"
printf '%s\n' "$INSTALLED_FROM" > "$VDIR/INSTALLED_FROM"
log "reinstalled $new_ver"
else
cur_from="$(cat "$VDIR/INSTALLED_FROM" 2>/dev/null || echo '<unknown source>')"
log "cast $new_ver is already installed ($cur_from) — nothing to do, and nothing was built."
log "(CAST_REINSTALL=1 replaces this version's tree; 'cast versions' lists what is installed.)"
fi
else
build_tree "$EXTRACTED"
log "installing $new_ver into $VDIR"
mkdir -p "$DEST/versions"
set_exec "$EXTRACTED"
mv "$EXTRACTED" "$VDIR"
newly_installed=1
# Record WHAT was installed, so a caller can assert it got what it asked
# for — an installer invoked with stale env vars silently falls back to the
# defaults, and INSTALLED_FROM is how that lie gets caught.
printf '%s\n' "$INSTALLED_FROM" > "$VDIR/INSTALLED_FROM"
fi
feat: cast — the Coolify executor, extracted from the infra state repo Public tool, private state. cast holds no hostnames, no bindings, no secrets: it joins a product repo's .infra/ manifest with a state directory you point it at, and makes Coolify match. Extracted from heavy-duty/infra, which was half tool and half state — the inconsistency that made it impossible to say whether "infra" named a CLI or a runbook. rig builds the boxes; cast fills them; infra is what they are filled with. Two changes were required to make it genuinely stateless and publishable: - The implicit cwd contract (environments.yaml / secrets/ / .coolify.env resolved against the working directory, silently reading the wrong file from the wrong place) is now an explicit --state <dir> / $CAST_STATE. - BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the only product knowledge in the executor — becomes the generic, operator- owned environments.<env>.forbidden_var_patterns. The guard now lives in private state, so a product-side change cannot lower its own guard, and it is a pattern rather than a list, so it catches unforeseen siblings. Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then ~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended apply mechanism, with no environment names known to the tool. Instance identity (org names, the GitHub App name, founder domains) is out of the fixtures and out of register-github-app.sh, which took APP_NAME and ORG as arguments rather than baking them in. 69 tests green; bin/cast + curl installer mirror rig's shape. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:25:44 +00:00
refactor: rescope to versioned installations — the release flow moves out Maintainer direction: this PR's one goal is the versioned layout, the same one box#79 built and rig#36 ported — the release flow (tags, release.yml, prebuilt assets, CHANGELOG) is its own PR later, the shape rig#40 has. So: release.yml, changelog-section.sh, CHANGELOG.md and the asset-aware installer channels leave this branch, and in their place cast gets the family layout for real: - install.sh lands each build at $DEST/versions/<package.json version>, 'current' names the default (atomic rename flips), $BINDIR/cast points through it. Converging no-op on an installed version (nothing rebuilt), CAST_REINSTALL=1 replaces, a new version installs beside and becomes default. Pre-versioning flat installs migrate in place, bit for bit. CAST_INSTALL_SOURCE=<dir|tarball> installs locally (CI/tests, rig's RIG_INSTALL_SOURCE precedent). No flip gate: box refuses under live boxes, rig warns on a converged host — cast is an API client, a flip strands nothing, 'cast use <old>' is one command away. - bin/cast grows the layout verbs in bash (they must work when dist/ is broken): versions (marks current+running), use (atomic flip, then asserts the chain ANSWERS the new version), uninstall (consent gate, CURRENT guard, dangling-current guard, ends with the absence assert). valid_version/pkg_version are byte-identical copies in both files; a test diffs them so the gates cannot drift. - cast --version stays: package.json is the single source of truth, printed with the install root, rig-style. - ci.yml gains the install job: the real installer, from this checkout, layout asserted, converge no-op asserted, uninstall --all asserted absent — the box CI precedent. - Tests drive the REAL install.sh and bin/cast offline (npm shim, local source): the layout, the chain answering end to end, no-op/reinstall/ side-by-side/migration semantics, the hostile-version gates, refs/heads download, every uninstall refusal. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 21:17:59 +00:00
# --- which version is the default? -------------------------------------------
# 'current' is the tracked default; flipping it is the ONLY step that changes
# what an operator's `cast` runs. A fresh host (or a dangling current) is
# claimed outright; an upgrade flips, because a re-run that silently left you
# on the old version would make "re-run any time to upgrade" a lie. Judged
# from versions/<v> itself (readlink -f), never from what a wedged current
# claims.
cur="$(readlink -f "$DEST/current" 2>/dev/null || true)"
want="$(readlink -f "$VDIR")"
if [ -z "$cur" ] || [ ! -d "$cur" ]; then
flip_current "$DEST" "$new_ver"
refactor: rescope to versioned installations — the release flow moves out Maintainer direction: this PR's one goal is the versioned layout, the same one box#79 built and rig#36 ported — the release flow (tags, release.yml, prebuilt assets, CHANGELOG) is its own PR later, the shape rig#40 has. So: release.yml, changelog-section.sh, CHANGELOG.md and the asset-aware installer channels leave this branch, and in their place cast gets the family layout for real: - install.sh lands each build at $DEST/versions/<package.json version>, 'current' names the default (atomic rename flips), $BINDIR/cast points through it. Converging no-op on an installed version (nothing rebuilt), CAST_REINSTALL=1 replaces, a new version installs beside and becomes default. Pre-versioning flat installs migrate in place, bit for bit. CAST_INSTALL_SOURCE=<dir|tarball> installs locally (CI/tests, rig's RIG_INSTALL_SOURCE precedent). No flip gate: box refuses under live boxes, rig warns on a converged host — cast is an API client, a flip strands nothing, 'cast use <old>' is one command away. - bin/cast grows the layout verbs in bash (they must work when dist/ is broken): versions (marks current+running), use (atomic flip, then asserts the chain ANSWERS the new version), uninstall (consent gate, CURRENT guard, dangling-current guard, ends with the absence assert). valid_version/pkg_version are byte-identical copies in both files; a test diffs them so the gates cannot drift. - cast --version stays: package.json is the single source of truth, printed with the install root, rig-style. - ci.yml gains the install job: the real installer, from this checkout, layout asserted, converge no-op asserted, uninstall --all asserted absent — the box CI precedent. - Tests drive the REAL install.sh and bin/cast offline (npm shim, local source): the layout, the chain answering end to end, no-op/reinstall/ side-by-side/migration semantics, the hostile-version gates, refs/heads download, every uninstall refusal. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 21:17:59 +00:00
log "default version: $new_ver"
elif [ "$cur" = "$want" ]; then
: # already the default — nothing to flip
elif [ "$newly_installed" -eq 0 ]; then
# A converge/no-op (or CAST_REINSTALL) of a version that is NOT the default
# never moves the default — a re-run must change nothing; switching is
# 'cast use', a deliberate act.
log "the default stays $(basename "$cur") — 'cast use $new_ver' switches."
else
old_ver="$(basename "$cur")"
flip_current "$DEST" "$new_ver"
refactor: rescope to versioned installations — the release flow moves out Maintainer direction: this PR's one goal is the versioned layout, the same one box#79 built and rig#36 ported — the release flow (tags, release.yml, prebuilt assets, CHANGELOG) is its own PR later, the shape rig#40 has. So: release.yml, changelog-section.sh, CHANGELOG.md and the asset-aware installer channels leave this branch, and in their place cast gets the family layout for real: - install.sh lands each build at $DEST/versions/<package.json version>, 'current' names the default (atomic rename flips), $BINDIR/cast points through it. Converging no-op on an installed version (nothing rebuilt), CAST_REINSTALL=1 replaces, a new version installs beside and becomes default. Pre-versioning flat installs migrate in place, bit for bit. CAST_INSTALL_SOURCE=<dir|tarball> installs locally (CI/tests, rig's RIG_INSTALL_SOURCE precedent). No flip gate: box refuses under live boxes, rig warns on a converged host — cast is an API client, a flip strands nothing, 'cast use <old>' is one command away. - bin/cast grows the layout verbs in bash (they must work when dist/ is broken): versions (marks current+running), use (atomic flip, then asserts the chain ANSWERS the new version), uninstall (consent gate, CURRENT guard, dangling-current guard, ends with the absence assert). valid_version/pkg_version are byte-identical copies in both files; a test diffs them so the gates cannot drift. - cast --version stays: package.json is the single source of truth, printed with the install root, rig-style. - ci.yml gains the install job: the real installer, from this checkout, layout asserted, converge no-op asserted, uninstall --all asserted absent — the box CI precedent. - Tests drive the REAL install.sh and bin/cast offline (npm shim, local source): the layout, the chain answering end to end, no-op/reinstall/ side-by-side/migration semantics, the hostile-version gates, refs/heads download, every uninstall refusal. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 21:17:59 +00:00
log "default version switched: $old_ver -> $new_ver ('cast use $old_ver' switches back)"
fi
feat: cast — the Coolify executor, extracted from the infra state repo Public tool, private state. cast holds no hostnames, no bindings, no secrets: it joins a product repo's .infra/ manifest with a state directory you point it at, and makes Coolify match. Extracted from heavy-duty/infra, which was half tool and half state — the inconsistency that made it impossible to say whether "infra" named a CLI or a runbook. rig builds the boxes; cast fills them; infra is what they are filled with. Two changes were required to make it genuinely stateless and publishable: - The implicit cwd contract (environments.yaml / secrets/ / .coolify.env resolved against the working directory, silently reading the wrong file from the wrong place) is now an explicit --state <dir> / $CAST_STATE. - BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the only product knowledge in the executor — becomes the generic, operator- owned environments.<env>.forbidden_var_patterns. The guard now lives in private state, so a product-side change cannot lower its own guard, and it is a pattern rather than a list, so it catches unforeseen siblings. Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then ~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended apply mechanism, with no environment names known to the tool. Instance identity (org names, the GitHub App name, founder domains) is out of the fixtures and out of register-github-app.sh, which took APP_NAME and ORG as arguments rather than baking them in. 69 tests green; bin/cast + curl installer mirror rig's shape. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:25:44 +00:00
refactor: rescope to versioned installations — the release flow moves out Maintainer direction: this PR's one goal is the versioned layout, the same one box#79 built and rig#36 ported — the release flow (tags, release.yml, prebuilt assets, CHANGELOG) is its own PR later, the shape rig#40 has. So: release.yml, changelog-section.sh, CHANGELOG.md and the asset-aware installer channels leave this branch, and in their place cast gets the family layout for real: - install.sh lands each build at $DEST/versions/<package.json version>, 'current' names the default (atomic rename flips), $BINDIR/cast points through it. Converging no-op on an installed version (nothing rebuilt), CAST_REINSTALL=1 replaces, a new version installs beside and becomes default. Pre-versioning flat installs migrate in place, bit for bit. CAST_INSTALL_SOURCE=<dir|tarball> installs locally (CI/tests, rig's RIG_INSTALL_SOURCE precedent). No flip gate: box refuses under live boxes, rig warns on a converged host — cast is an API client, a flip strands nothing, 'cast use <old>' is one command away. - bin/cast grows the layout verbs in bash (they must work when dist/ is broken): versions (marks current+running), use (atomic flip, then asserts the chain ANSWERS the new version), uninstall (consent gate, CURRENT guard, dangling-current guard, ends with the absence assert). valid_version/pkg_version are byte-identical copies in both files; a test diffs them so the gates cannot drift. - cast --version stays: package.json is the single source of truth, printed with the install root, rig-style. - ci.yml gains the install job: the real installer, from this checkout, layout asserted, converge no-op asserted, uninstall --all asserted absent — the box CI precedent. - Tests drive the REAL install.sh and bin/cast offline (npm shim, local source): the layout, the chain answering end to end, no-op/reinstall/ side-by-side/migration semantics, the hostile-version gates, refs/heads download, every uninstall refusal. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 21:17:59 +00:00
# --- put cast on PATH --------------------------------------------------------
# Through the current chain, and converging — that includes HEALING: a stale
# or dangling $BINDIR/cast (say, its tree half-removed by hand) must never
# block or wedge an install — it gets repointed at the current chain,
# whatever it said before.
feat: cast — the Coolify executor, extracted from the infra state repo Public tool, private state. cast holds no hostnames, no bindings, no secrets: it joins a product repo's .infra/ manifest with a state directory you point it at, and makes Coolify match. Extracted from heavy-duty/infra, which was half tool and half state — the inconsistency that made it impossible to say whether "infra" named a CLI or a runbook. rig builds the boxes; cast fills them; infra is what they are filled with. Two changes were required to make it genuinely stateless and publishable: - The implicit cwd contract (environments.yaml / secrets/ / .coolify.env resolved against the working directory, silently reading the wrong file from the wrong place) is now an explicit --state <dir> / $CAST_STATE. - BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the only product knowledge in the executor — becomes the generic, operator- owned environments.<env>.forbidden_var_patterns. The guard now lives in private state, so a product-side change cannot lower its own guard, and it is a pattern rather than a list, so it catches unforeseen siblings. Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then ~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended apply mechanism, with no environment names known to the tool. Instance identity (org names, the GitHub App name, founder domains) is out of the fixtures and out of register-github-app.sh, which took APP_NAME and ORG as arguments rather than baking them in. 69 tests green; bin/cast + curl installer mirror rig's shape. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:25:44 +00:00
mkdir -p "$BINDIR"
refactor: rescope to versioned installations — the release flow moves out Maintainer direction: this PR's one goal is the versioned layout, the same one box#79 built and rig#36 ported — the release flow (tags, release.yml, prebuilt assets, CHANGELOG) is its own PR later, the shape rig#40 has. So: release.yml, changelog-section.sh, CHANGELOG.md and the asset-aware installer channels leave this branch, and in their place cast gets the family layout for real: - install.sh lands each build at $DEST/versions/<package.json version>, 'current' names the default (atomic rename flips), $BINDIR/cast points through it. Converging no-op on an installed version (nothing rebuilt), CAST_REINSTALL=1 replaces, a new version installs beside and becomes default. Pre-versioning flat installs migrate in place, bit for bit. CAST_INSTALL_SOURCE=<dir|tarball> installs locally (CI/tests, rig's RIG_INSTALL_SOURCE precedent). No flip gate: box refuses under live boxes, rig warns on a converged host — cast is an API client, a flip strands nothing, 'cast use <old>' is one command away. - bin/cast grows the layout verbs in bash (they must work when dist/ is broken): versions (marks current+running), use (atomic flip, then asserts the chain ANSWERS the new version), uninstall (consent gate, CURRENT guard, dangling-current guard, ends with the absence assert). valid_version/pkg_version are byte-identical copies in both files; a test diffs them so the gates cannot drift. - cast --version stays: package.json is the single source of truth, printed with the install root, rig-style. - ci.yml gains the install job: the real installer, from this checkout, layout asserted, converge no-op asserted, uninstall --all asserted absent — the box CI precedent. - Tests drive the REAL install.sh and bin/cast offline (npm shim, local source): the layout, the chain answering end to end, no-op/reinstall/ side-by-side/migration semantics, the hostile-version gates, refs/heads download, every uninstall refusal. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 21:17:59 +00:00
ln -sfn "$DEST/current/bin/cast" "$BINDIR/cast"
log "linked $BINDIR/cast -> $DEST/current/bin/cast"
feat: cast — the Coolify executor, extracted from the infra state repo Public tool, private state. cast holds no hostnames, no bindings, no secrets: it joins a product repo's .infra/ manifest with a state directory you point it at, and makes Coolify match. Extracted from heavy-duty/infra, which was half tool and half state — the inconsistency that made it impossible to say whether "infra" named a CLI or a runbook. rig builds the boxes; cast fills them; infra is what they are filled with. Two changes were required to make it genuinely stateless and publishable: - The implicit cwd contract (environments.yaml / secrets/ / .coolify.env resolved against the working directory, silently reading the wrong file from the wrong place) is now an explicit --state <dir> / $CAST_STATE. - BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the only product knowledge in the executor — becomes the generic, operator- owned environments.<env>.forbidden_var_patterns. The guard now lives in private state, so a product-side change cannot lower its own guard, and it is a pattern rather than a list, so it catches unforeseen siblings. Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then ~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended apply mechanism, with no environment names known to the tool. Instance identity (org names, the GitHub App name, founder domains) is out of the fixtures and out of register-github-app.sh, which took APP_NAME and ORG as arguments rather than baking them in. 69 tests green; bin/cast + curl installer mirror rig's shape. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:25:44 +00:00
refactor: rescope to versioned installations — the release flow moves out Maintainer direction: this PR's one goal is the versioned layout, the same one box#79 built and rig#36 ported — the release flow (tags, release.yml, prebuilt assets, CHANGELOG) is its own PR later, the shape rig#40 has. So: release.yml, changelog-section.sh, CHANGELOG.md and the asset-aware installer channels leave this branch, and in their place cast gets the family layout for real: - install.sh lands each build at $DEST/versions/<package.json version>, 'current' names the default (atomic rename flips), $BINDIR/cast points through it. Converging no-op on an installed version (nothing rebuilt), CAST_REINSTALL=1 replaces, a new version installs beside and becomes default. Pre-versioning flat installs migrate in place, bit for bit. CAST_INSTALL_SOURCE=<dir|tarball> installs locally (CI/tests, rig's RIG_INSTALL_SOURCE precedent). No flip gate: box refuses under live boxes, rig warns on a converged host — cast is an API client, a flip strands nothing, 'cast use <old>' is one command away. - bin/cast grows the layout verbs in bash (they must work when dist/ is broken): versions (marks current+running), use (atomic flip, then asserts the chain ANSWERS the new version), uninstall (consent gate, CURRENT guard, dangling-current guard, ends with the absence assert). valid_version/pkg_version are byte-identical copies in both files; a test diffs them so the gates cannot drift. - cast --version stays: package.json is the single source of truth, printed with the install root, rig-style. - ci.yml gains the install job: the real installer, from this checkout, layout asserted, converge no-op asserted, uninstall --all asserted absent — the box CI precedent. - Tests drive the REAL install.sh and bin/cast offline (npm shim, local source): the layout, the chain answering end to end, no-op/reinstall/ side-by-side/migration semantics, the hostile-version gates, refs/heads download, every uninstall refusal. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 21:17:59 +00:00
# --- wire $BINDIR onto PATH, durably -----------------------------------------
# `curl | bash` runs in a subshell, so exporting PATH here would die with this
# process. The only durable place is the user's shell profile — so append there,
# once, marked. Opt out with CAST_NO_MODIFY_PATH=1 and wire it yourself.
MARKER='# added by cast-install'
on_path() {
case ":$PATH:" in
*":$1:"*) return 0 ;;
*) return 1 ;;
esac
}
# Which file a *login/interactive* shell of this flavour actually reads.
profile_for_shell() {
case "${SHELL##*/}" in
zsh) printf '%s\n' "${ZDOTDIR:-$HOME}/.zshrc" ;;
bash)
# macOS terminals start login shells (.bash_profile); Linux does not.
if [ "$(uname -s)" = "Darwin" ]; then
printf '%s\n' "$HOME/.bash_profile"
else
printf '%s\n' "$HOME/.bashrc"
fi
;;
fish) printf '%s\n' "${XDG_CONFIG_HOME:-$HOME/.config}/fish/config.fish" ;;
*) printf '%s\n' "$HOME/.profile" ;;
esac
}
path_line_for() {
case "$1" in
*/config.fish) printf 'fish_add_path %s\n' "$BINDIR" ;;
*) printf 'export PATH="%s:$PATH"\n' "$BINDIR" ;;
esac
}
if on_path "$BINDIR"; then
: # already reachable — nothing to wire
elif [ -n "${CAST_NO_MODIFY_PATH:-}" ]; then
warn "$BINDIR is not on your PATH (CAST_NO_MODIFY_PATH set — leaving your profile alone)."
warn " add: $(path_line_for "$(profile_for_shell)")"
else
PROFILE="$(profile_for_shell)"
if [ -f "$PROFILE" ] && grep -qF "$MARKER" "$PROFILE"; then
log "$PROFILE already puts $BINDIR on PATH — this shell just predates it."
else
mkdir -p "$(dirname "$PROFILE")"
{ printf '\n%s\n' "$MARKER"; path_line_for "$PROFILE"; } >>"$PROFILE" \
|| die "could not write $PROFILE — add this line yourself: $(path_line_for "$PROFILE")"
log "wired $BINDIR onto PATH in $PROFILE"
fi
log "this shell does not have it yet — open a new one, or: source $PROFILE"
fi
feat: cast — the Coolify executor, extracted from the infra state repo Public tool, private state. cast holds no hostnames, no bindings, no secrets: it joins a product repo's .infra/ manifest with a state directory you point it at, and makes Coolify match. Extracted from heavy-duty/infra, which was half tool and half state — the inconsistency that made it impossible to say whether "infra" named a CLI or a runbook. rig builds the boxes; cast fills them; infra is what they are filled with. Two changes were required to make it genuinely stateless and publishable: - The implicit cwd contract (environments.yaml / secrets/ / .coolify.env resolved against the working directory, silently reading the wrong file from the wrong place) is now an explicit --state <dir> / $CAST_STATE. - BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the only product knowledge in the executor — becomes the generic, operator- owned environments.<env>.forbidden_var_patterns. The guard now lives in private state, so a product-side change cannot lower its own guard, and it is a pattern rather than a list, so it catches unforeseen siblings. Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then ~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended apply mechanism, with no environment names known to the tool. Instance identity (org names, the GitHub App name, founder domains) is out of the fixtures and out of register-github-app.sh, which took APP_NAME and ORG as arguments rather than baking them in. 69 tests green; bin/cast + curl installer mirror rig's shape. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:25:44 +00:00
refactor: rescope to versioned installations — the release flow moves out Maintainer direction: this PR's one goal is the versioned layout, the same one box#79 built and rig#36 ported — the release flow (tags, release.yml, prebuilt assets, CHANGELOG) is its own PR later, the shape rig#40 has. So: release.yml, changelog-section.sh, CHANGELOG.md and the asset-aware installer channels leave this branch, and in their place cast gets the family layout for real: - install.sh lands each build at $DEST/versions/<package.json version>, 'current' names the default (atomic rename flips), $BINDIR/cast points through it. Converging no-op on an installed version (nothing rebuilt), CAST_REINSTALL=1 replaces, a new version installs beside and becomes default. Pre-versioning flat installs migrate in place, bit for bit. CAST_INSTALL_SOURCE=<dir|tarball> installs locally (CI/tests, rig's RIG_INSTALL_SOURCE precedent). No flip gate: box refuses under live boxes, rig warns on a converged host — cast is an API client, a flip strands nothing, 'cast use <old>' is one command away. - bin/cast grows the layout verbs in bash (they must work when dist/ is broken): versions (marks current+running), use (atomic flip, then asserts the chain ANSWERS the new version), uninstall (consent gate, CURRENT guard, dangling-current guard, ends with the absence assert). valid_version/pkg_version are byte-identical copies in both files; a test diffs them so the gates cannot drift. - cast --version stays: package.json is the single source of truth, printed with the install root, rig-style. - ci.yml gains the install job: the real installer, from this checkout, layout asserted, converge no-op asserted, uninstall --all asserted absent — the box CI precedent. - Tests drive the REAL install.sh and bin/cast offline (npm shim, local source): the layout, the chain answering end to end, no-op/reinstall/ side-by-side/migration semantics, the hostile-version gates, refs/heads download, every uninstall refusal. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 21:17:59 +00:00
log "done ($SRCDESC, version $new_ver) — try: cast --help"