2 Commits
Author SHA1 Message Date
Michael Wesemann 1e06e99012 [mike@mwxm4] 2026-08-12 16:25:34 +02:00
Michael Wesemann 55418a1040 [mike@mwxm4] 2026-08-12 16:23:52 +02:00
12 changed files with 680 additions and 293 deletions
+1 -2
View File
@@ -7,8 +7,7 @@
.Spotlight-V100
.TemporaryItems
.Trashes
# Binaries
goca
dist/
bin/
.mgshrc
+49
View File
@@ -185,6 +185,55 @@ The interactive shell supports the following commands:
---
## 🔄 Updates
goca updates itself from the releases of the Gitea repository at
[git.fhi.mpg.de/mike/goca](https://git.fhi.mpg.de/mike/goca):
```bash
goca --version # print the running version
goca --check-update # only look for a newer release
goca --update # download and install the newest release
```
`--update` fetches the asset `goca-<goos>-<goarch>` of the newest release, runs
the downloaded binary once with `--version` to make sure it works, and only then
replaces the running file — following a symlink to the real file behind it.
Besides that, goca looks for a new release **once a day, in the background**: the
foreground run never touches the network for it, it only reads the note in
`~/.cache/goca/update.json` (on macOS `~/Library/Caches/goca/update.json`) left
by the last look and puts a dimmed line on stderr when something newer is out.
`GOCA_NO_UPDATE_CHECK=1` or the `-y` flag turns that off, and without a terminal
(pipe, script, cron) goca stays quiet anyway.
### Building and publishing a release
`build.sh` builds every platform into `./bin`, each carrying the same version:
```bash
./build.sh # 1.1.2 -> 1.1.3, all platforms
PLATFORMS="linux/amd64" ./build.sh # just one
VERSION=1.2.0 ./build.sh # a minor or major step, named outright
```
`version.txt` holds the version just built and is stepped by 0.0.1 on every run;
the number goes into the binaries via `-ldflags`, so the literal in `version.go`
is only what a bare `go build` picks up. The host platform additionally gets the
symlink `bin/goca`.
For macOS, sign and notarize the two Darwin binaries in place before uploading:
```bash
./notarize.sh # needs APPLE_ID, APPLE_PASSWORD, TEAM_ID
```
Then create a release at git.fhi.mpg.de whose tag is the bare version from
`version.txt` (e.g. `1.1.3`) and attach the files from `bin/` to it. The asset
names are what `--update` looks for, so they must stay as `build.sh` writes them.
---
## 📦 Project Architecture
* [cli.go](file:///Users/mike/src/goca/cli.go) - Command loop, line-completion, history, and user input handler.
+60 -30
View File
@@ -1,40 +1,70 @@
#!/bin/bash
#!/bin/sh
# Build goca for the usual platforms into ./bin, auto-incrementing the patch
# version by 0.0.1 on every build.
#
# version.txt holds the currently built version. Each run increments the patch
# component, then builds every platform with that one version injected via
# -ldflags, and writes it back. So version.txt always reflects the version of
# the binaries just built, and all of them carry the same one. The literal in
# version.go is only ever what a bare `go build` picks up.
#
# The names in ./bin are what selfupdate.go looks for in a release:
# "goca-<goos>-<goarch>", with .exe on Windows. Upload exactly these files to a
# Gitea release whose tag is the bare version number, and --update finds them.
#
# Override the platform list to build just one:
# PLATFORMS="linux/amd64" ./build.sh
#
# A plain run only ever steps the patch. A minor or major step is taken by
# naming the version outright, which is then written back like any other:
# VERSION=1.2.0 ./build.sh
#
# -s -w drops the symbol table and DWARF info, -trimpath keeps build paths out
# of the binary; together they roughly halve it. Neither affects a panic trace.
set -e
# Make sure we are in the script's directory
cd "$(dirname "$0")"
# Read version from version.go
VERSION_LINE=$(grep "var Version =" version.go)
CURRENT_VERSION=$(echo "$VERSION_LINE" | sed -E 's/.*"([^"]+)".*/\1/')
PLATFORMS=${PLATFORMS:-"darwin/arm64 darwin/amd64 linux/amd64 linux/arm64 windows/amd64"}
if [ -z "$CURRENT_VERSION" ]; then
echo "❌ Error: Could not parse version from version.go"
exit 1
if [ -n "$VERSION" ]; then
NV="$VERSION"
else
V=$(cat version.txt 2>/dev/null || echo 1.1.2)
# split MAJOR.MINOR.PATCH and increment PATCH (no carry: 1.1.9 -> 1.1.10)
MAJOR=${V%%.*}
REST=${V#*.}
MINOR=${REST%%.*}
PATCH=${REST#*.}
PATCH=$((PATCH + 1))
NV="$MAJOR.$MINOR.$PATCH"
fi
# Split version by dot
IFS='.' read -r -a VERSION_PARTS <<< "$CURRENT_VERSION"
MAJOR="${VERSION_PARTS[0]}"
MINOR="${VERSION_PARTS[1]}"
PATCH="${VERSION_PARTS[2]}"
mkdir -p bin
HOST="$(go env GOOS)/$(go env GOARCH)"
# Increment patch version
NEW_PATCH=$((PATCH + 1))
NEW_VERSION="$MAJOR.$MINOR.$NEW_PATCH"
for p in $PLATFORMS; do
os=${p%/*}
arch=${p#*/}
ext=""
if [ "$os" = "windows" ]; then
ext=".exe" # Windows runs nothing without it, and --update knows that
fi
out="bin/goca-$os-$arch$ext"
echo "🚀 Incrementing version: $CURRENT_VERSION ➡️ $NEW_VERSION"
# CGO_ENABLED=0 throughout: it makes the cross builds work without a
# toolchain per target and the binaries static. goca only needs the network
# and the filesystem, so nothing is lost by dropping cgo.
CGO_ENABLED=0 GOOS="$os" GOARCH="$arch" \
go build -trimpath -ldflags "-s -w -X main.Version=$NV" -o "$out" .
# Write back to version.go using perl for portable in-place edit
perl -pi -e "s/var Version = \"$CURRENT_VERSION\"/var Version = \"$NEW_VERSION\"/" version.go
if [ "$p" = "$HOST" ]; then
ln -sf "goca-$os-$arch$ext" bin/goca # the one for this machine
echo " $out -> bin/goca"
else
echo " $out"
fi
done
# Update README.md version output
if [ -f README.md ]; then
perl -pi -e "s/goca v$CURRENT_VERSION/goca v$NEW_VERSION/g" README.md
fi
# Build the project
echo "🛠️ Building goca binary..."
go build -o goca
echo "🎉 Build successful! version: v$NEW_VERSION"
echo "$NV" > version.txt
echo "built goca v$NV"
-30
View File
@@ -1,30 +0,0 @@
#!/bin/bash
set -e
# Make sure we are in the script's directory
cd "$(dirname "$0")"
# Read version from version.go
VERSION_LINE=$(grep "var Version =" version.go)
VERSION=$(echo "$VERSION_LINE" | sed -E 's/.*"([^"]+)".*/\1/')
echo "🛠️ Compiling release binaries for version v$VERSION..."
mkdir -p bin
# macOS Intel
echo "🍏 Building macOS Intel (amd64)..."
GOOS=darwin GOARCH=amd64 go build -o bin/goca-darwin-amd64
# macOS Apple Silicon
echo "🍏 Building macOS Apple Silicon (arm64)..."
GOOS=darwin GOARCH=arm64 go build -o bin/goca-darwin-arm64
# Linux Intel
echo "🐧 Building Linux Intel (amd64)..."
GOOS=linux GOARCH=amd64 go build -o bin/goca-linux-amd64
# Windows Intel
echo "🏁 Building Windows Intel (amd64)..."
GOOS=windows GOARCH=amd64 go build -o bin/goca-windows-amd64.exe
echo "🎉 All release binaries built in bin/!"
+4 -15
View File
@@ -3,19 +3,8 @@ module goca
go 1.26.1
require (
aead.dev/minisign v0.2.0 // indirect
github.com/AlecAivazis/survey/v2 v2.3.7 // indirect
github.com/Masterminds/semver/v3 v3.5.0 // indirect
github.com/chzyer/readline v1.5.1 // indirect
github.com/fatih/color v1.19.0 // indirect
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b // indirect
github.com/minio/selfupdate v0.6.0 // indirect
github.com/shopspring/decimal v1.4.0 // indirect
golang.org/x/crypto v0.0.0-20211209193657-4570a0811e8b // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 // indirect
golang.org/x/text v0.4.0 // indirect
github.com/chzyer/readline v1.5.1
github.com/shopspring/decimal v1.4.0
)
require golang.org/x/sys v0.42.0 // indirect
+2 -69
View File
@@ -1,78 +1,11 @@
aead.dev/minisign v0.2.0 h1:kAWrq/hBRu4AARY6AlciO83xhNnW9UaC8YipS2uhLPk=
aead.dev/minisign v0.2.0/go.mod h1:zdq6LdSd9TbuSxchxwhpA9zEb9YXcVGoE8JakuiGaIQ=
github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ=
github.com/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1reCfTwbto0Fduo=
github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE=
github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w=
github.com/chzyer/logex v1.2.1 h1:XHDu3E6q+gdHgsdTPH6ImJMIp436vR6MPtH8gP05QzM=
github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ=
github.com/chzyer/readline v1.5.1 h1:upd/6fQk4src78LMRzh5vItIt361/o4uq553V8B5sGI=
github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObkaSkeBlk=
github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04=
github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8=
github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4=
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
github.com/minio/selfupdate v0.6.0 h1:i76PgT0K5xO9+hjzKcacQtO7+MjJ4JKA8Ak8XQ9DDwU=
github.com/minio/selfupdate v0.6.0/go.mod h1:bO02GTIPCMQFTEvE5h4DjYB58bCoZ35XLeBf0buTDdM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20211209193657-4570a0811e8b h1:QAqMVf3pSa6eeTsuklijukjXBlj7Es2QQplab+/RbQ4=
golang.org/x/crypto v0.0.0-20211209193657-4570a0811e8b/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210228012217-479acdf4ea46/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5 h1:y/woIyUBFbpQGKS0u1aHF/40WUDnek3fPOyD08H5Vng=
golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.4.0 h1:BrVqGRd7+k1DiOgtnFvAkoQEWQvBc25ouMJM6429SFg=
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+51 -5
View File
@@ -140,6 +140,12 @@ func printHelp() {
fmt.Println(" base <C> Change base currency (e.g., base USD)")
fmt.Println(" clear Clear the terminal screen")
fmt.Println(" exit/quit Exit goca")
fmt.Println("\033[1mUpdates:\033[0m")
fmt.Println(" goca --version Print the version")
fmt.Println(" goca --check-update Look for a newer release")
fmt.Println(" goca --update Download and install the newest release")
fmt.Println(" goca looks for a new release once a day, in the background, and says so on")
fmt.Println(" stderr. GOCA_NO_UPDATE_CHECK=1 or -y turns that off.")
}
type CalcCompleter struct{}
@@ -533,22 +539,55 @@ func handleLine(line string, useReadline bool, variablesFile string, functionsFi
return false
}
func main() {
initUnits()
// printUpdateHint puts the note of the daily look on stderr, dimmed, so that it
// never lands in a pipe that only expects the result.
func printUpdateHint(hint string) {
if hint != "" {
fmt.Fprintf(os.Stderr, "\033[2m%s\033[0m\n", hint)
}
}
func main() {
// The options come before everything else: --update-refresh is the
// background run and must touch neither units nor rates, and --version has
// to answer at once — the updater probes the downloaded binary with it.
optY := false
newArgs := []string{os.Args[0]}
for i := 1; i < len(os.Args); i++ {
if os.Args[i] == "-y" {
switch os.Args[i] {
case "-y":
optY = true
} else {
case "--version":
fmt.Printf("goca %s\n", Version)
return
case "--update":
if err := selfUpdate.install(os.Stdout); err != nil {
fmt.Fprintf(os.Stderr, "goca: %v\n", err)
os.Exit(1)
}
return
case "--check-update":
if err := selfUpdate.check(os.Stdout); err != nil {
fmt.Fprintf(os.Stderr, "goca: %v\n", err)
os.Exit(1)
}
return
case updateRefreshFlag: // the background run, not in the help
selfUpdate.refresh()
return
default:
newArgs = append(newArgs, os.Args[i])
}
}
os.Args = newArgs
initUnits()
// Costs nothing: the hint comes from the note in the cache, and the asking
// happens once a day at most, in the background. -y keeps it quiet.
updateHint := ""
if !optY {
checkforupdate(UPDATEURL)
updateHint = selfUpdate.daily()
}
hasArgs := len(os.Args) > 1
@@ -576,6 +615,7 @@ func main() {
if arg == "-p" || arg == "--port" {
if i+1 < len(os.Args) {
port := os.Args[i+1]
printUpdateHint(updateHint)
startWebServer(port)
return
} else {
@@ -587,6 +627,7 @@ func main() {
input := strings.Join(os.Args[1:], " ")
handleLine(input, false, variablesFile, functionsFile)
printUpdateHint(updateHint)
return
}
@@ -614,6 +655,7 @@ func main() {
if useReadline {
fmt.Printf("\033[1;34mgoca v%s\033[0m, type 'help' for examples.\n", Version)
printUpdateHint(updateHint)
}
var scanner *bufio.Scanner
@@ -639,4 +681,8 @@ func main() {
break
}
}
if !useReadline { // piped input: the hint comes at the end, not before it
printUpdateHint(updateHint)
}
}
+15 -11
View File
@@ -1,5 +1,9 @@
#!/bin/bash
# Script to sign and notarize goca Darwin binaries
# Script to sign and notarize goca Darwin binaries.
#
# Runs after ./build.sh and before the release is uploaded: it signs the files
# in ./bin in place, so what goes into the Gitea release — and thus what
# --update hands out — is the signed and notarized binary.
set -e
@@ -21,30 +25,30 @@ fi
SIGNING_IDENTITY="Developer ID Application: Mike Wesemann ($TEAM_ID)"
# Verify binaries exist
if [ ! -f "dist/goca-darwin-amd64" ] || [ ! -f "dist/goca-darwin-arm64" ]; then
echo "❌ Error: Darwin binaries not found in 'dist/'. Run ./build_releases.sh first."
if [ ! -f "bin/goca-darwin-amd64" ] || [ ! -f "bin/goca-darwin-arm64" ]; then
echo "❌ Error: Darwin binaries not found in 'bin/'. Run ./build.sh first."
exit 1
fi
echo "🔐 Step 1: Codesigning Darwin binaries..."
codesign --force --options runtime --timestamp --sign "$SIGNING_IDENTITY" dist/goca-darwin-amd64
codesign --force --options runtime --timestamp --sign "$SIGNING_IDENTITY" dist/goca-darwin-arm64
codesign --force --options runtime --timestamp --sign "$SIGNING_IDENTITY" bin/goca-darwin-amd64
codesign --force --options runtime --timestamp --sign "$SIGNING_IDENTITY" bin/goca-darwin-arm64
echo "📦 Step 2: Packaging binaries into ZIP archives..."
mkdir -p dist/notarize
rm -f dist/notarize/*.zip
ditto -c -k --keepParent dist/goca-darwin-amd64 dist/notarize/goca-darwin-amd64.zip
ditto -c -k --keepParent dist/goca-darwin-arm64 dist/notarize/goca-darwin-arm64.zip
mkdir -p bin/notarize
rm -f bin/notarize/*.zip
ditto -c -k --keepParent bin/goca-darwin-amd64 bin/notarize/goca-darwin-amd64.zip
ditto -c -k --keepParent bin/goca-darwin-arm64 bin/notarize/goca-darwin-arm64.zip
echo "🚀 Step 3: Submitting macOS Intel binary (amd64) to Apple Notarization..."
xcrun notarytool submit dist/notarize/goca-darwin-amd64.zip \
xcrun notarytool submit bin/notarize/goca-darwin-amd64.zip \
--apple-id "$APPLE_ID" \
--password "$APPLE_PASSWORD" \
--team-id "$TEAM_ID" \
--wait
echo "🚀 Step 4: Submitting macOS Apple Silicon binary (arm64) to Apple Notarization..."
xcrun notarytool submit dist/notarize/goca-darwin-arm64.zip \
xcrun notarytool submit bin/notarize/goca-darwin-arm64.zip \
--apple-id "$APPLE_ID" \
--password "$APPLE_PASSWORD" \
--team-id "$TEAM_ID" \
+493
View File
@@ -0,0 +1,493 @@
// selfupdate.go — updating oneself from the releases of a Gitea instance.
//
// The file is meant to be copied: take it into another program, adjust the
// configuration block below, hang `--update` and `--check-update` into the
// options — done. It needs nothing but the standard library, and apart from
// that block it brings no names that do not begin with "selfUpdate" or
// "update".
//
// It assumes the layout build_releases.sh produces: one release per version,
// whose tag is the bare number (1.1.2, a leading "v" is allowed), holding one
// asset "<name>-<goos>-<goarch>" each — that is, exactly the files from ./bin.
// Under /api/v1/repos/<owner>/<repo>/releases/latest Gitea hands out the newest
// release that is neither a draft nor a prerelease; GitHub speaks the same
// route with different field names and is therefore not covered.
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
"time"
)
// ------------------------------------------------------------ Configuration
var selfUpdate = selfUpdater{
repo: "https://git.fhi.mpg.de/mike/goca",
asset: "goca",
current: Version, // from version.go, bumped by build.sh
verify: []string{"--version"},
every: 24 * time.Hour,
quietEnv: "GOCA_NO_UPDATE_CHECK",
}
type selfUpdater struct {
repo string // repo URL as in the browser: https://host/owner/repo
asset string // base name of the assets, "-<goos>-<goarch>" is added
current string // the running version
verify []string // trial run of the download; empty skips it
every time.Duration // how often to look on its own; 0 turns that off
quietEnv string // this environment variable set: keep quiet as well
}
// updateRefreshFlag is the option the program calls itself with, in the
// background. It is deliberately absent from the help.
const updateRefreshFlag = "--update-refresh"
// ------------------------------------------------------------ Looking by itself
// daily is the hook for the ordinary run of the program. It costs nothing: in
// the foreground the network is never touched. What comes back is the line
// pointing at a new version — or "", when there is nothing to say; what it
// looks like is up to the caller. Should the note be older than `every`, daily
// starts a background run on the side, whose answer the next call will find
// waiting.
func (u selfUpdater) daily() string {
if u.every <= 0 || os.Getenv(u.quietEnv) != "" || !updateOnTerminal() {
return ""
}
st := u.loadState() // no file: the zero value, hence due at once
if time.Since(st.Checked) >= u.every {
// The timestamp moves on before the asking, not after: otherwise two
// simultaneous runs start two queries, and a server that is not in the
// mood would get a new one on every call. If the note does not stay
// put, nothing is asked either — else an unwritable cache directory
// would mean one process per call.
st.Checked = time.Now()
if u.saveState(st) == nil {
u.spawnRefresh()
}
}
if st.Latest == "" || updateCompare(st.Latest, u.current) <= 0 {
return ""
}
return fmt.Sprintf("%s %s is available, run '%s --update'", u.asset, st.Latest, u.asset)
}
// refresh is the background run: ask, write it down, stay quiet. The writing
// down is done by latest; if the query fails, the old state remains.
func (u selfUpdater) refresh() {
_, _ = u.latest()
}
// spawnRefresh calls this program once more, only to ask, and does not wait.
// Without a Wait the child is adopted by init when this process ends — it thus
// outlives the call, and the call's output stays untouched by it.
func (u selfUpdater) spawnRefresh() {
exe, err := os.Executable()
if err != nil {
return
}
cmd := exec.Command(exe, updateRefreshFlag)
cmd.Stdin, cmd.Stdout, cmd.Stderr = nil, nil, nil // everything to /dev/null
if cmd.Start() == nil {
cmd.Process.Release()
}
}
// The hint is meant for the person sitting there. Running in a pipe, in a
// script or under cron, the program neither asks nor says anything.
func updateOnTerminal() bool {
st, err := os.Stderr.Stat()
return err == nil && st.Mode()&os.ModeCharDevice != 0
}
// --------------------------------------------------------------------- Note
// updateState is what is left between two calls: when the last question was
// asked and what came of it.
type updateState struct {
Checked time.Time `json:"checked"`
Latest string `json:"latest"`
}
// The note lives in the cache directory, not in the configuration: if it gets
// lost, the only cost is asking once too early.
func (u selfUpdater) statePath() (string, error) {
dir, err := os.UserCacheDir()
if err != nil {
return "", err
}
return filepath.Join(dir, u.asset, "update.json"), nil
}
func (u selfUpdater) loadState() updateState {
var st updateState
path, err := u.statePath()
if err != nil {
return st
}
b, err := os.ReadFile(path)
if err != nil {
return st
}
json.Unmarshal(b, &st) // a broken file counts as none
return st
}
func (u selfUpdater) saveState(st updateState) error {
path, err := u.statePath()
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
b, err := json.Marshal(st)
if err != nil {
return err
}
// By way of a file alongside, so that a simultaneous run never comes upon
// half a JSON.
tmp := path + ".new"
if err := os.WriteFile(tmp, b, 0o644); err != nil {
return err
}
if err := os.Rename(tmp, path); err != nil {
os.Remove(tmp)
return err
}
return nil
}
// ------------------------------------------------------------------ The work
// check only looks and touches nothing.
func (u selfUpdater) check(w io.Writer) error {
rel, err := u.latest()
if err != nil {
return err
}
if updateCompare(rel.TagName, u.current) <= 0 {
fmt.Fprintf(w, "%s %s is up to date\n", u.asset, u.current)
return nil
}
fmt.Fprintf(w, "%s %s is available, running %s\n %s\n run '%s --update' to install it\n",
u.asset, rel.TagName, u.current, rel.HTMLURL, u.asset)
return nil
}
// install fetches the newest release and replaces the running file with it.
func (u selfUpdater) install(w io.Writer) error {
rel, err := u.latest()
if err != nil {
return err
}
if updateCompare(rel.TagName, u.current) <= 0 {
fmt.Fprintf(w, "%s %s is up to date\n", u.asset, u.current)
return nil
}
want := fmt.Sprintf("%s-%s-%s", u.asset, runtime.GOOS, runtime.GOARCH)
if runtime.GOOS == "windows" {
want += ".exe" // build_releases.sh keeps the extension on the Windows asset
}
var src *updateAsset
for i := range rel.Assets {
if rel.Assets[i].Name == want {
src = &rel.Assets[i]
break
}
}
if src == nil {
names := make([]string, len(rel.Assets))
for i, a := range rel.Assets {
names[i] = a.Name
}
return fmt.Errorf("release %s has no %q (only %s)", rel.TagName, want, strings.Join(names, ", "))
}
exe, err := os.Executable()
if err != nil {
return fmt.Errorf("cannot locate the running binary: %w", err)
}
// An installed goca is often a symlink into ./bin. What should be replaced
// is the file behind it, not the link.
if real, err := filepath.EvalSymlinks(exe); err == nil {
exe = real
}
mode := os.FileMode(0o755)
if st, err := os.Stat(exe); err == nil {
mode = st.Mode().Perm()
}
fmt.Fprintf(w, "downloading %s %s (%s)\n", want, rel.TagName, updateSize(src.Size))
tmp, err := u.download(src, exe, mode)
if err != nil {
return err
}
defer os.Remove(tmp) // only bites when the renaming below falls through
if err := u.probe(tmp, rel.TagName); err != nil {
return err
}
if err := updateReplace(tmp, exe); err != nil {
return err
}
fmt.Fprintf(w, "%s %s → %s, at %s\n", u.asset, u.current, rel.TagName, exe)
return nil
}
func (u selfUpdater) download(a *updateAsset, exe string, mode os.FileMode) (string, error) {
// The new file comes into being next to the old one: same filesystem, so
// the renaming at the end is one atomic step and not half a copy. It also
// comes into being before the first byte — a missing write permission ought
// to show up before a few megabytes have gone down the wire.
dir := filepath.Dir(exe)
f, err := os.CreateTemp(dir, "."+filepath.Base(exe)+".new")
if err != nil {
var pe *os.PathError // the path is in the message already
if errors.As(err, &pe) {
err = pe.Err
}
return "", fmt.Errorf("cannot write to %s: %w", dir, err)
}
tmp := f.Name()
resp, err := updateGet(context.Background(), a.URL)
if err != nil {
f.Close()
os.Remove(tmp)
return "", err
}
defer resp.Body.Close()
n, err := io.Copy(f, resp.Body)
if cerr := f.Close(); err == nil {
err = cerr
}
if err == nil && a.Size > 0 && n != a.Size {
err = fmt.Errorf("got %d of %d bytes from %s", n, a.Size, a.URL)
}
if err == nil {
err = os.Chmod(tmp, mode)
}
if err != nil {
os.Remove(tmp)
return "", err
}
return tmp, nil
}
// probe calls the freshly fetched runner once. That catches a file that is
// truncated, built for the wrong platform, or not executable in the first
// place, before it replaces the running one.
func (u selfUpdater) probe(path, tag string) error {
if len(u.verify) == 0 {
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
out, err := exec.CommandContext(ctx, path, u.verify...).CombinedOutput()
if err != nil {
return fmt.Errorf("the downloaded binary does not run: %w", err)
}
if !strings.Contains(string(out), strings.TrimPrefix(tag, "v")) {
return fmt.Errorf("the downloaded binary reports %q, expected %s",
strings.TrimSpace(string(out)), tag)
}
return nil
}
// updateReplace swaps the running file for the new one.
func updateReplace(tmp, exe string) error {
if err := os.Rename(tmp, exe); err == nil {
return nil
}
// Unix overwrites the file of a running program without complaint, Windows
// does not: there the old one has to be got out of the way first. Deleting
// it becomes possible when this process ends at the earliest — so the
// tidying up is allowed to fail.
old := exe + ".old"
os.Remove(old)
if err := os.Rename(exe, old); err != nil {
return fmt.Errorf("cannot replace %s: %w", exe, err)
}
if err := os.Rename(tmp, exe); err != nil {
os.Rename(old, exe) // back to how it was
return fmt.Errorf("cannot replace %s: %w", exe, err)
}
os.Remove(old)
return nil
}
// -------------------------------------------------------------------- Gitea
type updateRelease struct {
TagName string `json:"tag_name"`
HTMLURL string `json:"html_url"`
Assets []updateAsset `json:"assets"`
}
type updateAsset struct {
Name string `json:"name"`
Size int64 `json:"size"`
URL string `json:"browser_download_url"`
}
func (u selfUpdater) latest() (updateRelease, error) {
base, err := u.apiBase()
if err != nil {
return updateRelease{}, err
}
// The question is a small one; if it hangs, it does not hang for long. The
// generous time limit of updateClient is meant for the download.
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
resp, err := updateGet(ctx, base+"/releases/latest")
if err != nil {
return updateRelease{}, err
}
defer resp.Body.Close()
var rel updateRelease
if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
return updateRelease{}, fmt.Errorf("unexpected answer from %s: %w", base, err)
}
if rel.TagName == "" {
return updateRelease{}, fmt.Errorf("%s has no releases", u.repo)
}
// Every question that succeeds fills the note — no matter whether it came
// from --update, from --check-update or from the background run.
u.saveState(updateState{Checked: time.Now(), Latest: rel.TagName})
return rel, nil
}
// apiBase turns https://host/owner/repo into the API root of the repo.
func (u selfUpdater) apiBase() (string, error) {
bad := fmt.Errorf("repo %q: expected https://host/owner/repo", u.repo)
ref, err := url.Parse(strings.TrimSuffix(strings.TrimSuffix(u.repo, "/"), ".git"))
if err != nil || ref.Host == "" {
return "", bad
}
parts := strings.Split(strings.Trim(ref.Path, "/"), "/")
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
return "", bad
}
return fmt.Sprintf("%s://%s/api/v1/repos/%s/%s", ref.Scheme, ref.Host, parts[0], parts[1]), nil
}
// One time limit for all of it: the look costs a few hundred milliseconds, the
// download a few megabytes — both may hang, but not forever.
//
// The certificate is verified, plainly, by the default transport. The copy of
// this file in dns talks to the same gitea and does not — that is a concession
// to machines there without a current trust store, and deliberately not made
// here: on a host that cannot build the chain, goca's update check fails loudly
// rather than accepting whatever binary the network hands it.
var updateClient = &http.Client{Timeout: 5 * time.Minute}
func updateGet(ctx context.Context, target string) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "selfupdate.go (+"+runtime.GOOS+"/"+runtime.GOARCH+")")
resp, err := updateClient.Do(req)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
return nil, fmt.Errorf("GET %s: %s", target, resp.Status)
}
return resp, nil
}
// ------------------------------------------------------------------ Numbers
// updateCompare compares two versions component by component, numerically, so
// that 1.1.10 lands behind 1.1.9 and not in front of it. A leading "v" does not
// count, missing places count as 0 (1.1 == 1.1.0), and a suffix on the number
// makes the version older, not newer (1.1.6-rc1 < 1.1.6). The result is the one
// of strings.Compare: -1, 0, 1.
func updateCompare(a, b string) int {
as := strings.Split(strings.TrimPrefix(a, "v"), ".")
bs := strings.Split(strings.TrimPrefix(b, "v"), ".")
for i := 0; i < len(as) || i < len(bs); i++ {
x, y := "0", "0"
if i < len(as) {
x = as[i]
}
if i < len(bs) {
y = bs[i]
}
if c := updateComparePart(x, y); c != 0 {
return c
}
}
return 0
}
func updateComparePart(a, b string) int {
na, ra := updateSplitNum(a)
nb, rb := updateSplitNum(b)
switch {
case na != nb:
if na < nb {
return -1
}
return 1
case ra == rb:
return 0
case ra == "": // 1.1.6 is finished, 1.1.6-rc1 is not yet
return 1
case rb == "":
return -1
}
return strings.Compare(ra, rb)
}
// updateSplitNum separates "10-rc1" into 10 and "-rc1".
func updateSplitNum(s string) (int, string) {
i := 0
for i < len(s) && s[i] >= '0' && s[i] <= '9' {
i++
}
n, _ := strconv.Atoi(s[:i])
return n, s[i:]
}
// updateSize is deliberately a small formatting of its own — the file is meant
// to stand on its own.
func updateSize(b int64) string {
const k = 1024
switch {
case b > k*k:
return fmt.Sprintf("%.1f MB", float64(b)/k/k)
case b > k:
return fmt.Sprintf("%.1f KB", float64(b)/k)
default:
return fmt.Sprintf("%d B", b)
}
}
-127
View File
@@ -1,127 +0,0 @@
package main
import (
"bufio"
"fmt"
"net/http"
"os"
"path/filepath"
"runtime"
"strings"
"github.com/AlecAivazis/survey/v2"
"github.com/AlecAivazis/survey/v2/terminal"
"github.com/Masterminds/semver/v3"
"github.com/fatih/color"
"github.com/minio/selfupdate"
)
var UPDATEURL = "http://gozilla.fhi.mpg.de/goca"
// Color function variables matching tools.go in dns
var Crb func(...interface{}) string = color.New(color.Bold, color.FgRed).SprintFunc()
var Cgb func(...interface{}) string = color.New(color.Bold, color.FgGreen).SprintFunc()
var Cwb func(...interface{}) string = color.New(color.Bold, color.FgWhite).SprintFunc()
func SF(format string, a ...any) string {
return fmt.Sprintf(format, a...)
}
func PE(msg ...string) (n int, err error) {
if len(msg) == 2 {
return fmt.Fprintf(os.Stdout, "%s: %s (%s)\n", Crb("ERROR"), Cwb(msg[0]), msg[1])
}
return fmt.Fprintf(os.Stdout, "%s: %s\n", Crb("ERROR"), Cwb(msg[0]))
}
func PO(msg ...string) (n int, err error) {
if len(msg) == 2 {
return fmt.Fprintf(os.Stdout, "%s: %s (%s)\n", Cgb("OK"), Cwb(msg[0]), msg[1])
}
return fmt.Fprintf(os.Stdout, "%s: %s\n", Cgb("OK"), Cwb(msg[0]))
}
func checkforupdate(URL string) { // ----------------------------------------------------- check for new version
prg := prgname()
resp, err := http.Get(URL + "/version.txt")
if err == nil {
scanner := bufio.NewScanner(resp.Body)
if scanner.Scan() {
lversion := strings.TrimSpace(scanner.Text())
sv_version, err := semver.NewVersion(Version)
if err == nil {
sv_lversion, err := semver.NewVersion(lversion)
if err == nil {
if sv_lversion.GreaterThan(sv_version) {
ans := Yesno(SF("new '%s' version found (%s -> %s), update now?",
prg, sv_version, sv_lversion), true, false)
if ans {
updateurl := SF("%s/%s_%s_%s_%s", URL, prg, lversion, runtime.GOOS, runtime.GOARCH)
if err := doupdate(updateurl); err != nil {
PE(SF("Update failed: %v\n", err))
os.Exit(1)
}
PO("Update successful!", "please run your last command again")
os.Exit(0)
}
}
}
}
}
resp.Body.Close()
}
}
func doupdate(url string) error { // ----------------------------------------------------------------- do update
resp, err := http.Get(url)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("server returned status: %v", resp.Status)
}
err = selfupdate.Apply(resp.Body, selfupdate.Options{})
if err != nil {
return err
}
return nil
}
func Yesno(msg string, def bool, overwrite bool) bool { // -------------------------- AlecAivazis/survey: yes/no
if overwrite {
return true
}
var err error
tmp := ""
if def {
err = survey.AskOne(&survey.Select{Message: msg, Options: []string{"Yes", "No"}}, &tmp)
} else {
err = survey.AskOne(&survey.Select{Message: msg, Options: []string{"No", "Yes"}}, &tmp)
}
if err != nil {
if err == terminal.InterruptErr {
fmt.Fprintln(os.Stdout, Crb("Interrupted."))
os.Exit(0)
}
}
if tmp == "Yes" {
return true
} else {
return false
}
}
func prgname() string { // ---------------------------------------------------------------- program name
exepath, err := os.Executable()
if err != nil {
PE(SF("Error getting executable path: %s", err))
return ""
}
exename := filepath.Base(exepath)
return exename
}
+4 -4
View File
@@ -1,6 +1,6 @@
package main
// Version is the current version of the application.
// It is automatically incremented by the build script.
var Version = "1.1.1"
// Version is a var, not a const, so build.sh can put the built number in via
// -ldflags "-X main.Version=…". The value here only ever shows up in a bare
// `go build`; the version actually built is the one in version.txt.
var Version = "1.1.2"
+1
View File
@@ -0,0 +1 @@
1.1.3