diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..0c4c11e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,45 @@ +name: CI + +on: + push: + branches: ["master"] + pull_request: + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +defaults: + run: + shell: bash + +jobs: + test: + name: Build, vet & test + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up Go + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version: "1.23" + + - name: gofmt + run: | + out="$(gofmt -l .)" + if [ -n "$out" ]; then + echo "gofmt would reformat:" + echo "$out" + exit 1 + fi + + - name: go vet + run: go vet ./... + + - name: go build + run: go build ./... + + - name: go test + run: go test ./... diff --git a/README.md b/README.md index 05cbf5f..3b24526 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,15 @@ msvc-go-wine version print the vers `--host-arch`, `--with-*` component toggles, `--ignore`, `--only-download`, `--only-unpack`, `--keep-unpack`, `--cache`, `--language`, `--include-optional`, `--skip-recommended`, `--major`, `--preview`, -`--manifest`. Run `msvc-go-wine download -h` for the full list. +`--manifest`, `--list-workloads`, `--list-components`, `--print-deps-tree`. +Run `msvc-go-wine download -h` for the full list. + +`--list-workloads`/`--list-components` print every workload/component id +(with its human-readable title) available in the fetched manifest and exit +without downloading anything - useful for discovering what to pass as a bare +package id or via `--with-*`. `--print-deps-tree` prints the dependency tree +of whatever would actually be selected (honoring every other flag), also +without downloading. ### Using clang-cl/lld-link instead of Wine @@ -116,10 +124,10 @@ telemetry, and don't hard-fail devcmd setup when an optional component ## Known gaps -- `download` doesn't yet support printing the dependency/reverse-dependency - tree, listing available workloads/components/packages, or installing the - Windows Driver Kit via `--with-wdk-installers`; the core selection/ - download/unpack/install pipeline is fully implemented. +- `download` doesn't yet support installing the Windows Driver Kit via + `--with-wdk-installers`; the core selection/download/unpack/install + pipeline, dependency tree printing, and workload/component listing are all + fully implemented. ## License diff --git a/cmd/msvc-go-wine/download.go b/cmd/msvc-go-wine/download.go index 35ceb79..d17570a 100644 --- a/cmd/msvc-go-wine/download.go +++ b/cmd/msvc-go-wine/download.go @@ -30,6 +30,9 @@ func runDownload(args []string) int { onlyUnpack := fs.Bool("only-unpack", false, "unpack selected packages and keep everything, without pruning to just the CLI tools") keepUnpack := fs.Bool("keep-unpack", false, "keep the scratch unpack dir instead of removing it after moving files into place") skipPatch := fs.Bool("skip-patch", false, "don't apply the Wine compatibility patches") + listWorkloads := fs.Bool("list-workloads", false, "list available workloads from the manifest and exit, without downloading anything") + listComponents := fs.Bool("list-components", false, "list available components from the manifest and exit, without downloading anything") + printDepsTree := fs.Bool("print-deps-tree", false, "print the dependency tree of the selected packages and exit, without downloading anything") var archsFlag stringList fs.Var(&archsFlag, "architecture", "target architecture to include (x86, x64, arm, arm64, host); repeatable") var ignoreFlag stringList @@ -78,7 +81,17 @@ func runDownload(args []string) int { idx := download.BuildIndex(manifest, opts.HostArch, opts.Language) - if !*acceptLicense { + if *listWorkloads || *listComponents { + if *listWorkloads { + printPackageList("Workload", download.PackagesByType(idx, "Workload"), opts.Language) + } + if *listComponents { + printPackageList("Component", download.PackagesByType(idx, "Component"), opts.Language) + } + return 0 + } + + if !*acceptLicense && !*printDepsTree { license := "the Visual Studio Build Tools license" if p := idx.Find("Microsoft.VisualStudio.Product.BuildTools", nil); p != nil && len(p.LocalizedResources) > 0 { license = p.LocalizedResources[0].License @@ -93,6 +106,11 @@ func runDownload(args []string) int { return 1 } + if *printDepsTree { + download.PrintDependencyTree(os.Stdout, idx, opts) + return 0 + } + selected, err := download.ExpandSelection(idx, opts) if err != nil { fmt.Fprintln(os.Stderr, "msvc-go-wine download:", err) @@ -184,6 +202,19 @@ func runDownload(args []string) int { return 0 } +// printPackageList prints one line per package: its ID, and (when the +// manifest carries one) its human-readable title in the requested language. +func printPackageList(kind string, pkgs []*download.Package, language string) { + fmt.Printf("Available %ss (%d):\n", kind, len(pkgs)) + for _, p := range pkgs { + if lr := p.Localized(language); lr != nil && lr.Title != "" { + fmt.Printf(" %-65s %s\n", p.ID, lr.Title) + } else { + fmt.Printf(" %s\n", p.ID) + } + } +} + func detectHostArch() string { if runtime.GOARCH == "arm64" { return "arm64" diff --git a/internal/download/list.go b/internal/download/list.go new file mode 100644 index 0000000..7705883 --- /dev/null +++ b/internal/download/list.go @@ -0,0 +1,18 @@ +package download + +import "sort" + +// PackagesByType returns the best (arch/language-matching) variant of every +// package in idx whose Type equals kind (e.g. "Workload" or "Component"), +// sorted by ID. +func PackagesByType(idx Index, kind string) []*Package { + var ret []*Package + for _, variants := range idx { + p := variants[0] + if p.Type == kind { + ret = append(ret, p) + } + } + sort.Slice(ret, func(i, j int) bool { return ret[i].ID < ret[j].ID }) + return ret +} diff --git a/internal/download/manifest.go b/internal/download/manifest.go index f9ada82..9c2bce1 100644 --- a/internal/download/manifest.go +++ b/internal/download/manifest.go @@ -40,11 +40,15 @@ type Dependency struct { Type string // "", "Optional" or "Recommended" } -// LocalizedResource carries the license URL shown before accepting a -// package's terms. +// LocalizedResource carries a package's human-readable title/description +// (shown by --list-workloads/--list-components) and the license URL shown +// before accepting a package's terms. type LocalizedResource struct { - Language string `json:"language"` - License string `json:"license"` + Language string `json:"language"` + Title string `json:"title"` + Description string `json:"description"` + Category string `json:"category"` + License string `json:"license"` } // Package is one entry from the installer manifest's "packages" array. @@ -108,6 +112,39 @@ func (p *Package) Key() string { return key } +// Localized returns p's LocalizedResource best matching language ("" means +// "en"), preferring an exact match, then any en-* entry, then whatever's +// first. Returns nil if p has no localized resources at all. +func (p *Package) Localized(language string) *LocalizedResource { + if len(p.LocalizedResources) == 0 { + return nil + } + if language == "" { + language = "en" + } + language = strings.ToLower(language) + best := &p.LocalizedResources[0] + bestScore := -1 + for i := range p.LocalizedResources { + lr := &p.LocalizedResources[i] + lang := strings.ToLower(lr.Language) + score := 0 + switch { + case lang == language: + score = 3 + case strings.HasPrefix(lang, language+"-"): + score = 2 + case strings.HasPrefix(lang, "en"): + score = 1 + } + if score > bestScore { + bestScore = score + best = lr + } + } + return best +} + func (p *Package) InstalledSize() int64 { var sum int64 for _, v := range p.InstallSizes { @@ -147,6 +184,15 @@ type Manifest struct { // restart of `download`. var httpClient = &http.Client{Timeout: 5 * time.Minute} +func init() { + // --manifest points at a local file, fetched through this same client + // via a "file:" URL (see cmd/msvc-go-wine's runDownload) - so it needs a + // registered "file" handler alongside the default http/https transport. + t := http.DefaultTransport.(*http.Transport).Clone() + t.RegisterProtocol("file", http.NewFileTransport(http.Dir("/"))) + httpClient.Transport = t +} + const maxManifestAttempts = 5 func httpGet(url string) ([]byte, error) { diff --git a/internal/download/select.go b/internal/download/select.go index 25cb705..0c218b1 100644 --- a/internal/download/select.go +++ b/internal/download/select.go @@ -2,7 +2,9 @@ package download import ( "fmt" + "io" "regexp" + "sort" "strings" ) @@ -367,3 +369,73 @@ func ExpandSelection(idx Index, opts *Options) ([]*Package, error) { } return ret, nil } + +// PrintDependencyTree writes an indented tree of opts.Package and everything +// they transitively depend on to w, applying the exact same +// arch/--ignore/Optional/Recommended filtering collectDependencyClosure +// (used by ExpandSelection) does, so what's printed matches what an actual +// download would select. A package already printed once elsewhere in the +// tree is shown again as a leaf ("(see above)") rather than re-expanded, to +// keep the output finite for packages multiple components depend on. +func PrintDependencyTree(w io.Writer, idx Index, opts *Options) { + printed := map[string]bool{} + for _, id := range opts.Package { + printDepNode(w, idx, id, nil, "", opts, 0, printed) + } +} + +func printDepNode(w io.Writer, idx Index, target string, constraints map[string]string, depType string, opts *Options, depth int, printed map[string]bool) { + if contains(opts.Ignore, strings.ToLower(target)) { + return + } + indent := strings.Repeat(" ", depth) + annotation := "" + if depType != "" { + annotation = " [" + depType + "]" + } + + p := idx.Find(target, constraints) + if p == nil { + fmt.Fprintf(w, "%s%s (not found)%s\n", indent, target, annotation) + return + } + if opts.OnlyHost && !HostArchCompatible(p, opts.HostArch) { + return + } + if !TargetArchCompatible(p, opts.Architecture) { + return + } + + key := p.Key() + if printed[key] { + fmt.Fprintf(w, "%s%s%s (see above)\n", indent, p.ID, annotation) + return + } + printed[key] = true + fmt.Fprintf(w, "%s%s@%s%s\n", indent, p.ID, p.Version, annotation) + + deps := p.Dependencies() + targets := make([]string, 0, len(deps)) + for t := range deps { + targets = append(targets, t) + } + sort.Strings(targets) + for _, depTarget := range targets { + dep := deps[depTarget] + id := depTarget + if dep.TargetID != "" { + id = dep.TargetID + } + if dep.Type == "Optional" && !opts.IncludeOptional { + continue + } + if dep.Type == "Recommended" && opts.SkipRecommended { + continue + } + c := map[string]string{} + if dep.Version != "" { + c["version"] = dep.Version + } + printDepNode(w, idx, id, c, dep.Type, opts, depth+1, printed) + } +}