diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index b15125a..5296e6c 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -188,10 +188,40 @@ jobs: name: coverage-flatpak path: coverage-flatpak.out + cross-compile: + name: Cross Compile + runs-on: ubuntu-latest + strategy: + matrix: + goos: [windows, darwin, freebsd, openbsd] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - name: Build for ${{ matrix.goos }} + run: GOOS=${{ matrix.goos }} go build ./... + + windows: + name: Windows (winget) + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - name: Unit tests + run: go test -race -coverprofile=coverage-windows.out ./winget/ ./detect/ + - uses: actions/upload-artifact@v4 + if: always() + with: + name: coverage-windows + path: coverage-windows.out + codecov: name: Upload Coverage runs-on: ubuntu-latest - needs: [lint, unit-tests, debian, ubuntu, fedora-dnf4, fedora-dnf5, alpine, arch, flatpak] + needs: [lint, unit-tests, debian, ubuntu, fedora-dnf4, fedora-dnf5, alpine, arch, flatpak, windows, cross-compile] if: always() steps: - uses: actions/checkout@v4 @@ -203,7 +233,7 @@ jobs: run: ls -la coverage-*.out 2>/dev/null || echo "No coverage files found" - uses: codecov/codecov-action@v5 with: - files: coverage-unit.out,coverage-debian.out,coverage-ubuntu-apt.out,coverage-ubuntu-snap.out,coverage-fedora39.out,coverage-fedora-latest.out,coverage-alpine.out,coverage-arch.out,coverage-flatpak.out + files: coverage-unit.out,coverage-debian.out,coverage-ubuntu-apt.out,coverage-ubuntu-snap.out,coverage-fedora39.out,coverage-fedora-latest.out,coverage-alpine.out,coverage-arch.out,coverage-flatpak.out,coverage-windows.out fail_ci_if_error: false env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} diff --git a/README.md b/README.md index c729650..e722a13 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ Part of the [grlx](https://github.com/gogrlx/grlx) ecosystem. | `brew` | Homebrew | macOS/Linux | ✅ | | `pkg` | pkg(8) | FreeBSD | ✅ | | `ports` | ports/packages | OpenBSD | ✅ | +| `winget` | Windows Package Manager | Windows | ✅ | | `detect` | Auto-detection | All | ✅ | ### Capability Matrix @@ -41,6 +42,7 @@ Part of the [grlx](https://github.com/gogrlx/grlx) ecosystem. | brew | ✅ | - | ✅ | ✅ | - | - | - | ✅ | - | ✅ | | pkg | ✅ | ✅ | ✅ | ✅ | ✅ | - | - | ✅ | ✅ | ✅ | | ports | ✅ | - | ✅ | ✅ | - | - | - | ✅ | - | ✅ | +| winget | ✅ | - | - | - | ✅ | - | - | ✅ | - | ✅ | ## Install @@ -139,6 +141,7 @@ if caps.Hold { 4. dnf + rpm (Fedora/RHEL) 5. flatpak + snap (cross-distro) 6. pkg + ports (BSD) +7. winget (Windows) ## CLI diff --git a/detect/detect_windows.go b/detect/detect_windows.go index 664b6d0..ec6cca9 100644 --- a/detect/detect_windows.go +++ b/detect/detect_windows.go @@ -2,13 +2,19 @@ package detect +import ( + "github.com/gogrlx/snack" + "github.com/gogrlx/snack/winget" +) + // candidates returns manager factories in probe order for Windows. -// Currently no Windows package managers are supported. func candidates() []managerFactory { - return nil + return []managerFactory{ + func() snack.Manager { return winget.New() }, + } } // allManagers returns all known manager factories (for ByName). func allManagers() []managerFactory { - return nil + return candidates() } diff --git a/winget/capabilities.go b/winget/capabilities.go new file mode 100644 index 0000000..ee48088 --- /dev/null +++ b/winget/capabilities.go @@ -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) +} diff --git a/winget/normalize.go b/winget/normalize.go new file mode 100644 index 0000000..cb37142 --- /dev/null +++ b/winget/normalize.go @@ -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, "" +} diff --git a/winget/parse.go b/winget/parse.go new file mode 100644 index 0000000..bf7450c --- /dev/null +++ b/winget/parse.go @@ -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 []" 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 +} diff --git a/winget/winget.go b/winget/winget.go new file mode 100644 index 0000000..0b893be --- /dev/null +++ b/winget/winget.go @@ -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...) +} diff --git a/winget/winget_integration_test.go b/winget/winget_integration_test.go new file mode 100644 index 0000000..c3561bd --- /dev/null +++ b/winget/winget_integration_test.go @@ -0,0 +1,140 @@ +//go:build integration + +package winget_test + +import ( + "context" + "testing" + + "github.com/gogrlx/snack" + "github.com/gogrlx/snack/winget" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestIntegration_Winget(t *testing.T) { + var mgr snack.Manager = winget.New() + if !mgr.Available() { + t.Skip("winget not available") + } + ctx := context.Background() + + assert.Equal(t, "winget", mgr.Name()) + + caps := snack.GetCapabilities(mgr) + assert.True(t, caps.VersionQuery, "winget should support VersionQuery") + assert.True(t, caps.RepoManagement, "winget should support RepoManagement") + assert.True(t, caps.NameNormalize, "winget should support NameNormalize") + assert.True(t, caps.PackageUpgrade, "winget should support PackageUpgrade") + assert.False(t, caps.Hold) + assert.False(t, caps.Clean) + assert.False(t, caps.FileOwnership) + assert.False(t, caps.KeyManagement) + assert.False(t, caps.Groups) + assert.False(t, caps.DryRun) + + t.Run("Update", func(t *testing.T) { + require.NoError(t, mgr.Update(ctx)) + }) + + t.Run("Search", func(t *testing.T) { + pkgs, err := mgr.Search(ctx, "Microsoft.PowerToys") + require.NoError(t, err) + require.NotEmpty(t, pkgs) + }) + + t.Run("Search_NoResults", func(t *testing.T) { + pkgs, err := mgr.Search(ctx, "xyznonexistentpackage999zzz") + require.NoError(t, err) + assert.Empty(t, pkgs) + }) + + t.Run("Info", func(t *testing.T) { + pkg, err := mgr.Info(ctx, "Microsoft.PowerToys") + require.NoError(t, err) + require.NotNil(t, pkg) + assert.Equal(t, "Microsoft.PowerToys", pkg.Name) + assert.NotEmpty(t, pkg.Version) + }) + + t.Run("Info_NotFound", func(t *testing.T) { + _, err := mgr.Info(ctx, "xyznonexistentpackage999.notreal") + assert.Error(t, err) + }) + + t.Run("List", func(t *testing.T) { + pkgs, err := mgr.List(ctx) + require.NoError(t, err) + t.Logf("installed packages: %d", len(pkgs)) + }) + + // --- VersionQuerier --- + t.Run("VersionQuerier", func(t *testing.T) { + vq, ok := mgr.(snack.VersionQuerier) + require.True(t, ok) + + t.Run("LatestVersion", func(t *testing.T) { + ver, err := vq.LatestVersion(ctx, "Microsoft.PowerToys") + require.NoError(t, err) + assert.NotEmpty(t, ver) + t.Logf("PowerToys latest: %s", ver) + }) + + t.Run("LatestVersion_NotFound", func(t *testing.T) { + _, err := vq.LatestVersion(ctx, "xyznonexistentpackage999.notreal") + assert.Error(t, err) + }) + + t.Run("ListUpgrades", func(t *testing.T) { + pkgs, err := vq.ListUpgrades(ctx) + require.NoError(t, err) + t.Logf("upgradable packages: %d", len(pkgs)) + }) + + t.Run("VersionCmp", func(t *testing.T) { + tests := []struct { + v1, v2 string + want int + }{ + {"1.0", "2.0", -1}, + {"2.0", "1.0", 1}, + {"1.0", "1.0", 0}, + {"1.0.1", "1.0.0", 1}, + } + for _, tt := range tests { + cmp, err := vq.VersionCmp(ctx, tt.v1, tt.v2) + require.NoError(t, err, "VersionCmp(%s, %s)", tt.v1, tt.v2) + assert.Equal(t, tt.want, cmp, "VersionCmp(%s, %s)", tt.v1, tt.v2) + } + }) + }) + + // --- RepoManager --- + t.Run("RepoManager", func(t *testing.T) { + rm, ok := mgr.(snack.RepoManager) + require.True(t, ok) + + t.Run("ListRepos", func(t *testing.T) { + repos, err := rm.ListRepos(ctx) + require.NoError(t, err) + require.NotEmpty(t, repos, "should have at least winget and msstore sources") + t.Logf("sources: %d", len(repos)) + for _, r := range repos { + t.Logf(" %s -> %s", r.Name, r.URL) + } + }) + }) + + // --- NameNormalizer --- + t.Run("NameNormalizer", func(t *testing.T) { + nn, ok := mgr.(snack.NameNormalizer) + require.True(t, ok) + + name := nn.NormalizeName(" Microsoft.VisualStudioCode ") + assert.Equal(t, "Microsoft.VisualStudioCode", name) + + n, arch := nn.ParseArch("Microsoft.VisualStudioCode") + assert.Equal(t, "Microsoft.VisualStudioCode", n) + assert.Empty(t, arch) + }) +} diff --git a/winget/winget_other.go b/winget/winget_other.go new file mode 100644 index 0000000..b6e12f3 --- /dev/null +++ b/winget/winget_other.go @@ -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 +} diff --git a/winget/winget_test.go b/winget/winget_test.go new file mode 100644 index 0000000..0ad9521 --- /dev/null +++ b/winget/winget_test.go @@ -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) + } + }) + } +} diff --git a/winget/winget_windows.go b/winget/winget_windows.go new file mode 100644 index 0000000..91ab6a2 --- /dev/null +++ b/winget/winget_windows.go @@ -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 +}