distkit

package module
v0.1.10 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 7, 2026 License: MIT Imports: 22 Imported by: 0

README

distkit

One Go module that owns binary distribution and self-update for the kfet family of tools — fir, harb, mintick, and the ACP relays.

It replaces four hand-written self-update implementations (each missing a different piece) and six divergent install.sh scripts.

  • No dependency on ACP, on any relay, or on fir. Every tool in the family must be able to import it, so it depends on nothing but the standard library.
  • Go 1.25.
go get github.com/kfet/distkit

Self-update in one call

import "github.com/kfet/distkit"

func main() {
    if len(os.Args) > 1 && os.Args[1] == "update" {
        os.Exit(distkit.Main(distkit.Config{
            Repo:        "kfet/zulip-acp",              // GitHub slug
            Binary:      "zulip-acp",                   // installed binary name
            AssetStem:   "zulip-acp",                   // asset is <stem>-<os>-<arch>
            Version:     version,                       // compiled-in current version
            RestartHint: "systemctl --user reload zulip-acp",
        }))
    }
    ...
}

That gives the tool a complete update subcommand:

zulip-acp update                       # install the latest release
zulip-acp update -check                # report only, install nothing
zulip-acp update -version v0.19.0      # install a specific release
zulip-acp update -repo kfet/other      # take releases from elsewhere
zulip-acp update -restart-cmd 'systemctl --user reload zulip-acp'

Exit codes: 0 success (including already up to date), 1 the update failed or was refused, 2 the flags did not parse, 3 a -check run found a newer release — so a timer or cron nag can act on the result without parsing stdout.

What it does, and why

With a token everything goes through the GitHub REST API — asset bytes included; without one, nothing does. Given a token the release is resolved with Accept: application/vnd.github+json and the asset fetched from its API URL with Accept: application/octet-stream, both carrying the bearer. That is the only shape that works against a repo that is still private. The token is taken from GITHUB_TOKEN, then GH_TOKEN, then gh auth token — a fleet host usually has gh configured but exports no token.

Anonymously the API is skipped entirely, because its limit is 60 requests an hour per IP address: a fleet behind one NAT spends it between its own hosts, a CI runner inherits a shared one, and an update or a curl … | sh must not fail for that. A pinned version then needs no lookup at all, and latest is read from the releases/latest redirect on the download host, which costs no quota; assets come from the download host too. The API stays as the fallback for when that redirect yields no tag, and a missing-token 404 and a spent-limit 403 are still reported as the different problems they are.

The download is verified before anything moves. The asset's sha256 is computed inline as it streams to disk (never read back) and compared with the release's checksums.txt.

The swap is ETXTBSY-safe and atomic. The new binary is staged in a temp directory next to the running one — same directory, therefore same filesystem — and os.Renamed over it. A running executable cannot be written or truncated in place, but its directory entry can be replaced; the live process keeps its old inode mapped until it re-execs. That is why a swapped binary is inert until you recycle the service, and why RestartHint exists.

A Homebrew install is upgraded, not refused. The executable is resolved through symlinks; a Cellar component under one of the three standard prefixes (/opt/homebrew, /usr/local, /home/linuxbrew/.linuxbrew) — or under whatever brew --prefix reports — is a keg. The fully-qualified formula (kfet/ai/fir) comes from the keg's INSTALL_RECEIPT.json, falling back to brew info --json=v2, so brew upgrade cannot bind to a same-named formula from another tap. Then brew update && brew upgrade <formula> runs with no timeout (it may compile) and its output streamed through. Self-updating a keg would be silently reverted by the next brew upgrade, leaving a host that reports one version and runs another.

Detection is deliberately conservative: every uncertain case reports not brew, because a false positive breaks update on a non-brew host while a false negative only falls back to the normal path. The one case that is refused outright is a confirmed keg with brew absent from PATH.

