Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e06e99012 | ||
|
|
55418a1040 |
+1
-2
@@ -7,8 +7,7 @@
|
|||||||
.Spotlight-V100
|
.Spotlight-V100
|
||||||
.TemporaryItems
|
.TemporaryItems
|
||||||
.Trashes
|
.Trashes
|
||||||
|
|
||||||
# Binaries
|
|
||||||
goca
|
goca
|
||||||
dist/
|
dist/
|
||||||
bin/
|
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
|
## 📦 Project Architecture
|
||||||
|
|
||||||
* [cli.go](file:///Users/mike/src/goca/cli.go) - Command loop, line-completion, history, and user input handler.
|
* [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
|
set -e
|
||||||
|
|
||||||
# Make sure we are in the script's directory
|
|
||||||
cd "$(dirname "$0")"
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
# Read version from version.go
|
PLATFORMS=${PLATFORMS:-"darwin/arm64 darwin/amd64 linux/amd64 linux/arm64 windows/amd64"}
|
||||||
VERSION_LINE=$(grep "var Version =" version.go)
|
|
||||||
CURRENT_VERSION=$(echo "$VERSION_LINE" | sed -E 's/.*"([^"]+)".*/\1/')
|
|
||||||
|
|
||||||
if [ -z "$CURRENT_VERSION" ]; then
|
if [ -n "$VERSION" ]; then
|
||||||
echo "❌ Error: Could not parse version from version.go"
|
NV="$VERSION"
|
||||||
exit 1
|
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
|
fi
|
||||||
|
|
||||||
# Split version by dot
|
mkdir -p bin
|
||||||
IFS='.' read -r -a VERSION_PARTS <<< "$CURRENT_VERSION"
|
HOST="$(go env GOOS)/$(go env GOARCH)"
|
||||||
MAJOR="${VERSION_PARTS[0]}"
|
|
||||||
MINOR="${VERSION_PARTS[1]}"
|
|
||||||
PATCH="${VERSION_PARTS[2]}"
|
|
||||||
|
|
||||||
# Increment patch version
|
for p in $PLATFORMS; do
|
||||||
NEW_PATCH=$((PATCH + 1))
|
os=${p%/*}
|
||||||
NEW_VERSION="$MAJOR.$MINOR.$NEW_PATCH"
|
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
|
if [ "$p" = "$HOST" ]; then
|
||||||
perl -pi -e "s/var Version = \"$CURRENT_VERSION\"/var Version = \"$NEW_VERSION\"/" version.go
|
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
|
echo "$NV" > version.txt
|
||||||
if [ -f README.md ]; then
|
echo "built goca v$NV"
|
||||||
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"
|
|
||||||
|
|||||||
@@ -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
|
go 1.26.1
|
||||||
|
|
||||||
require (
|
require (
|
||||||
aead.dev/minisign v0.2.0 // indirect
|
github.com/chzyer/readline v1.5.1
|
||||||
github.com/AlecAivazis/survey/v2 v2.3.7 // indirect
|
github.com/shopspring/decimal v1.4.0
|
||||||
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
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
require golang.org/x/sys v0.42.0 // indirect
|
||||||
|
|||||||
@@ -1,78 +1,11 @@
|
|||||||
aead.dev/minisign v0.2.0 h1:kAWrq/hBRu4AARY6AlciO83xhNnW9UaC8YipS2uhLPk=
|
github.com/chzyer/logex v1.2.1 h1:XHDu3E6q+gdHgsdTPH6ImJMIp436vR6MPtH8gP05QzM=
|
||||||
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/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ=
|
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 h1:upd/6fQk4src78LMRzh5vItIt361/o4uq553V8B5sGI=
|
||||||
github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObkaSkeBlk=
|
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/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 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
|
||||||
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
|
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-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 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
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(" base <C> Change base currency (e.g., base USD)")
|
||||||
fmt.Println(" clear Clear the terminal screen")
|
fmt.Println(" clear Clear the terminal screen")
|
||||||
fmt.Println(" exit/quit Exit goca")
|
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{}
|
type CalcCompleter struct{}
|
||||||
@@ -533,22 +539,55 @@ func handleLine(line string, useReadline bool, variablesFile string, functionsFi
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
// printUpdateHint puts the note of the daily look on stderr, dimmed, so that it
|
||||||
initUnits()
|
// 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
|
optY := false
|
||||||
newArgs := []string{os.Args[0]}
|
newArgs := []string{os.Args[0]}
|
||||||
for i := 1; i < len(os.Args); i++ {
|
for i := 1; i < len(os.Args); i++ {
|
||||||
if os.Args[i] == "-y" {
|
switch os.Args[i] {
|
||||||
|
case "-y":
|
||||||
optY = true
|
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])
|
newArgs = append(newArgs, os.Args[i])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
os.Args = newArgs
|
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 {
|
if !optY {
|
||||||
checkforupdate(UPDATEURL)
|
updateHint = selfUpdate.daily()
|
||||||
}
|
}
|
||||||
|
|
||||||
hasArgs := len(os.Args) > 1
|
hasArgs := len(os.Args) > 1
|
||||||
@@ -576,6 +615,7 @@ func main() {
|
|||||||
if arg == "-p" || arg == "--port" {
|
if arg == "-p" || arg == "--port" {
|
||||||
if i+1 < len(os.Args) {
|
if i+1 < len(os.Args) {
|
||||||
port := os.Args[i+1]
|
port := os.Args[i+1]
|
||||||
|
printUpdateHint(updateHint)
|
||||||
startWebServer(port)
|
startWebServer(port)
|
||||||
return
|
return
|
||||||
} else {
|
} else {
|
||||||
@@ -587,6 +627,7 @@ func main() {
|
|||||||
|
|
||||||
input := strings.Join(os.Args[1:], " ")
|
input := strings.Join(os.Args[1:], " ")
|
||||||
handleLine(input, false, variablesFile, functionsFile)
|
handleLine(input, false, variablesFile, functionsFile)
|
||||||
|
printUpdateHint(updateHint)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -614,6 +655,7 @@ func main() {
|
|||||||
|
|
||||||
if useReadline {
|
if useReadline {
|
||||||
fmt.Printf("\033[1;34mgoca v%s\033[0m, type 'help' for examples.\n", Version)
|
fmt.Printf("\033[1;34mgoca v%s\033[0m, type 'help' for examples.\n", Version)
|
||||||
|
printUpdateHint(updateHint)
|
||||||
}
|
}
|
||||||
|
|
||||||
var scanner *bufio.Scanner
|
var scanner *bufio.Scanner
|
||||||
@@ -639,4 +681,8 @@ func main() {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !useReadline { // piped input: the hint comes at the end, not before it
|
||||||
|
printUpdateHint(updateHint)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-11
@@ -1,5 +1,9 @@
|
|||||||
#!/bin/bash
|
#!/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
|
set -e
|
||||||
|
|
||||||
@@ -21,30 +25,30 @@ fi
|
|||||||
SIGNING_IDENTITY="Developer ID Application: Mike Wesemann ($TEAM_ID)"
|
SIGNING_IDENTITY="Developer ID Application: Mike Wesemann ($TEAM_ID)"
|
||||||
|
|
||||||
# Verify binaries exist
|
# Verify binaries exist
|
||||||
if [ ! -f "dist/goca-darwin-amd64" ] || [ ! -f "dist/goca-darwin-arm64" ]; then
|
if [ ! -f "bin/goca-darwin-amd64" ] || [ ! -f "bin/goca-darwin-arm64" ]; then
|
||||||
echo "❌ Error: Darwin binaries not found in 'dist/'. Run ./build_releases.sh first."
|
echo "❌ Error: Darwin binaries not found in 'bin/'. Run ./build.sh first."
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "🔐 Step 1: Codesigning Darwin binaries..."
|
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" bin/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-arm64
|
||||||
|
|
||||||
echo "📦 Step 2: Packaging binaries into ZIP archives..."
|
echo "📦 Step 2: Packaging binaries into ZIP archives..."
|
||||||
mkdir -p dist/notarize
|
mkdir -p bin/notarize
|
||||||
rm -f dist/notarize/*.zip
|
rm -f bin/notarize/*.zip
|
||||||
ditto -c -k --keepParent dist/goca-darwin-amd64 dist/notarize/goca-darwin-amd64.zip
|
ditto -c -k --keepParent bin/goca-darwin-amd64 bin/notarize/goca-darwin-amd64.zip
|
||||||
ditto -c -k --keepParent dist/goca-darwin-arm64 dist/notarize/goca-darwin-arm64.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..."
|
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" \
|
--apple-id "$APPLE_ID" \
|
||||||
--password "$APPLE_PASSWORD" \
|
--password "$APPLE_PASSWORD" \
|
||||||
--team-id "$TEAM_ID" \
|
--team-id "$TEAM_ID" \
|
||||||
--wait
|
--wait
|
||||||
|
|
||||||
echo "🚀 Step 4: Submitting macOS Apple Silicon binary (arm64) to Apple Notarization..."
|
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" \
|
--apple-id "$APPLE_ID" \
|
||||||
--password "$APPLE_PASSWORD" \
|
--password "$APPLE_PASSWORD" \
|
||||||
--team-id "$TEAM_ID" \
|
--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
|
package main
|
||||||
|
|
||||||
// Version is the current version of the application.
|
// Version is a var, not a const, so build.sh can put the built number in via
|
||||||
// It is automatically incremented by the build script.
|
// -ldflags "-X main.Version=…". The value here only ever shows up in a bare
|
||||||
var Version = "1.1.1"
|
// `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