mirror of
https://github.com/gogrlx/snack.git
synced 2026-04-02 05:08:42 -07:00
feat(winget): add Windows Package Manager support
Implements the full snack.Manager interface for winget: - Install/Remove/Purge/Upgrade via winget CLI - Search/List/Info/IsInstalled/Version queries - Source (repository) management via RepoManager - Version querying via VersionQuerier - Targeted package upgrades via PackageUpgrader - Name normalization via NameNormalizer All commands use --disable-interactivity, --accept-source-agreements, and --accept-package-agreements for non-interactive operation. Parser handles winget's fixed-width tabular output by detecting column positions from the header/separator lines. Includes VT100 escape sequence stripping and progress line filtering. Windows-only via build tags; other platforms return ErrUnsupportedPlatform. Registered in detect_windows.go as the default Windows package manager.
This commit is contained in:
@@ -2,13 +2,19 @@
|
|||||||
|
|
||||||
package detect
|
package detect
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gogrlx/snack"
|
||||||
|
"github.com/gogrlx/snack/winget"
|
||||||
|
)
|
||||||
|
|
||||||
// candidates returns manager factories in probe order for Windows.
|
// candidates returns manager factories in probe order for Windows.
|
||||||
// Currently no Windows package managers are supported.
|
|
||||||
func candidates() []managerFactory {
|
func candidates() []managerFactory {
|
||||||
return nil
|
return []managerFactory{
|
||||||
|
func() snack.Manager { return winget.New() },
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// allManagers returns all known manager factories (for ByName).
|
// allManagers returns all known manager factories (for ByName).
|
||||||
func allManagers() []managerFactory {
|
func allManagers() []managerFactory {
|
||||||
return nil
|
return candidates()
|
||||||
}
|
}
|
||||||
|
|||||||
49
winget/capabilities.go
Normal file
49
winget/capabilities.go
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
package winget
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/gogrlx/snack"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Compile-time interface checks.
|
||||||
|
var (
|
||||||
|
_ snack.VersionQuerier = (*Winget)(nil)
|
||||||
|
_ snack.RepoManager = (*Winget)(nil)
|
||||||
|
_ snack.NameNormalizer = (*Winget)(nil)
|
||||||
|
)
|
||||||
|
|
||||||
|
// LatestVersion returns the latest available version of a package.
|
||||||
|
func (w *Winget) LatestVersion(ctx context.Context, pkg string) (string, error) {
|
||||||
|
return latestVersion(ctx, pkg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListUpgrades returns packages that have newer versions available.
|
||||||
|
func (w *Winget) ListUpgrades(ctx context.Context) ([]snack.Package, error) {
|
||||||
|
return listUpgrades(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpgradeAvailable reports whether a newer version is available.
|
||||||
|
func (w *Winget) UpgradeAvailable(ctx context.Context, pkg string) (bool, error) {
|
||||||
|
return upgradeAvailable(ctx, pkg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// VersionCmp compares two version strings.
|
||||||
|
func (w *Winget) VersionCmp(ctx context.Context, ver1, ver2 string) (int, error) {
|
||||||
|
return versionCmp(ctx, ver1, ver2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListRepos returns configured winget sources.
|
||||||
|
func (w *Winget) ListRepos(ctx context.Context) ([]snack.Repository, error) {
|
||||||
|
return sourceList(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddRepo adds a new winget source.
|
||||||
|
func (w *Winget) AddRepo(ctx context.Context, repo snack.Repository) error {
|
||||||
|
return sourceAdd(ctx, repo)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveRepo removes a configured winget source.
|
||||||
|
func (w *Winget) RemoveRepo(ctx context.Context, id string) error {
|
||||||
|
return sourceRemove(ctx, id)
|
||||||
|
}
|
||||||
19
winget/normalize.go
Normal file
19
winget/normalize.go
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
package winget
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
// normalizeName returns the canonical form of a winget package ID.
|
||||||
|
// Winget IDs use dot-separated Publisher.Package format (e.g.
|
||||||
|
// "Microsoft.VisualStudioCode"). This trims whitespace but otherwise
|
||||||
|
// preserves the ID as-is since winget IDs are case-insensitive but
|
||||||
|
// conventionally PascalCase.
|
||||||
|
func normalizeName(name string) string {
|
||||||
|
return strings.TrimSpace(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseArch extracts the architecture from a winget package name if present.
|
||||||
|
// Winget IDs do not include architecture suffixes, so this returns the
|
||||||
|
// name unchanged with an empty string.
|
||||||
|
func parseArch(name string) (string, string) {
|
||||||
|
return name, ""
|
||||||
|
}
|
||||||
333
winget/parse.go
Normal file
333
winget/parse.go
Normal file
@@ -0,0 +1,333 @@
|
|||||||
|
package winget
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gogrlx/snack"
|
||||||
|
)
|
||||||
|
|
||||||
|
// parseTable parses winget tabular output (list, search, upgrade).
|
||||||
|
//
|
||||||
|
// Winget uses fixed-width columns whose positions are determined by a
|
||||||
|
// header row with dashes (e.g. "---- ------ -------").
|
||||||
|
// The header names vary by locale, so we detect columns positionally.
|
||||||
|
//
|
||||||
|
// Typical `winget list` output:
|
||||||
|
//
|
||||||
|
// Name Id Version Available Source
|
||||||
|
// --------------------------------------------------------------
|
||||||
|
// Visual Studio Microsoft.VisualStudio 17.8.0 17.9.0 winget
|
||||||
|
//
|
||||||
|
// Typical `winget search` output:
|
||||||
|
//
|
||||||
|
// Name Id Version Match Source
|
||||||
|
// --------------------------------------------------------------
|
||||||
|
// Visual Studio Microsoft.VisualStudio 17.9.0 winget
|
||||||
|
//
|
||||||
|
// When installed is true, we mark all parsed packages as Installed.
|
||||||
|
func parseTable(output string, installed bool) []snack.Package {
|
||||||
|
lines := strings.Split(output, "\n")
|
||||||
|
|
||||||
|
// Find the separator line (all dashes/spaces) to determine column positions.
|
||||||
|
sepIdx := -1
|
||||||
|
for i, line := range lines {
|
||||||
|
trimmed := strings.TrimSpace(line)
|
||||||
|
if trimmed == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if isSeparatorLine(trimmed) {
|
||||||
|
sepIdx = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if sepIdx < 1 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the header line (just above separator) to determine column starts.
|
||||||
|
header := lines[sepIdx-1]
|
||||||
|
cols := detectColumns(header)
|
||||||
|
if len(cols) < 2 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var pkgs []snack.Package
|
||||||
|
for _, line := range lines[sepIdx+1:] {
|
||||||
|
if strings.TrimSpace(line) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Skip footer lines like "X upgrades available."
|
||||||
|
if isFooterLine(line) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fields := extractFields(line, cols)
|
||||||
|
if len(fields) < 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pkg := snack.Package{
|
||||||
|
Name: fields[0],
|
||||||
|
Installed: installed,
|
||||||
|
}
|
||||||
|
// Column order: Name, Id, Version, [Available], [Source]
|
||||||
|
// For search: Name, Id, Version, [Match], [Source]
|
||||||
|
if len(fields) >= 3 {
|
||||||
|
pkg.Version = fields[2]
|
||||||
|
}
|
||||||
|
// Use the ID as the package name for consistency (winget uses
|
||||||
|
// Publisher.Package IDs as the canonical identifier).
|
||||||
|
if len(fields) >= 2 && fields[1] != "" {
|
||||||
|
pkg.Description = fields[0] // keep display name as description
|
||||||
|
pkg.Name = fields[1]
|
||||||
|
}
|
||||||
|
// If there's a Source column (typically the last), use it.
|
||||||
|
if len(fields) >= 5 && fields[len(fields)-1] != "" {
|
||||||
|
pkg.Repository = fields[len(fields)-1]
|
||||||
|
}
|
||||||
|
pkgs = append(pkgs, pkg)
|
||||||
|
}
|
||||||
|
return pkgs
|
||||||
|
}
|
||||||
|
|
||||||
|
// isSeparatorLine returns true if the line is a column separator
|
||||||
|
// (composed entirely of dashes and spaces, with at least some dashes).
|
||||||
|
func isSeparatorLine(line string) bool {
|
||||||
|
hasDash := false
|
||||||
|
for _, c := range line {
|
||||||
|
switch c {
|
||||||
|
case '-':
|
||||||
|
hasDash = true
|
||||||
|
case ' ', '\t':
|
||||||
|
// allowed
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return hasDash
|
||||||
|
}
|
||||||
|
|
||||||
|
// detectColumns returns the starting index of each column based on
|
||||||
|
// the header line. Columns are separated by 2+ spaces.
|
||||||
|
func detectColumns(header string) []int {
|
||||||
|
var cols []int
|
||||||
|
inWord := false
|
||||||
|
for i, c := range header {
|
||||||
|
if c != ' ' && c != '\t' {
|
||||||
|
if !inWord {
|
||||||
|
cols = append(cols, i)
|
||||||
|
inWord = true
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Need at least 2 spaces to end a column
|
||||||
|
if inWord && i+1 < len(header) && (header[i+1] == ' ' || header[i+1] == '\t') {
|
||||||
|
inWord = false
|
||||||
|
} else if inWord && i+1 >= len(header) {
|
||||||
|
inWord = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cols
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractFields splits a data line according to detected column positions.
|
||||||
|
func extractFields(line string, cols []int) []string {
|
||||||
|
fields := make([]string, len(cols))
|
||||||
|
for i, start := range cols {
|
||||||
|
if start >= len(line) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
end := len(line)
|
||||||
|
if i+1 < len(cols) {
|
||||||
|
end = cols[i+1]
|
||||||
|
if end > len(line) {
|
||||||
|
end = len(line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fields[i] = strings.TrimSpace(line[start:end])
|
||||||
|
}
|
||||||
|
return fields
|
||||||
|
}
|
||||||
|
|
||||||
|
// isFooterLine returns true for winget output footer lines.
|
||||||
|
func isFooterLine(line string) bool {
|
||||||
|
trimmed := strings.TrimSpace(line)
|
||||||
|
if trimmed == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
lower := strings.ToLower(trimmed)
|
||||||
|
if strings.Contains(lower, "upgrades available") ||
|
||||||
|
strings.Contains(lower, "package(s)") ||
|
||||||
|
strings.Contains(lower, "installed package") ||
|
||||||
|
strings.HasPrefix(lower, "the following") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseShow parses `winget show` key-value output into a Package.
|
||||||
|
//
|
||||||
|
// Typical output:
|
||||||
|
//
|
||||||
|
// Found Visual Studio Code [Microsoft.VisualStudioCode]
|
||||||
|
// Version: 1.85.0
|
||||||
|
// Publisher: Microsoft Corporation
|
||||||
|
// Description: Code editing. Redefined.
|
||||||
|
// ...
|
||||||
|
func parseShow(output string) *snack.Package {
|
||||||
|
pkg := &snack.Package{}
|
||||||
|
for _, line := range strings.Split(output, "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Parse "Found <Name> [<Id>]" header line.
|
||||||
|
if strings.HasPrefix(line, "Found ") {
|
||||||
|
if idx := strings.LastIndex(line, "["); idx > 0 {
|
||||||
|
endIdx := strings.LastIndex(line, "]")
|
||||||
|
if endIdx > idx {
|
||||||
|
pkg.Name = strings.TrimSpace(line[idx+1 : endIdx])
|
||||||
|
pkg.Description = strings.TrimSpace(line[6:idx])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
idx := strings.Index(line, ":")
|
||||||
|
if idx < 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := strings.TrimSpace(line[:idx])
|
||||||
|
val := strings.TrimSpace(line[idx+1:])
|
||||||
|
switch strings.ToLower(key) {
|
||||||
|
case "version":
|
||||||
|
pkg.Version = val
|
||||||
|
case "description":
|
||||||
|
if pkg.Description == "" {
|
||||||
|
pkg.Description = val
|
||||||
|
}
|
||||||
|
case "publisher":
|
||||||
|
// informational only
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if pkg.Name == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return pkg
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseSourceList parses `winget source list` output into Repositories.
|
||||||
|
//
|
||||||
|
// Output format:
|
||||||
|
//
|
||||||
|
// Name Argument
|
||||||
|
// ----------------------------------------------
|
||||||
|
// winget https://cdn.winget.microsoft.com/cache
|
||||||
|
// msstore https://storeedgefd.dsx.mp.microsoft.com/v9.0
|
||||||
|
func parseSourceList(output string) []snack.Repository {
|
||||||
|
lines := strings.Split(output, "\n")
|
||||||
|
sepIdx := -1
|
||||||
|
for i, line := range lines {
|
||||||
|
if isSeparatorLine(strings.TrimSpace(line)) {
|
||||||
|
sepIdx = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if sepIdx < 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var repos []snack.Repository
|
||||||
|
for _, line := range lines[sepIdx+1:] {
|
||||||
|
trimmed := strings.TrimSpace(line)
|
||||||
|
if trimmed == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fields := strings.Fields(trimmed)
|
||||||
|
if len(fields) < 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
repos = append(repos, snack.Repository{
|
||||||
|
ID: fields[0],
|
||||||
|
Name: fields[0],
|
||||||
|
URL: fields[1],
|
||||||
|
Enabled: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return repos
|
||||||
|
}
|
||||||
|
|
||||||
|
// stripVT removes ANSI/VT100 escape sequences from a string.
|
||||||
|
func stripVT(s string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
b.Grow(len(s))
|
||||||
|
i := 0
|
||||||
|
for i < len(s) {
|
||||||
|
if s[i] == 0x1b && i+1 < len(s) && s[i+1] == '[' {
|
||||||
|
// Skip CSI sequence: ESC [ ... final byte
|
||||||
|
j := i + 2
|
||||||
|
for j < len(s) && s[j] >= 0x20 && s[j] <= 0x3f {
|
||||||
|
j++
|
||||||
|
}
|
||||||
|
if j < len(s) {
|
||||||
|
j++ // skip final byte
|
||||||
|
}
|
||||||
|
i = j
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b.WriteByte(s[i])
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// isProgressLine returns true for lines that are only progress indicators.
|
||||||
|
func isProgressLine(line string) bool {
|
||||||
|
trimmed := strings.TrimSpace(line)
|
||||||
|
if trimmed == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// Lines containing block characters and/or percentage
|
||||||
|
hasBlock := strings.ContainsAny(trimmed, "█▓░")
|
||||||
|
hasPercent := strings.Contains(trimmed, "%")
|
||||||
|
if hasBlock || (hasPercent && len(trimmed) < 40) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// semverCmp does a basic semver-ish comparison.
|
||||||
|
// Returns -1 if a < b, 0 if equal, 1 if a > b.
|
||||||
|
func semverCmp(a, b string) int {
|
||||||
|
partsA := strings.Split(a, ".")
|
||||||
|
partsB := strings.Split(b, ".")
|
||||||
|
|
||||||
|
maxLen := len(partsA)
|
||||||
|
if len(partsB) > maxLen {
|
||||||
|
maxLen = len(partsB)
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < maxLen; i++ {
|
||||||
|
var numA, numB int
|
||||||
|
if i < len(partsA) {
|
||||||
|
numA, _ = strconv.Atoi(stripNonNumeric(partsA[i]))
|
||||||
|
}
|
||||||
|
if i < len(partsB) {
|
||||||
|
numB, _ = strconv.Atoi(stripNonNumeric(partsB[i]))
|
||||||
|
}
|
||||||
|
if numA < numB {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
if numA > numB {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// stripNonNumeric keeps only leading digits from a string.
|
||||||
|
func stripNonNumeric(s string) string {
|
||||||
|
for i, c := range s {
|
||||||
|
if c < '0' || c > '9' {
|
||||||
|
return s[:i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
103
winget/winget.go
Normal file
103
winget/winget.go
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
// Package winget provides Go bindings for the Windows Package Manager (winget).
|
||||||
|
package winget
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/gogrlx/snack"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Winget wraps the winget CLI.
|
||||||
|
type Winget struct {
|
||||||
|
snack.Locker
|
||||||
|
}
|
||||||
|
|
||||||
|
// New returns a new Winget manager.
|
||||||
|
func New() *Winget {
|
||||||
|
return &Winget{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Name returns "winget".
|
||||||
|
func (w *Winget) Name() string { return "winget" }
|
||||||
|
|
||||||
|
// Available reports whether winget is present on the system.
|
||||||
|
func (w *Winget) Available() bool { return available() }
|
||||||
|
|
||||||
|
// Install one or more packages.
|
||||||
|
func (w *Winget) Install(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) (snack.InstallResult, error) {
|
||||||
|
w.Lock()
|
||||||
|
defer w.Unlock()
|
||||||
|
return install(ctx, pkgs, opts...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove one or more packages.
|
||||||
|
func (w *Winget) Remove(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) (snack.RemoveResult, error) {
|
||||||
|
w.Lock()
|
||||||
|
defer w.Unlock()
|
||||||
|
return remove(ctx, pkgs, opts...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Purge removes packages including configuration data.
|
||||||
|
func (w *Winget) Purge(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) error {
|
||||||
|
w.Lock()
|
||||||
|
defer w.Unlock()
|
||||||
|
return purge(ctx, pkgs, opts...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upgrade all installed packages.
|
||||||
|
func (w *Winget) Upgrade(ctx context.Context, opts ...snack.Option) error {
|
||||||
|
w.Lock()
|
||||||
|
defer w.Unlock()
|
||||||
|
return upgrade(ctx, opts...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update refreshes the winget source index.
|
||||||
|
func (w *Winget) Update(ctx context.Context) error {
|
||||||
|
return update(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// List returns all installed packages.
|
||||||
|
func (w *Winget) List(ctx context.Context) ([]snack.Package, error) {
|
||||||
|
return list(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search queries the winget repository for packages matching the query.
|
||||||
|
func (w *Winget) Search(ctx context.Context, query string) ([]snack.Package, error) {
|
||||||
|
return search(ctx, query)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Info returns details about a specific package.
|
||||||
|
func (w *Winget) Info(ctx context.Context, pkg string) (*snack.Package, error) {
|
||||||
|
return info(ctx, pkg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsInstalled reports whether a package is currently installed.
|
||||||
|
func (w *Winget) IsInstalled(ctx context.Context, pkg string) (bool, error) {
|
||||||
|
return isInstalled(ctx, pkg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Version returns the installed version of a package.
|
||||||
|
func (w *Winget) Version(ctx context.Context, pkg string) (string, error) {
|
||||||
|
return version(ctx, pkg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NormalizeName returns the canonical form of a winget package ID.
|
||||||
|
func (w *Winget) NormalizeName(name string) string {
|
||||||
|
return normalizeName(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseArch extracts the architecture from a package name if present.
|
||||||
|
func (w *Winget) ParseArch(name string) (string, string) {
|
||||||
|
return parseArch(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify interface compliance at compile time.
|
||||||
|
var _ snack.Manager = (*Winget)(nil)
|
||||||
|
var _ snack.PackageUpgrader = (*Winget)(nil)
|
||||||
|
|
||||||
|
// UpgradePackages upgrades specific installed packages.
|
||||||
|
func (w *Winget) UpgradePackages(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) (snack.InstallResult, error) {
|
||||||
|
w.Lock()
|
||||||
|
defer w.Unlock()
|
||||||
|
return upgradePackages(ctx, pkgs, opts...)
|
||||||
|
}
|
||||||
83
winget/winget_other.go
Normal file
83
winget/winget_other.go
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package winget
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/gogrlx/snack"
|
||||||
|
)
|
||||||
|
|
||||||
|
func available() bool { return false }
|
||||||
|
|
||||||
|
func install(_ context.Context, _ []snack.Target, _ ...snack.Option) (snack.InstallResult, error) {
|
||||||
|
return snack.InstallResult{}, snack.ErrUnsupportedPlatform
|
||||||
|
}
|
||||||
|
|
||||||
|
func remove(_ context.Context, _ []snack.Target, _ ...snack.Option) (snack.RemoveResult, error) {
|
||||||
|
return snack.RemoveResult{}, snack.ErrUnsupportedPlatform
|
||||||
|
}
|
||||||
|
|
||||||
|
func purge(_ context.Context, _ []snack.Target, _ ...snack.Option) error {
|
||||||
|
return snack.ErrUnsupportedPlatform
|
||||||
|
}
|
||||||
|
|
||||||
|
func upgrade(_ context.Context, _ ...snack.Option) error {
|
||||||
|
return snack.ErrUnsupportedPlatform
|
||||||
|
}
|
||||||
|
|
||||||
|
func update(_ context.Context) error {
|
||||||
|
return snack.ErrUnsupportedPlatform
|
||||||
|
}
|
||||||
|
|
||||||
|
func list(_ context.Context) ([]snack.Package, error) {
|
||||||
|
return nil, snack.ErrUnsupportedPlatform
|
||||||
|
}
|
||||||
|
|
||||||
|
func search(_ context.Context, _ string) ([]snack.Package, error) {
|
||||||
|
return nil, snack.ErrUnsupportedPlatform
|
||||||
|
}
|
||||||
|
|
||||||
|
func info(_ context.Context, _ string) (*snack.Package, error) {
|
||||||
|
return nil, snack.ErrUnsupportedPlatform
|
||||||
|
}
|
||||||
|
|
||||||
|
func isInstalled(_ context.Context, _ string) (bool, error) {
|
||||||
|
return false, snack.ErrUnsupportedPlatform
|
||||||
|
}
|
||||||
|
|
||||||
|
func version(_ context.Context, _ string) (string, error) {
|
||||||
|
return "", snack.ErrUnsupportedPlatform
|
||||||
|
}
|
||||||
|
|
||||||
|
func latestVersion(_ context.Context, _ string) (string, error) {
|
||||||
|
return "", snack.ErrUnsupportedPlatform
|
||||||
|
}
|
||||||
|
|
||||||
|
func listUpgrades(_ context.Context) ([]snack.Package, error) {
|
||||||
|
return nil, snack.ErrUnsupportedPlatform
|
||||||
|
}
|
||||||
|
|
||||||
|
func upgradeAvailable(_ context.Context, _ string) (bool, error) {
|
||||||
|
return false, snack.ErrUnsupportedPlatform
|
||||||
|
}
|
||||||
|
|
||||||
|
func versionCmp(_ context.Context, _, _ string) (int, error) {
|
||||||
|
return 0, snack.ErrUnsupportedPlatform
|
||||||
|
}
|
||||||
|
|
||||||
|
func upgradePackages(_ context.Context, _ []snack.Target, _ ...snack.Option) (snack.InstallResult, error) {
|
||||||
|
return snack.InstallResult{}, snack.ErrUnsupportedPlatform
|
||||||
|
}
|
||||||
|
|
||||||
|
func sourceList(_ context.Context) ([]snack.Repository, error) {
|
||||||
|
return nil, snack.ErrUnsupportedPlatform
|
||||||
|
}
|
||||||
|
|
||||||
|
func sourceAdd(_ context.Context, _ snack.Repository) error {
|
||||||
|
return snack.ErrUnsupportedPlatform
|
||||||
|
}
|
||||||
|
|
||||||
|
func sourceRemove(_ context.Context, _ string) error {
|
||||||
|
return snack.ErrUnsupportedPlatform
|
||||||
|
}
|
||||||
449
winget/winget_test.go
Normal file
449
winget/winget_test.go
Normal file
@@ -0,0 +1,449 @@
|
|||||||
|
package winget
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gogrlx/snack"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseTableList(t *testing.T) {
|
||||||
|
input := `Name Id Version Available Source
|
||||||
|
---------------------------------------------------------------------------------------------------------
|
||||||
|
Visual Studio Code Microsoft.VisualStudioCode 1.85.0 1.86.0 winget
|
||||||
|
Git Git.Git 2.43.0 winget
|
||||||
|
Google Chrome Google.Chrome 120.0.6099.130 winget
|
||||||
|
`
|
||||||
|
pkgs := parseTable(input, true)
|
||||||
|
if len(pkgs) != 3 {
|
||||||
|
t.Fatalf("expected 3 packages, got %d", len(pkgs))
|
||||||
|
}
|
||||||
|
if pkgs[0].Name != "Microsoft.VisualStudioCode" {
|
||||||
|
t.Errorf("expected ID as name, got %q", pkgs[0].Name)
|
||||||
|
}
|
||||||
|
if pkgs[0].Description != "Visual Studio Code" {
|
||||||
|
t.Errorf("expected display name as description, got %q", pkgs[0].Description)
|
||||||
|
}
|
||||||
|
if pkgs[0].Version != "1.85.0" {
|
||||||
|
t.Errorf("expected version '1.85.0', got %q", pkgs[0].Version)
|
||||||
|
}
|
||||||
|
if !pkgs[0].Installed {
|
||||||
|
t.Error("expected Installed=true")
|
||||||
|
}
|
||||||
|
if pkgs[0].Repository != "winget" {
|
||||||
|
t.Errorf("expected repository 'winget', got %q", pkgs[0].Repository)
|
||||||
|
}
|
||||||
|
if pkgs[1].Name != "Git.Git" {
|
||||||
|
t.Errorf("expected 'Git.Git', got %q", pkgs[1].Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseTableSearch(t *testing.T) {
|
||||||
|
input := `Name Id Version Match Source
|
||||||
|
-------------------------------------------------------------------------------------------------
|
||||||
|
Visual Studio Code Microsoft.VisualStudioCode 1.86.0 Moniker: vscode winget
|
||||||
|
VSCodium VSCodium.VSCodium 1.85.2 winget
|
||||||
|
`
|
||||||
|
pkgs := parseTable(input, false)
|
||||||
|
if len(pkgs) != 2 {
|
||||||
|
t.Fatalf("expected 2 packages, got %d", len(pkgs))
|
||||||
|
}
|
||||||
|
if pkgs[0].Name != "Microsoft.VisualStudioCode" {
|
||||||
|
t.Errorf("expected 'Microsoft.VisualStudioCode', got %q", pkgs[0].Name)
|
||||||
|
}
|
||||||
|
if pkgs[0].Installed {
|
||||||
|
t.Error("expected Installed=false for search")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseTableEmpty(t *testing.T) {
|
||||||
|
input := `Name Id Version Source
|
||||||
|
------------------------------
|
||||||
|
`
|
||||||
|
pkgs := parseTable(input, true)
|
||||||
|
if len(pkgs) != 0 {
|
||||||
|
t.Fatalf("expected 0 packages, got %d", len(pkgs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseTableNoSeparator(t *testing.T) {
|
||||||
|
pkgs := parseTable("No installed package found matching input criteria.", true)
|
||||||
|
if len(pkgs) != 0 {
|
||||||
|
t.Fatalf("expected 0 packages, got %d", len(pkgs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseTableWithFooter(t *testing.T) {
|
||||||
|
input := `Name Id Version Available Source
|
||||||
|
--------------------------------------------------------------
|
||||||
|
Git Git.Git 2.43.0 2.44.0 winget
|
||||||
|
3 upgrades available.
|
||||||
|
`
|
||||||
|
pkgs := parseTable(input, false)
|
||||||
|
if len(pkgs) != 1 {
|
||||||
|
t.Fatalf("expected 1 package, got %d", len(pkgs))
|
||||||
|
}
|
||||||
|
if pkgs[0].Name != "Git.Git" {
|
||||||
|
t.Errorf("expected 'Git.Git', got %q", pkgs[0].Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseTableUpgrade(t *testing.T) {
|
||||||
|
input := `Name Id Version Available Source
|
||||||
|
---------------------------------------------------------------------------------------------------------
|
||||||
|
Visual Studio Code Microsoft.VisualStudioCode 1.85.0 1.86.0 winget
|
||||||
|
Node.js OpenJS.NodeJS 20.10.0 21.5.0 winget
|
||||||
|
2 upgrades available.
|
||||||
|
`
|
||||||
|
pkgs := parseTable(input, false)
|
||||||
|
if len(pkgs) != 2 {
|
||||||
|
t.Fatalf("expected 2 packages, got %d", len(pkgs))
|
||||||
|
}
|
||||||
|
if pkgs[0].Name != "Microsoft.VisualStudioCode" {
|
||||||
|
t.Errorf("expected 'Microsoft.VisualStudioCode', got %q", pkgs[0].Name)
|
||||||
|
}
|
||||||
|
if pkgs[1].Name != "OpenJS.NodeJS" {
|
||||||
|
t.Errorf("expected 'OpenJS.NodeJS', got %q", pkgs[1].Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseShow(t *testing.T) {
|
||||||
|
input := `Found Visual Studio Code [Microsoft.VisualStudioCode]
|
||||||
|
Version: 1.86.0
|
||||||
|
Publisher: Microsoft Corporation
|
||||||
|
Publisher URL: https://code.visualstudio.com
|
||||||
|
Author: Microsoft Corporation
|
||||||
|
Moniker: vscode
|
||||||
|
Description: Code editing. Redefined.
|
||||||
|
License: MIT
|
||||||
|
Installer Type: inno
|
||||||
|
`
|
||||||
|
pkg := parseShow(input)
|
||||||
|
if pkg == nil {
|
||||||
|
t.Fatal("expected non-nil package")
|
||||||
|
}
|
||||||
|
if pkg.Name != "Microsoft.VisualStudioCode" {
|
||||||
|
t.Errorf("expected 'Microsoft.VisualStudioCode', got %q", pkg.Name)
|
||||||
|
}
|
||||||
|
if pkg.Version != "1.86.0" {
|
||||||
|
t.Errorf("expected version '1.86.0', got %q", pkg.Version)
|
||||||
|
}
|
||||||
|
if pkg.Description != "Visual Studio Code" {
|
||||||
|
t.Errorf("expected 'Visual Studio Code', got %q", pkg.Description)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseShowNotFound(t *testing.T) {
|
||||||
|
pkg := parseShow("No package found matching input criteria.")
|
||||||
|
if pkg != nil {
|
||||||
|
t.Error("expected nil for not-found output")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseShowEmpty(t *testing.T) {
|
||||||
|
pkg := parseShow("")
|
||||||
|
if pkg != nil {
|
||||||
|
t.Error("expected nil for empty input")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseShowNoID(t *testing.T) {
|
||||||
|
input := `Version: 1.0.0
|
||||||
|
Publisher: Someone
|
||||||
|
`
|
||||||
|
pkg := parseShow(input)
|
||||||
|
if pkg != nil {
|
||||||
|
t.Error("expected nil when no Found header with ID")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseSourceList(t *testing.T) {
|
||||||
|
input := `Name Argument
|
||||||
|
--------------------------------------------------------------
|
||||||
|
winget https://cdn.winget.microsoft.com/cache
|
||||||
|
msstore https://storeedgefd.dsx.mp.microsoft.com/v9.0
|
||||||
|
`
|
||||||
|
repos := parseSourceList(input)
|
||||||
|
if len(repos) != 2 {
|
||||||
|
t.Fatalf("expected 2 repos, got %d", len(repos))
|
||||||
|
}
|
||||||
|
if repos[0].Name != "winget" {
|
||||||
|
t.Errorf("expected 'winget', got %q", repos[0].Name)
|
||||||
|
}
|
||||||
|
if repos[0].URL != "https://cdn.winget.microsoft.com/cache" {
|
||||||
|
t.Errorf("unexpected URL: %q", repos[0].URL)
|
||||||
|
}
|
||||||
|
if !repos[0].Enabled {
|
||||||
|
t.Error("expected Enabled=true")
|
||||||
|
}
|
||||||
|
if repos[1].Name != "msstore" {
|
||||||
|
t.Errorf("expected 'msstore', got %q", repos[1].Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseSourceListEmpty(t *testing.T) {
|
||||||
|
repos := parseSourceList("")
|
||||||
|
if len(repos) != 0 {
|
||||||
|
t.Fatalf("expected 0 repos, got %d", len(repos))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsSeparatorLine(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
input string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"---", true},
|
||||||
|
{"--------------------------------------------------------------", true},
|
||||||
|
{"--- --- ---", true},
|
||||||
|
{"Name Id Version", false},
|
||||||
|
{"", false},
|
||||||
|
{" ", false},
|
||||||
|
{"--a--", false},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.input, func(t *testing.T) {
|
||||||
|
got := isSeparatorLine(tt.input)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("isSeparatorLine(%q) = %v, want %v", tt.input, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectColumns(t *testing.T) {
|
||||||
|
header := "Name Id Version Available Source"
|
||||||
|
cols := detectColumns(header)
|
||||||
|
if len(cols) != 5 {
|
||||||
|
t.Fatalf("expected 5 columns, got %d: %v", len(cols), cols)
|
||||||
|
}
|
||||||
|
if cols[0] != 0 {
|
||||||
|
t.Errorf("expected first column at 0, got %d", cols[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractFields(t *testing.T) {
|
||||||
|
line := "Visual Studio Code Microsoft.VisualStudioCode 1.85.0 1.86.0 winget"
|
||||||
|
cols := []int{0, 34, 67, 82, 93}
|
||||||
|
fields := extractFields(line, cols)
|
||||||
|
if len(fields) != 5 {
|
||||||
|
t.Fatalf("expected 5 fields, got %d", len(fields))
|
||||||
|
}
|
||||||
|
if fields[0] != "Visual Studio Code" {
|
||||||
|
t.Errorf("field[0] = %q, want 'Visual Studio Code'", fields[0])
|
||||||
|
}
|
||||||
|
if fields[1] != "Microsoft.VisualStudioCode" {
|
||||||
|
t.Errorf("field[1] = %q, want 'Microsoft.VisualStudioCode'", fields[1])
|
||||||
|
}
|
||||||
|
if fields[2] != "1.85.0" {
|
||||||
|
t.Errorf("field[2] = %q, want '1.85.0'", fields[2])
|
||||||
|
}
|
||||||
|
if fields[3] != "1.86.0" {
|
||||||
|
t.Errorf("field[3] = %q, want '1.86.0'", fields[3])
|
||||||
|
}
|
||||||
|
if fields[4] != "winget" {
|
||||||
|
t.Errorf("field[4] = %q, want 'winget'", fields[4])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractFieldsShortLine(t *testing.T) {
|
||||||
|
line := "Git"
|
||||||
|
cols := []int{0, 34, 67}
|
||||||
|
fields := extractFields(line, cols)
|
||||||
|
if fields[0] != "Git" {
|
||||||
|
t.Errorf("field[0] = %q, want 'Git'", fields[0])
|
||||||
|
}
|
||||||
|
if fields[1] != "" {
|
||||||
|
t.Errorf("field[1] = %q, want ''", fields[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsFooterLine(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
input string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"3 upgrades available.", true},
|
||||||
|
{"1 package(s) found.", true},
|
||||||
|
{"No installed package found.", true},
|
||||||
|
{"The following packages have upgrades:", true},
|
||||||
|
{"Git Git.Git 2.43.0", false},
|
||||||
|
{"", true},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.input, func(t *testing.T) {
|
||||||
|
got := isFooterLine(tt.input)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("isFooterLine(%q) = %v, want %v", tt.input, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSemverCmp(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
a, b string
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"equal", "1.0.0", "1.0.0", 0},
|
||||||
|
{"less major", "1.0.0", "2.0.0", -1},
|
||||||
|
{"greater major", "2.0.0", "1.0.0", 1},
|
||||||
|
{"less patch", "1.2.3", "1.2.4", -1},
|
||||||
|
{"multi-digit minor", "1.10.0", "1.9.0", 1},
|
||||||
|
{"short vs long equal", "1.0", "1.0.0", 0},
|
||||||
|
{"real versions", "1.85.0", "1.86.0", -1},
|
||||||
|
{"single component", "5", "3", 1},
|
||||||
|
{"empty vs empty", "", "", 0},
|
||||||
|
{"empty vs version", "", "1.0", -1},
|
||||||
|
{"version vs empty", "1.0", "", 1},
|
||||||
|
{"four components", "120.0.6099.130", "120.0.6099.131", -1},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := semverCmp(tt.a, tt.b)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("semverCmp(%q, %q) = %d, want %d", tt.a, tt.b, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStripNonNumeric(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
input string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"123", "123"},
|
||||||
|
{"123abc", "123"},
|
||||||
|
{"abc", ""},
|
||||||
|
{"0beta", "0"},
|
||||||
|
{"", ""},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.input, func(t *testing.T) {
|
||||||
|
got := stripNonNumeric(tt.input)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("stripNonNumeric(%q) = %q, want %q", tt.input, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInterfaceCompliance(t *testing.T) {
|
||||||
|
var _ snack.Manager = (*Winget)(nil)
|
||||||
|
var _ snack.VersionQuerier = (*Winget)(nil)
|
||||||
|
var _ snack.RepoManager = (*Winget)(nil)
|
||||||
|
var _ snack.NameNormalizer = (*Winget)(nil)
|
||||||
|
var _ snack.PackageUpgrader = (*Winget)(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCapabilities(t *testing.T) {
|
||||||
|
caps := snack.GetCapabilities(New())
|
||||||
|
if !caps.VersionQuery {
|
||||||
|
t.Error("expected VersionQuery=true")
|
||||||
|
}
|
||||||
|
if !caps.RepoManagement {
|
||||||
|
t.Error("expected RepoManagement=true")
|
||||||
|
}
|
||||||
|
if !caps.NameNormalize {
|
||||||
|
t.Error("expected NameNormalize=true")
|
||||||
|
}
|
||||||
|
if !caps.PackageUpgrade {
|
||||||
|
t.Error("expected PackageUpgrade=true")
|
||||||
|
}
|
||||||
|
// Should be false
|
||||||
|
if caps.Clean {
|
||||||
|
t.Error("expected Clean=false")
|
||||||
|
}
|
||||||
|
if caps.FileOwnership {
|
||||||
|
t.Error("expected FileOwnership=false")
|
||||||
|
}
|
||||||
|
if caps.DryRun {
|
||||||
|
t.Error("expected DryRun=false")
|
||||||
|
}
|
||||||
|
if caps.Hold {
|
||||||
|
t.Error("expected Hold=false")
|
||||||
|
}
|
||||||
|
if caps.KeyManagement {
|
||||||
|
t.Error("expected KeyManagement=false")
|
||||||
|
}
|
||||||
|
if caps.Groups {
|
||||||
|
t.Error("expected Groups=false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestName(t *testing.T) {
|
||||||
|
w := New()
|
||||||
|
if w.Name() != "winget" {
|
||||||
|
t.Errorf("Name() = %q, want %q", w.Name(), "winget")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeName(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
input string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"Microsoft.VisualStudioCode", "Microsoft.VisualStudioCode"},
|
||||||
|
{" Git.Git ", "Git.Git"},
|
||||||
|
{"", ""},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.input, func(t *testing.T) {
|
||||||
|
got := normalizeName(tt.input)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("normalizeName(%q) = %q, want %q", tt.input, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseArch(t *testing.T) {
|
||||||
|
name, arch := parseArch("Microsoft.VisualStudioCode")
|
||||||
|
if name != "Microsoft.VisualStudioCode" || arch != "" {
|
||||||
|
t.Errorf("parseArch returned (%q, %q), want (%q, %q)",
|
||||||
|
name, arch, "Microsoft.VisualStudioCode", "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStripVT(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"no escapes", "hello", "hello"},
|
||||||
|
{"simple CSI", "\x1b[2Khello", "hello"},
|
||||||
|
{"color code", "\x1b[32mgreen\x1b[0m", "green"},
|
||||||
|
{"empty", "", ""},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := stripVT(tt.input)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("stripVT(%q) = %q, want %q", tt.input, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsProgressLine(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
input string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"████████ 50%", true},
|
||||||
|
{"100%", true},
|
||||||
|
{"Git Git.Git 2.43.0", false},
|
||||||
|
{"", false},
|
||||||
|
{"Installing...", false},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.input, func(t *testing.T) {
|
||||||
|
got := isProgressLine(tt.input)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("isProgressLine(%q) = %v, want %v", tt.input, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
360
winget/winget_windows.go
Normal file
360
winget/winget_windows.go
Normal file
@@ -0,0 +1,360 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package winget
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gogrlx/snack"
|
||||||
|
)
|
||||||
|
|
||||||
|
func available() bool {
|
||||||
|
_, err := exec.LookPath("winget")
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// commonArgs returns flags used by all winget commands for non-interactive operation.
|
||||||
|
func commonArgs() []string {
|
||||||
|
return []string{
|
||||||
|
"--accept-source-agreements",
|
||||||
|
"--disable-interactivity",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func run(ctx context.Context, args []string) (string, error) {
|
||||||
|
c := exec.CommandContext(ctx, "winget", args...)
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
c.Stdout = &stdout
|
||||||
|
c.Stderr = &stderr
|
||||||
|
err := c.Run()
|
||||||
|
out := stdout.String()
|
||||||
|
// winget writes progress and VT sequences to stdout; strip them.
|
||||||
|
out = stripProgress(out)
|
||||||
|
if err != nil {
|
||||||
|
se := stderr.String()
|
||||||
|
if strings.Contains(se, "Access is denied") ||
|
||||||
|
strings.Contains(out, "administrator") {
|
||||||
|
return "", fmt.Errorf("winget: %w", snack.ErrPermissionDenied)
|
||||||
|
}
|
||||||
|
combined := se + out
|
||||||
|
if strings.Contains(combined, "No package found") ||
|
||||||
|
strings.Contains(combined, "No installed package found") {
|
||||||
|
return "", fmt.Errorf("winget: %w", snack.ErrNotFound)
|
||||||
|
}
|
||||||
|
errMsg := strings.TrimSpace(se)
|
||||||
|
if errMsg == "" {
|
||||||
|
errMsg = strings.TrimSpace(out)
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("winget: %s: %w", errMsg, err)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// stripProgress removes VT100 escape sequences and progress lines from output.
|
||||||
|
func stripProgress(s string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
lines := strings.Split(s, "\n")
|
||||||
|
for _, line := range lines {
|
||||||
|
clean := stripVT(line)
|
||||||
|
clean = strings.TrimRight(clean, "\r")
|
||||||
|
// Skip pure progress lines (e.g. "██████████████ 50%")
|
||||||
|
if isProgressLine(clean) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b.WriteString(clean)
|
||||||
|
b.WriteByte('\n')
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func install(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) (snack.InstallResult, error) {
|
||||||
|
o := snack.ApplyOptions(opts...)
|
||||||
|
var toInstall []snack.Target
|
||||||
|
var unchanged []string
|
||||||
|
for _, t := range pkgs {
|
||||||
|
if o.Reinstall || t.Version != "" || o.DryRun {
|
||||||
|
toInstall = append(toInstall, t)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ok, err := isInstalled(ctx, t.Name)
|
||||||
|
if err != nil {
|
||||||
|
return snack.InstallResult{}, err
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
unchanged = append(unchanged, t.Name)
|
||||||
|
} else {
|
||||||
|
toInstall = append(toInstall, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, t := range toInstall {
|
||||||
|
args := []string{"install", "--id", t.Name, "--exact", "--silent"}
|
||||||
|
args = append(args, commonArgs()...)
|
||||||
|
args = append(args, "--accept-package-agreements")
|
||||||
|
if t.Version != "" {
|
||||||
|
args = append(args, "--version", t.Version)
|
||||||
|
}
|
||||||
|
if t.FromRepo != "" {
|
||||||
|
args = append(args, "--source", t.FromRepo)
|
||||||
|
} else if o.FromRepo != "" {
|
||||||
|
args = append(args, "--source", o.FromRepo)
|
||||||
|
}
|
||||||
|
if _, err := run(ctx, args); err != nil {
|
||||||
|
return snack.InstallResult{}, fmt.Errorf("winget install %s: %w", t.Name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var installed []snack.Package
|
||||||
|
for _, t := range toInstall {
|
||||||
|
v, _ := version(ctx, t.Name)
|
||||||
|
installed = append(installed, snack.Package{Name: t.Name, Version: v, Installed: true})
|
||||||
|
}
|
||||||
|
return snack.InstallResult{Installed: installed, Unchanged: unchanged}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func remove(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) (snack.RemoveResult, error) {
|
||||||
|
o := snack.ApplyOptions(opts...)
|
||||||
|
var toRemove []snack.Target
|
||||||
|
var unchanged []string
|
||||||
|
for _, t := range pkgs {
|
||||||
|
if o.DryRun {
|
||||||
|
toRemove = append(toRemove, t)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ok, err := isInstalled(ctx, t.Name)
|
||||||
|
if err != nil {
|
||||||
|
return snack.RemoveResult{}, err
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
unchanged = append(unchanged, t.Name)
|
||||||
|
} else {
|
||||||
|
toRemove = append(toRemove, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, t := range toRemove {
|
||||||
|
args := []string{"uninstall", "--id", t.Name, "--exact", "--silent"}
|
||||||
|
args = append(args, commonArgs()...)
|
||||||
|
if _, err := run(ctx, args); err != nil {
|
||||||
|
return snack.RemoveResult{}, fmt.Errorf("winget uninstall %s: %w", t.Name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var removed []snack.Package
|
||||||
|
for _, t := range toRemove {
|
||||||
|
removed = append(removed, snack.Package{Name: t.Name})
|
||||||
|
}
|
||||||
|
return snack.RemoveResult{Removed: removed, Unchanged: unchanged}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func purge(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) error {
|
||||||
|
for _, t := range pkgs {
|
||||||
|
args := []string{"uninstall", "--id", t.Name, "--exact", "--silent", "--purge"}
|
||||||
|
args = append(args, commonArgs()...)
|
||||||
|
if _, err := run(ctx, args); err != nil {
|
||||||
|
return fmt.Errorf("winget purge %s: %w", t.Name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func upgrade(ctx context.Context, _ ...snack.Option) error {
|
||||||
|
args := []string{"upgrade", "--all", "--silent"}
|
||||||
|
args = append(args, commonArgs()...)
|
||||||
|
args = append(args, "--accept-package-agreements")
|
||||||
|
_, err := run(ctx, args)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func update(ctx context.Context) error {
|
||||||
|
_, err := run(ctx, []string{"source", "update"})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func list(ctx context.Context) ([]snack.Package, error) {
|
||||||
|
args := []string{"list"}
|
||||||
|
args = append(args, commonArgs()...)
|
||||||
|
out, err := run(ctx, args)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("winget list: %w", err)
|
||||||
|
}
|
||||||
|
return parseTable(out, true), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func search(ctx context.Context, query string) ([]snack.Package, error) {
|
||||||
|
args := []string{"search", query}
|
||||||
|
args = append(args, commonArgs()...)
|
||||||
|
out, err := run(ctx, args)
|
||||||
|
if err != nil {
|
||||||
|
if strings.Contains(err.Error(), string(snack.ErrNotFound.Error())) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("winget search: %w", err)
|
||||||
|
}
|
||||||
|
return parseTable(out, false), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func info(ctx context.Context, pkg string) (*snack.Package, error) {
|
||||||
|
args := []string{"show", "--id", pkg, "--exact"}
|
||||||
|
args = append(args, commonArgs()...)
|
||||||
|
out, err := run(ctx, args)
|
||||||
|
if err != nil {
|
||||||
|
if strings.Contains(err.Error(), string(snack.ErrNotFound.Error())) {
|
||||||
|
return nil, fmt.Errorf("winget info %s: %w", pkg, snack.ErrNotFound)
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("winget info: %w", err)
|
||||||
|
}
|
||||||
|
p := parseShow(out)
|
||||||
|
if p == nil {
|
||||||
|
return nil, fmt.Errorf("winget info %s: %w", pkg, snack.ErrNotFound)
|
||||||
|
}
|
||||||
|
// Check if installed
|
||||||
|
ok, _ := isInstalled(ctx, pkg)
|
||||||
|
p.Installed = ok
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isInstalled(ctx context.Context, pkg string) (bool, error) {
|
||||||
|
args := []string{"list", "--id", pkg, "--exact"}
|
||||||
|
args = append(args, commonArgs()...)
|
||||||
|
out, err := run(ctx, args)
|
||||||
|
if err != nil {
|
||||||
|
// "No installed package found" is returned as an error by run()
|
||||||
|
if strings.Contains(err.Error(), string(snack.ErrNotFound.Error())) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, fmt.Errorf("winget isInstalled: %w", err)
|
||||||
|
}
|
||||||
|
pkgs := parseTable(out, true)
|
||||||
|
return len(pkgs) > 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func version(ctx context.Context, pkg string) (string, error) {
|
||||||
|
args := []string{"list", "--id", pkg, "--exact"}
|
||||||
|
args = append(args, commonArgs()...)
|
||||||
|
out, err := run(ctx, args)
|
||||||
|
if err != nil {
|
||||||
|
if strings.Contains(err.Error(), string(snack.ErrNotFound.Error())) {
|
||||||
|
return "", fmt.Errorf("winget version %s: %w", pkg, snack.ErrNotInstalled)
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("winget version: %w", err)
|
||||||
|
}
|
||||||
|
pkgs := parseTable(out, true)
|
||||||
|
if len(pkgs) == 0 {
|
||||||
|
return "", fmt.Errorf("winget version %s: %w", pkg, snack.ErrNotInstalled)
|
||||||
|
}
|
||||||
|
return pkgs[0].Version, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func latestVersion(ctx context.Context, pkg string) (string, error) {
|
||||||
|
args := []string{"show", "--id", pkg, "--exact"}
|
||||||
|
args = append(args, commonArgs()...)
|
||||||
|
out, err := run(ctx, args)
|
||||||
|
if err != nil {
|
||||||
|
if strings.Contains(err.Error(), string(snack.ErrNotFound.Error())) {
|
||||||
|
return "", fmt.Errorf("winget latestVersion %s: %w", pkg, snack.ErrNotFound)
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("winget latestVersion: %w", err)
|
||||||
|
}
|
||||||
|
p := parseShow(out)
|
||||||
|
if p == nil || p.Version == "" {
|
||||||
|
return "", fmt.Errorf("winget latestVersion %s: %w", pkg, snack.ErrNotFound)
|
||||||
|
}
|
||||||
|
return p.Version, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func listUpgrades(ctx context.Context) ([]snack.Package, error) {
|
||||||
|
args := []string{"upgrade"}
|
||||||
|
args = append(args, commonArgs()...)
|
||||||
|
out, err := run(ctx, args)
|
||||||
|
if err != nil {
|
||||||
|
// No upgrades available may exit non-zero on some versions
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return parseTable(out, false), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func upgradeAvailable(ctx context.Context, pkg string) (bool, error) {
|
||||||
|
upgrades, err := listUpgrades(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
for _, u := range upgrades {
|
||||||
|
if strings.EqualFold(u.Name, pkg) {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func versionCmp(_ context.Context, ver1, ver2 string) (int, error) {
|
||||||
|
return semverCmp(ver1, ver2), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func upgradePackages(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) (snack.InstallResult, error) {
|
||||||
|
o := snack.ApplyOptions(opts...)
|
||||||
|
var toUpgrade []snack.Target
|
||||||
|
var unchanged []string
|
||||||
|
for _, t := range pkgs {
|
||||||
|
if o.DryRun {
|
||||||
|
toUpgrade = append(toUpgrade, t)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ok, err := isInstalled(ctx, t.Name)
|
||||||
|
if err != nil {
|
||||||
|
return snack.InstallResult{}, err
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
unchanged = append(unchanged, t.Name)
|
||||||
|
} else {
|
||||||
|
toUpgrade = append(toUpgrade, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, t := range toUpgrade {
|
||||||
|
args := []string{"upgrade", "--id", t.Name, "--exact", "--silent"}
|
||||||
|
args = append(args, commonArgs()...)
|
||||||
|
args = append(args, "--accept-package-agreements")
|
||||||
|
if t.Version != "" {
|
||||||
|
args = append(args, "--version", t.Version)
|
||||||
|
}
|
||||||
|
if _, err := run(ctx, args); err != nil {
|
||||||
|
return snack.InstallResult{}, fmt.Errorf("winget upgrade %s: %w", t.Name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var upgraded []snack.Package
|
||||||
|
for _, t := range toUpgrade {
|
||||||
|
v, _ := version(ctx, t.Name)
|
||||||
|
upgraded = append(upgraded, snack.Package{Name: t.Name, Version: v, Installed: true})
|
||||||
|
}
|
||||||
|
return snack.InstallResult{Installed: upgraded, Unchanged: unchanged}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// sourceList returns configured winget sources.
|
||||||
|
func sourceList(ctx context.Context) ([]snack.Repository, error) {
|
||||||
|
args := []string{"source", "list"}
|
||||||
|
args = append(args, commonArgs()...)
|
||||||
|
out, err := run(ctx, args)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("winget source list: %w", err)
|
||||||
|
}
|
||||||
|
return parseSourceList(out), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// sourceAdd adds a new winget source.
|
||||||
|
func sourceAdd(ctx context.Context, repo snack.Repository) error {
|
||||||
|
args := []string{"source", "add", "--name", repo.Name, "--arg", repo.URL}
|
||||||
|
args = append(args, commonArgs()...)
|
||||||
|
if repo.Type != "" {
|
||||||
|
args = append(args, "--type", repo.Type)
|
||||||
|
}
|
||||||
|
_, err := run(ctx, args)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// sourceRemove removes a winget source.
|
||||||
|
func sourceRemove(ctx context.Context, name string) error {
|
||||||
|
args := []string{"source", "remove", "--name", name}
|
||||||
|
args = append(args, commonArgs()...)
|
||||||
|
_, err := run(ctx, args)
|
||||||
|
return err
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user