An install we do not own is refused up front, with the command to use instead — not three network round-trips later with permission denied. That means a package-manager prefix (/usr/bin, /usr/sbin, /bin, /sbin, the Homebrew trees) or an install directory belonging to another user. Directory ownership is what matters: the swap creates a temp file there and renames it, so a directory owned by someone else fails regardless of the binary's mode. Refusals are *distkit.ErrManaged (use errors.As), so a caller can tell "you must not" from "it went wrong". /usr/local/bin is deliberately not treated as package-manager owned — it is where this project's own install.sh puts binaries — so a root-owned install there is refused by the ownership check, which knows to suggest sudo <tool> update rather than "use your package manager".

Off unix the whole design (directory ownership, rename over a running executable, ETXTBSY) does not hold, so checkOwnership refuses outright rather than performing an unverified overwrite. The module still compiles for GOOS=windows.

Config

field required default notes
Repo ✓ GitHub owner/name
Binary ✓ installed file name; also the name looked for inside an archive
Version ✓ compiled-in version, with or without a leading v
AssetStem Binary the {stem} in the asset name
RestartHint printed after a successful swap, e.g. systemctl --user reload zulip-acp
AssetTemplate {stem}-{os}-{arch} placeholders below; an archive suffix triggers unpacking
ChecksumsAsset checksums.txt name of the manifest in the release
SkipChecksums false install unverified (do not)
ArmSuffix armv6 the asset token for 32-bit ARM, where GOARCH is just arm
DisableBrew false turn off Homebrew handling
Token discovered GITHUB_TOKEN → GH_TOKEN → gh auth token
Anonymous false never authenticate: no discovery, no gh exec, no token header — for an embedded background check (public repos only)
APIBase https://api.github.com GitHub Enterprise, or a test double
HTTPClient see below
StallTimeout 2 min abandon a download that makes no progress for this long
Stdout / Stderr the process's
ExecPath os.Executable test seam
Args os.Args[2:] flags after the update word
TargetVersion latest pin a release
CheckOnly false resolve and report only

AssetTemplate placeholders: {stem}, {binary}, {os}, {arch}, {version}, {version_no_v}. A rendered name ending in .tar.gz, .tgz or .zip is unpacked — the file named Binary is pulled out at any depth, so both a flat archive and harb's harb-0.4.0-linux-arm64/harb layout work.

The default HTTP client has no total request deadline: a release binary is tens of megabytes and a Pi on a slow link legitimately takes minutes. Only the parts that can actually hang are bounded — a 30 s ResponseHeaderTimeout for a server that accepts the connection and never answers, and StallTimeout for a transfer that dies mid-body. Slow is fine; no progress at all is not.

A dev build is refused. Version: "dev" (or unknown, (devel), snapshot, …), and anything carrying a -dev, +dev, -snapshot or -dirty suffix — v0.4.1-dev is what a working tree usually compiles as — can never equal a tag, so every run would report an update available and then rename a release binary over the developer's own build. Semver build metadata is stripped first, so v0.4.1-dev+abc1234.dirty is refused exactly like a bare v0.4.1-dev: whether your Makefile appends a commit sha says nothing about whether the build is a release. A prerelease tag (-rc1, -beta.2) is a real release and still updates, with or without metadata. distkit.IsDevBuild is exported if you want to hide the subcommand instead.

A pinned TargetVersion is the escape hatch and gets past that guard. A binary cross-compiled and scp'd to a server carries the same -dev version string as a developer's ./bin/tool and cannot be told apart from it, so there has to be a supported way back onto a real release — tool update -version vX.Y.Z names one release instead of chasing latest, which makes it a statement of intent rather than an accident. The refusal message says so.

Version comparison is exact tag equality, not "is newer". With TargetVersion pinned the target may be older than what is installed, and a deliberate downgrade is a thing an operator is allowed to ask for.

The pieces, without the CLI

Main is a thin shell over exported functions, for callers (fir) that want their own UI:

st, err := distkit.Check(ctx, cfg)          // resolve + compare; touches no disk
dir, err := distkit.StagingDir(exe, "fir")  // temp dir beside the binary
defer os.RemoveAll(dir)
staged, err := distkit.Download(ctx, cfg, st.Release, dir) // fetch + verify + unpack
err = distkit.Apply(staged, exe)            // the atomic rename

