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:
2026-03-06 03:26:47 +00:00
parent 84f9cbc9cf
commit aed2ee8b86
8 changed files with 1405 additions and 3 deletions

View File

@@ -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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
}