From aada9afeacb31d2ce8ac6eb20c94ab267f9f3302 Mon Sep 17 00:00:00 2001 From: Robert Laszczak Date: Wed, 25 Mar 2026 12:50:35 +0100 Subject: [PATCH 1/6] added self update --- go.mod | 39 ++- go.sum | 84 +++++-- internal/confirm.go | 2 +- internal/selfupdate.go | 474 ++++++++++++++++++++++++++++++++++++ internal/selfupdate_test.go | 210 ++++++++++++++++ internal/update.go | 11 +- tdl/main.go | 28 ++- trainings/files/write.go | 2 +- 8 files changed, 807 insertions(+), 43 deletions(-) create mode 100644 internal/selfupdate.go create mode 100644 internal/selfupdate_test.go diff --git a/go.mod b/go.mod index ec9ffe1..a6c3807 100644 --- a/go.mod +++ b/go.mod @@ -1,12 +1,11 @@ module github.com/ThreeDotsLabs/cli -go 1.23.0 - -toolchain go1.24.0 +go 1.24.11 require ( github.com/BurntSushi/toml v0.4.1 - github.com/fatih/color v1.12.0 + github.com/creativeprojects/go-selfupdate v1.5.2 + github.com/fatih/color v1.16.0 github.com/golang/protobuf v1.5.4 github.com/google/uuid v1.6.0 github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3 @@ -17,19 +16,29 @@ require ( github.com/schollz/progressbar/v3 v3.19.0 github.com/sirupsen/logrus v1.8.1 github.com/spf13/afero v1.6.0 - github.com/stretchr/testify v1.10.0 + github.com/stretchr/testify v1.11.1 github.com/urfave/cli/v2 v2.3.0 - golang.org/x/crypto v0.38.0 + golang.org/x/crypto v0.46.0 google.golang.org/grpc v1.74.2 - google.golang.org/protobuf v1.36.6 + google.golang.org/protobuf v1.36.11 ) require ( + code.gitea.io/sdk/gitea v0.22.1 // indirect + github.com/42wim/httpsig v1.2.3 // indirect + github.com/Masterminds/semver/v3 v3.4.0 // indirect github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e // indirect github.com/cpuguy83/go-md2man/v2 v2.0.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/davidmz/go-pageant v1.0.2 // indirect + github.com/go-fed/httpsig v1.1.0 // indirect + github.com/google/go-github/v74 v74.0.0 // indirect + github.com/google/go-querystring v1.1.0 // indirect + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect + github.com/hashicorp/go-retryablehttp v0.7.8 // indirect + github.com/hashicorp/go-version v1.8.0 // indirect github.com/kr/pretty v0.3.0 // indirect - github.com/mattn/go-colorable v0.1.8 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect github.com/pmezard/go-difflib v1.0.0 // indirect @@ -37,11 +46,15 @@ require ( github.com/rogpeppe/go-internal v1.9.0 // indirect github.com/russross/blackfriday/v2 v2.0.1 // indirect github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect - golang.org/x/net v0.40.0 // indirect - golang.org/x/sys v0.33.0 // indirect - golang.org/x/term v0.32.0 // indirect - golang.org/x/text v0.25.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a // indirect + github.com/ulikunitz/xz v0.5.15 // indirect + gitlab.com/gitlab-org/api/client-go v1.9.1 // indirect + golang.org/x/net v0.47.0 // indirect + golang.org/x/oauth2 v0.34.0 // indirect + golang.org/x/sys v0.39.0 // indirect + golang.org/x/term v0.38.0 // indirect + golang.org/x/text v0.32.0 // indirect + golang.org/x/time v0.14.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250811230008-5f3141c8851a // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 4213ff6..69ee946 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,12 @@ +code.gitea.io/sdk/gitea v0.22.1 h1:7K05KjRORyTcTYULQ/AwvlVS6pawLcWyXZcTr7gHFyA= +code.gitea.io/sdk/gitea v0.22.1/go.mod h1:yyF5+GhljqvA30sRDreoyHILruNiy4ASufugzYg0VHM= +github.com/42wim/httpsig v1.2.3 h1:xb0YyWhkYj57SPtfSttIobJUPJZB9as1nsfo7KWVcEs= +github.com/42wim/httpsig v1.2.3/go.mod h1:nZq9OlYKDrUBhptd77IHx4/sZZD+IxTBADvAPI9G/EM= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/toml v0.4.1 h1:GaI7EiDXDRfa8VshkTj7Fym7ha+y8/XxIgD2okUIjLw= github.com/BurntSushi/toml v0.4.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= +github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= +github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/chengxilo/virtualterm v1.0.4 h1:Z6IpERbRVlfB8WkOmtbHiDbBANU7cimRIof7mk9/PwM= github.com/chengxilo/virtualterm v1.0.4/go.mod h1:DyxxBZz/x1iqJjFxTFcr6/x+jSpqN0iwWCOK1q10rlY= github.com/chzyer/logex v1.1.10 h1:Swpa1K6QvQznwJRcfTfQJmTE72DqScAa40E+fbHEXEE= @@ -13,23 +19,42 @@ github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:ma github.com/cpuguy83/go-md2man/v2 v2.0.0 h1:EoUDS0afbrsXAZ9YQ9jdu/mZ2sXgT1/2yyNng4PGlyM= github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/creativeprojects/go-selfupdate v1.5.2 h1:3KR3JLrq70oplb9yZzbmJ89qRP78D1AN/9u+l3k0LJ4= +github.com/creativeprojects/go-selfupdate v1.5.2/go.mod h1:BCOuwIl1dRRCmPNRPH0amULeZqayhKyY2mH/h4va7Dk= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/fatih/color v1.12.0 h1:mRhaKNwANqRgUBGKmnI5ZxEk7QXmjQeCcuYFMX2bfcc= -github.com/fatih/color v1.12.0/go.mod h1:ELkj/draVOlAH/xkhN6mQ50Qd0MPOk5AAr3maGEBuJM= +github.com/davidmz/go-pageant v1.0.2 h1:bPblRCh5jGU+Uptpz6LgMZGD5hJoOt7otgT454WvHn0= +github.com/davidmz/go-pageant v1.0.2/go.mod h1:P2EDDnMqIwG5Rrp05dTRITj9z2zpGcD9efWSkTNKLIE= +github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= +github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= +github.com/go-fed/httpsig v1.1.0 h1:9M+hb0jkEICD8/cAiNqEB66R87tTINszBRTjwjQzWcI= +github.com/go-fed/httpsig v1.1.0/go.mod h1:RCMrTZvN1bJYtofsG4rd5NaO5obxQ5xBkdiS7xsT7bM= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-github/v74 v74.0.0 h1:yZcddTUn8DPbj11GxnMrNiAnXH14gNs559AsUpNpPgM= +github.com/google/go-github/v74 v74.0.0/go.mod h1:ubn/YdyftV80VPSI26nSJvaEsTOnsjrxG3o9kJhcyak= +github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= +github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3 h1:B+8ClL/kCQkRiU82d9xajRPKYMrB7E0MbtzWVi1K4ns= github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3/go.mod h1:NbCUVmiS4foBGBHOYlCT25+YmGpJ32dZPi75pGEUpj4= +github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= +github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= +github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48= +github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw= +github.com/hashicorp/go-version v1.8.0 h1:KAkNb1HAiZd1ukkxDFGmokVZe1Xy9HG6NUp+bPle2i4= +github.com/hashicorp/go-version v1.8.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= @@ -43,9 +68,9 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/manifoldco/promptui v0.9.0 h1:3V4HzJk1TtXW1MTZMP7mdlwbBpIinw3HztaIlYthEiA= github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg= -github.com/mattn/go-colorable v0.1.8 h1:c1ghPdyEDarC70ftn0y+A/Ee++9zz8ljHG1b13eJ0s8= -github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= -github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 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/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= @@ -78,10 +103,14 @@ github.com/spf13/afero v1.6.0/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY= +github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= github.com/urfave/cli/v2 v2.3.0 h1:qph92Y649prgesehzOrQjdWyxFOp/QVM+6imKHad91M= github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI= +gitlab.com/gitlab-org/api/client-go v1.9.1 h1:tZm+URa36sVy8UCEHQyGGJ8COngV4YqMHpM6k9O5tK8= +gitlab.com/gitlab-org/api/client-go v1.9.1/go.mod h1:71yTJk1lnHCWcZLvM5kPAXzeJ2fn5GjaoV8gTOPd4ME= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= go.opentelemetry.io/otel v1.36.0 h1:UumtzIklRBY6cI/lllNZlALOF5nNIzJVb16APdvgTXg= @@ -96,33 +125,42 @@ go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKr go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= -golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= +golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= +golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY= -golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= +golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= +golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= -golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= -golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= +golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= 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.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= -golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= +golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a h1:v2PbRU4K3llS09c7zodFpNePeamkAwG3mPrAery9VeE= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250811230008-5f3141c8851a h1:tPE/Kp+x9dMSwUm/uM0JKK0IfdiJkwAbSMSeZBXXJXc= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250811230008-5f3141c8851a/go.mod h1:gw1tLEfykwDz2ET4a12jcXt4couGAm7IwsVaTy0Sflo= google.golang.org/grpc v1.74.2 h1:WoosgB65DlWVC9FqI82dGsZhWFNBSLjQ84bjROOpMu4= google.golang.org/grpc v1.74.2/go.mod h1:CtQ+BGjaAIXHs/5YS3i473GqwBBa1zGQNevxdeBEXrM= -google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= -google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= diff --git a/internal/confirm.go b/internal/confirm.go index 96f97da..851c9a0 100644 --- a/internal/confirm.go +++ b/internal/confirm.go @@ -116,7 +116,7 @@ func Prompt(actions Actions, stdin io.Reader, stdout io.Writer) rune { )) } - _, _ = fmt.Fprintf(stdout, "Press "+formatActionsMessage(actionsStr)+" ") + _, _ = fmt.Fprintf(stdout, "%s", "Press "+formatActionsMessage(actionsStr)+" ") for { char, _, err := in.ReadRune() diff --git a/internal/selfupdate.go b/internal/selfupdate.go new file mode 100644 index 0000000..4585599 --- /dev/null +++ b/internal/selfupdate.go @@ -0,0 +1,474 @@ +package internal + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "runtime/debug" + "strings" + + "github.com/creativeprojects/go-selfupdate" + "github.com/fatih/color" + "github.com/sirupsen/logrus" +) + +const ( + repoOwner = "ThreeDotsLabs" + repoName = "cli" +) + +// InstallMethod represents how the tdl binary was installed. +type InstallMethod int + +const ( + InstallMethodUnknown InstallMethod = iota + InstallMethodHomebrew + InstallMethodGoInstall + InstallMethodScoop + InstallMethodNix + InstallMethodDirectBinary +) + +func (m InstallMethod) String() string { + switch m { + case InstallMethodHomebrew: + return "Homebrew" + case InstallMethodGoInstall: + return "go install" + case InstallMethodScoop: + return "Scoop" + case InstallMethodNix: + return "Nix" + case InstallMethodDirectBinary: + return "direct binary" + default: + return "unknown" + } +} + +// UpdateOptions configures the update behavior. +type UpdateOptions struct { + SkipConfirm bool + TargetVersion string // e.g., "v1.2.3", "master", or "" for latest +} + +// DetectInstallMethod determines the installation method by examining the binary path. +func DetectInstallMethod() InstallMethod { + exePath, err := os.Executable() + if err != nil { + logrus.WithError(err).Debug("Cannot determine executable path") + return InstallMethodUnknown + } + + resolved, err := filepath.EvalSymlinks(exePath) + if err != nil { + logrus.WithError(err).Debug("Cannot resolve symlinks for executable path") + resolved = exePath + } + + home, _ := os.UserHomeDir() + + method := detectInstallMethodFromPath(resolved, os.Getenv("GOPATH"), os.Getenv("GOBIN"), home, runtime.GOOS) + logrus.WithFields(logrus.Fields{ + "binary_path": resolved, + "method": method.String(), + }).Debug("Detected install method") + return method +} + +// detectInstallMethodFromPath is the testable core of DetectInstallMethod. +func detectInstallMethodFromPath(resolvedPath, gopath, gobin, home, goos string) InstallMethod { + // Normalize all backslashes to forward slashes for consistent matching across platforms. + // filepath.ToSlash only converts the OS-native separator, so on macOS it won't + // convert Windows backslashes in test paths. We do a manual replace for robustness. + normalizedPath := strings.ReplaceAll(resolvedPath, "\\", "/") + lowerPath := strings.ToLower(normalizedPath) + + // Homebrew: check for /Cellar/ or /homebrew/ in the resolved path + if strings.Contains(lowerPath, "/cellar/") || strings.Contains(lowerPath, "/homebrew/") { + return InstallMethodHomebrew + } + // Linux Homebrew + if strings.Contains(lowerPath, "/linuxbrew/") { + return InstallMethodHomebrew + } + + // Nix: check for /nix/store/ in the path + if strings.Contains(lowerPath, "/nix/store/") { + return InstallMethodNix + } + + // Scoop (Windows): check for scoop/apps/ or scoop/shims/ in the path + if goos == "windows" { + if strings.Contains(lowerPath, "scoop/apps/") || strings.Contains(lowerPath, "scoop/shims/") { + return InstallMethodScoop + } + } + + // Go install: check if binary is in GOBIN, GOPATH/bin, or $HOME/go/bin + if gobin != "" { + gobinNorm := strings.ReplaceAll(gobin, "\\", "/") + if strings.HasPrefix(normalizedPath, gobinNorm+"/") { + return InstallMethodGoInstall + } + } + if gopath != "" { + gopathBin := strings.ReplaceAll(gopath, "\\", "/") + "/bin" + if strings.HasPrefix(normalizedPath, gopathBin+"/") { + return InstallMethodGoInstall + } + } + if home != "" { + defaultGoBin := strings.ReplaceAll(home, "\\", "/") + "/go/bin" + if strings.HasPrefix(normalizedPath, defaultGoBin+"/") { + return InstallMethodGoInstall + } + } + + return InstallMethodDirectBinary +} + +// canWriteBinary checks if the current process can write to the binary file. +func canWriteBinary(path string) bool { + f, err := os.OpenFile(path, os.O_WRONLY, 0) + if err != nil { + logrus.WithFields(logrus.Fields{"path": path, "error": err}).Debug("Binary is not writable") + return false + } + _ = f.Close() + logrus.WithField("path", path).Debug("Binary is writable") + return true +} + +// ResolveVersion returns the effective version, falling back to debug.ReadBuildInfo +// for go install builds where ldflags version is "dev". +func ResolveVersion(ldflagsVersion string) string { + if ldflagsVersion != "" && ldflagsVersion != "dev" { + logrus.WithField("version", ldflagsVersion).Debug("Using ldflags version") + return ldflagsVersion + } + if bi, ok := debug.ReadBuildInfo(); ok && bi.Main.Version != "" && bi.Main.Version != "(devel)" { + v := strings.TrimPrefix(bi.Main.Version, "v") + logrus.WithField("version", v).Debug("Using version from debug.ReadBuildInfo (go install build)") + return v + } + logrus.Debug("No version available (dev build)") + return "" +} + +func newUpdater() (*selfupdate.Updater, error) { + return selfupdate.NewUpdater(selfupdate.Config{ + Validator: &selfupdate.ChecksumValidator{UniqueFilename: "checksums.txt"}, + }) +} + +func repoSlug() selfupdate.RepositorySlug { + return selfupdate.NewRepositorySlug(repoOwner, repoName) +} + +// RunUpdate checks for updates and applies them based on the installation method. +func RunUpdate(ctx context.Context, currentVersion string, opts UpdateOptions) error { + if os.Getenv("TDL_NO_UPDATE_CHECK") != "" { + logrus.Debug("Update disabled via TDL_NO_UPDATE_CHECK") + fmt.Println("Update checks are disabled (TDL_NO_UPDATE_CHECK is set).") + return nil + } + + logrus.WithFields(logrus.Fields{ + "ldflags_version": currentVersion, + "target_version": opts.TargetVersion, + "skip_confirm": opts.SkipConfirm, + }).Debug("Starting update") + + effectiveVersion := ResolveVersion(currentVersion) + if effectiveVersion == "" { + fmt.Println("You are running a development build. Update is only available for released versions.") + fmt.Printf("Run %s to install from source.\n", SprintCommand("go install github.com/ThreeDotsLabs/cli/tdl@latest")) + return nil + } + + updater, err := newUpdater() + if err != nil { + return fmt.Errorf("failed to initialize updater: %w", err) + } + + method := DetectInstallMethod() + + // Detect the target release + var release *selfupdate.Release + var found bool + + if opts.TargetVersion != "" { + target := opts.TargetVersion + if !strings.HasPrefix(target, "v") { + target = "v" + target + } + release, found, err = updater.DetectVersion(ctx, repoSlug(), target) + if err != nil { + return fmt.Errorf("failed to check for version %s: %w", opts.TargetVersion, err) + } + if !found { + logrus.WithField("target", opts.TargetVersion).Debug("Version not found as release tag, trying as branch") + return handleBranchInstall(ctx, opts.TargetVersion, method, opts.SkipConfirm) + } + } else { + release, found, err = updater.DetectLatest(ctx, repoSlug()) + if err != nil { + return fmt.Errorf("failed to check for updates: %w", err) + } + if !found { + logrus.Debug("No releases found on GitHub") + fmt.Println("No releases found.") + return nil + } + logrus.WithField("latest", release.Version()).Debug("Latest release detected") + if release.LessOrEqual(effectiveVersion) { + fmt.Printf("You are already running the latest version (%s).\n", effectiveVersion) + return nil + } + } + + targetVersion := release.Version() + + // Show update info with release notes BEFORE confirmation + fmt.Printf("\nUpdate available: %s → %s\n", effectiveVersion, targetVersion) + + if notes := release.ReleaseNotes; notes != "" { + formatted := formatReleaseNotes(notes, 15) + if formatted != "" { + fmt.Println() + fmt.Println("Release notes:") + fmt.Println(formatted) + } + } + fmt.Println() + + logrus.WithField("method", method.String()).Debug("Updating via install method") + + // Branch on install method + switch method { + case InstallMethodHomebrew: + return updateViaCommand(ctx, "brew", opts, effectiveVersion, targetVersion, + []string{"upgrade", "tdl"}) + + case InstallMethodGoInstall: + ref := "latest" + if opts.TargetVersion != "" { + ref = "v" + strings.TrimPrefix(opts.TargetVersion, "v") + } + return updateViaCommand(ctx, "go", opts, effectiveVersion, targetVersion, + []string{"install", "github.com/ThreeDotsLabs/cli/tdl@" + ref}) + + case InstallMethodNix: + return updateViaCommand(ctx, "nix", opts, effectiveVersion, targetVersion, + []string{"profile", "upgrade", "--flake", "github:ThreeDotsLabs/cli"}) + + case InstallMethodScoop: + return updateViaCommand(ctx, "scoop", opts, effectiveVersion, targetVersion, + []string{"update", "tdl"}) + + case InstallMethodDirectBinary, InstallMethodUnknown: + return updateDirectBinary(ctx, updater, effectiveVersion, targetVersion, release, opts) + } + + return nil +} + +func updateViaCommand(ctx context.Context, tool string, opts UpdateOptions, currentVer, targetVer string, args []string) error { + fullCmd := tool + " " + strings.Join(args, " ") + + toolPath, err := exec.LookPath(tool) + if err != nil { + logrus.WithFields(logrus.Fields{"tool": tool, "error": err}).Debug("Tool not found in PATH") + fmt.Printf("Could not find %s in PATH. Please run manually:\n", tool) + fmt.Printf(" %s\n", color.CyanString(fullCmd)) + return nil + } + logrus.WithFields(logrus.Fields{"tool": tool, "path": toolPath}).Debug("Found tool in PATH") + fmt.Println(color.CyanString("••• ") + fullCmd) + + if !opts.SkipConfirm && IsStdinTerminal() { + if !ConfirmPromptDefaultYes("update") { + return nil + } + } + + cmd := exec.CommandContext(ctx, tool, args...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Stdin = os.Stdin + + if err := cmd.Run(); err != nil { + return fmt.Errorf("%s failed: %w", fullCmd, err) + } + + fmt.Println(color.GreenString("\nSuccessfully updated to %s.", targetVer)) + return nil +} + +func updateDirectBinary(ctx context.Context, updater *selfupdate.Updater, currentVer, targetVer string, release *selfupdate.Release, opts UpdateOptions) error { + binaryPath, err := os.Executable() + if err != nil { + return fmt.Errorf("cannot determine binary path: %w", err) + } + binaryPath, err = filepath.EvalSymlinks(binaryPath) + if err != nil { + return fmt.Errorf("cannot resolve binary path: %w", err) + } + logrus.WithField("binary_path", binaryPath).Debug("Resolved binary path for direct update") + + // Check writability BEFORE any confirmation + if !canWriteBinary(binaryPath) { + fmt.Printf("The binary at %s requires elevated permissions to update.\n\n", binaryPath) + + if runtime.GOOS == "windows" { + fmt.Println("Please re-open your terminal as Administrator and run:") + fmt.Println(" " + color.CyanString("tdl update")) + } else { + fmt.Println("Please run:") + fmt.Println(" " + color.CyanString("sudo tdl update")) + } + + fmt.Printf("\nOr download from: %s/releases/latest\n\n", repoURL) + + if IsStdinTerminal() { + fmt.Println("Press " + color.New(color.Bold).Sprint("ENTER") + " to exit.") + ConfirmPromptDefaultYes("exit") + } + + return nil + } + + fmt.Printf("Updating tdl: %s → %s (%s)\n", currentVer, targetVer, binaryPath) + + if !opts.SkipConfirm && IsStdinTerminal() { + if !ConfirmPromptDefaultYes("update") { + return nil + } + } + + if err := updater.UpdateTo(ctx, release, binaryPath); err != nil { + return fmt.Errorf("update failed: %w", err) + } + + fmt.Println(color.GreenString("\nSuccessfully updated to %s.", targetVer)) + return nil +} + +// handleBranchInstall handles the case where --version is a branch name (not a release tag). +func handleBranchInstall(ctx context.Context, branch string, method InstallMethod, skipConfirm bool) error { + logrus.WithFields(logrus.Fields{"branch": branch, "method": method.String()}).Debug("Handling branch install") + + switch method { + case InstallMethodGoInstall: + return updateViaCommand(ctx, "go", UpdateOptions{SkipConfirm: skipConfirm}, "", branch, + []string{"install", "github.com/ThreeDotsLabs/cli/tdl@" + branch}) + + case InstallMethodNix: + return updateViaCommand(ctx, "nix", UpdateOptions{SkipConfirm: skipConfirm}, "", branch, + []string{"profile", "install", "github:ThreeDotsLabs/cli/" + branch}) + + default: + fmt.Printf("'%s' is not a release tag. Only tagged releases are available for %s installs.\n", branch, method) + fmt.Printf("To install from a branch, use:\n") + fmt.Printf(" %s\n", SprintCommand("go install github.com/ThreeDotsLabs/cli/tdl@"+branch)) + return nil + } +} + +// formatReleaseNotes prepares release notes for terminal display. +func formatReleaseNotes(body string, maxLines int) string { + if strings.TrimSpace(body) == "" { + return "" + } + + // Light markdown stripping + lines := strings.Split(body, "\n") + var cleaned []string + for _, line := range lines { + // Strip markdown headers + line = stripMarkdownHeader(line) + // Strip bold markers + line = strings.ReplaceAll(line, "**", "") + // Strip links: [text](url) → text + line = stripMarkdownLinks(line) + cleaned = append(cleaned, line) + } + + // Trim leading/trailing blank lines + cleaned = trimBlankLines(cleaned) + + if len(cleaned) == 0 { + return "" + } + + truncated := false + if len(cleaned) > maxLines { + cleaned = cleaned[:maxLines] + truncated = true + } + + var result strings.Builder + for _, line := range cleaned { + result.WriteString(color.HiBlackString(" " + line)) + result.WriteString("\n") + } + if truncated { + result.WriteString(color.HiBlackString(fmt.Sprintf(" ... see full release notes at %s/releases", repoURL))) + result.WriteString("\n") + } + + return strings.TrimRight(result.String(), "\n") +} + +func stripMarkdownHeader(line string) string { + trimmed := strings.TrimLeft(line, " ") + if strings.HasPrefix(trimmed, "# ") { + return strings.TrimPrefix(trimmed, "# ") + } + if strings.HasPrefix(trimmed, "## ") { + return strings.TrimPrefix(trimmed, "## ") + } + if strings.HasPrefix(trimmed, "### ") { + return strings.TrimPrefix(trimmed, "### ") + } + return line +} + +func stripMarkdownLinks(line string) string { + result := line + for { + start := strings.Index(result, "[") + if start == -1 { + break + } + mid := strings.Index(result[start:], "](") + if mid == -1 { + break + } + mid += start + end := strings.Index(result[mid:], ")") + if end == -1 { + break + } + end += mid + text := result[start+1 : mid] + result = result[:start] + text + result[end+1:] + } + return result +} + +func trimBlankLines(lines []string) []string { + // Trim leading blank lines + for len(lines) > 0 && strings.TrimSpace(lines[0]) == "" { + lines = lines[1:] + } + // Trim trailing blank lines + for len(lines) > 0 && strings.TrimSpace(lines[len(lines)-1]) == "" { + lines = lines[:len(lines)-1] + } + return lines +} diff --git a/internal/selfupdate_test.go b/internal/selfupdate_test.go new file mode 100644 index 0000000..d73f108 --- /dev/null +++ b/internal/selfupdate_test.go @@ -0,0 +1,210 @@ +package internal + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestDetectInstallMethodFromPath(t *testing.T) { + tests := []struct { + name string + resolvedPath string + gopath string + gobin string + home string + goos string + expected InstallMethod + }{ + { + name: "Homebrew Apple Silicon", + resolvedPath: "/opt/homebrew/Cellar/tdl/1.0.0/bin/tdl", + home: "/Users/bob", + goos: "darwin", + expected: InstallMethodHomebrew, + }, + { + name: "Homebrew Intel macOS", + resolvedPath: "/usr/local/Cellar/tdl/1.0.0/bin/tdl", + home: "/Users/bob", + goos: "darwin", + expected: InstallMethodHomebrew, + }, + { + name: "Homebrew Linux", + resolvedPath: "/home/linuxbrew/.linuxbrew/Cellar/tdl/1.0.0/bin/tdl", + home: "/home/bob", + goos: "linux", + expected: InstallMethodHomebrew, + }, + { + name: "Go install default GOPATH", + resolvedPath: "/Users/bob/go/bin/tdl", + home: "/Users/bob", + goos: "darwin", + expected: InstallMethodGoInstall, + }, + { + name: "Go install custom GOPATH", + resolvedPath: "/home/bob/go/bin/tdl", + gopath: "/home/bob/go", + home: "/home/bob", + goos: "linux", + expected: InstallMethodGoInstall, + }, + { + name: "Go install custom GOBIN", + resolvedPath: "/custom/gobin/tdl", + gobin: "/custom/gobin", + home: "/home/bob", + goos: "linux", + expected: InstallMethodGoInstall, + }, + { + name: "Scoop apps Windows", + resolvedPath: `C:\Users\bob\scoop\apps\tdl\current\tdl.exe`, + home: `C:\Users\bob`, + goos: "windows", + expected: InstallMethodScoop, + }, + { + name: "Scoop shims Windows", + resolvedPath: `C:\Users\bob\scoop\shims\tdl.exe`, + home: `C:\Users\bob`, + goos: "windows", + expected: InstallMethodScoop, + }, + { + name: "Nix store", + resolvedPath: "/nix/store/abc123-tdl-1.2.3/bin/tdl", + home: "/home/bob", + goos: "linux", + expected: InstallMethodNix, + }, + { + name: "Direct binary /usr/local/bin", + resolvedPath: "/usr/local/bin/tdl", + home: "/Users/bob", + goos: "darwin", + expected: InstallMethodDirectBinary, + }, + { + name: "Direct binary Windows home", + resolvedPath: `C:\Users\bob\ThreeDotsLabs\bin\tdl.exe`, + home: `C:\Users\bob`, + goos: "windows", + expected: InstallMethodDirectBinary, + }, + { + name: "Direct binary random path", + resolvedPath: "/some/random/path/tdl", + home: "/home/bob", + goos: "linux", + expected: InstallMethodDirectBinary, + }, + { + name: "Scoop path on non-Windows is not Scoop", + resolvedPath: "/home/bob/scoop/apps/tdl/tdl", + home: "/home/bob", + goos: "linux", + expected: InstallMethodDirectBinary, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := detectInstallMethodFromPath(tt.resolvedPath, tt.gopath, tt.gobin, tt.home, tt.goos) + assert.Equal(t, tt.expected, result, "expected %s, got %s", tt.expected, result) + }) + } +} + +func TestInstallMethodString(t *testing.T) { + assert.Equal(t, "Homebrew", InstallMethodHomebrew.String()) + assert.Equal(t, "go install", InstallMethodGoInstall.String()) + assert.Equal(t, "Scoop", InstallMethodScoop.String()) + assert.Equal(t, "Nix", InstallMethodNix.String()) + assert.Equal(t, "direct binary", InstallMethodDirectBinary.String()) + assert.Equal(t, "unknown", InstallMethodUnknown.String()) +} + +func TestCanWriteBinary(t *testing.T) { + t.Run("writable file", func(t *testing.T) { + f, err := os.CreateTemp(t.TempDir(), "tdl-test-*") + assert.NoError(t, err) + _ = f.Close() + assert.True(t, canWriteBinary(f.Name())) + }) + + t.Run("read-only file", func(t *testing.T) { + f, err := os.CreateTemp(t.TempDir(), "tdl-test-*") + assert.NoError(t, err) + _ = f.Close() + err = os.Chmod(f.Name(), 0444) + assert.NoError(t, err) + assert.False(t, canWriteBinary(f.Name())) + }) + + t.Run("non-existent file", func(t *testing.T) { + assert.False(t, canWriteBinary(filepath.Join(t.TempDir(), "does-not-exist"))) + }) +} + +func TestFormatReleaseNotes(t *testing.T) { + t.Run("empty body", func(t *testing.T) { + assert.Equal(t, "", formatReleaseNotes("", 15)) + assert.Equal(t, "", formatReleaseNotes(" \n\n ", 15)) + }) + + t.Run("short body under limit", func(t *testing.T) { + body := "- Fixed bug in exercise reset\n- Added module skipping" + result := formatReleaseNotes(body, 15) + assert.Contains(t, result, "Fixed bug in exercise reset") + assert.Contains(t, result, "Added module skipping") + assert.NotContains(t, result, "see full release notes") + }) + + t.Run("long body truncated", func(t *testing.T) { + var lines []string + for i := 0; i < 20; i++ { + lines = append(lines, "- Change number "+string(rune('A'+i))) + } + body := strings.Join(lines, "\n") + result := formatReleaseNotes(body, 5) + assert.Contains(t, result, "Change number A") + assert.Contains(t, result, "see full release notes") + assert.NotContains(t, result, "Change number F") + }) + + t.Run("strips markdown headers", func(t *testing.T) { + body := "## What's Changed\n- Bug fix" + result := formatReleaseNotes(body, 15) + assert.Contains(t, result, "What's Changed") + assert.NotContains(t, result, "##") + }) + + t.Run("strips bold markers", func(t *testing.T) { + body := "**Important**: This is a breaking change" + result := formatReleaseNotes(body, 15) + assert.Contains(t, result, "Important") + assert.NotContains(t, result, "**") + }) + + t.Run("strips markdown links", func(t *testing.T) { + body := "See [the docs](https://example.com) for details" + result := formatReleaseNotes(body, 15) + assert.Contains(t, result, "the docs") + assert.NotContains(t, result, "https://example.com") + assert.NotContains(t, result, "[") + }) + + t.Run("trims leading and trailing blank lines", func(t *testing.T) { + body := "\n\n- First line\n- Second line\n\n" + result := formatReleaseNotes(body, 15) + assert.Contains(t, result, "First line") + assert.Contains(t, result, "Second line") + }) +} diff --git a/internal/update.go b/internal/update.go index 4584bc6..a946d8d 100644 --- a/internal/update.go +++ b/internal/update.go @@ -11,6 +11,7 @@ import ( "time" "github.com/fatih/color" + "github.com/sirupsen/logrus" ) type releaseResponse struct { @@ -21,7 +22,13 @@ const repoURL = "https://github.com/ThreeDotsLabs/cli" const releasesURL = "https://api.github.com/repos/ThreeDotsLabs/cli/releases/latest" func CheckForUpdate(currentVersion string) { - if currentVersion == "" || currentVersion == "dev" { + if os.Getenv("TDL_NO_UPDATE_CHECK") != "" { + logrus.Debug("Update check disabled via TDL_NO_UPDATE_CHECK") + return + } + + currentVersion = ResolveVersion(currentVersion) + if currentVersion == "" { return } @@ -59,7 +66,7 @@ func printVersionNotice(currentVersion string, availableVersion string) { c := color.New(color.FgHiYellow) _, _ = c.Printf("A new version of the CLI is available: %s (current: %s)\n", availableVersion, currentVersion) _, _ = c.Printf("Some features may be missing or not work correctly. Please update soon!\n") - _, _ = c.Printf("See instructions at: %v\n", repoURL) + _, _ = c.Printf("Run %s to update, or see: %v/releases\n", SprintCommand("tdl update"), repoURL) fmt.Println() } diff --git a/tdl/main.go b/tdl/main.go index 01da91c..15d26c3 100644 --- a/tdl/main.go +++ b/tdl/main.go @@ -95,9 +95,9 @@ var app = &cli.App{ if errors.As(err, &userFacingErr) { separator := color.HiBlackString(strings.Repeat("─", internal.TerminalWidth())) fmt.Println(separator) - fmt.Printf(color.RedString("ERROR: ") + userFacingErr.Msg + "\n") + fmt.Println(color.RedString("ERROR: ") + userFacingErr.Msg) fmt.Println(separator) - fmt.Printf(color.GreenString("\nHow to solve: \n") + userFacingErr.SolutionHint + "\n") + fmt.Println(color.GreenString("\nHow to solve: \n") + userFacingErr.SolutionHint) os.Exit(1) return } @@ -366,6 +366,28 @@ Note: after completing this exercise, the next exercise will be the last one you return nil }, }, + { + Name: "update", + Aliases: []string{"u"}, + Usage: "Update tdl to the latest version", + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "yes", + Aliases: []string{"y"}, + Usage: "skip confirmation prompt", + }, + &cli.StringFlag{ + Name: "version", + Usage: "update to a specific version or branch (e.g., v1.2.3, master)", + }, + }, + Action: func(c *cli.Context) error { + return internal.RunUpdate(c.Context, version, internal.UpdateOptions{ + SkipConfirm: c.Bool("yes"), + TargetVersion: c.String("version"), + }) + }, + }, }, } @@ -420,7 +442,7 @@ func formatUnexpectedError(err error) { separator := color.HiBlackString(strings.Repeat("─", internal.TerminalWidth())) fmt.Println(separator) - fmt.Printf(color.RedString("ERROR: ") + err.Error() + "\n") + fmt.Println(color.RedString("ERROR: ") + err.Error()) fmt.Println(separator) var st stackTracer diff --git a/trainings/files/write.go b/trainings/files/write.go index d982172..14555dc 100644 --- a/trainings/files/write.go +++ b/trainings/files/write.go @@ -427,6 +427,6 @@ func printFilesList(files []fileItem) { textColor = color.New(color.FgWhite) } - fmt.Printf("%s %s\n", textColor.Sprintf(sign), textColor.Sprintf(file.Path)) + fmt.Printf("%s %s\n", textColor.Sprint(sign), textColor.Sprint(file.Path)) } } From 6fdbdf4aa86f01ca71d920305f09fc2238353967 Mon Sep 17 00:00:00 2001 From: Robert Laszczak Date: Wed, 25 Mar 2026 13:09:16 +0100 Subject: [PATCH 2/6] refactor: simplify version handling in self-update logic --- internal/selfupdate.go | 38 ++++++++++---------------------------- internal/update.go | 1 - tdl/main.go | 6 ++++++ 3 files changed, 16 insertions(+), 29 deletions(-) diff --git a/internal/selfupdate.go b/internal/selfupdate.go index 4585599..da23209 100644 --- a/internal/selfupdate.go +++ b/internal/selfupdate.go @@ -7,7 +7,6 @@ import ( "os/exec" "path/filepath" "runtime" - "runtime/debug" "strings" "github.com/creativeprojects/go-selfupdate" @@ -143,22 +142,6 @@ func canWriteBinary(path string) bool { return true } -// ResolveVersion returns the effective version, falling back to debug.ReadBuildInfo -// for go install builds where ldflags version is "dev". -func ResolveVersion(ldflagsVersion string) string { - if ldflagsVersion != "" && ldflagsVersion != "dev" { - logrus.WithField("version", ldflagsVersion).Debug("Using ldflags version") - return ldflagsVersion - } - if bi, ok := debug.ReadBuildInfo(); ok && bi.Main.Version != "" && bi.Main.Version != "(devel)" { - v := strings.TrimPrefix(bi.Main.Version, "v") - logrus.WithField("version", v).Debug("Using version from debug.ReadBuildInfo (go install build)") - return v - } - logrus.Debug("No version available (dev build)") - return "" -} - func newUpdater() (*selfupdate.Updater, error) { return selfupdate.NewUpdater(selfupdate.Config{ Validator: &selfupdate.ChecksumValidator{UniqueFilename: "checksums.txt"}, @@ -178,13 +161,12 @@ func RunUpdate(ctx context.Context, currentVersion string, opts UpdateOptions) e } logrus.WithFields(logrus.Fields{ - "ldflags_version": currentVersion, + "current_version": currentVersion, "target_version": opts.TargetVersion, "skip_confirm": opts.SkipConfirm, }).Debug("Starting update") - effectiveVersion := ResolveVersion(currentVersion) - if effectiveVersion == "" { + if currentVersion == "" { fmt.Println("You are running a development build. Update is only available for released versions.") fmt.Printf("Run %s to install from source.\n", SprintCommand("go install github.com/ThreeDotsLabs/cli/tdl@latest")) return nil @@ -225,8 +207,8 @@ func RunUpdate(ctx context.Context, currentVersion string, opts UpdateOptions) e return nil } logrus.WithField("latest", release.Version()).Debug("Latest release detected") - if release.LessOrEqual(effectiveVersion) { - fmt.Printf("You are already running the latest version (%s).\n", effectiveVersion) + if release.LessOrEqual(currentVersion) { + fmt.Printf("You are already running the latest version (%s).\n", currentVersion) return nil } } @@ -234,7 +216,7 @@ func RunUpdate(ctx context.Context, currentVersion string, opts UpdateOptions) e targetVersion := release.Version() // Show update info with release notes BEFORE confirmation - fmt.Printf("\nUpdate available: %s → %s\n", effectiveVersion, targetVersion) + fmt.Printf("\nUpdate available: %s → %s\n", currentVersion, targetVersion) if notes := release.ReleaseNotes; notes != "" { formatted := formatReleaseNotes(notes, 15) @@ -251,7 +233,7 @@ func RunUpdate(ctx context.Context, currentVersion string, opts UpdateOptions) e // Branch on install method switch method { case InstallMethodHomebrew: - return updateViaCommand(ctx, "brew", opts, effectiveVersion, targetVersion, + return updateViaCommand(ctx, "brew", opts, currentVersion, targetVersion, []string{"upgrade", "tdl"}) case InstallMethodGoInstall: @@ -259,19 +241,19 @@ func RunUpdate(ctx context.Context, currentVersion string, opts UpdateOptions) e if opts.TargetVersion != "" { ref = "v" + strings.TrimPrefix(opts.TargetVersion, "v") } - return updateViaCommand(ctx, "go", opts, effectiveVersion, targetVersion, + return updateViaCommand(ctx, "go", opts, currentVersion, targetVersion, []string{"install", "github.com/ThreeDotsLabs/cli/tdl@" + ref}) case InstallMethodNix: - return updateViaCommand(ctx, "nix", opts, effectiveVersion, targetVersion, + return updateViaCommand(ctx, "nix", opts, currentVersion, targetVersion, []string{"profile", "upgrade", "--flake", "github:ThreeDotsLabs/cli"}) case InstallMethodScoop: - return updateViaCommand(ctx, "scoop", opts, effectiveVersion, targetVersion, + return updateViaCommand(ctx, "scoop", opts, currentVersion, targetVersion, []string{"update", "tdl"}) case InstallMethodDirectBinary, InstallMethodUnknown: - return updateDirectBinary(ctx, updater, effectiveVersion, targetVersion, release, opts) + return updateDirectBinary(ctx, updater, currentVersion, targetVersion, release, opts) } return nil diff --git a/internal/update.go b/internal/update.go index a946d8d..790e5bf 100644 --- a/internal/update.go +++ b/internal/update.go @@ -27,7 +27,6 @@ func CheckForUpdate(currentVersion string) { return } - currentVersion = ResolveVersion(currentVersion) if currentVersion == "" { return } diff --git a/tdl/main.go b/tdl/main.go index 15d26c3..ddafcf6 100644 --- a/tdl/main.go +++ b/tdl/main.go @@ -46,6 +46,12 @@ func main() { } }() + if version == "" || version == "dev" { + if bi, ok := debug.ReadBuildInfo(); ok && bi.Main.Version != "" && bi.Main.Version != "(devel)" { + version = strings.TrimPrefix(bi.Main.Version, "v") + } + } + ctx, cancel := context.WithCancel(context.Background()) defer cancel() From 6baa65768ab9c0e7a97ff6c8b13f2257620db7f2 Mon Sep 17 00:00:00 2001 From: Robert Laszczak Date: Wed, 25 Mar 2026 13:34:27 +0100 Subject: [PATCH 3/6] fix: improve update instructions for elevated permissions --- internal/selfupdate.go | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/internal/selfupdate.go b/internal/selfupdate.go index da23209..d1762e9 100644 --- a/internal/selfupdate.go +++ b/internal/selfupdate.go @@ -306,20 +306,16 @@ func updateDirectBinary(ctx context.Context, updater *selfupdate.Updater, curren if !canWriteBinary(binaryPath) { fmt.Printf("The binary at %s requires elevated permissions to update.\n\n", binaryPath) + cmdName := os.Args[0] if runtime.GOOS == "windows" { fmt.Println("Please re-open your terminal as Administrator and run:") - fmt.Println(" " + color.CyanString("tdl update")) + fmt.Println(" " + color.CyanString("%s update", cmdName)) } else { fmt.Println("Please run:") - fmt.Println(" " + color.CyanString("sudo tdl update")) + fmt.Println(" " + color.CyanString("sudo %s update", cmdName)) } - fmt.Printf("\nOr download from: %s/releases/latest\n\n", repoURL) - - if IsStdinTerminal() { - fmt.Println("Press " + color.New(color.Bold).Sprint("ENTER") + " to exit.") - ConfirmPromptDefaultYes("exit") - } + fmt.Printf("\nOr download from: %s/releases/latest\n", repoURL) return nil } From 03563dc85c0b9804697f7778d9ffb3c70929872b Mon Sep 17 00:00:00 2001 From: Robert Laszczak Date: Wed, 25 Mar 2026 13:36:27 +0100 Subject: [PATCH 4/6] fix: update command reference in update message --- internal/update.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/update.go b/internal/update.go index 790e5bf..843fc75 100644 --- a/internal/update.go +++ b/internal/update.go @@ -65,7 +65,7 @@ func printVersionNotice(currentVersion string, availableVersion string) { c := color.New(color.FgHiYellow) _, _ = c.Printf("A new version of the CLI is available: %s (current: %s)\n", availableVersion, currentVersion) _, _ = c.Printf("Some features may be missing or not work correctly. Please update soon!\n") - _, _ = c.Printf("Run %s to update, or see: %v/releases\n", SprintCommand("tdl update"), repoURL) + _, _ = c.Printf("Run %s to update, or see: %v/releases\n", SprintCommand(os.Args[0]+" update"), repoURL) fmt.Println() } From 90f5a52ed08a651e718cf5f4577ad525643fc509 Mon Sep 17 00:00:00 2001 From: Robert Laszczak Date: Wed, 25 Mar 2026 20:02:37 +0100 Subject: [PATCH 5/6] feat: enhance update prompt with release notes and dismissal logic --- internal/update.go | 199 +++++++++++++++++++++++++++++++++++++--- internal/update_test.go | 108 ++++++++++++++++++++++ tdl/main.go | 15 ++- 3 files changed, 307 insertions(+), 15 deletions(-) create mode 100644 internal/update_test.go diff --git a/internal/update.go b/internal/update.go index 843fc75..ef6c2e0 100644 --- a/internal/update.go +++ b/internal/update.go @@ -7,6 +7,8 @@ import ( "net/http" "os" "path" + "path/filepath" + "runtime" "strings" "time" @@ -16,12 +18,20 @@ import ( type releaseResponse struct { TagName string `json:"tag_name"` + Body string `json:"body"` } const repoURL = "https://github.com/ThreeDotsLabs/cli" const releasesURL = "https://api.github.com/repos/ThreeDotsLabs/cli/releases/latest" +const updateCheckInterval = 30 * time.Minute +const dismissalDuration = 30 * time.Minute -func CheckForUpdate(currentVersion string) { +type latestRelease struct { + Version string + ReleaseNotes string +} + +func CheckForUpdate(currentVersion string, commandName string, forcePrompt bool) { if os.Getenv("TDL_NO_UPDATE_CHECK") != "" { logrus.Debug("Update check disabled via TDL_NO_UPDATE_CHECK") return @@ -31,34 +41,184 @@ func CheckForUpdate(currentVersion string) { return } + isUpdateCommand := commandName == "update" || commandName == "u" + updateInfo, _ := getUpdateInfo() + // Fast path: cached update available for this version — no API call needed if updateInfo.UpdateAvailable && updateInfo.CurrentVersion == currentVersion { - printVersionNotice(updateInfo.CurrentVersion, updateInfo.AvailableVersion) + showUpdatePromptOrNotice(updateInfo, currentVersion, isUpdateCommand, forcePrompt) return } - if time.Since(updateInfo.LastChecked) < time.Hour { + // Fast path: check interval not elapsed — return immediately + if !forcePrompt && time.Since(updateInfo.LastChecked) < updateCheckInterval { return } - latestVersion := getLatestVersion() + release := getLatestRelease() + if release == nil { + return + } - if latestVersion != "" && latestVersion != currentVersion { + if release.Version != "" && release.Version != currentVersion { updateInfo.CurrentVersion = currentVersion - updateInfo.AvailableVersion = latestVersion + updateInfo.AvailableVersion = release.Version updateInfo.UpdateAvailable = true + updateInfo.ReleaseNotes = release.ReleaseNotes + + updateInfo.LastChecked = time.Now() + _ = storeUpdateInfo(updateInfo) - printVersionNotice(currentVersion, latestVersion) + showUpdatePromptOrNotice(updateInfo, currentVersion, isUpdateCommand, forcePrompt) } else { updateInfo.CurrentVersion = currentVersion updateInfo.AvailableVersion = "" updateInfo.UpdateAvailable = false + updateInfo.ReleaseNotes = "" + // Clear stale dismissal since there's no pending update + updateInfo.DismissedVersion = "" + updateInfo.DismissedAt = time.Time{} + + updateInfo.LastChecked = time.Now() + _ = storeUpdateInfo(updateInfo) } +} - updateInfo.LastChecked = time.Now() +func showUpdatePromptOrNotice(updateInfo UpdateInfo, currentVersion string, isUpdateCommand bool, forcePrompt bool) { + // If user is running "tdl update", skip — they're already updating + if isUpdateCommand { + return + } - _ = storeUpdateInfo(updateInfo) + // Non-interactive terminal (CI, piped stdin) — passive notice only + if !IsStdinTerminal() { + printVersionNotice(currentVersion, updateInfo.AvailableVersion) + return + } + + if forcePrompt || shouldShowBlockingPrompt(updateInfo) { + showBlockingUpdatePrompt(updateInfo, currentVersion) + } else { + printVersionNotice(currentVersion, updateInfo.AvailableVersion) + } +} + +func shouldShowBlockingPrompt(info UpdateInfo) bool { + // Never dismissed — show prompt + if info.DismissedVersion == "" { + return true + } + + // Dismissed a different version — new release, re-prompt + if info.DismissedVersion != info.AvailableVersion { + return true + } + + // Dismissed same version — only re-prompt after dismissal duration + return time.Since(info.DismissedAt) > dismissalDuration +} + +func showBlockingUpdatePrompt(updateInfo UpdateInfo, currentVersion string) { + c := color.New(color.FgHiYellow) + _, _ = c.Printf("A new version of the CLI is available: %s \u2192 %s\n", currentVersion, updateInfo.AvailableVersion) + _, _ = c.Printf("Some features may be missing or not work correctly.\n") + + if updateInfo.ReleaseNotes != "" { + formatted := formatReleaseNotes(updateInfo.ReleaseNotes, 15) + if formatted != "" { + fmt.Println() + fmt.Println("Release notes:") + fmt.Println(formatted) + } + } + fmt.Println() + + method := DetectInstallMethod() + + // Check if binary requires elevated permissions (direct binary install) + if method == InstallMethodDirectBinary || method == InstallMethodUnknown { + binaryPath, err := os.Executable() + if err == nil { + binaryPath, _ = filepath.EvalSymlinks(binaryPath) + } + if err != nil || !canWriteBinary(binaryPath) { + cmdName := os.Args[0] + if runtime.GOOS == "windows" { + fmt.Println("The binary requires elevated permissions to update.") + fmt.Println("Please re-open your terminal as Administrator and run:") + fmt.Println(" " + SprintCommand(fmt.Sprintf("%s update", cmdName))) + } else { + fmt.Printf("The binary at %s requires elevated permissions to update.\n", binaryPath) + fmt.Println("Please run:") + fmt.Println(" " + SprintCommand(fmt.Sprintf("sudo %s update", cmdName))) + } + fmt.Printf("\nOr download from: %s/releases/latest\n", repoURL) + + // Store dismissal so this doesn't block every invocation + updateInfo.DismissedVersion = updateInfo.AvailableVersion + updateInfo.DismissedAt = time.Now() + _ = storeUpdateInfo(updateInfo) + + os.Exit(0) + return + } + } + + hint := updateCommandHint(method) + action := "update now" + if hint != "" { + action = fmt.Sprintf("run %s", SprintCommand(hint)) + } + + result := Prompt( + Actions{ + {Shortcut: '\n', Action: action, ShortcutAliases: []rune{'\r'}}, + {Shortcut: 's', Action: "skip"}, + }, + os.Stdin, + os.Stdout, + ) + + if result == 's' { + // User declined — record dismissal + updateInfo.DismissedVersion = updateInfo.AvailableVersion + updateInfo.DismissedAt = time.Now() + _ = storeUpdateInfo(updateInfo) + fmt.Println() + return + } + + // User pressed ENTER — run update with SkipConfirm (no double confirmation) + fmt.Println() + ctx := context.Background() + err := RunUpdate(ctx, currentVersion, UpdateOptions{SkipConfirm: true}) + if err != nil { + fmt.Println(color.RedString("Update failed: %v", err)) + fmt.Println(color.HiBlackString("Continuing with current version...")) + fmt.Println() + return + } + + // Update succeeded — binary is replaced, must exit + fmt.Println() + fmt.Println("Please re-run your command.") + os.Exit(0) +} + +func updateCommandHint(method InstallMethod) string { + switch method { + case InstallMethodHomebrew: + return "brew upgrade tdl" + case InstallMethodGoInstall: + return "go install github.com/ThreeDotsLabs/cli/tdl@latest" + case InstallMethodNix: + return "nix profile upgrade --flake github:ThreeDotsLabs/cli" + case InstallMethodScoop: + return "scoop update tdl" + default: + return "" + } } func printVersionNotice(currentVersion string, availableVersion string) { @@ -69,18 +229,18 @@ func printVersionNotice(currentVersion string, availableVersion string) { fmt.Println() } -func getLatestVersion() string { +func getLatestRelease() *latestRelease { ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() req, err := http.NewRequestWithContext(ctx, http.MethodGet, releasesURL, nil) if err != nil { - return "" + return nil } resp, err := http.DefaultClient.Do(req) if err != nil { - return "" + return nil } defer func() { _ = resp.Body.Close() @@ -88,10 +248,18 @@ func getLatestVersion() string { var release releaseResponse if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { - return "" + return nil } - return strings.TrimLeft(release.TagName, "v") + version := strings.TrimLeft(release.TagName, "v") + if version == "" { + return nil + } + + return &latestRelease{ + Version: version, + ReleaseNotes: release.Body, + } } func updateInfoPath() string { @@ -103,6 +271,9 @@ type UpdateInfo struct { AvailableVersion string `json:"available_version"` UpdateAvailable bool `json:"update_available"` LastChecked time.Time `json:"last_checked"` + ReleaseNotes string `json:"release_notes,omitempty"` + DismissedVersion string `json:"dismissed_version,omitempty"` + DismissedAt time.Time `json:"dismissed_at,omitempty"` } func getUpdateInfo() (UpdateInfo, error) { diff --git a/internal/update_test.go b/internal/update_test.go new file mode 100644 index 0000000..3eee5b5 --- /dev/null +++ b/internal/update_test.go @@ -0,0 +1,108 @@ +package internal + +import ( + "encoding/json" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestShouldShowBlockingPrompt(t *testing.T) { + t.Run("never dismissed", func(t *testing.T) { + info := UpdateInfo{ + AvailableVersion: "1.2.0", + } + assert.True(t, shouldShowBlockingPrompt(info)) + }) + + t.Run("dismissed different version", func(t *testing.T) { + info := UpdateInfo{ + AvailableVersion: "1.3.0", + DismissedVersion: "1.2.0", + DismissedAt: time.Now(), + } + assert.True(t, shouldShowBlockingPrompt(info)) + }) + + t.Run("dismissed same version recently", func(t *testing.T) { + info := UpdateInfo{ + AvailableVersion: "1.2.0", + DismissedVersion: "1.2.0", + DismissedAt: time.Now().Add(-10 * time.Minute), + } + assert.False(t, shouldShowBlockingPrompt(info)) + }) + + t.Run("dismissed same version expired", func(t *testing.T) { + info := UpdateInfo{ + AvailableVersion: "1.2.0", + DismissedVersion: "1.2.0", + DismissedAt: time.Now().Add(-31 * time.Minute), + } + assert.True(t, shouldShowBlockingPrompt(info)) + }) +} + +func TestUpdateCommandHint(t *testing.T) { + tests := []struct { + method InstallMethod + expected string + }{ + {InstallMethodHomebrew, "brew upgrade tdl"}, + {InstallMethodGoInstall, "go install github.com/ThreeDotsLabs/cli/tdl@latest"}, + {InstallMethodNix, "nix profile upgrade --flake github:ThreeDotsLabs/cli"}, + {InstallMethodScoop, "scoop update tdl"}, + {InstallMethodDirectBinary, ""}, + {InstallMethodUnknown, ""}, + } + + for _, tt := range tests { + t.Run(tt.method.String(), func(t *testing.T) { + assert.Equal(t, tt.expected, updateCommandHint(tt.method)) + }) + } +} + +func TestUpdateInfoBackwardCompatibility(t *testing.T) { + t.Run("old JSON without new fields deserializes cleanly", func(t *testing.T) { + oldJSON := `{"current_version":"1.0.0","available_version":"1.1.0","update_available":true,"last_checked":"2025-01-01T00:00:00Z"}` + var info UpdateInfo + err := json.Unmarshal([]byte(oldJSON), &info) + require.NoError(t, err) + + assert.Equal(t, "1.0.0", info.CurrentVersion) + assert.Equal(t, "1.1.0", info.AvailableVersion) + assert.True(t, info.UpdateAvailable) + // New fields default to zero values + assert.Empty(t, info.ReleaseNotes) + assert.Empty(t, info.DismissedVersion) + assert.True(t, info.DismissedAt.IsZero()) + }) + + t.Run("round trip with all fields", func(t *testing.T) { + now := time.Now().Truncate(time.Second) + info := UpdateInfo{ + CurrentVersion: "1.0.0", + AvailableVersion: "1.1.0", + UpdateAvailable: true, + LastChecked: now, + ReleaseNotes: "- bug fix", + DismissedVersion: "1.1.0", + DismissedAt: now, + } + data, err := json.Marshal(info) + require.NoError(t, err) + + var decoded UpdateInfo + err = json.Unmarshal(data, &decoded) + require.NoError(t, err) + + assert.Equal(t, info.CurrentVersion, decoded.CurrentVersion) + assert.Equal(t, info.AvailableVersion, decoded.AvailableVersion) + assert.Equal(t, info.ReleaseNotes, decoded.ReleaseNotes) + assert.Equal(t, info.DismissedVersion, decoded.DismissedVersion) + assert.True(t, info.DismissedAt.Equal(decoded.DismissedAt)) + }) +} diff --git a/tdl/main.go b/tdl/main.go index ddafcf6..817acd0 100644 --- a/tdl/main.go +++ b/tdl/main.go @@ -120,6 +120,11 @@ var app = &cli.App{ Aliases: []string{"v"}, EnvVars: []string{"VERBOSE"}, }, + &cli.BoolFlag{ + Name: "force-update-prompt", + Usage: "force the update prompt to appear (for testing)", + Hidden: true, + }, }, Before: func(c *cli.Context) error { if verbose := c.Bool("verbose"); verbose { @@ -129,7 +134,15 @@ var app = &cli.App{ logrus.SetLevel(logrus.WarnLevel) } - internal.CheckForUpdate(version) + commandName := "" + for _, arg := range os.Args[1:] { + if !strings.HasPrefix(arg, "-") { + commandName = arg + break + } + } + + internal.CheckForUpdate(version, commandName, c.Bool("force-update-prompt")) return nil }, From 5ab110adb4c0a3b38f86914c4df310cc12aa5691 Mon Sep 17 00:00:00 2001 From: Robert Laszczak Date: Wed, 25 Mar 2026 21:00:38 +0100 Subject: [PATCH 6/6] improve update prompt logic --- go.mod | 2 +- internal/selfupdate.go | 25 ++++++++++++--------- internal/update.go | 51 +++++++++++++++++++++++++++++++++--------- 3 files changed, 56 insertions(+), 22 deletions(-) diff --git a/go.mod b/go.mod index a6c3807..cb11c09 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.24.11 require ( github.com/BurntSushi/toml v0.4.1 + github.com/Masterminds/semver/v3 v3.4.0 github.com/creativeprojects/go-selfupdate v1.5.2 github.com/fatih/color v1.16.0 github.com/golang/protobuf v1.5.4 @@ -26,7 +27,6 @@ require ( require ( code.gitea.io/sdk/gitea v0.22.1 // indirect github.com/42wim/httpsig v1.2.3 // indirect - github.com/Masterminds/semver/v3 v3.4.0 // indirect github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e // indirect github.com/cpuguy83/go-md2man/v2 v2.0.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect diff --git a/internal/selfupdate.go b/internal/selfupdate.go index d1762e9..c94527f 100644 --- a/internal/selfupdate.go +++ b/internal/selfupdate.go @@ -52,6 +52,7 @@ func (m InstallMethod) String() string { type UpdateOptions struct { SkipConfirm bool TargetVersion string // e.g., "v1.2.3", "master", or "" for latest + ForceUpdate bool // skip "already on latest" check } // DetectInstallMethod determines the installation method by examining the binary path. @@ -207,7 +208,7 @@ func RunUpdate(ctx context.Context, currentVersion string, opts UpdateOptions) e return nil } logrus.WithField("latest", release.Version()).Debug("Latest release detected") - if release.LessOrEqual(currentVersion) { + if !opts.ForceUpdate && release.LessOrEqual(currentVersion) { fmt.Printf("You are already running the latest version (%s).\n", currentVersion) return nil } @@ -215,18 +216,20 @@ func RunUpdate(ctx context.Context, currentVersion string, opts UpdateOptions) e targetVersion := release.Version() - // Show update info with release notes BEFORE confirmation - fmt.Printf("\nUpdate available: %s → %s\n", currentVersion, targetVersion) - - if notes := release.ReleaseNotes; notes != "" { - formatted := formatReleaseNotes(notes, 15) - if formatted != "" { - fmt.Println() - fmt.Println("Release notes:") - fmt.Println(formatted) + // Show update info with release notes BEFORE confirmation (skip if caller already showed it) + if !opts.SkipConfirm { + fmt.Printf("\nUpdate available: %s → %s\n", currentVersion, targetVersion) + + if notes := release.ReleaseNotes; notes != "" { + formatted := formatReleaseNotes(notes, 15) + if formatted != "" { + fmt.Println() + fmt.Println("Release notes:") + fmt.Println(formatted) + } } + fmt.Println() } - fmt.Println() logrus.WithField("method", method.String()).Debug("Updating via install method") diff --git a/internal/update.go b/internal/update.go index ef6c2e0..2c6edb1 100644 --- a/internal/update.go +++ b/internal/update.go @@ -12,6 +12,7 @@ import ( "strings" "time" + semver "github.com/Masterminds/semver/v3" "github.com/fatih/color" "github.com/sirupsen/logrus" ) @@ -45,8 +46,8 @@ func CheckForUpdate(currentVersion string, commandName string, forcePrompt bool) updateInfo, _ := getUpdateInfo() - // Fast path: cached update available for this version — no API call needed - if updateInfo.UpdateAvailable && updateInfo.CurrentVersion == currentVersion { + // Fast path: cached update available — no API call needed + if updateInfo.UpdateAvailable && isNewerVersion(updateInfo.AvailableVersion, currentVersion) { showUpdatePromptOrNotice(updateInfo, currentVersion, isUpdateCommand, forcePrompt) return } @@ -61,7 +62,10 @@ func CheckForUpdate(currentVersion string, commandName string, forcePrompt bool) return } - if release.Version != "" && release.Version != currentVersion { + isNewer := release.Version != "" && isNewerVersion(release.Version, currentVersion) + isDifferent := release.Version != "" && release.Version != currentVersion + + if isNewer || (forcePrompt && isDifferent) { updateInfo.CurrentVersion = currentVersion updateInfo.AvailableVersion = release.Version updateInfo.UpdateAvailable = true @@ -144,23 +148,38 @@ func showBlockingUpdatePrompt(updateInfo UpdateInfo, currentVersion string) { } if err != nil || !canWriteBinary(binaryPath) { cmdName := os.Args[0] + var updateCmd string if runtime.GOOS == "windows" { + updateCmd = fmt.Sprintf("%s update", cmdName) fmt.Println("The binary requires elevated permissions to update.") - fmt.Println("Please re-open your terminal as Administrator and run:") - fmt.Println(" " + SprintCommand(fmt.Sprintf("%s update", cmdName))) + fmt.Println("To update, re-open your terminal as Administrator and run:") } else { + updateCmd = fmt.Sprintf("sudo %s update", cmdName) fmt.Printf("The binary at %s requires elevated permissions to update.\n", binaryPath) - fmt.Println("Please run:") - fmt.Println(" " + SprintCommand(fmt.Sprintf("sudo %s update", cmdName))) + fmt.Println("To update, run:") } + fmt.Println(" " + SprintCommand(updateCmd)) fmt.Printf("\nOr download from: %s/releases/latest\n", repoURL) + fmt.Println() + + result := Prompt( + Actions{ + {Shortcut: '\n', Action: "exit", ShortcutAliases: []rune{'\r'}}, + {Shortcut: 's', Action: "skip and continue"}, + }, + os.Stdin, + os.Stdout, + ) - // Store dismissal so this doesn't block every invocation + // Store dismissal regardless of choice updateInfo.DismissedVersion = updateInfo.AvailableVersion updateInfo.DismissedAt = time.Now() _ = storeUpdateInfo(updateInfo) - os.Exit(0) + if result == '\n' { + os.Exit(0) + } + fmt.Println() return } } @@ -192,7 +211,7 @@ func showBlockingUpdatePrompt(updateInfo UpdateInfo, currentVersion string) { // User pressed ENTER — run update with SkipConfirm (no double confirmation) fmt.Println() ctx := context.Background() - err := RunUpdate(ctx, currentVersion, UpdateOptions{SkipConfirm: true}) + err := RunUpdate(ctx, currentVersion, UpdateOptions{SkipConfirm: true, ForceUpdate: true}) if err != nil { fmt.Println(color.RedString("Update failed: %v", err)) fmt.Println(color.HiBlackString("Continuing with current version...")) @@ -307,6 +326,18 @@ func storeUpdateInfo(info UpdateInfo) error { return nil } +func isNewerVersion(latest, current string) bool { + latestV, err := semver.NewVersion(latest) + if err != nil { + return latest != current + } + currentV, err := semver.NewVersion(current) + if err != nil { + return latest != current + } + return latestV.GreaterThan(currentV) +} + func fileExists(path string) bool { _, err := os.Stat(path) if err == nil {