err = distkit.CheckWritable(exe, "fir")     // the refusal, on its own
inst, err := distkit.DetectBrewInstall(ctx) // nil, nil when not brew-managed
err = distkit.UpgradeViaBrew(ctx, inst, "fir", os.Stdout, os.Stderr)

rel, err := distkit.FetchRelease(ctx, cfg, "v1.2.3") // rel.Assets is []distkit.Asset
tok := distkit.DiscoverToken()
sum, err := distkit.LookupChecksum(manifest, "fir-linux-amd64")

distkit.Update(ctx, cfg) is the whole flow, and is what Main calls.

install.sh: generated and drift-checked

Shell cannot be imported, so the honest form of "shared" is generated-and-verified. One canonical template lives in installsh/template.sh; each consumer repo holds a small spec and a generated install.sh at its root (it must be at the root for curl …/main/install.sh | sh to resolve), plus a dev-only check that fails when the checked-in copy has drifted.

install.sh.json in the consumer repo:

{
  "repo": "kfet/harb",
  "binary": "harb",
  "asset_template": "{stem}-{version_no_v}-{os}-{arch}.tar.gz",
  "brew_formula": "kfet/tap/harb",
  "version_flag": "-version",
  "next_steps": ["harb init    # bootstrap config + password",
                 "harb serve   # start the server"]
}

Two Make targets:

install.sh: install.sh.json
	go run github.com/kfet/distkit/cmd/distkit-installsh -o $@

check-installsh:
	go run github.com/kfet/distkit/cmd/distkit-installsh -check

check-installsh belongs in CI and in make check. It is dev-only and never runs on a user's machine. Or do it from a test:

func TestInstallShIsNotDrifted(t *testing.T) {
    spec, err := installsh.LoadSpec("install.sh.json")
    if err != nil { t.Fatal(err) }
    if err := installsh.CheckDrift("install.sh", spec); err != nil { t.Fatal(err) }
}

installsh.FromConfig(cfg) derives the spec from the same distkit.Config the binary self-updates with, so asset naming has exactly one definition per project.

What the generated script does

The template is the union of the six hand-written scripts it replaces:

  • curl or wget; only one of them needs to exist.
  • uname mapping for linux/darwin (optionally freebsd), amd64/arm64 (optionally 386), and all three 32-bit ARM spellings — armv6l, armv7l, armv8l — which collapse to the single GOARM=6 asset that every one of them can execute.
  • Version resolution honouring GITHUB_TOKEN, so the same script works against a private repo. Anonymously the API is not touched at all: latest comes from the releases/latest redirect and the asset from the plain releases/download URL (one redirect each, no JSON parsing), because GitHub's unauthenticated REST limit is 60/hour per IP and a NAT'd fleet or a CI runner routinely arrives with it spent. With a token it resolves the release through the API and picks the asset's API URL out of the release JSON, because the download host 404s a private repo whatever you send it. The API is still the fallback when the redirect yields no tag.
  • sha256 verification against checksums.txt (sha256sum or shasum -a 256).
  • Optional tarball unpacking, finding the binary at any depth.
  • BIN_DIR, with PREFIX accepted as the legacy alias ($PREFIX/bin) — the two schemes the old scripts disagreed on — defaulting to /usr/local/bin when writable and $HOME/.local/bin otherwise.
  • Install via a sibling temp file and mv -f, so a running copy is replaced atomically; sudo escalation when the target directory is not writable.
  • A $PATH warning, optional runtime-dependency warnings (acp-tmux needs tmux at runtime), an optional version smoke test, and optional next-step hints.

Spec fields: repo, binary, asset_stem, asset_template, arm_suffix, no_checksums, checksums_asset, darwin, freebsd, arch_386, brew_formula, runtime_deps ([{name, message}]), version_flag, next_steps, example_tag. Unknown fields are rejected — a silently ignored typo means a silently wrong installer.

