Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e06e99012 | ||
|
|
55418a1040 |
+1
-2
@@ -7,8 +7,7 @@
|
||||
.Spotlight-V100
|
||||
.TemporaryItems
|
||||
.Trashes
|
||||
|
||||
# Binaries
|
||||
goca
|
||||
dist/
|
||||
bin/
|
||||
.mgshrc
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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/!"
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
1.1.3
|
||||
Reference in New Issue
Block a user