Script 'mail_helper' called by obssrc Hello community, here is the log from the commit of package arkade for openSUSE:Factory checked in at 2026-07-01 16:54:35 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Comparing /work/SRC/openSUSE:Factory/arkade (Old) and /work/SRC/openSUSE:Factory/.arkade.new.11887 (New) ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "arkade" Wed Jul 1 16:54:35 2026 rev:83 rq:1362905 version:0.11.105 Changes: -------- --- /work/SRC/openSUSE:Factory/arkade/arkade.changes 2026-06-23 17:43:06.142972855 +0200 +++ /work/SRC/openSUSE:Factory/.arkade.new.11887/arkade.changes 2026-07-01 16:54:53.843093512 +0200 @@ -1,0 +2,17 @@ +Wed Jul 01 08:06:34 UTC 2026 - Johannes Kastl <[email protected]> + +- Update to version 0.11.105: + * Improve search ranking and add fallback for unmatched queries +- Update to version 0.11.104: + * Initial search command for arkade +- Update to version 0.11.103: + * Fix trailing newline + * Flat option for arkade oci install + * Remove faasd tool definition + * Update number of tools after adding pluto + * Add pluto CLI to find deprecated K8s APIs + * Make symlink extraction in UntarNested conditional + * Fix symlink path-traversal escape in UntarNested + * Remove stray file + +------------------------------------------------------------------- Old: ---- arkade-0.11.102.obscpio New: ---- arkade-0.11.105.obscpio ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Other differences: ------------------ ++++++ arkade.spec ++++++ --- /var/tmp/diff_new_pack.CrtcEo/_old 2026-07-01 16:54:56.443183233 +0200 +++ /var/tmp/diff_new_pack.CrtcEo/_new 2026-07-01 16:54:56.475184337 +0200 @@ -17,7 +17,7 @@ Name: arkade -Version: 0.11.102 +Version: 0.11.105 Release: 0 Summary: Open Source Kubernetes Marketplace License: Apache-2.0 ++++++ _service ++++++ --- /var/tmp/diff_new_pack.CrtcEo/_old 2026-07-01 16:54:56.967201315 +0200 +++ /var/tmp/diff_new_pack.CrtcEo/_new 2026-07-01 16:54:57.007202696 +0200 @@ -1,9 +1,9 @@ <services> <service name="obs_scm" mode="manual"> - <param name="url">https://github.com/alexellis/arkade</param> + <param name="url">https://github.com/alexellis/arkade.git</param> <param name="scm">git</param> <param name="exclude">.git</param> - <param name="revision">0.11.102</param> + <param name="revision">refs/tags/0.11.105</param> <param name="versionformat">@PARENT_TAG@</param> <param name="versionrewrite-pattern">v(.*)</param> <param name="changesgenerate">enable</param> ++++++ _servicedata ++++++ --- /var/tmp/diff_new_pack.CrtcEo/_old 2026-07-01 16:54:57.187208907 +0200 +++ /var/tmp/diff_new_pack.CrtcEo/_new 2026-07-01 16:54:57.239210701 +0200 @@ -1,6 +1,8 @@ <servicedata> <service name="tar_scm"> <param name="url">https://github.com/alexellis/arkade</param> - <param name="changesrevision">37af31c7b58de8f16a10067051e3bbe7ecb6aa79</param></service></servicedata> + <param name="changesrevision">37af31c7b58de8f16a10067051e3bbe7ecb6aa79</param></service><service name="tar_scm"> + <param name="url">https://github.com/alexellis/arkade.git</param> + <param name="changesrevision">15dcd3c06fd80143553e9fc53fb2ef59fb84a409</param></service></servicedata> (No newline at EOF) ++++++ arkade-0.11.102.obscpio -> arkade-0.11.105.obscpio ++++++ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/arkade-0.11.102/README.md new/arkade-0.11.105/README.md --- old/arkade-0.11.102/README.md 2026-06-22 16:31:10.000000000 +0200 +++ new/arkade-0.11.105/README.md 2026-06-30 13:53:49.000000000 +0200 @@ -183,6 +183,13 @@ ``` > This is a time saver compared to searching for download pages every time you > need a tool. +Search CLIs available via `arkade get` by name or keyword, with alias support (e.g. "k8s" expands to "Kubernetes"): + +```bash +arkade search helm +arkade search k8s +``` + Files are stored at `$HOME/.arkade/bin/` Want to download tools to a custom path such as into the GitHub Actions cached tool folder? @@ -965,7 +972,8 @@ | [websocat](https://github.com/vi/websocat) | Command-line client for WebSockets, like netcat/socat but for WebSockets | | [yq](https://github.com/mikefarah/yq) | Portable command-line YAML processor. | | [yt-dlp](https://github.com/yt-dlp/yt-dlp) | Fork of youtube-dl with additional features and fixes | -There are 196 tools, use `arkade get NAME` to download one. +There are 196 tools, use `arkade get NAME` to download one. + <!-- end of tool list --> diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/arkade-0.11.102/cmd/oci/install.go new/arkade-0.11.105/cmd/oci/install.go --- old/arkade-0.11.102/cmd/oci/install.go 2026-06-22 16:31:10.000000000 +0200 +++ new/arkade-0.11.105/cmd/oci/install.go 2026-06-30 13:53:49.000000000 +0200 @@ -44,6 +44,10 @@ # Use a shortcut for the image name (vmmeter, slicer, k3sup-pro) arkade oci install k3sup-pro + + # Flatten the archive so files are extracted directly into the install path, + # ignoring directory structure in the image (e.g. ./usr/local/bin/FILE => ./FILE) + arkade oci install ghcr.io/openfaasltd/slicer --flat `, SilenceUsage: true, } @@ -57,6 +61,7 @@ command.Flags().BoolP("gzipped", "g", false, "Is this a gzipped tarball?") command.Flags().Bool("quiet", false, "Suppress progress output") command.Flags().Bool("symlink", false, "Write symlinks when unpacking OCI image, only use with trusted sources") + command.Flags().Bool("flat", false, "Extract all files directly into the install path. Caution: files sharing a basename will overwrite each other and symlinks are skipped") // Hide the deprecated --path flag command.Flags().MarkHidden("path") @@ -71,6 +76,7 @@ quiet, _ := cmd.Flags().GetBool("quiet") allowSymlinks, _ := cmd.Flags().GetBool("symlink") showProgress, _ := cmd.Flags().GetBool("progress") + flatExtract, _ := cmd.Flags().GetBool("flat") if len(args) < 1 { return fmt.Errorf("please provide an image name") @@ -262,7 +268,7 @@ // When the alt-screen is active, suppress UntarNested's // per-file logging so it doesn't corrupt the live frame. untarQuiet := quiet || (tty && renderLive) - if uErr := archive.UntarNested(tarFile, installPath, gzipped, untarQuiet, allowSymlinks); uErr != nil { + if uErr := archive.UntarNested(tarFile, installPath, gzipped, untarQuiet, allowSymlinks, flatExtract); uErr != nil { workErr = fmt.Errorf("failed to untar %s: %w", tempFile.Name(), uErr) } } diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/arkade-0.11.102/cmd/search.go new/arkade-0.11.105/cmd/search.go --- old/arkade-0.11.102/cmd/search.go 1970-01-01 01:00:00.000000000 +0100 +++ new/arkade-0.11.105/cmd/search.go 2026-06-30 13:53:49.000000000 +0200 @@ -0,0 +1,367 @@ +// Copyright (c) arkade author(s) 2022. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +package cmd + +import ( + "errors" + "fmt" + "math" + "sort" + "strings" + + "github.com/spf13/cobra" + + "github.com/alexellis/arkade/pkg/get" +) + +type scoreRank struct { + Tool get.Tool + Score float64 +} + +// aliases maps common shorthand to full term. Each alias expands to one or more +// space-separated terms so that multi-word expansions are scored correctly. +var aliasMap = map[string]string{ + "k8s": "kubernetes", + "kube": "kubernetes", + "eksctl": "amazon eks kubernetes cluster management", + "gke": "google kubernetes engine", + "aks": "azure kubernetes service", +} + +func MakeSearch() *cobra.Command { + tools := get.MakeTools() + + cmd := &cobra.Command{ + Use: "search [query]", + Short: `Search for a tool available in arkade get`, + Long: `Search for tools by name or description using relevance ranking. Tools that share keywords with your query are ranked first. Common aliases like k8s are expanded to kubernetes, and fuzzy matching finds similar names (e.g., "openfaas" matches faas-cli). Multi-word queries match and rank tools containing multiple terms higher.`, + Example: ` arkade search helm + + # Expand "k8s" to Kubernetes and rank by relevance + arkade search k8s + + # Fuzzy name matching — finds faas-cli even though the user types "openfaas" + arkade search openfaas + + # Multi-word query (tools with both words ranked higher) + arkade search container runtime + + # Show as a list instead of table + arkade search helm --format list`, + } + + cmd.Flags().String("format", "table", "Output format: list, markdown or table") + + cmd.RunE = func(cmd *cobra.Command, args []string) error { + query := strings.TrimSpace(strings.Join(args, " ")) + if query == "" { + return errors.New("please provide a search query") + } + + format, _ := cmd.Flags().GetString("format") + + ranked := rankByTFIDF(tools, query) + + sort.SliceStable(ranked, func(i, j int) bool { + return ranked[i].Score > ranked[j].Score + }) + + var matches []scoreRank + for _, r := range ranked { + if r.Score > 0 { + matches = append(matches, r) + } + } + + // Last resort: substring fallback on Name, Owner and Repo when TF-IDF found nothing. + if len(matches) == 0 { + queryTerms := tokenize(expandAliases(query)) + matches = fuzzySubstringFallback(tools, queryTerms) + } + + if len(matches) == 0 { + cmd.Printf("No tools found matching \"%s\"\n", query) + return nil + } + + switch format { + case "list": + for i, r := range matches { + fmt.Printf("%d. %s (%.3f)\t%s\n", i+1, r.Tool.Name, r.Score, r.Tool.Description) + } + case "markdown": + fmt.Println("| Rank | Name | Score | Description |") + fmt.Println("|------|------|-------|-------------|") + for i, r := range matches { + fmt.Printf("| %d | %s | %.3f | %s |\n", i+1, r.Tool.Name, r.Score, r.Tool.Description) + } + default: + matchesOnly := make([]get.Tool, len(matches)) + for i, r := range matches { + matchesOnly[i] = r.Tool + } + cmd.Printf("Found %d tool(s) matching \"%s\":\n\n", len(matches), query) + get.CreateToolsTable(matchesOnly, get.TableStyle) + } + + return nil + } + + return cmd +} + +// fuzzySubstringFallback does a simple case-insensitive substring match across +// Name, Owner, Repo (not Description) when the TF-IDF index returned no results. +func fuzzySubstringFallback(tools []get.Tool, queryTerms []string) []scoreRank { + results := make([]scoreRank, 0) + for _, t := range tools { + var score float64 + for _, q := range queryTerms { + if strings.Contains(strings.ToLower(t.Name), q) { + score += 3.0 + } else if strings.Contains(strings.ToLower(t.Owner), q) { + score += 2.0 + } else if strings.Contains(strings.ToLower(t.Repo), q) { + score += 1.5 + } + } + if score > 0 { + results = append(results, scoreRank{Tool: t, Score: score}) + } + } + sort.SliceStable(results, func(i, j int) bool { + return results[i].Score > results[j].Score + }) + return results +} + +// splitOnSeparators returns a slice of substrings obtained by splitting on - and _. +func splitOnSeparators(s string) []string { + parts := strings.Split(strings.ToLower(s), "-") + result := make([]string, 0) + for _, p := range parts { + subparts := strings.Split(p, "_") + for _, sp := range subparts { + if sp != "" { + result = append(result, sp) + } + } + } + return result +} + +func rankByTFIDF(tools []get.Tool, rawQuery string) []scoreRank { + docs := make([][]string, len(tools)) + for i, t := range tools { + // Tokenize name with separator splitting to create individual IDF entries + // for parts like "faas" in "faas-cli", then append the expanded description. + nameTokens := splitOnSeparators(t.Name) + docs[i] = tokenize( + strings.Join(nameTokens, " ") + " " + + t.Owner + " " + + t.Repo + " " + + expandAliases(t.Description), + ) + } + + df := make(map[string]int) + for _, doc := range docs { + seen := map[string]bool{} + for _, w := range doc { + if !seen[w] { + df[w]++ + seen[w] = true + } + } + } + + nDocs := float64(len(tools)) + idf := make(map[string]float64) + for t, freq := range df { + idf[t] = math.Log(nDocs/float64(freq)) + 1.0 + } + + queryTerms := tokenize(expandAliases(rawQuery)) + + scores := make([]scoreRank, len(tools)) + for i, t := range tools { + tf := termFreq(docs[i]) + + var score float64 + termsMatched := 0 + + // TF-IDF contribution from name + description. + for _, q := range queryTerms { + if tf[q] > 0 { + score += tf[q] * idf[q] + termsMatched++ + } + } + + // Exact name match bonus: if the tool name equals any query term (or vice versa), + // give it a very high score so it ranks first. + lowerName := strings.ToLower(t.Name) + for _, q := range queryTerms { + if lowerName == q { + score += 5.0 + } + } + + // Substring name bonus: if a query term is a substring of the tool name but not + // matched by TF-IDF (because it's not an independent token), score using the + // best available IDF weight from that tool's own tokens. + for _, q := range queryTerms { + if tf[q] == 0 && strings.Contains(lowerName, q) { + // Use a fallback weight: log(N/1)+1 which is the maximum possible IDF, + // giving partial name matches strong relevance. + maxIDF := math.Log(nDocs) + 1.0 + score += maxIDF * 2.0 + termsMatched++ + } else if tf[q] > 0 { + // Name and description both matched — slight extra boost. + score += idf[q] * 0.5 + } + } + + // Levenshtein fuzzy matching on tool name parts only. + fuzzyBonus := levenshteinFuzzyScore(queryTerms, t.Name) + score += fuzzyBonus + if fuzzyBonus > 0 { + termsMatched++ + } + + // Multi-word query boost: tools that match multiple distinct query terms are + // ranked higher. The boost is proportional to the fraction of query terms matched. + if len(queryTerms) > 1 { + frac := float64(termsMatched) / float64(len(queryTerms)) + score += score * frac * 0.5 + } + + scores[i] = scoreRank{Tool: t, Score: score} + } + + return scores +} + +// levenshteinFuzzyScore computes a bonus for tools whose name contains +// words within edit distance of any query token that aren't already matched by exact text. +func levenshteinFuzzyScore(queryTerms []string, toolName string) float64 { + if len(queryTerms) == 0 { + return 0 + } + + nameLower := strings.ToLower(toolName) + // Use separator-split name parts for comparison rather than whitespace tokens. + nameWords := splitOnSeparators(nameLower) + + var bonus float64 + distMax := 2 + + for _, q := range queryTerms { + if len(q) < 5 { + continue + } + + // Quick exact substring check — if already matched, no need to fuzzy. + if strings.Contains(nameLower, q) { + continue + } + + bestDist := distMax + 1 + for _, w := range nameWords { + if len(w) < 4 { + continue + } + d := levenshteinDistance(q, w) + if d < bestDist && d <= distMax { + bestDist = d + } + } + + if bestDist > 0 && bestDist <= distMax { + bonus += float64(distMax-bestDist+1) * 0.8 + } + } + + return bonus +} + +func levenshteinDistance(a, b string) int { + aLen := len(a) + bLen := len(b) + + if aLen == 0 { + return bLen + } + if bLen == 0 { + return aLen + } + + dp := make([]int, bLen+1) + for j := 0; j <= bLen; j++ { + dp[j] = j + } + + for i := 1; i <= aLen; i++ { + prevDiag := dp[0] + dp[0] = i + for j := 1; j <= bLen; j++ { + temp := dp[j] + if a[i-1] == b[j-1] { + dp[j] = prevDiag + } else { + m := dp[j-1] + 1 + if d := dp[j] + 1; d < m { + m = d + } + if r := prevDiag + 1; r < m { + m = r + } + dp[j] = m + } + prevDiag = temp + } + } + + return dp[bLen] +} + +func expandAliases(s string) string { + s = strings.ToLower(s) + for alias, expansion := range aliasMap { + padded := " " + s + " " + s = strings.ReplaceAll(padded, " "+alias+" ", " "+expansion+" ") + } + return strings.Trim(s, " ") +} + +func tokenize(s string) []string { + lower := strings.ToLower(s) + fields := strings.Fields(lower) + cleaned := make([]string, 0, len(fields)) + for _, f := range fields { + f = strings.Trim(f, ".,:;()'\"") + if f != "" { + cleaned = append(cleaned, f) + } + } + return cleaned +} + +func termFreq(words []string) map[string]float64 { + counts := make(map[string]int) + for _, w := range words { + counts[w]++ + } + tf := make(map[string]float64) + n := float64(len(words)) + if n == 0 { + return tf + } + for w, c := range counts { + tf[w] = float64(c) / n + } + return tf +} diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/arkade-0.11.102/cmd/search_test.go new/arkade-0.11.105/cmd/search_test.go --- old/arkade-0.11.102/cmd/search_test.go 1970-01-01 01:00:00.000000000 +0100 +++ new/arkade-0.11.105/cmd/search_test.go 2026-06-30 13:53:49.000000000 +0200 @@ -0,0 +1,90 @@ +package cmd + +import ( + "testing" + + "github.com/alexellis/arkade/pkg/get" +) + +func makeTestTools() []get.Tool { + return []get.Tool{ + {Name: "helm", Owner: "helm", Repo: "helm", Description: "The Kubernetes Package Manager"}, + {Name: "faas-cli", Owner: "openfaas", Repo: "faas-cli", Description: "CLI for OpenFaaS"}, + {Name: "kubectl", Owner: "kubernetes", Repo: "kubernetes", Description: "Control plane CLI"}, + } +} + +func Test_ExactNameMatchRanksFirst(t *testing.T) { + ranked := rankByTFIDF(makeTestTools(), "helm") + + if ranked[0].Tool.Name != "helm" { + t.Errorf("expected helm to rank first, got %s", ranked[0].Tool.Name) + } +} + +func Test_MultiWordQueryBoost(t *testing.T) { + ranked := rankByTFIDF(makeTestTools(), "kubernetes package") + + var found int + for _, r := range ranked { + if r.Tool.Name == "helm" && r.Score > 0 { + found++ + } + } + if found != 1 { + t.Error("expected helm to match 'kubernetes package' query") + } +} + +func Test_OwnerRepoInTFIDF(t *testing.T) { + ranked := rankByTFIDF(makeTestTools(), "openfaas") + + var found bool + for _, r := range ranked { + if r.Tool.Name == "faas-cli" && r.Score > 0 { + found = true + break + } + } + if !found { + t.Error("expected faas-cli to match 'openfaas' via Owner field") + } +} + +func Test_FallbackFiresWhenTFIDFFails(t *testing.T) { + matches := fuzzySubstringFallback(makeTestTools(), []string{"kube"}) + + var found bool + for _, r := range matches { + if r.Tool.Name == "kubectl" && r.Score > 0 { + found = true + break + } + } + if !found { + t.Error("expected kubectl to match 'kube' via substring fallback on Owner") + } +} + +func Test_LevenshteinFuzzyNearMiss(t *testing.T) { + score := levenshteinFuzzyScore([]string{"faasd"}, "faas-cli") + if score <= 0 { + t.Error("expected positive fuzzy score for 'faasd' vs 'faas-cli'") + } +} + +func Test_FallbackReturnsEmptyForNoMatch(t *testing.T) { + matches := fuzzySubstringFallback(makeTestTools(), []string{"xyznonexistent"}) + if len(matches) != 0 { + t.Errorf("expected no fallback matches, got %d", len(matches)) + } +} + +func Test_FallbackSortedByScore(t *testing.T) { + matches := fuzzySubstringFallback(makeTestTools(), []string{"cli"}) + for i := 1; i < len(matches); i++ { + if matches[i].Score > matches[i-1].Score { + t.Errorf("results not sorted by score: %.2f > %.2f", matches[i].Score, matches[i-1].Score) + } + } +} diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/arkade-0.11.102/cmd/system/actions_runner.go new/arkade-0.11.105/cmd/system/actions_runner.go --- old/arkade-0.11.102/cmd/system/actions_runner.go 2026-06-22 16:31:10.000000000 +0200 +++ new/arkade-0.11.105/cmd/system/actions_runner.go 2026-06-30 13:53:49.000000000 +0200 @@ -100,7 +100,7 @@ fmt.Printf("Unpacking Actions Runner to: %s\n", path.Join(installPath, "actions-runner")) if err := spinWhile("Unpacking Actions Runner", func() error { - return archive.UntarNested(f, installPath, true, true, true) + return archive.UntarNested(f, installPath, true, true, true, false) }); err != nil { return err } diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/arkade-0.11.102/cmd/system/containerd.go new/arkade-0.11.105/cmd/system/containerd.go --- old/arkade-0.11.102/cmd/system/containerd.go 2026-06-22 16:31:10.000000000 +0200 +++ new/arkade-0.11.105/cmd/system/containerd.go 2026-06-30 13:53:49.000000000 +0200 @@ -120,7 +120,7 @@ tempDirName := os.TempDir() + "/containerd" if err := spinWhile("Unpacking containerd", func() error { - return archive.UntarNested(f, tempDirName, true, true, true) + return archive.UntarNested(f, tempDirName, true, true, true, false) }); err != nil { return err } diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/arkade-0.11.102/cmd/system/go.go new/arkade-0.11.105/cmd/system/go.go --- old/arkade-0.11.102/cmd/system/go.go 2026-06-22 16:31:10.000000000 +0200 +++ new/arkade-0.11.105/cmd/system/go.go 2026-06-30 13:53:49.000000000 +0200 @@ -96,7 +96,7 @@ fmt.Printf("Unpacking Go to: %s\n", path.Join(installPath, "go")) if err := spinWhile("Unpacking Go", func() error { - return archive.UntarNested(f, installPath, true, true, true) + return archive.UntarNested(f, installPath, true, true, true, false) }); err != nil { return err } diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/arkade-0.11.102/cmd/system/node.go new/arkade-0.11.105/cmd/system/node.go --- old/arkade-0.11.102/cmd/system/node.go 2026-06-22 16:31:10.000000000 +0200 +++ new/arkade-0.11.105/cmd/system/node.go 2026-06-30 13:53:49.000000000 +0200 @@ -149,7 +149,7 @@ fmt.Printf("Unpacking binaries to: %s\n", tempUnpackPath) } if err = spinWhile("Unpacking Node.js", func() error { - return archive.UntarNested(f, tempUnpackPath, true, true, true) + return archive.UntarNested(f, tempUnpackPath, true, true, true, false) }); err != nil { return err } diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/arkade-0.11.102/cmd/system/registry.go new/arkade-0.11.105/cmd/system/registry.go --- old/arkade-0.11.102/cmd/system/registry.go 2026-06-22 16:31:10.000000000 +0200 +++ new/arkade-0.11.105/cmd/system/registry.go 2026-06-30 13:53:49.000000000 +0200 @@ -135,7 +135,7 @@ tempDirName := fmt.Sprintf("%s/%s", os.TempDir(), toolName) defer os.RemoveAll(tempDirName) if err := spinWhile("Unpacking "+toolName, func() error { - return archive.UntarNested(f, tempDirName, true, true, true) + return archive.UntarNested(f, tempDirName, true, true, true, false) }); err != nil { return err } diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/arkade-0.11.102/main.go new/arkade-0.11.105/main.go --- old/arkade-0.11.102/main.go 2026-06-22 16:31:10.000000000 +0200 +++ new/arkade-0.11.105/main.go 2026-06-30 13:53:49.000000000 +0200 @@ -52,6 +52,7 @@ rootCmd.AddCommand(gha.MakeGHA()) rootCmd.AddCommand(system.MakeSystem()) rootCmd.AddCommand(oci.MakeOci()) + rootCmd.AddCommand(cmd.MakeSearch()) if err := rootCmd.Execute(); err != nil { os.Exit(1) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/arkade-0.11.102/pkg/archive/untar_nested.go new/arkade-0.11.105/pkg/archive/untar_nested.go --- old/arkade-0.11.102/pkg/archive/untar_nested.go 2026-06-22 16:31:10.000000000 +0200 +++ new/arkade-0.11.105/pkg/archive/untar_nested.go 2026-06-30 13:53:49.000000000 +0200 @@ -15,14 +15,18 @@ // UntarNested reads the gzip-compressed tar file from r and writes it into dir. // When allowSymlinks is false, any symlink entry in the archive causes an // error; when true, symlinks are extracted subject to containment checks. +// When flatExtract is true, all files are extracted directly into dir using +// only their basename, ignoring the archive's directory structure (e.g. +// usr/local/bin/foo -> dir/foo). This is analogous to tar's --strip-components, +// but strips all levels in one go. // Copyright 2017 The Go Authors. All rights reserved. // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. -func UntarNested(r io.Reader, dir string, gzipped, quiet, allowSymlinks bool) error { - return untarNested(r, dir, gzipped, quiet, allowSymlinks) +func UntarNested(r io.Reader, dir string, gzipped, quiet, allowSymlinks, flatExtract bool) error { + return untarNested(r, dir, gzipped, quiet, allowSymlinks, flatExtract) } -func untarNested(r io.Reader, dir string, gzipped, quiet, allowSymlinks bool) (err error) { +func untarNested(r io.Reader, dir string, gzipped, quiet, allowSymlinks, flatExtract bool) (err error) { t0 := time.Now() nFiles := 0 madeDir := map[string]bool{} @@ -59,6 +63,7 @@ cleanDir := filepath.Clean(dir) tr := tar.NewReader(r) + loggedChtimesError := false for { f, err := tr.Next() @@ -72,7 +77,11 @@ if !validRelPath(f.Name) { return fmt.Errorf("tar contained invalid name error %q", f.Name) } - rel := filepath.FromSlash(f.Name) + name := f.Name + if flatExtract { + name = filepath.Base(name) + } + rel := filepath.FromSlash(name) abs := filepath.Join(dir, rel) fi := f.FileInfo() @@ -141,6 +150,9 @@ } nFiles++ case mode.IsDir(): + if flatExtract { + continue + } // Guard before MkdirAll, as with regular files. if err := assertExistingPrefixWithinRoot(cleanDir, abs); err != nil { return err @@ -150,6 +162,10 @@ } madeDir[abs] = true case mode.Type() == os.ModeSymlink: + if flatExtract { + log.Printf("skipping symlink %q during flat extraction (target may not resolve correctly)", f.Name) + continue + } if !allowSymlinks { return fmt.Errorf("tar file entry %s is a symlink, but symlink extraction is disabled", f.Name) } diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/arkade-0.11.102/pkg/archive/untar_nested_test.go new/arkade-0.11.105/pkg/archive/untar_nested_test.go --- old/arkade-0.11.102/pkg/archive/untar_nested_test.go 2026-06-22 16:31:10.000000000 +0200 +++ new/arkade-0.11.105/pkg/archive/untar_nested_test.go 2026-06-30 13:53:49.000000000 +0200 @@ -60,7 +60,7 @@ {hdr: tar.Header{Name: "escape-link/escape.txt", Typeflag: tar.TypeReg, Mode: 0644}, body: []byte("escaped\n")}, }) - if err := UntarNested(bytes.NewReader(data), installDir, false, true, true); err == nil { + if err := UntarNested(bytes.NewReader(data), installDir, false, true, true, false); err == nil { t.Fatal("want error, got nil") } @@ -92,7 +92,7 @@ {hdr: tar.Header{Name: "escape-link/escape.txt", Typeflag: tar.TypeReg, Mode: 0644}, body: []byte("escaped\n")}, }) - if err := UntarNested(bytes.NewReader(data), installDir, false, true, true); err == nil { + if err := UntarNested(bytes.NewReader(data), installDir, false, true, true, false); err == nil { t.Fatal("want error, got nil") } @@ -121,7 +121,7 @@ {hdr: tar.Header{Name: "hop1/hop2/outside/escape.txt", Typeflag: tar.TypeReg, Mode: 0644}, body: []byte("escaped\n")}, }) - if err := UntarNested(bytes.NewReader(data), installDir, false, true, true); err == nil { + if err := UntarNested(bytes.NewReader(data), installDir, false, true, true, false); err == nil { t.Fatal("want error, got nil") } @@ -149,7 +149,7 @@ {hdr: tar.Header{Name: "hop1/hop2", Typeflag: tar.TypeSymlink, Linkname: "..", Mode: 0777}}, }) - if err := UntarNested(bytes.NewReader(data), installDir, false, true, true); err == nil { + if err := UntarNested(bytes.NewReader(data), installDir, false, true, true, false); err == nil { t.Fatal("want error, got nil") } @@ -188,7 +188,7 @@ {hdr: tar.Header{Name: "planted", Typeflag: tar.TypeSymlink, Linkname: "safe/file", Mode: 0777}}, }) - if err := UntarNested(bytes.NewReader(data), installDir, false, true, true); err == nil { + if err := UntarNested(bytes.NewReader(data), installDir, false, true, true, false); err == nil { t.Fatalf("expected extraction to be rejected, got nil error") } @@ -213,7 +213,7 @@ {hdr: tar.Header{Name: "README.md", Typeflag: tar.TypeReg, Mode: 0644}, body: []byte("hello\n")}, }) - if err := UntarNested(bytes.NewReader(data), installDir, false, true, true); err != nil { + if err := UntarNested(bytes.NewReader(data), installDir, false, true, true, false); err != nil { t.Fatalf("expected clean extraction, got: %v", err) } for _, rel := range []string{"bin/tool", "README.md"} { @@ -238,7 +238,7 @@ {hdr: tar.Header{Name: "link/file.txt", Typeflag: tar.TypeReg, Mode: 0644}, body: []byte("hello\n")}, }) - if err := UntarNested(bytes.NewReader(data), installDir, false, true, true); err != nil { + if err := UntarNested(bytes.NewReader(data), installDir, false, true, true, false); err != nil { t.Fatalf("expected clean extraction with write through internal symlink, got: %v", err) } if _, err := os.Stat(filepath.Join(installDir, "subdir", "file.txt")); err != nil { @@ -260,7 +260,7 @@ {hdr: tar.Header{Name: "link/newdir", Typeflag: tar.TypeDir, Mode: 0755}}, }) - if err := UntarNested(bytes.NewReader(data), installDir, false, true, true); err != nil { + if err := UntarNested(bytes.NewReader(data), installDir, false, true, true, false); err != nil { t.Fatalf("expected clean extraction with dir write-through internal symlink, got: %v", err) } if _, err := os.Stat(filepath.Join(installDir, "subdir", "newdir")); err != nil { @@ -296,7 +296,7 @@ {hdr: tar.Header{Name: "link/subdir/escape.txt", Typeflag: tar.TypeReg, Mode: 0644}, body: []byte("escaped\n")}, }) - if err := UntarNested(bytes.NewReader(data), installDir, false, true, true); err == nil { + if err := UntarNested(bytes.NewReader(data), installDir, false, true, true, false); err == nil { t.Fatal("want error, got nil") } @@ -332,7 +332,7 @@ {hdr: tar.Header{Name: "evil", Typeflag: tar.TypeReg, Mode: 0644}, body: []byte("HACKED")}, }) - if err := UntarNested(bytes.NewReader(data), installDir, false, true, true); err == nil { + if err := UntarNested(bytes.NewReader(data), installDir, false, true, true, false); err == nil { t.Fatal("want error, got nil") } @@ -354,7 +354,7 @@ {hdr: tar.Header{Name: "tool", Typeflag: tar.TypeSymlink, Linkname: "tool-v1", Mode: 0777}}, }) - if err := UntarNested(bytes.NewReader(data), installDir, false, true, true); err != nil { + if err := UntarNested(bytes.NewReader(data), installDir, false, true, true, false); err != nil { t.Fatalf("expected clean extraction with internal symlink, got: %v", err) } linkPath := filepath.Join(installDir, "tool") @@ -381,7 +381,7 @@ {hdr: tar.Header{Name: "nested/link", Typeflag: tar.TypeSymlink, Linkname: "tool-v1", Mode: 0777}}, }) - if err := UntarNested(bytes.NewReader(data), installDir, false, true, true); err != nil { + if err := UntarNested(bytes.NewReader(data), installDir, false, true, true, false); err != nil { t.Fatalf("expected clean extraction with on-demand parent dir for symlink, got: %v", err) } linkPath := filepath.Join(installDir, "nested", "link") @@ -408,10 +408,49 @@ {hdr: tar.Header{Name: "tool", Typeflag: tar.TypeSymlink, Linkname: "tool-v1", Mode: 0777}}, }) - if err := UntarNested(bytes.NewReader(data), installDir, false, true, false); err == nil { + if err := UntarNested(bytes.NewReader(data), installDir, false, true, false, false); err == nil { t.Fatalf("expected error when extracting symlink with symlinks disabled, got nil") } if _, err := os.Lstat(filepath.Join(installDir, "tool")); err == nil { t.Fatalf("expected symlink not to be created when symlinks disabled") } } + +// Flat extraction should place all files at the top level of the install dir, +// skip directory entries, and skip symlink entries. +func Test_UntarNested_FlatExtraction(t *testing.T) { + installDir, err := os.MkdirTemp("", "arkade-untar-flat-*") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(installDir) + + data := buildTar(t, []tarEntry{ + {hdr: tar.Header{Name: "usr/local/bin/", Typeflag: tar.TypeDir, Mode: 0755}}, + {hdr: tar.Header{Name: "usr/local/bin/slicer", Typeflag: tar.TypeReg, Mode: 0755}, body: []byte("bin\n")}, + {hdr: tar.Header{Name: "usr/share/man/slicer.1.gz", Typeflag: tar.TypeReg, Mode: 0644}, body: []byte("man\n")}, + {hdr: tar.Header{Name: "link", Typeflag: tar.TypeSymlink, Linkname: "slicer", Mode: 0777}}, + }) + + if err := UntarNested(bytes.NewReader(data), installDir, false, true, true, true); err != nil { + t.Fatalf("expected clean flat extraction, got: %v", err) + } + + // Files should be at top level. + for _, rel := range []string{"slicer", "slicer.1.gz"} { + if _, err := os.Stat(filepath.Join(installDir, rel)); err != nil { + t.Fatalf("expected %q to exist: %v", rel, err) + } + } + + // No nested directories should remain. + files, _ := filepath.Glob(filepath.Join(installDir, "usr")) + if len(files) > 0 { + t.Fatalf("expected no nested dirs with flat extraction, found: %v", files) + } + + // Symlink should not be created. + if _, err := os.Lstat(filepath.Join(installDir, "link")); err == nil { + t.Fatal("expected symlink to be skipped in flat mode") + } +} diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/arkade-0.11.102/pkg/get/get_test.go new/arkade-0.11.105/pkg/get/get_test.go --- old/arkade-0.11.102/pkg/get/get_test.go 2026-06-22 16:31:10.000000000 +0200 +++ new/arkade-0.11.105/pkg/get/get_test.go 2026-06-30 13:53:49.000000000 +0200 @@ -8147,45 +8147,6 @@ } } -func Test_DownloadFaasd(t *testing.T) { - tools := MakeTools() - name := "faasd" - const version = "0.18.8" - - tool := getTool(name, tools) - - tests := []test{ - { - os: "linux", - arch: arch64bit, - version: version, - url: `https://github.com/openfaas/faasd/releases/download/0.18.8/faasd`, - }, - { - os: "linux", - arch: archARM64, - version: version, - url: `https://github.com/openfaas/faasd/releases/download/0.18.8/faasd-arm64`, - }, - { - os: "linux", - arch: archARM7, - version: version, - url: `https://github.com/openfaas/faasd/releases/download/0.18.8/faasd-armhf`, - }, - } - - for _, tc := range tests { - got, _, err := tool.GetURL(tc.os, tc.arch, tc.version, false) - if err != nil { - t.Fatal(err) - } - if got != tc.url { - t.Errorf("want: %s, got: %s", tc.url, got) - } - } -} - func Test_DownloadKubeScore(t *testing.T) { tools := MakeTools() name := "kube-score" diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/arkade-0.11.102/pkg/get/tools.go new/arkade-0.11.105/pkg/get/tools.go --- old/arkade-0.11.102/pkg/get/tools.go 2026-06-22 16:31:10.000000000 +0200 +++ new/arkade-0.11.105/pkg/get/tools.go 2026-06-30 13:53:49.000000000 +0200 @@ -4545,25 +4545,6 @@ tools = append(tools, Tool{ - Owner: "openfaas", - Repo: "faasd", - Name: "faasd", - Description: "faasd - a lightweight & portable faas engine", - BinaryTemplate: ` - {{$arch := ""}} - - {{- if or (eq .Arch "aarch64") (eq .Arch "arm64") -}} - {{$arch = "-arm64"}} - {{- else if or (eq .Arch "armv6l") (eq .Arch "armv7l") -}} - {{$arch = "-armhf"}} - {{- end -}} - - {{.Name}}{{$arch}} - `, - }) - - tools = append(tools, - Tool{ Owner: "zegl", Repo: "kube-score", Name: "kube-score", ++++++ arkade.obsinfo ++++++ --- /var/tmp/diff_new_pack.CrtcEo/_old 2026-07-01 16:55:00.299316295 +0200 +++ /var/tmp/diff_new_pack.CrtcEo/_new 2026-07-01 16:55:00.335317538 +0200 @@ -1,5 +1,5 @@ name: arkade -version: 0.11.102 -mtime: 1782138670 -commit: 37af31c7b58de8f16a10067051e3bbe7ecb6aa79 +version: 0.11.105 +mtime: 1782820429 +commit: 15dcd3c06fd80143553e9fc53fb2ef59fb84a409 ++++++ vendor.tar.gz ++++++