Values that are interpolated into shell source (repo, binary, arm_suffix, …) are rejected if they contain shell metacharacters. Values that are prose (next_steps, runtime_deps[].message) are single-quoted instead, so "tmux is not on $PATH" is a legal message.

Env overrides the generated script honours: VERSION, BIN_DIR, PREFIX, REPO, OS, ARCH, GITHUB_TOKEN, GITHUB_API, GITHUB_HOST.

til's installer is deliberately not in scope: it clones a repo and installs through pipx, which is a different program that happens to share a file name.

Development

make check      # gofmt, vet, tests
make cover

The install.sh tests generate the script and actually run it against a fake GitHub over httptest, for the raw-binary and tarball shapes, the anonymous and tokenised download paths, PREFIX, and a checksum mismatch. sh -n alone proves the script parses, not that it installs.

Documentation

Overview

Package distkit owns binary distribution and self-update for the kfet family of tools: fir, harb, mintick, and the ACP relays.

It replaces four hand-written self-update implementations, each of which was missing a different piece, and it carries the union of what they got right:

  • With a token every byte moves through the GitHub REST API, asset payloads included (`Accept: application/octet-stream`). That is the only shape that works against a repo that is still private. Without one — the ordinary public case — the API is skipped entirely: the unauthenticated limit is 60 requests/hour per IP ADDRESS, so a NAT'd fleet spends it between its own hosts, and an update must not fail for that. "latest" then comes from the /releases/latest redirect and assets from the download host, at zero API cost.
  • sha256 of the downloaded asset is checked against the release's `checksums.txt` before anything is moved into place.
  • The replacement is an ETXTBSY-safe atomic swap: stage in a temp dir NEXT TO the running binary (same directory → same filesystem) and os.Rename over it. A running executable cannot be written or truncated in place, but renaming a sibling over it replaces the directory entry while the live process keeps its old inode mapped.
  • A Homebrew-managed install is detected and UPGRADED THROUGH BREW rather than merely refused: self-updating a keg would be silently reverted by the next `brew upgrade`.
  • An install this process does not own — a package-manager prefix, a directory belonging to another user — is refused up front, with the command to use instead, rather than failing three network round-trips later with "permission denied".

The package depends on nothing outside the standard library, and on nothing from ACP, any relay, or fir: every tool in the family must be able to import it.

The usual consumer wires it up in one call:

distkit.Main(distkit.Config{
    Repo:        "kfet/zulip-acp",
    Binary:      "zulip-acp",
    AssetStem:   "zulip-acp",
    Version:     version,
    RestartHint: "systemctl --user reload zulip-acp",
})

Check, Download, Apply, DetectBrewInstall and UpgradeViaBrew are exported separately for callers that want the pieces without the CLI.

Index

Constants

View Source
const DefaultAPIBase = "https://api.github.com"

DefaultAPIBase is the GitHub REST API root.

View Source
const DefaultAssetTemplate = "{stem}-{os}-{arch}"

DefaultAssetTemplate names a raw-binary release asset, which is what goreleaser's `formats: [binary]` produces. See Config.AssetTemplate.

View Source
const DefaultChecksums = "checksums.txt"

DefaultChecksums is the release asset listing sha256 sums, in the `sha256sum` output format that goreleaser writes.

View Source
const DefaultDownloadBase = "https://github.com"

DefaultDownloadBase is the host serving release downloads and the /releases/latest redirect. Unlike the API it has no per-IP request budget, which is what makes the anonymous path in github.go possible.

View Source
const DefaultStallTimeout = 2 * time.Minute

DefaultStallTimeout is how long a download may make no progress at all before it is abandoned. See Config.StallTimeout.

View Source
const ExitUpdateAvailable = 3

Main implements the `<tool> update` subcommand and returns the process exit code. It is the whole integration surface for a consumer:

