diff --git a/go.mod b/go.mod index 5c61439..40e05da 100644 --- a/go.mod +++ b/go.mod @@ -8,8 +8,10 @@ require ( github.com/carabiner-dev/collector v0.3.7 github.com/carabiner-dev/hasher v0.2.4 github.com/carabiner-dev/policy v0.5.1 + github.com/charmbracelet/huh v1.0.0 github.com/fatih/color v1.19.0 github.com/google/go-github/v60 v60.0.0 + github.com/mattn/go-isatty v0.0.22 github.com/rodaine/table v1.3.1 github.com/sirupsen/logrus v1.9.4 github.com/spf13/cobra v1.10.2 @@ -29,7 +31,9 @@ require ( github.com/anchore/go-struct-converter v0.1.0 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect + github.com/atotto/clipboard v0.1.4 // indirect github.com/avast/retry-go/v4 v4.7.0 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/blang/semver v3.5.1+incompatible // indirect github.com/blang/semver/v4 v4.0.0 // indirect github.com/carabiner-dev/command v0.3.1 // indirect @@ -42,8 +46,17 @@ require ( github.com/carabiner-dev/sbomfs v0.1.0 // indirect github.com/carabiner-dev/signer v0.5.2 // indirect github.com/carabiner-dev/vcslocator v0.4.4 // indirect + github.com/catppuccin/go v0.3.0 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect + github.com/charmbracelet/bubbletea v1.3.6 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/lipgloss v1.1.0 // indirect + github.com/charmbracelet/x/ansi v0.9.3 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13 // indirect + github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect github.com/clipperhouse/displaywidth v0.11.0 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/cloudflare/circl v1.6.3 // indirect @@ -57,7 +70,9 @@ require ( github.com/docker/cli v29.4.3+incompatible // indirect github.com/docker/docker-credential-helpers v0.9.5 // indirect github.com/docker/libtrust v0.0.0-20160708172513-aabc10ec26b7 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect github.com/emirpasic/gods v1.18.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/github/smimesign v0.2.0 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/go-git/go-billy/v5 v5.9.0 // indirect @@ -109,9 +124,14 @@ require ( github.com/kevinburke/ssh_config v1.6.0 // indirect github.com/klauspost/compress v1.18.6 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-colorable v0.1.15 // indirect - github.com/mattn/go-isatty v0.0.22 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.24 // indirect + github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect github.com/nozzle/throttler v0.0.0-20180817012639-2ea982251481 // indirect github.com/oklog/ulid/v2 v2.1.1 // indirect github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 // indirect @@ -130,6 +150,7 @@ require ( github.com/protobom/cel v0.1.0 // indirect github.com/protobom/protobom v0.5.6 // indirect github.com/regclient/regclient v0.11.5 // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/sassoftware/relic v7.2.1+incompatible // indirect github.com/secure-systems-lab/go-securesystemslib v0.11.0 // indirect github.com/sergi/go-diff v1.4.0 // indirect @@ -151,6 +172,7 @@ require ( github.com/transparency-dev/merkle v0.0.2 // indirect github.com/ulikunitz/xz v0.5.15 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/otel v1.44.0 // indirect diff --git a/go.sum b/go.sum index 89245ad..077b479 100644 --- a/go.sum +++ b/go.sum @@ -39,6 +39,8 @@ github.com/AzureAD/microsoft-authentication-library-for-go v1.7.0 h1:4iB+IesclUX github.com/AzureAD/microsoft-authentication-library-for-go v1.7.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk= github.com/CycloneDX/cyclonedx-go v0.11.0 h1:GokP8FiRC+foiuwWhSSLpSD5H4hSWtGnR3wo7apkBFI= github.com/CycloneDX/cyclonedx-go v0.11.0/go.mod h1:vUvbCXQsEm48OI6oOlanxstwNByXjCZ2wuleUlwGEO8= +github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= +github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= 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/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= @@ -56,6 +58,8 @@ github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPd github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/avast/retry-go/v4 v4.7.0 h1:yjDs35SlGvKwRNSykujfjdMxMhMQQM0TnIjJaHB+Zio= github.com/avast/retry-go/v4 v4.7.0/go.mod h1:ZMPDa3sY2bKgpLtap9JRUgk2yTAba7cgiFhqxY2Sg6Q= github.com/aws/aws-sdk-go-v2 v1.41.7 h1:DWpAJt66FmnnaRIOT/8ASTucrvuDPZASqhhLey6tLY8= @@ -88,6 +92,10 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 h1:F/M5Y9I3nwr2IEpshZgh1GeHpOIt github.com/aws/aws-sdk-go-v2/service/sts v1.42.1/go.mod h1:mTNxImtovCOEEuD65mKW7DCsL+2gjEH+RPEAexAzAio= github.com/aws/smithy-go v1.25.1 h1:J8ERsGSU7d+aCmdQur5Txg6bVoYelvQJgtZehD12GkI= github.com/aws/smithy-go v1.25.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= +github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ= github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= @@ -124,6 +132,8 @@ github.com/carabiner-dev/signer v0.5.2 h1:0mWCaekAinluwm/gxLlX3Btrof2r04v68dO1kU github.com/carabiner-dev/signer v0.5.2/go.mod h1:QSdF3/d+MqKehGQMw8NYSVa1vIm4cZ32bJ4smcV8PTw= github.com/carabiner-dev/vcslocator v0.4.4 h1:5uzb2yKfslMHY9RkkpUW28jLx2iVX93Al/GjSvG/2Ok= github.com/carabiner-dev/vcslocator v0.4.4/go.mod h1:qfYEs44nf9Fm/kiN120rTgruJn7PoHQyLXWQ9aO+SwE= +github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= +github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= @@ -131,6 +141,34 @@ github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F9 github.com/certifi/gocertifi v0.0.0-20180118203423-deb3ae2ef261/go.mod h1:GJKEexRPVJrBSOjoqN5VNOIKJ5Q3RViH6eu3puDRwx4= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws= +github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw= +github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU= +github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/huh v1.0.0 h1:wOnedH8G4qzJbmhftTqrpppyqHakl/zbbNdXIWJyIxw= +github.com/charmbracelet/huh v1.0.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0= +github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= +github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U= +github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI= +github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4= github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= @@ -144,6 +182,8 @@ github.com/common-nighthawk/go-figure v0.0.0-20210622060536-734e95fb86be/go.mod github.com/coreos/go-oidc/v3 v3.18.0 h1:V9orjXynvu5wiC9SemFTWnG4F45v403aIcjWo0d41+A= github.com/coreos/go-oidc/v3 v3.18.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467 h1:uX1JmpONuD549D73r6cgnxyUu18Zb7yHAy5AYU0Pm4Q= github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467/go.mod h1:uzvlm1mxhHkdfqitSA92i7Se+S9ksOn3a3qmv/kyOCw= github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE= @@ -165,10 +205,14 @@ github.com/docker/docker-credential-helpers v0.9.5 h1:EFNN8DHvaiK8zVqFA2DT6BjXE0 github.com/docker/docker-credential-helpers v0.9.5/go.mod h1:v1S+hepowrQXITkEfw6o4+BMbGot02wiKpzWhGUZK6c= github.com/docker/libtrust v0.0.0-20160708172513-aabc10ec26b7 h1:UhxFibDNY/bfvqU5CAUmr9zpesgbU6SWc8/B4mflAE4= github.com/docker/libtrust v0.0.0-20160708172513-aabc10ec26b7/go.mod h1:cyGadeNEkKy96OOhEzfZl+yxihPEzKnqJwvfuSUqbZE= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o= github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= 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/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= @@ -350,16 +394,28 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0 github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/letsencrypt/boulder v0.20260309.0 h1:kZynrxK3QfqLGx6hhoz+Rfs3hgltJs1p9Mp+4+VwnY0= github.com/letsencrypt/boulder v0.20260309.0/go.mod h1:yG8lj8pNPZ8taq3oNdTpfBS+eC74IaEuiewqzVpXiWE= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY= github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU= github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= +github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c h1:cqn374mizHuIWj+OSJCajGr/phAmuMug9qIX3l9CflE= github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/natefinch/atomic v1.0.1 h1:ZPYKxkqQOx3KZ+RsbnP/YsgvxWQPGxjC0oBt2AhwV0A= github.com/natefinch/atomic v1.0.1/go.mod h1:N/D/ELrljoqDyT3rZrsUmtsuzvHkeB/wWjHV22AZRbM= github.com/nozzle/throttler v0.0.0-20180817012639-2ea982251481 h1:Up6+btDp321ZG5/zdSLo48H9Iaq0UQGthrhWC6pCxzE= @@ -406,6 +462,8 @@ github.com/protobom/protobom v0.5.6 h1:X8NzX9PzSUdNM/0wfeq+WMbblfc6hngIU0kaFUlX4 github.com/protobom/protobom v0.5.6/go.mod h1:0qUbAUOKKg/m1RLibtom+IFXkiBz/x1MqxpWbDL3lQw= github.com/regclient/regclient v0.11.5 h1:OHRsXO0F3qHGfa4HEUv+EkMH9NXNcCTBKjNzyC/UhIA= github.com/regclient/regclient v0.11.5/go.mod h1:DZUOfIT14WFTK2Pj4vjd93avy9O4Fdpjrf9ir23TbRE= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rodaine/table v1.3.1 h1:jBVgg1bEu5EzEdYSrwUUlQpayDtkvtTmgFS0FPAxOq8= github.com/rodaine/table v1.3.1/go.mod h1:VYCJRCHa2DpD25uFALcB6hi5ECF3eEJQVhCXRjHgXc4= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= @@ -499,6 +557,8 @@ github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHo github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= github.com/ysmood/fetchup v0.2.3 h1:ulX+SonA0Vma5zUFXtv52Kzip/xe7aj4vqT5AJwQ+ZQ= @@ -578,6 +638,7 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/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-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= diff --git a/internal/cmd/install.go b/internal/cmd/install.go index 7e1f51f..4f3d6b5 100644 --- a/internal/cmd/install.go +++ b/internal/cmd/install.go @@ -6,14 +6,30 @@ package cmd import ( "errors" "fmt" + "os" + "strings" + "github.com/charmbracelet/huh" + "github.com/mattn/go-isatty" "github.com/spf13/cobra" + + "github.com/carabiner-dev/drop/internal/notifier" + "github.com/carabiner-dev/drop/pkg/drop" + "github.com/carabiner-dev/drop/pkg/github" ) type installOptions struct { - AppUrl string + AppUrl string + PolicyRepo string + InstallType string + Timeout int + Quiet bool + Insecure bool + BinDir string } +var installTypes = []string{string(drop.ArtifactBinary), string(drop.ArtifactPackage)} + // Validates the options in context with arguments func (io *installOptions) Validate() error { errs := []error{} @@ -21,6 +37,22 @@ func (io *installOptions) Validate() error { errs = append(errs, errors.New("app url not set")) } + if io.Timeout == 0 { + errs = append(errs, errors.New("timeout must be larger than zero")) + } + + switch io.InstallType { + case "", "b", "p", string(drop.ArtifactBinary), string(drop.ArtifactPackage): + case "a", "archive": + errs = append(errs, errors.New("archives cannot be installed, use \"drop get\" to download them")) + default: + errs = append(errs, fmt.Errorf("invalid install type, valid types are %v", installTypes)) + } + + if io.BinDir == "" { + errs = append(errs, errors.New("binary directory cannot be empty")) + } + return errors.Join(errs...) } @@ -29,12 +61,59 @@ func (io *installOptions) AddFlags(cmd *cobra.Command) { cmd.PersistentFlags().StringVarP( &io.AppUrl, "app", "a", "", "app to install", ) + + cmd.PersistentFlags().StringVar( + &io.PolicyRepo, "policy-repo", "", "alternative repository to use as policy source", + ) + + cmd.PersistentFlags().IntVar( + &io.Timeout, "timeout", 900, "timeout (in seconds) to timeout downloads", + ) + + cmd.PersistentFlags().BoolVarP( + &io.Quiet, "quiet", "q", false, "less verbose output (for scripts, etc)", + ) + + cmd.PersistentFlags().BoolVar( + &io.Insecure, "insecure", false, "skip security verification (not recommended)", + ) + + cmd.PersistentFlags().StringVarP( + &io.InstallType, "type", "t", "", fmt.Sprintf("artifact type to install (%v)", installTypes), + ) + + cmd.PersistentFlags().StringVar( + &io.BinDir, "bin-dir", "/usr/local/bin", "directory to install binaries into", + ) } func addInstall(parentCmd *cobra.Command) { opts := &installOptions{} attCmd := &cobra.Command{ - Short: "installs a binary or package after verifying it", + Short: "installs a binary or package after verifying it", + Long: fmt.Sprintf(` +%s + +The %s subcommand downloads an app from a GitHub release, verifies it +and installs it in the local system. + +After verifying the artifact, %s picks the best way to install the +app: if the release only publishes a binary for the local platform, it gets +installed into the binaries directory (--bin-dir, /usr/local/bin by default). +If the release only ships a package matching the system's package format +(rpm, deb, apk), drop installs it using the package manager. + +When both a binary and a package are available, %s first checks if +the app is already installed as a package (to keep it managed by the package +manager) and otherwise asks which one to install. Use --type to force a +choice without prompting: + + drop install --type=package github.com/org/repo + +Installing to system locations usually requires elevated privileges: drop +shells out to sudo, which may ask for your password. + +`, DropBanner("Download, verify and install apps from GitHub releases"), w2("install"), w2("drop install"), w2("drop install")), Use: "install", Example: fmt.Sprintf(`%s install github.com/app/repo`, appname), SilenceUsage: false, @@ -56,9 +135,81 @@ func addInstall(parentCmd *cobra.Command) { return err } cmd.SilenceUsage = true + + // Parse the asset URL + asset := github.NewAssetFromURLString(opts.AppUrl) + if asset == nil { + return fmt.Errorf("unable to parse app URL: %q", opts.AppUrl) + } + + if asset.Host == "" { + asset.Host = "github.com" + } + + // Set the CLI notifier as the notifier, unless -q was specified + var lstnr drop.ProgressListener = ¬ifier.Listener{} + if opts.Quiet { + lstnr = &drop.NoopListener{} + } + + // Create the new dropper instance + dropper, err := drop.New( + drop.WithPolicyRepository(opts.PolicyRepo), + drop.WithListener(lstnr), + ) + if err != nil { + return fmt.Errorf("creating dropper: %w", err) + } + + installOpts := []drop.FuncGetOption{ + drop.WithTransferTimeOut(opts.Timeout), + drop.WithVerifyDownloads(!opts.Insecure), + drop.WithDownloadType(opts.InstallType), + drop.WithBinDir(opts.BinDir), + } + + // When running interactively (and no type was forced), let the + // user choose between a binary and a package with a prompt. + if opts.InstallType == "" && + isatty.IsTerminal(os.Stdin.Fd()) && isatty.IsTerminal(os.Stdout.Fd()) { + installOpts = append(installOpts, drop.WithArtifactSelector(huhSelector())) + } + + // Run the installation: + if err := dropper.Install(asset, installOpts...); err != nil { + if errors.Is(err, drop.ErrOnlyArchives) || errors.Is(err, drop.ErrNoInstallableArtifact) { + return fmt.Errorf("%w (try downloading with \"drop get\")", err) + } + return fmt.Errorf("error installing: %w", err) + } return nil }, } opts.AddFlags(attCmd) parentCmd.AddCommand(attCmd) } + +// huhSelector returns an artifact selector that prompts the user to choose +// between the install candidates using the arrow keys. +func huhSelector() drop.ArtifactSelector { + return func(candidates []*drop.InstallArtifact) (*drop.InstallArtifact, error) { + huhOpts := make([]huh.Option[*drop.InstallArtifact], 0, len(candidates)) + for _, candidate := range candidates { + label := "Binary" + if candidate.Kind == drop.ArtifactPackage { + label = strings.ToUpper(candidate.PackageFormat) + " package" + } + huhOpts = append(huhOpts, huh.NewOption(label, candidate)) + } + + var chosen *drop.InstallArtifact + if err := huh.NewSelect[*drop.InstallArtifact](). + Title("How do you want to install it?"). + Options(huhOpts...). + Value(&chosen). + Run(); err != nil { + return nil, fmt.Errorf("reading user selection: %w", err) + } + return chosen, nil + } +} diff --git a/internal/notifier/listener.go b/internal/notifier/listener.go index c79b0e8..6b86645 100644 --- a/internal/notifier/listener.go +++ b/internal/notifier/listener.go @@ -60,6 +60,31 @@ func (l *Listener) HandleEvent(event *drop.Event) { } fmt.Printf(" 💾 %s%s\n", w("Download complete!"), p) } + case drop.EventObjectInstall: + switch event.Verb { + case drop.EventVerbRunning: + sudo := "" + if event.GetDataField("sudo") == "true" { + sudo = " with sudo (you may be asked for your password)" + } + if event.GetDataField("kind") == string(drop.ArtifactPackage) { + format := event.GetDataField("format") + fmt.Printf(" đŸ“Ļ %s\n", w(fmt.Sprintf("Installing %s package%s...", format, sudo))) + } else { + target := event.GetDataField("target") + fmt.Printf(" 🔧 %s\n", w(fmt.Sprintf("Installing binary to %s%s...", target, sudo))) + } + case drop.EventVerbDone: + name := "app" + if s := event.GetDataField("name"); s != "" { + name = s + } + fmt.Printf(" 🎉 %s\n", w(fmt.Sprintf("%s installed!", name))) + case drop.EventVerbSkipped: + if reason := event.GetDataField("reason"); reason != "" { + fmt.Printf(" â„šī¸ %s\n", reason) + } + } case drop.EventObjectVerification: switch event.Verb { case drop.EventVerbRunning: diff --git a/pkg/drop/dropper.go b/pkg/drop/dropper.go index b44ca3e..a574573 100644 --- a/pkg/drop/dropper.go +++ b/pkg/drop/dropper.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "os" + "path/filepath" "github.com/carabiner-dev/drop/pkg/github" ) @@ -37,7 +38,7 @@ func New(funcs ...FuncOption) (*Dropper, error) { d := &Dropper{ Options: opts, client: client, - impl: &defaultImplementation{}, + impl: &defaultImplementation{runner: &execRunner{}}, } for _, fn := range funcs { @@ -134,38 +135,51 @@ func (dropper *Dropper) Install(spec github.AssetDataProvider, funcs ...FuncGetO return fmt.Errorf("reading system information: %w", err) } - asset, err := dropper.impl.ChooseAsset(&opts, dropper.client, spec) + artifact, err := dropper.impl.SelectInstallArtifact(&opts, dropper.client, sysinfo, spec) if err != nil { return fmt.Errorf("unable to locate a suitable asset: %w", err) } // Look for the asset polcies - policies, err := dropper.impl.FetchPolicies(&dropper.Options, asset) + policies, err := dropper.impl.FetchPolicies(&dropper.Options, artifact.Asset) if err != nil { return fmt.Errorf("finding asset polcies: %w", err) } + if len(policies) == 0 && !opts.SkipVerification { + return ErrNoPolicyAvailable + } + // Downlad the asset to install - downloadPath, err := dropper.impl.DownloadAssetToTmp(&opts, asset) + downloadPath, err := dropper.impl.DownloadAssetToTmp(&opts, artifact.Asset) if err != nil { return fmt.Errorf("downloading asset: %w", err) } + // The asset is downloaded to its own temporary directory, remove it + // (and the verified artifact) once installed or on error. + defer os.RemoveAll(filepath.Dir(downloadPath)) //nolint:errcheck // Verify the asset data - ok, _, err := dropper.impl.VerifyAsset(&dropper.Options, policies, asset, downloadPath) - if err != nil { - return fmt.Errorf("error verifying asset: %w", err) - } + if opts.SkipVerification { + opts.Listener.HandleEvent( + &Event{Object: EventObjectVerification, Verb: EventVerbSkipped}, + ) + } else { + ok, _, err := dropper.impl.VerifyAsset(&dropper.Options, policies, artifact.Asset, downloadPath) + if err != nil { + return fmt.Errorf("error verifying asset: %w", err) + } - // If verification failed, we're done - if !ok { - return ErrVerificationFailed + // If verification failed, we're done + if !ok { + return ErrVerificationFailed + } } // TODO(puerco): Probably here we should output a summary of the verification // Install the asset in the system - if err := dropper.impl.InstallAsset(&dropper.Options, sysinfo, downloadPath); err != nil { + if err := dropper.impl.InstallAsset(&opts, sysinfo, artifact, downloadPath); err != nil { return fmt.Errorf("installing asset: %w", err) } diff --git a/pkg/drop/implementation.go b/pkg/drop/implementation.go index dec7ddc..40f2271 100644 --- a/pkg/drop/implementation.go +++ b/pkg/drop/implementation.go @@ -38,6 +38,10 @@ type installerImplementation interface { // and install in the system. ChooseAsset(*GetOptions, *github.Client, github.AssetDataProvider) (github.AssetDataProvider, error) + // SelectInstallArtifact decides which release artifact (binary or system + // package) will be installed on the local system. + SelectInstallArtifact(*GetOptions, *github.Client, *system.Info, github.AssetDataProvider) (*InstallArtifact, error) + // Fetch policies uses a provider to look for policies in a structured data source. FetchPolicies(*Options, github.AssetDataProvider) ([]*papi.PolicySet, error) @@ -55,15 +59,29 @@ type installerImplementation interface { // InstallAsset invokes the system mechanism to set up the downloaded artifact // in the local machine. - InstallAsset(*Options, *system.Info, string) error + InstallAsset(*GetOptions, *system.Info, *InstallArtifact, string) error } -type defaultImplementation struct{} +type defaultImplementation struct { + runner commandRunner +} func (di *defaultImplementation) GetSystemInfo(*Options) (*system.Info, error) { return system.GetInfo() } +// findInstallable looks in a list of release assets for the installable (or +// plain asset) matching the spec name, defaulting to the repository name. +func findInstallable(assets []github.AssetDataProvider, spec github.AssetDataProvider) github.AssetDataProvider { + name := specName(spec) + for _, asset := range assets { + if asset.GetName() == name { + return asset + } + } + return nil +} + // ChooseAsset selects an installable matching the spec name and local platform func (di *defaultImplementation) ChooseAsset(opts *GetOptions, client *github.Client, spec github.AssetDataProvider) (github.AssetDataProvider, error) { assets, err := client.ListReleaseInstallables(spec) @@ -71,17 +89,7 @@ func (di *defaultImplementation) ChooseAsset(opts *GetOptions, client *github.Cl return nil, fmt.Errorf("fetching release assets: %w", err) } - // We look a for an installable with the same name as the repo - name := spec.GetRepo() - // .. unless the asset spec has a name defined - if spec.GetName() != "" { - name = spec.GetName() - } - - for _, asset := range assets { - if asset.GetName() != name { - continue - } + if asset := findInstallable(assets, spec); asset != nil { // Found. Now check if it has variants for the local OS if installable, ok := asset.(*github.Installable); ok { var wantedVariant github.AssetDataProvider @@ -159,6 +167,7 @@ func (di *defaultImplementation) ChooseAsset(opts *GetOptions, client *github.Cl // Before we go, now check all variants for a matching filename in case // the user specified the exact name in the URL spec: + name := specName(spec) for _, asset := range assets { installable, ok := asset.(*github.Installable) if !ok { @@ -269,19 +278,41 @@ func (di *defaultImplementation) FetchPolicies(opts *Options, asset github.Asset return ret, nil } -// DownloadAssetToTmp fetches the asset to a temporary location +// DownloadAssetToTmp fetches the asset to a temporary directory, keeping its +// filename (package managers require local files to have proper extensions). func (di *defaultImplementation) DownloadAssetToTmp(opts *GetOptions, asset github.AssetDataProvider) (string, error) { - tmpfile, err := os.CreateTemp("", "drop-download-") + dir, err := os.MkdirTemp("", "drop-install-") + if err != nil { + return "", fmt.Errorf("creating temporary directory: %w", err) + } + + filename := opts.computedFilename + if filename == "" { + filename = asset.GetName() + } + + // Send the event to the notifier + opts.Listener.HandleEvent( + &Event{ + Object: EventObjectAsset, Verb: EventVerbGet, + Data: map[string]string{"filename": filename, "size": fmt.Sprintf("%d", asset.GetSize())}, + }, + ) + + filePath := filepath.Join(dir, filename) + tmpfile, err := os.Create(filePath) //nolint:gosec if err != nil { + _ = os.RemoveAll(dir) //nolint:errcheck return "", fmt.Errorf("creating temporary file: %w", err) } defer tmpfile.Close() //nolint:errcheck // Get the data if err := di.DownloadAssetToWriter(opts, tmpfile, asset); err != nil { + _ = os.RemoveAll(dir) //nolint:errcheck return "", err } - return tmpfile.Name(), nil + return filePath, nil } func (di *defaultImplementation) VerifyAsset( @@ -354,10 +385,6 @@ func (di *defaultImplementation) VerifyAsset( return passed, resultSet, nil } -func (di *defaultImplementation) InstallAsset(*Options, *system.Info, string) error { - return nil -} - // DownloadAssetToWriter downloads the asset data to the supplied writer func (di *defaultImplementation) DownloadAssetToWriter(opts *GetOptions, w io.Writer, asset github.AssetDataProvider) error { if asset.GetDownloadURL() == "" { diff --git a/pkg/drop/install.go b/pkg/drop/install.go new file mode 100644 index 0000000..691b9fd --- /dev/null +++ b/pkg/drop/install.go @@ -0,0 +1,528 @@ +// SPDX-FileCopyrightText: Copyright 2025 Carabiner Systems, Inc +// SPDX-License-Identifier: Apache-2.0 + +package drop + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + + "github.com/carabiner-dev/drop/pkg/github" + "github.com/carabiner-dev/drop/pkg/system" +) + +var ( + ErrNoInstallableArtifact = errors.New("release has no binary or compatible package for this platform") + ErrOnlyArchives = errors.New("release only ships archives for this platform") +) + +// ArtifactKind distinguishes the kinds of artifacts the installer can handle. +type ArtifactKind string + +const ( + ArtifactBinary ArtifactKind = "binary" + ArtifactPackage ArtifactKind = "package" +) + +// Command and filename constants used when installing artifacts +const ( + cmdSudo = "sudo" + cmdDnf = "dnf" + cmdYum = "yum" + cmdRPM = "rpm" + cmdApt = "apt" + cmdDpkg = "dpkg" + cmdApk = "apk" + verbInstall = "install" + exeSuffix = ".exe" + + dataKeyKind = "kind" + dataKeyName = "name" + dataKeySudo = "sudo" +) + +// InstallArtifact is a concrete release asset chosen for installation. +type InstallArtifact struct { + // Kind is the artifact type, binary or package. + Kind ArtifactKind + + // PackageFormat is the package type (rpm, deb, apk) when Kind is package. + PackageFormat string + + // Asset is the release asset variant to download. + Asset *github.Asset + + // InstallName is the name the binary gets when installed into the path. + InstallName string +} + +// ArtifactSelector resolves an ambiguous choice between install candidates. +// The CLI injects an interactive implementation when running on a terminal. +type ArtifactSelector func(candidates []*InstallArtifact) (*InstallArtifact, error) + +// installCandidates is the classified view of an installable's variants +// for a single platform. +type installCandidates struct { + Binary *InstallArtifact + Package *InstallArtifact + HasArchives bool + HasOtherPkg bool +} + +// classifyInstallCandidates inspects an installable's variants for the given +// platform and classifies them into a binary candidate and a package candidate +// matching the system's package format. +func classifyInstallCandidates(inst *github.Installable, osName, arch, pkgFormat string) *installCandidates { + cands := &installCandidates{} + for _, variant := range inst.Variants { + if variant.Os != osName || variant.Arch != arch { + continue + } + + packageType := system.PackageExtensions.GetTypeFromFile(variant.GetName()) + archiveType := system.ArchiveExtensions.GetTypeFromFile(variant.GetName()) + + switch { + case archiveType != "": + cands.HasArchives = true + case packageType == "": + name := inst.GetName() + if variant.Os == system.OSWindows { + name += exeSuffix + } + cands.Binary = &InstallArtifact{ + Kind: ArtifactBinary, Asset: variant, InstallName: name, + } + case pkgFormat != "" && packageType == pkgFormat: + cands.Package = &InstallArtifact{ + Kind: ArtifactPackage, PackageFormat: packageType, + Asset: variant, InstallName: inst.GetName(), + } + default: + cands.HasOtherPkg = true + } + } + return cands +} + +// classifySingleAsset builds an install artifact from a single concrete asset, +// for when the user pinned an exact file instead of an installable. +func classifySingleAsset(asset *github.Asset, installName, pkgFormat string) (*InstallArtifact, error) { + name := asset.GetName() + if system.ArchiveExtensions.GetTypeFromFile(name) != "" { + return nil, ErrOnlyArchives + } + if pkgType := system.PackageExtensions.GetTypeFromFile(name); pkgType != "" { + if pkgFormat == "" || pkgType != pkgFormat { + return nil, ErrNoInstallableArtifact + } + return &InstallArtifact{ + Kind: ArtifactPackage, PackageFormat: pkgType, + Asset: asset, InstallName: installName, + }, nil + } + if asset.Os == system.OSWindows && !strings.HasSuffix(installName, exeSuffix) { + installName += exeSuffix + } + return &InstallArtifact{ + Kind: ArtifactBinary, Asset: asset, InstallName: installName, + }, nil +} + +// decideArtifact applies the install selection algorithm: honor a forced type, +// use the only candidate available, stay with the package manager when the app +// is already installed as a package, otherwise ask the selector (or default to +// the binary when running non-interactively). +func decideArtifact(c *installCandidates, opts *GetOptions, pkgInstalled func(name string) bool) (*InstallArtifact, error) { + switch opts.DownloadType { + case "b": + if c.Binary == nil { + return nil, fmt.Errorf("no binary available: %w", ErrNoInstallableArtifact) + } + return c.Binary, nil + case "p": + if c.Package == nil { + return nil, fmt.Errorf("no package in the system format available: %w", ErrNoInstallableArtifact) + } + return c.Package, nil + } + + switch { + case c.Binary == nil && c.Package == nil: + if c.HasArchives { + return nil, ErrOnlyArchives + } + return nil, ErrNoInstallableArtifact + case c.Package == nil: + return c.Binary, nil + case c.Binary == nil: + return c.Package, nil + } + + if pkgInstalled != nil && pkgInstalled(c.Package.InstallName) { + return c.Package, nil + } + + if opts.Selector != nil { + return opts.Selector([]*InstallArtifact{c.Binary, c.Package}) + } + + return c.Binary, nil +} + +// buildPackageInstallCmd returns the argv to install a local package file +// using the system's package manager. +func buildPackageInstallCmd(format, pkgPath string, sudo bool, lookPath func(string) (string, error)) ([]string, error) { + has := func(tool string) bool { + _, err := lookPath(tool) + return err == nil + } + + var argv []string + switch format { + case system.PackageRPM: + switch { + case has(cmdDnf): + argv = []string{cmdDnf, verbInstall, "-y", pkgPath} + case has(cmdYum): + argv = []string{cmdYum, verbInstall, "-y", pkgPath} + case has(cmdRPM): + argv = []string{cmdRPM, "-Uvh", pkgPath} + default: + return nil, errors.New("no rpm package manager (dnf/yum/rpm) found in PATH") + } + case system.PackageDeb: + // apt needs a path (not a package name) to install a local file + abs, err := filepath.Abs(pkgPath) + if err != nil { + return nil, fmt.Errorf("resolving package path: %w", err) + } + switch { + case has(cmdApt): + argv = []string{cmdApt, verbInstall, "-y", abs} + case has(cmdDpkg): + argv = []string{cmdDpkg, "-i", abs} + default: + return nil, errors.New("no deb package manager (apt/dpkg) found in PATH") + } + case system.PackageApk: + if !has(cmdApk) { + return nil, errors.New("apk not found in PATH") + } + // Local apk files are not signed by a repository key, the artifact + // was already verified against its policies before reaching this. + argv = []string{cmdApk, "add", "--allow-untrusted", pkgPath} + default: + return nil, fmt.Errorf("unsupported package format %q", format) + } + + if sudo { + if !has(cmdSudo) { + return nil, errors.New("sudo not found in PATH, rerun as root") + } + argv = append([]string{cmdSudo}, argv...) + } + return argv, nil +} + +// buildPackageQueryCmd returns the argv that checks if a package is installed +// in the system. A zero exit code means the package is present. +func buildPackageQueryCmd(format, name string, lookPath func(string) (string, error)) ([]string, error) { + has := func(tool string) bool { + _, err := lookPath(tool) + return err == nil + } + + switch format { + case system.PackageRPM: + if !has(cmdRPM) { + return nil, errors.New("rpm not found in PATH") + } + return []string{cmdRPM, "-q", name}, nil + case system.PackageDeb: + if !has(cmdDpkg) { + return nil, errors.New("dpkg not found in PATH") + } + return []string{cmdDpkg, "-s", name}, nil + case system.PackageApk: + if !has(cmdApk) { + return nil, errors.New("apk not found in PATH") + } + return []string{cmdApk, "info", "-e", name}, nil + default: + return nil, fmt.Errorf("unsupported package format %q", format) + } +} + +// commandRunner abstracts running external commands so the install logic +// can be tested without touching the system. +type commandRunner interface { + // Run executes a command inheriting the standard streams (so tools + // like sudo can prompt the user). + Run(argv []string) error + + // RunSilent executes a command discarding its output. + RunSilent(argv []string) error + + // LookPath checks if an executable is available in the system path. + LookPath(file string) (string, error) +} + +type execRunner struct{} + +func (*execRunner) Run(argv []string) error { + cmd := exec.CommandContext(context.Background(), argv[0], argv[1:]...) //nolint:gosec // argv comes from fixed command tables + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +func (*execRunner) RunSilent(argv []string) error { + return exec.CommandContext(context.Background(), argv[0], argv[1:]...).Run() //nolint:gosec // argv comes from fixed command tables +} + +func (*execRunner) LookPath(file string) (string, error) { + return exec.LookPath(file) +} + +// dirWritable probes a directory for write access by creating a temp file. +func dirWritable(dir string) bool { + f, err := os.CreateTemp(dir, ".drop-write-check-") + if err != nil { + return false + } + name := f.Name() + _ = f.Close() //nolint:errcheck + _ = os.Remove(name) //nolint:errcheck + return true +} + +// copyFile copies src to dst setting the supplied mode. +func copyFile(src, dst string, mode os.FileMode) error { + in, err := os.Open(src) //nolint:gosec + if err != nil { + return fmt.Errorf("opening source file: %w", err) + } + defer in.Close() //nolint:errcheck + + out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, mode) //nolint:gosec + if err != nil { + return fmt.Errorf("creating target file: %w", err) + } + + if _, err := io.Copy(out, in); err != nil { + _ = out.Close() //nolint:errcheck + return fmt.Errorf("copying file data: %w", err) + } + if err := out.Close(); err != nil { + return fmt.Errorf("closing target file: %w", err) + } + return os.Chmod(dst, mode) +} + +// specName returns the name of the artifact a spec points to, defaulting to +// the repository name when the spec does not pin an asset name. +func specName(spec github.AssetDataProvider) string { + if spec.GetName() != "" { + return spec.GetName() + } + return spec.GetRepo() +} + +// SelectInstallArtifact decides which release artifact (binary or system +// package) will be installed on the local system. +func (di *defaultImplementation) SelectInstallArtifact( + opts *GetOptions, client *github.Client, info *system.Info, spec github.AssetDataProvider, +) (*InstallArtifact, error) { + assets, err := client.ListReleaseInstallables(spec) + if err != nil { + return nil, fmt.Errorf("fetching release assets: %w", err) + } + + // Compute the package format the system prefers. dmg and msi installs + // are not supported yet, so macOS and Windows are binary-only for now. + pkgFormat := system.GetPreferredPackage(info.Family) + binaryOnly := info.Family == system.OSFamilyMacOS || info.Family == system.OSFamilyWindows + if binaryOnly { + pkgFormat = "" + } + + found := findInstallable(assets, spec) + if found == nil { + // Check the variant filenames in case the user pinned an exact file + // in the URL spec: + name := specName(spec) + for _, a := range assets { + inst, ok := a.(*github.Installable) + if !ok { + continue + } + for _, v := range inst.Variants { + if v.GetName() != name { + continue + } + artifact, err := classifySingleAsset(v, inst.GetName(), pkgFormat) + if err != nil { + return nil, err + } + opts.computedFilename = v.GetName() + return artifact, nil + } + } + return nil, fmt.Errorf("no asset found for %s", spec.GetRepo()) + } + + inst, ok := found.(*github.Installable) + if !ok { + // A plain asset without platform variants, treat it as a single file + plain, ok := found.(*github.Asset) + if !ok { + return nil, ErrNoInstallableArtifact + } + artifact, err := classifySingleAsset(plain, plain.GetName(), pkgFormat) + if err != nil { + return nil, err + } + opts.computedFilename = plain.GetName() + return artifact, nil + } + + cands := classifyInstallCandidates(inst, opts.OS, opts.Arch, pkgFormat) + + if binaryOnly && cands.HasOtherPkg { + opts.Listener.HandleEvent(&Event{ + Object: EventObjectInstall, Verb: EventVerbSkipped, + Data: map[string]string{"reason": "dmg/msi installation is not supported yet"}, + }) + } + + artifact, err := decideArtifact(cands, opts, func(name string) bool { + return di.packageInstalled(pkgFormat, name) + }) + if err != nil { + return nil, err + } + + opts.computedFilename = artifact.Asset.GetName() + return artifact, nil +} + +// packageInstalled checks (best effort) if a package is already installed in +// the system. The queried name is the installable name, which may differ from +// the actual package name; a miss only means the user gets asked. +func (di *defaultImplementation) packageInstalled(format, name string) bool { + if format == "" || name == "" { + return false + } + argv, err := buildPackageQueryCmd(format, name, di.runner.LookPath) + if err != nil { + return false + } + return di.runner.RunSilent(argv) == nil +} + +// InstallAsset invokes the system mechanism to set up the downloaded artifact +// in the local machine. +func (di *defaultImplementation) InstallAsset( + opts *GetOptions, info *system.Info, artifact *InstallArtifact, path string, +) error { + switch artifact.Kind { + case ArtifactBinary: + return di.installBinary(opts, info, artifact, path) + case ArtifactPackage: + return di.installPackage(opts, artifact, path) + default: + return fmt.Errorf("unknown artifact kind %q", artifact.Kind) + } +} + +// installBinary copies the downloaded binary to the configured directory, +// shelling out to sudo when the directory is not writable by the user. +func (di *defaultImplementation) installBinary( + opts *GetOptions, info *system.Info, artifact *InstallArtifact, path string, +) error { + target := filepath.Join(opts.BinDir, artifact.InstallName) + sudo := !dirWritable(opts.BinDir) + + if sudo { + if info.Os == system.OSWindows { + return fmt.Errorf("directory %q is not writable", opts.BinDir) + } + if _, err := di.runner.LookPath(cmdSudo); err != nil { + return fmt.Errorf("%q is not writable and sudo is not available, rerun as root or set another binary directory", opts.BinDir) + } + } + + opts.Listener.HandleEvent(&Event{ + Object: EventObjectInstall, Verb: EventVerbRunning, + Data: map[string]string{ + dataKeyKind: string(ArtifactBinary), + dataKeyName: artifact.InstallName, + "target": target, + dataKeySudo: strconv.FormatBool(sudo), + }, + }) + + if sudo { + if err := di.runner.Run([]string{cmdSudo, verbInstall, "-m", "0755", path, target}); err != nil { + return fmt.Errorf("installing binary: %w", err) + } + } else { + if err := copyFile(path, target, 0o755); err != nil { + return fmt.Errorf("installing binary: %w", err) + } + } + + opts.Listener.HandleEvent(&Event{ + Object: EventObjectInstall, Verb: EventVerbDone, + Data: map[string]string{ + dataKeyKind: string(ArtifactBinary), + dataKeyName: artifact.InstallName, + "path": target, + }, + }) + return nil +} + +// installPackage installs the downloaded package using the system's package +// manager, through sudo when not running as root. +func (di *defaultImplementation) installPackage( + opts *GetOptions, artifact *InstallArtifact, path string, +) error { + sudo := os.Geteuid() != 0 + argv, err := buildPackageInstallCmd(artifact.PackageFormat, path, sudo, di.runner.LookPath) + if err != nil { + return err + } + + opts.Listener.HandleEvent(&Event{ + Object: EventObjectInstall, Verb: EventVerbRunning, + Data: map[string]string{ + dataKeyKind: string(ArtifactPackage), + "format": artifact.PackageFormat, + dataKeyName: artifact.InstallName, + dataKeySudo: strconv.FormatBool(sudo), + }, + }) + + if err := di.runner.Run(argv); err != nil { + return fmt.Errorf("installing %s package: %w", artifact.PackageFormat, err) + } + + opts.Listener.HandleEvent(&Event{ + Object: EventObjectInstall, Verb: EventVerbDone, + Data: map[string]string{ + dataKeyKind: string(ArtifactPackage), + dataKeyName: artifact.InstallName, + }, + }) + return nil +} diff --git a/pkg/drop/install_test.go b/pkg/drop/install_test.go new file mode 100644 index 0000000..b5bbbba --- /dev/null +++ b/pkg/drop/install_test.go @@ -0,0 +1,398 @@ +// SPDX-FileCopyrightText: Copyright 2025 Carabiner Systems, Inc +// SPDX-License-Identifier: Apache-2.0 + +package drop + +import ( + "errors" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/carabiner-dev/drop/pkg/github" + "github.com/carabiner-dev/drop/pkg/system" +) + +type fakeRunner struct { + paths map[string]bool + run [][]string + silent [][]string + runErr error + silentErr error +} + +func (f *fakeRunner) Run(argv []string) error { + f.run = append(f.run, argv) + return f.runErr +} + +func (f *fakeRunner) RunSilent(argv []string) error { + f.silent = append(f.silent, argv) + return f.silentErr +} + +func (f *fakeRunner) LookPath(file string) (string, error) { + if f.paths[file] { + return "/usr/bin/" + file, nil + } + return "", errors.New("executable file not found in $PATH") +} + +func testInstallable() *github.Installable { + return &github.Installable{ + Name: "drop", + Variants: []*github.Asset{ + {Name: "drop-linux-amd64", Os: "linux", Arch: "amd64"}, + {Name: "drop-linux-arm64", Os: "linux", Arch: "arm64"}, + {Name: "drop_1.0.0_amd64.deb", Os: "linux", Arch: "amd64"}, + {Name: "drop-1.0.0-1.x86_64.rpm", Os: "linux", Arch: "amd64"}, + {Name: "drop-linux-amd64.tar.gz", Os: "linux", Arch: "amd64"}, + {Name: "drop-darwin-arm64.dmg", Os: "darwin", Arch: "arm64"}, + {Name: "drop-windows-amd64.exe", Os: "windows", Arch: "amd64"}, + }, + } +} + +func TestClassifyInstallCandidates(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name string + os string + arch string + pkgFormat string + binaryName string // expected variant filename, "" = no binary + installName string + pkgName string // expected variant filename, "" = no package + hasArchives bool + hasOtherPkg bool + }{ + { + name: "linux-rpm", os: "linux", arch: "amd64", pkgFormat: "rpm", + binaryName: "drop-linux-amd64", installName: "drop", + pkgName: "drop-1.0.0-1.x86_64.rpm", hasArchives: true, hasOtherPkg: true, + }, + { + name: "linux-deb", os: "linux", arch: "amd64", pkgFormat: "deb", + binaryName: "drop-linux-amd64", installName: "drop", + pkgName: "drop_1.0.0_amd64.deb", hasArchives: true, hasOtherPkg: true, + }, + { + name: "linux-arm64-binary-only", os: "linux", arch: "arm64", pkgFormat: "rpm", + binaryName: "drop-linux-arm64", installName: "drop", + }, + { + name: "windows-exe", os: "windows", arch: "amd64", pkgFormat: "", + binaryName: "drop-windows-amd64.exe", installName: "drop.exe", + }, + { + name: "darwin-dmg-unsupported", os: "darwin", arch: "arm64", pkgFormat: "", + hasOtherPkg: true, + }, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + cands := classifyInstallCandidates(testInstallable(), tc.os, tc.arch, tc.pkgFormat) + + if tc.binaryName == "" { + require.Nil(t, cands.Binary) + } else { + require.NotNil(t, cands.Binary) + require.Equal(t, tc.binaryName, cands.Binary.Asset.GetName()) + require.Equal(t, tc.installName, cands.Binary.InstallName) + require.Equal(t, ArtifactBinary, cands.Binary.Kind) + } + + if tc.pkgName == "" { + require.Nil(t, cands.Package) + } else { + require.NotNil(t, cands.Package) + require.Equal(t, tc.pkgName, cands.Package.Asset.GetName()) + require.Equal(t, tc.pkgFormat, cands.Package.PackageFormat) + require.Equal(t, ArtifactPackage, cands.Package.Kind) + } + + require.Equal(t, tc.hasArchives, cands.HasArchives) + require.Equal(t, tc.hasOtherPkg, cands.HasOtherPkg) + }) + } +} + +func TestDecideArtifact(t *testing.T) { + t.Parallel() + binary := &InstallArtifact{Kind: ArtifactBinary, InstallName: "drop"} + pkg := &InstallArtifact{Kind: ArtifactPackage, PackageFormat: "rpm", InstallName: "drop"} + + pickPackage := func(cands []*InstallArtifact) (*InstallArtifact, error) { + require.Len(t, cands, 2) + return cands[1], nil + } + + for _, tc := range []struct { + name string + cands *installCandidates + downloadType string + selector ArtifactSelector + installed bool + expect *InstallArtifact + expectErr error + }{ + {name: "forced-binary", cands: &installCandidates{Binary: binary, Package: pkg}, downloadType: "b", expect: binary}, + {name: "forced-package", cands: &installCandidates{Binary: binary, Package: pkg}, downloadType: "p", expect: pkg}, + {name: "forced-binary-missing", cands: &installCandidates{Package: pkg}, downloadType: "b", expectErr: ErrNoInstallableArtifact}, + {name: "forced-package-missing", cands: &installCandidates{Binary: binary}, downloadType: "p", expectErr: ErrNoInstallableArtifact}, + {name: "only-binary", cands: &installCandidates{Binary: binary}, expect: binary}, + {name: "only-package", cands: &installCandidates{Package: pkg}, expect: pkg}, + {name: "none", cands: &installCandidates{}, expectErr: ErrNoInstallableArtifact}, + {name: "only-archives", cands: &installCandidates{HasArchives: true}, expectErr: ErrOnlyArchives}, + {name: "both-already-installed", cands: &installCandidates{Binary: binary, Package: pkg}, installed: true, expect: pkg}, + {name: "both-selector", cands: &installCandidates{Binary: binary, Package: pkg}, selector: pickPackage, expect: pkg}, + {name: "both-no-selector-defaults-binary", cands: &installCandidates{Binary: binary, Package: pkg}, expect: binary}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + opts := &GetOptions{DownloadType: tc.downloadType, Selector: tc.selector} + res, err := decideArtifact(tc.cands, opts, func(string) bool { return tc.installed }) + if tc.expectErr != nil { + require.ErrorIs(t, err, tc.expectErr) + return + } + require.NoError(t, err) + require.Same(t, tc.expect, res) + }) + } +} + +func TestBuildPackageInstallCmd(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name string + format string + path string + sudo bool + paths map[string]bool + expect []string + expectErr bool + }{ + { + name: "rpm-dnf-sudo", format: "rpm", path: "/tmp/d/drop.rpm", sudo: true, + paths: map[string]bool{"dnf": true, "yum": true, "sudo": true}, + expect: []string{"sudo", "dnf", "install", "-y", "/tmp/d/drop.rpm"}, + }, + { + name: "rpm-yum-fallback", format: "rpm", path: "/tmp/d/drop.rpm", sudo: false, + paths: map[string]bool{"yum": true}, + expect: []string{"yum", "install", "-y", "/tmp/d/drop.rpm"}, + }, + { + name: "rpm-rpm-fallback", format: "rpm", path: "/tmp/d/drop.rpm", sudo: false, + paths: map[string]bool{"rpm": true}, + expect: []string{"rpm", "-Uvh", "/tmp/d/drop.rpm"}, + }, + { + name: "rpm-no-manager", format: "rpm", path: "/tmp/d/drop.rpm", + paths: map[string]bool{}, expectErr: true, + }, + { + name: "deb-apt", format: "deb", path: "/tmp/d/drop.deb", sudo: true, + paths: map[string]bool{"apt": true, "sudo": true}, + expect: []string{"sudo", "apt", "install", "-y", "/tmp/d/drop.deb"}, + }, + { + name: "deb-dpkg-fallback", format: "deb", path: "/tmp/d/drop.deb", sudo: false, + paths: map[string]bool{"dpkg": true}, + expect: []string{"dpkg", "-i", "/tmp/d/drop.deb"}, + }, + { + name: "apk", format: "apk", path: "/tmp/d/drop.apk", sudo: false, + paths: map[string]bool{"apk": true}, + expect: []string{"apk", "add", "--allow-untrusted", "/tmp/d/drop.apk"}, + }, + { + name: "sudo-missing", format: "rpm", path: "/tmp/d/drop.rpm", sudo: true, + paths: map[string]bool{"dnf": true}, expectErr: true, + }, + { + name: "unsupported-format", format: "msi", path: "/tmp/d/drop.msi", + paths: map[string]bool{}, expectErr: true, + }, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + runner := &fakeRunner{paths: tc.paths} + argv, err := buildPackageInstallCmd(tc.format, tc.path, tc.sudo, runner.LookPath) + if tc.expectErr { + require.Error(t, err) + return + } + require.NoError(t, err) + require.Equal(t, tc.expect, argv) + }) + } +} + +func TestBuildPackageQueryCmd(t *testing.T) { + t.Parallel() + allTools := map[string]bool{"rpm": true, "dpkg": true, "apk": true} + for _, tc := range []struct { + name string + format string + paths map[string]bool + expect []string + expectErr bool + }{ + {name: "rpm", format: "rpm", paths: allTools, expect: []string{"rpm", "-q", "drop"}}, + {name: "deb", format: "deb", paths: allTools, expect: []string{"dpkg", "-s", "drop"}}, + {name: "apk", format: "apk", paths: allTools, expect: []string{"apk", "info", "-e", "drop"}}, + {name: "tool-missing", format: "rpm", paths: map[string]bool{}, expectErr: true}, + {name: "unsupported", format: "dmg", paths: allTools, expectErr: true}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + runner := &fakeRunner{paths: tc.paths} + argv, err := buildPackageQueryCmd(tc.format, "drop", runner.LookPath) + if tc.expectErr { + require.Error(t, err) + return + } + require.NoError(t, err) + require.Equal(t, tc.expect, argv) + }) + } +} + +func TestInstallAssetBinary(t *testing.T) { + t.Parallel() + writeSource := func(t *testing.T) string { + t.Helper() + src := filepath.Join(t.TempDir(), "drop-linux-amd64") + require.NoError(t, os.WriteFile(src, []byte("#!/bin/true"), 0o600)) + return src + } + artifact := &InstallArtifact{Kind: ArtifactBinary, InstallName: "drop"} + info := &system.Info{Os: "linux", Arch: "amd64"} + + t.Run("writable-dir", func(t *testing.T) { + t.Parallel() + binDir := t.TempDir() + runner := &fakeRunner{paths: map[string]bool{"sudo": true}} + di := &defaultImplementation{runner: runner} + opts := &GetOptions{BinDir: binDir} + opts.Listener = &NoopListener{} + + require.NoError(t, di.InstallAsset(opts, info, artifact, writeSource(t))) + + target := filepath.Join(binDir, "drop") + st, err := os.Stat(target) + require.NoError(t, err) + require.Equal(t, os.FileMode(0o755), st.Mode().Perm()) + require.Empty(t, runner.run, "no command should run when the dir is writable") + }) + + t.Run("non-writable-dir-uses-sudo", func(t *testing.T) { + t.Parallel() + if os.Geteuid() == 0 { + t.Skip("running as root, no dir is non-writable") + } + binDir := filepath.Join(t.TempDir(), "bin") + require.NoError(t, os.Mkdir(binDir, 0o555)) //nolint:gosec // intentionally non-writable + runner := &fakeRunner{paths: map[string]bool{"sudo": true}} + di := &defaultImplementation{runner: runner} + opts := &GetOptions{BinDir: binDir} + opts.Listener = &NoopListener{} + + src := writeSource(t) + require.NoError(t, di.InstallAsset(opts, info, artifact, src)) + require.Equal(t, [][]string{ + {"sudo", "install", "-m", "0755", src, filepath.Join(binDir, "drop")}, + }, runner.run) + }) + + t.Run("non-writable-dir-no-sudo", func(t *testing.T) { + t.Parallel() + if os.Geteuid() == 0 { + t.Skip("running as root, no dir is non-writable") + } + binDir := filepath.Join(t.TempDir(), "bin") + require.NoError(t, os.Mkdir(binDir, 0o555)) //nolint:gosec // intentionally non-writable + runner := &fakeRunner{paths: map[string]bool{}} + di := &defaultImplementation{runner: runner} + opts := &GetOptions{BinDir: binDir} + opts.Listener = &NoopListener{} + + require.Error(t, di.InstallAsset(opts, info, artifact, writeSource(t))) + require.Empty(t, runner.run) + }) +} + +func TestInstallAssetPackage(t *testing.T) { + t.Parallel() + runner := &fakeRunner{paths: map[string]bool{"dnf": true, "sudo": true}} + di := &defaultImplementation{runner: runner} + opts := &GetOptions{} + opts.Listener = &NoopListener{} + artifact := &InstallArtifact{ + Kind: ArtifactPackage, PackageFormat: "rpm", InstallName: "drop", + } + + require.NoError(t, di.InstallAsset(opts, &system.Info{Os: "linux"}, artifact, "/tmp/d/drop.rpm")) + require.Len(t, runner.run, 1) + if os.Geteuid() == 0 { + require.Equal(t, []string{"dnf", "install", "-y", "/tmp/d/drop.rpm"}, runner.run[0]) + } else { + require.Equal(t, []string{"sudo", "dnf", "install", "-y", "/tmp/d/drop.rpm"}, runner.run[0]) + } +} + +func TestPackageInstalled(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name string + format string + silentErr error + expect bool + }{ + {name: "installed", format: "rpm", expect: true}, + {name: "not-installed", format: "rpm", silentErr: errors.New("exit 1"), expect: false}, + {name: "no-format", format: "", expect: false}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + runner := &fakeRunner{ + paths: map[string]bool{"rpm": true}, + silentErr: tc.silentErr, + } + di := &defaultImplementation{runner: runner} + require.Equal(t, tc.expect, di.packageInstalled(tc.format, "drop")) + }) + } +} + +func TestDownloadAssetToTmp(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("artifact-data")) //nolint:errcheck + })) + defer srv.Close() + + di := &defaultImplementation{} + opts := &GetOptions{TransferTimeOut: 10} + opts.computedFilename = "drop-1.0.0-1.x86_64.rpm" + opts.Listener = &NoopListener{} + asset := &github.Asset{ + Name: "drop-1.0.0-1.x86_64.rpm", + DownloadURL: srv.URL + "/drop-1.0.0-1.x86_64.rpm", + } + + path, err := di.DownloadAssetToTmp(opts, asset) + require.NoError(t, err) + defer os.RemoveAll(filepath.Dir(path)) //nolint:errcheck + + require.Equal(t, "drop-1.0.0-1.x86_64.rpm", filepath.Base(path), "tmp file must keep the package extension") + data, err := os.ReadFile(path) //nolint:gosec // path is a test-controlled tmp file + require.NoError(t, err) + require.Equal(t, "artifact-data", string(data)) +} diff --git a/pkg/drop/listener.go b/pkg/drop/listener.go index 614290d..15fa106 100644 --- a/pkg/drop/listener.go +++ b/pkg/drop/listener.go @@ -5,6 +5,7 @@ package drop const ( EventObjectAsset = "asset" + EventObjectInstall = "install" EventObjectPolicy = "policy" EventObjectVerification = "verification" diff --git a/pkg/drop/options.go b/pkg/drop/options.go index b9c2132..3dab610 100644 --- a/pkg/drop/options.go +++ b/pkg/drop/options.go @@ -15,11 +15,14 @@ import ( var defaultOptions = Options{} +// The default platform is normalized to the canonical OS/arch labels so it +// matches the values parsed from the release asset filenames. var defaultGetOptions = GetOptions{ DownloadPath: ".", - OS: runtime.GOOS, - Arch: runtime.GOARCH, + OS: system.GetOS(runtime.GOOS), + Arch: system.GetArch(runtime.GOARCH), TransferTimeOut: 900, + BinDir: "/usr/local/bin", } type Options struct { @@ -56,6 +59,14 @@ type GetOptions struct { // DownloadType is "a","b" or "p" and determines which download we do DownloadType string + + // BinDir is the directory where binaries are installed by the install + // subcommand. + BinDir string + + // Selector resolves the choice between a binary and a package when a + // release offers both for the local system. + Selector ArtifactSelector } type ( @@ -127,6 +138,23 @@ func WithVerifyDownloads(verify bool) FuncGetOption { } } +func WithBinDir(dir string) FuncGetOption { + return func(o *GetOptions) error { + if dir == "" { + return errors.New("binary directory cannot be empty") + } + o.BinDir = dir + return nil + } +} + +func WithArtifactSelector(fn ArtifactSelector) FuncGetOption { + return func(o *GetOptions) error { + o.Selector = fn + return nil + } +} + func WithDownloadType(t string) FuncGetOption { return func(o *GetOptions) error { if t != "" { diff --git a/pkg/system/info.go b/pkg/system/info.go index 51f0195..8aab8e2 100644 --- a/pkg/system/info.go +++ b/pkg/system/info.go @@ -8,14 +8,16 @@ import ( ) type Info struct { - Os string - Arch string + Os string + Arch string + Family string } // GetInfo returns information about the running system func GetInfo() (*Info, error) { return &Info{ - Os: runtime.GOOS, - Arch: runtime.GOARCH, + Os: runtime.GOOS, + Arch: runtime.GOARCH, + Family: GetSystemOSFamily(), }, nil } diff --git a/pkg/system/system.go b/pkg/system/system.go index 4f152cb..9333321 100644 --- a/pkg/system/system.go +++ b/pkg/system/system.go @@ -6,7 +6,6 @@ package system import ( "bufio" "cmp" - "fmt" "io" "os" "regexp" @@ -180,7 +179,6 @@ func parseOSReleaseForFamily(r io.Reader) string { case "wolfi": return OSFamilyWolfi default: - fmt.Println("A A A A A " + v) return "" } }