case "update":
    os.Exit(distkit.Main(distkit.Config{
        Repo:        "kfet/zulip-acp",
        Binary:      "zulip-acp",
        Version:     version,
        RestartHint: "systemctl --user reload zulip-acp",
    }))

Flags parsed from Config.Args (default os.Args[2:], i.e. everything after the subcommand word):

-check              report whether an update is available; install nothing
-version <tag>      install a specific release instead of the latest
-repo <owner/name>  override the source repository
-restart-cmd <sh>   run this after a successful update

Exit codes: 0 success (including "already up to date"), 1 the update failed or was refused, 2 the flags did not parse, and ExitUpdateAvailable (3) for a -check run that found a newer release. ExitUpdateAvailable is the exit code of a `-check` run that found a release different from the one running. It is distinct from 0 ("already up to date") so a scheduled check can act on the result without parsing stdout.

Variables

This section is empty.

Functions

func Apply

func Apply(staged, target string) error

Apply replaces target with the file at staged.

This is the ETXTBSY-safe swap. A running executable cannot be opened for writing or truncated — the kernel returns ETXTBSY — but its *directory entry* can be replaced. os.Rename does exactly that, atomically, and the process that is still running keeps the old inode mapped until it re-execs. That is why staged must be a sibling of target: a rename across filesystems degrades to copy-then-unlink, which is neither atomic nor permitted here.

func CheckWritable

func CheckWritable(path, binary string) error

CheckWritable reports whether the resolved binary at path may be replaced by this process: not under a package-manager prefix, and in a directory owned by the effective user.

The directory ownership matters rather than the file's: the swap creates a temp file in the install directory and renames it over the binary, so a directory belonging to someone else fails no matter what the file's mode says. Checking it here turns a confusing "permission denied" after three network round-trips into an immediate, explainable refusal.

func DiscoverToken

func DiscoverToken() string

DiscoverToken finds a GitHub token the way the surrounding tooling does: the standard environment variables first, then a logged-in `gh`. A fleet host usually has gh configured but rarely exports a token. Returns "" when there is none, which is fine against a public repo.

func Download

func Download(ctx context.Context, cfg Config, rel *Release, dir string) (string, error)

Download fetches the release asset for the current platform into dir, verifies its sha256 against the release's checksum manifest, unpacks it if it is an archive, and returns the path of the executable ready to be installed.

dir must be on the same filesystem as the binary that will be replaced for Apply to be atomic; StagingDir creates such a directory. The caller owns dir and is responsible for removing it.

Verification is not optional in any sane configuration: the bytes are only trusted once the manifest agrees, and the manifest is fetched over the same authenticated API call chain as the asset itself.

func EnsureV

func EnsureV(s string) string

EnsureV prepends "v" to a version string that lacks it, so that a tag written "0.4.1" and one written "v0.4.1" compare equal.

func IsDevBuild

func IsDevBuild(v string) bool

IsDevBuild reports whether v is a placeholder or a working-tree build rather than a released tag.

Self-updating one would rename a release binary over a developer's own build — it can never equal a tag, so every check reports "available" — which is a surprising thing to do to someone's working tree.

Semver build metadata (everything from the first "+") is stripped before the suffix test, because it says nothing about whether this is a release: a Makefile that stamps "0.6.1-dev+abc1234.dirty" is describing the same working-tree build as one that stamps a bare "0.6.1-dev", and the guard must not fire for one and not the other merely because the first appends a commit sha. Note that stripping also disarms the "+dev" suffix, so that case is checked before.

func LookupChecksum

func LookupChecksum(manifest []byte, asset string) (string, error)

LookupChecksum returns the hex sha256 recorded for asset in a manifest in `sha256sum` format ("<hex> <name>"), as written by goreleaser.

func Main

func Main(cfg Config) int

func StagingDir

func StagingDir(target, binary string) (string, error)

StagingDir creates a temporary directory in the same directory as target, which is what makes the subsequent Apply an atomic same-filesystem rename. The caller must os.RemoveAll it.

func UpgradeViaBrew

func UpgradeViaBrew(ctx context.Context, inst *BrewInstall, binary string, stdout, stderr io.Writer) error

UpgradeViaBrew runs `brew update` followed by `brew upgrade <formula>`, streaming both commands' output to the supplied writers so a multi-minute upgrade is not swallowed into silence.

No timeout is imposed: a brew upgrade can legitimately take many minutes (it may compile). Cancellation is the caller's ctx.

Types

type Asset

type Asset struct {
	// Name is the file name, e.g. "zulip-acp-linux-amd64".
	Name string `json:"name"`
	// URL is the asset's API URL. Fetching it with
	// Accept: application/octet-stream returns the bytes; without that
	// header it returns the asset's JSON metadata.
	URL string `json:"url"`
}

Asset is one file attached to a release.

type BrewInstall

type BrewInstall struct {
	// ExePath is the running executable with symlinks resolved — the real
	// file inside the Cellar.
	ExePath string
	// Prefix is the Homebrew prefix the Cellar belongs to, e.g. "/opt/homebrew".
	Prefix string
	// Formula is the name to hand to `brew upgrade`. Fully qualified when we
	// could resolve it (e.g. "kfet/ai/fir"), otherwise the bare keg name.
	Formula string
	// BrewPath is the resolved `brew` executable, empty when brew is not on
	// PATH. An install with a non-empty Prefix and an empty BrewPath is the
	// one case we must refuse to self-update.
	BrewPath string
}

BrewInstall describes a detected Homebrew-managed installation.

func DetectBrewInstall

func DetectBrewInstall(ctx context.Context) (*BrewInstall, error)

DetectBrewInstall reports whether the running executable is managed by Homebrew. It returns (nil, nil) when the install is not brew-managed — including every case where we are merely unsure, since a false positive breaks `<tool> update` on non-brew hosts while a false negative only leaves us on the pre-existing self-update path.

type Config

type Config struct {
	// Repo is the GitHub "owner/name" releases are taken from, e.g.
	// "kfet/zulip-acp". Required.
	Repo string

	// Binary is the installed file name, e.g. "zulip-acp". It is also the
	// name searched for inside an archive asset. Required.
	Binary string

	// AssetStem is the "{stem}" of the release asset name. Defaults to
	// Binary, which is right for every consumer today.
	AssetStem string

	// Version is the compiled-in version of the running binary, with or
	// without a leading "v". Required.
	Version string

	// RestartHint is the command that makes a swapped binary take effect,
	// e.g. "systemctl --user reload zulip-acp". A replaced binary is inert
	// until the process re-execs — the running one still has the old inode
	// mapped — so this is printed after every successful update. Optional:
	// a one-shot CLI has nothing to recycle.
	RestartHint string

	// AssetTemplate names the release asset. Placeholders:
	//
	//	{stem}        AssetStem
	//	{binary}      Binary
	//	{os}          runtime.GOOS
	//	{arch}        release arch suffix (see ArmSuffix)
	//	{version}     resolved tag, with leading v
	//	{version_no_v} resolved tag, without leading v
	//
	// Defaults to DefaultAssetTemplate ("{stem}-{os}-{arch}"), a raw
	// binary. When the rendered name ends in .tar.gz, .tgz or .zip the
	// asset is treated as an archive and Binary is extracted from it —
	// harb publishes "{stem}-{version_no_v}-{os}-{arch}.tar.gz".
	AssetTemplate string

	// ChecksumsAsset is the name of the checksum manifest in the release.
	// Defaults to DefaultChecksums.
	ChecksumsAsset string

	// SkipChecksums installs the asset without verifying it. No consumer
	// should set this: the bytes are only trustworthy once the manifest
	// agrees. It exists for a project whose release process genuinely
	// publishes no manifest.
	SkipChecksums bool

	// ArmSuffix is the arch token used in asset names for 32-bit ARM,
	// where GOARCH is the uninformative "arm". The family builds at
	// GOARM=6 and publishes "armv6"; that is the default. A project whose
	// assets say plain "arm" sets it to "arm".
	ArmSuffix string

	// DisableBrew turns off Homebrew detection. With it set, a keg install
	// is refused by the managed-prefix guard instead of being upgraded
	// through `brew upgrade`. Only useful for a binary that is never
	// distributed as a formula.
	DisableBrew bool

	// Token is a GitHub token. Empty means discover one from the
	// environment (GITHUB_TOKEN, GH_TOKEN) and then from a logged-in `gh`.
	Token string

	// Anonymous forbids authentication entirely: no discovery, no `gh`
	// exec, and no Authorization header even if Token or GITHUB_TOKEN is
	// set. Resolution then always takes the /releases/latest redirect on
	// the download host, and only falls back to the API unauthenticated.
	//
	// It exists for an EMBEDDED caller — a background version check inside
	// another program — where the two costs of discovery are unacceptable:
	// exec'ing `gh auth token` on a startup path (up to 10s, outside the
	// caller's context), and a resolution path that silently differs
	// between a host with `gh` logged in and one without. A private repo
	// cannot be resolved this way; that is the trade.
	Anonymous bool

	// APIBase is the GitHub API root; empty means DefaultAPIBase. Tests
	// point it at an httptest server.
	APIBase string

	// DownloadBase is the host release assets are fetched from when no
	// token is in play, and the host whose /releases/latest redirect
	// resolves "latest" without spending API quota. Empty means
	// DefaultDownloadBase. Tests point it at an httptest server; a GitHub
	// Enterprise install points it at its own web host.
	DownloadBase string

	// HTTPClient overrides the default client. The default deliberately
	// has NO total-request deadline: a release binary is ~10 MB and a Pi
	// on a slow link legitimately takes minutes. It bounds the part that
	// can actually hang — a server that accepts the connection and then
	// never answers — with ResponseHeaderTimeout.
	HTTPClient *http.Client

	// StallTimeout bounds how long a download may make NO progress before it
	// is abandoned. It is not a total deadline — a slow link is fine, a
	// stalled one is not — and it is what stops a dropped connection
	// mid-body from wedging a systemd timer forever. Defaults to
	// DefaultStallTimeout; a negative value disables the check.
	StallTimeout time.Duration

	// Stdout and Stderr default to the process's. Progress and results go
	// to Stdout, diagnostics to Stderr.
	Stdout io.Writer
	Stderr io.Writer

	// ExecPath overrides os.Executable. Tests use it; production does not.
	ExecPath func() (string, error)

	// Args are the CLI arguments AFTER the "update" subcommand word. nil
	// means take them from os.Args[2:], which is what a consumer that
	// dispatches `<tool> update ...` straight into Main wants. A consumer
	// with global flags BEFORE the subcommand (`tool -config x update -check`)
	// must set this explicitly — os.Args[2:] would be wrong there.
	Args []string

	// TargetVersion pins the release to install, e.g. "v0.19.0". Empty
	// means latest. The -version flag sets it when going through Main.
	TargetVersion string

	// CheckOnly resolves and reports without downloading or replacing
	// anything. The -check flag sets it when going through Main.
	CheckOnly bool
	// contains filtered or unexported fields
}

Config describes one consumer's distribution setup. Repo, Binary and Version are required; everything else has a working default.

func (*Config) AssetName

func (c *Config) AssetName(tag string) string

AssetName renders Config.AssetTemplate for the current platform and the given resolved tag.

type ErrManaged

type ErrManaged struct {
	// Path is the resolved binary.
	Path string
	// Reason explains what makes it unsafe.
	Reason string
	// Hint is the command to use instead, if there is one.
	Hint string
}

ErrManaged is returned when the install is owned by something other than this process. Callers can errors.As it to distinguish "you must not" from "it went wrong".

func (*ErrManaged) Error

func (e *ErrManaged) Error() string

type Release

type Release struct {
	TagName string `json:"tag_name"`
	// Assets is the release's asset list as the API reports it. It is EMPTY
	// for a release resolved without a token, where no such list is ever
	// fetched — use AssetURL, which covers both cases, rather than ranging
	// over this.
	Assets []Asset `json:"assets"`
	// contains filtered or unexported fields
}

Release is the subset of the GitHub release JSON this package uses.

Asset.URL is the *API* URL (…/releases/assets/123), not the browser_download_url: the API URL serves the bytes to a bearer token, which is the only thing that works while the repo is private.

func FetchRelease

func FetchRelease(ctx context.Context, cfg Config, tag string) (*Release, error)

FetchRelease resolves a release: the latest one when tag is empty, otherwise that exact tag.

With a token this goes through the GitHub REST API, which is the only thing that works against a private repo. WITHOUT one it deliberately does not: the unauthenticated API limit is 60 requests/hour PER IP ADDRESS, so a fleet behind one NAT, a CI runner, or a shared office link can arrive with it already spent, and `update` must not fail for that. Anonymously a pinned tag needs no lookup at all, and "latest" is read from the /releases/latest redirect on the download host, which costs no quota. The API remains the fallback for when that redirect yields nothing usable.

Anonymously a pinned tag is therefore taken at its word: it is not checked to exist, so a Check of one that does not will report it as available and only the download will fail (with an error that says so).

func (*Release) AssetURL

func (r *Release) AssetURL(name string) (string, error)

AssetURL returns the URL of the named asset: its API URL for a release resolved through the API, or its download URL for one resolved anonymously, where no asset list was ever fetched. In the anonymous case the name is not validated here — a file that is not in the release surfaces as a 404 from the download itself.

type Result

type Result struct {
	// Current is the version that was running, with a leading v.
	Current string
	// Target is the version resolved for installation, with a leading v.
	// Empty when ViaBrew is set (brew resolves the version itself) and
	// when the run failed before the release was resolved.
	Target string
	// Updated is true iff the binary was actually replaced (or upgraded
	// through brew).
	Updated bool
	// ExecPath is the resolved path of the binary, symlinks followed.
	ExecPath string
	// ViaBrew is true when the work was done by `brew upgrade` rather than
	// by downloading a release asset.
	ViaBrew bool
	// Available is set by a CheckOnly run that found a different release.
	// It is the machine-readable form of what -check prints.
	Available bool
}

Result reports what an update run did, so the caller can decide follow-up actions such as recycling a service.

func Update

func Update(ctx context.Context, cfg Config) (Result, error)

Update runs the whole flow: locate the running binary, hand a Homebrew install to brew, refuse an install we must not touch, then resolve, download, verify and swap.

It returns Updated=false with a nil error for the two ordinary non-outcomes: already up to date, and CheckOnly.

type Status

type Status struct {
	// Current is the running version, with a leading v.
	Current string
	// Target is the resolved release tag, with a leading v.
	Target string
	// Available reports whether Target differs from Current. With
	// TargetVersion pinned this may be a downgrade — the field says
	// "different", not "newer", because installing a pin is legitimate.
	Available bool
	// Release is the resolved release, carrying the asset API URLs.
	Release *Release
}

Status is what Check resolved, without touching the installed binary.

func Check

func Check(ctx context.Context, cfg Config) (*Status, error)

Check resolves the release that `update` would install and compares it with the running version. It touches nothing on disk, so it is safe to call from a status command or a background nag. Without a token it also spends no GitHub API quota — see FetchRelease.

The comparison is exact-match on the tag, not "is newer": with Config.TargetVersion pinned the target may legitimately be older than what is installed, and calling a deliberate downgrade "no update available" would refuse to do a thing the operator explicitly asked for.

Directories

Path Synopsis
cmd
distkit-installsh command
Command distkit-installsh generates a repo's root install.sh from the canonical distkit template, or checks the checked-in copy for drift.
Command distkit-installsh generates a repo's root install.sh from the canonical distkit template, or checks the checked-in copy for drift.
Package installsh generates a consumer repo's root install.sh from one canonical template.
Package installsh generates a consumer repo's root install.sh from one canonical template.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL