From b1a7f030f53895e34570d3b8b86193c0b9b39fd1 Mon Sep 17 00:00:00 2001 From: Rick Guo Date: Tue, 25 Aug 2026 15:14:12 +0800 Subject: [PATCH 1/5] feat(llar): add install command wrapper --- llar/llar.go | 119 +++++++++++++++++++++++++++++++++++++++ llar/llar_test.go | 138 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 257 insertions(+) create mode 100644 llar/llar.go create mode 100644 llar/llar_test.go diff --git a/llar/llar.go b/llar/llar.go new file mode 100644 index 0000000000..bda815c63b --- /dev/null +++ b/llar/llar.go @@ -0,0 +1,119 @@ +// Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// Package llar provides a small Go wrapper around the llar command. +package llar + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "os" + "os/exec" + "sort" + "strings" +) + +// Cmd represents an llar command. +type Cmd struct { + bin string + verbose bool +} + +// New creates an llar command wrapper. An empty bin uses llar from PATH. +func New(bin string, verbose bool) *Cmd { + if bin == "" { + bin = "llar" + } + return &Cmd{bin: bin, verbose: verbose} +} + +// Module identifies an llar module to install. +type Module struct { + Path string + Version string +} + +// Config selects the build matrix for an installation. +type Config struct { + To string + OS string + Arch string + Libc string + Options map[string][]string +} + +// Dependency is a module returned in an llar install result. +type Dependency struct { + Path string `json:"path"` + Version string `json:"version"` + Dir string `json:"dir"` +} + +// Result is the JSON result returned by llar install. +type Result struct { + Path string `json:"path"` + Version string `json:"version"` + Dir string `json:"dir"` + Deps []Dependency `json:"deps,omitempty"` + Metadata string `json:"metadata"` +} + +// Install runs llar install for mod and decodes the command's JSON result. +func (p *Cmd) Install(mod Module, config Config) (Result, error) { + args := []string{"install", "--json"} + if config.To != "" { + args = append(args, "--output", config.To) + } + if p.verbose { + args = append(args, "--verbose") + } + if config.OS != "" { + args = append(args, "--os", config.OS) + } + if config.Arch != "" { + args = append(args, "--arch", config.Arch) + } + if config.Libc != "" { + args = append(args, "--libc", config.Libc) + } + + keys := make([]string, 0, len(config.Options)) + for key := range config.Options { + keys = append(keys, key) + } + sort.Strings(keys) + for _, key := range keys { + for _, value := range config.Options[key] { + args = append(args, "--option", key+"="+value) + } + } + target := mod.Path + if mod.Version != "" { + target += "@" + mod.Version + } + args = append(args, target) + + var stdout, stderr bytes.Buffer + cmd := exec.Command(p.bin, args...) + cmd.Stdout = &stdout + var stderrWriter io.Writer = &stderr + if p.verbose { + stderrWriter = io.MultiWriter(&stderr, os.Stderr) + } + cmd.Stderr = stderrWriter + if err := cmd.Run(); err != nil { + if message := strings.TrimSpace(stderr.String()); message != "" { + return Result{}, fmt.Errorf("llar install: %w: %s", err, message) + } + return Result{}, fmt.Errorf("llar install: %w", err) + } + + var result Result + if err := json.Unmarshal(stdout.Bytes(), &result); err != nil { + return Result{}, fmt.Errorf("llar install: decode result: %w", err) + } + return result, nil +} diff --git a/llar/llar_test.go b/llar/llar_test.go new file mode 100644 index 0000000000..d3dd1bb149 --- /dev/null +++ b/llar/llar_test.go @@ -0,0 +1,138 @@ +package llar + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +func writeFakeLLAR(t *testing.T, output string) (bin, argsFile string) { + t.Helper() + dir := t.TempDir() + bin = filepath.Join(dir, "llar") + argsFile = filepath.Join(dir, "args") + script := "#!/bin/sh\n" + + "printf '%s\\n' \"$@\" > \"$LLAR_TEST_ARGS\"\n" + + "printf '%s\\n' '" + output + "'\n" + if err := os.WriteFile(bin, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("LLAR_TEST_ARGS", argsFile) + return bin, argsFile +} + +func readArgs(t *testing.T, path string) []string { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + return strings.Split(strings.TrimSuffix(string(data), "\n"), "\n") +} + +func TestInstall(t *testing.T) { + bin, argsFile := writeFakeLLAR(t, `{"path":"owner/root","version":"v1.2.3","dir":"/tmp/root","deps":[{"path":"owner/dep","version":"v1.0.0","dir":"/tmp/dep"}],"metadata":"-L/tmp/root/lib -lroot"}`) + + result, err := New(bin, true).Install(Module{Path: "owner/root", Version: "v1.2.3"}, Config{ + To: "/tmp/root", + OS: "linux", + Arch: "amd64", + Libc: "glibc", + Options: map[string][]string{ + "zlib": {"system", "bundled"}, + "debug": {"true"}, + }, + }) + if err != nil { + t.Fatalf("Install() error = %v", err) + } + if result.Path != "owner/root" || result.Version != "v1.2.3" || result.Dir != "/tmp/root" { + t.Fatalf("result = %+v", result) + } + if len(result.Deps) != 1 || result.Deps[0].Path != "owner/dep" { + t.Fatalf("deps = %+v", result.Deps) + } + if result.Metadata != "-L/tmp/root/lib -lroot" { + t.Fatalf("metadata = %q", result.Metadata) + } + + wantArgs := []string{ + "install", "--json", "--output", "/tmp/root", + "--verbose", + "--os", "linux", "--arch", "amd64", "--libc", "glibc", + "--option", "debug=true", + "--option", "zlib=system", + "--option", "zlib=bundled", + "owner/root@v1.2.3", + } + gotArgs := readArgs(t, argsFile) + if len(gotArgs) != len(wantArgs) { + t.Fatalf("args = %q, want %q", gotArgs, wantArgs) + } + for i := range wantArgs { + if gotArgs[i] != wantArgs[i] { + t.Fatalf("args[%d] = %q, want %q", i, gotArgs[i], wantArgs[i]) + } + } +} + +func TestInstallWithoutOutput(t *testing.T) { + bin, argsFile := writeFakeLLAR(t, `{"path":"owner/root","version":"v1.0.0","metadata":"-lroot"}`) + if _, err := New(bin, false).Install(Module{Path: "owner/root", Version: "v1.0.0"}, Config{}); err != nil { + t.Fatalf("Install() error = %v", err) + } + + want := []string{"install", "--json", "owner/root@v1.0.0"} + got := readArgs(t, argsFile) + if len(got) != len(want) { + t.Fatalf("args = %q, want %q", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("args[%d] = %q, want %q", i, got[i], want[i]) + } + } +} + +func TestInstallReturnsCommandError(t *testing.T) { + dir := t.TempDir() + bin := filepath.Join(dir, "llar") + script := "#!/bin/sh\nprintf '%s\\n' 'install failed' >&2\nexit 7\n" + if err := os.WriteFile(bin, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + + _, err := New(bin, false).Install(Module{Path: "owner/root"}, Config{To: t.TempDir()}) + if err == nil || !strings.Contains(err.Error(), "install failed") || !strings.Contains(err.Error(), "exit status 7") { + t.Fatalf("error = %v", err) + } +} + +func TestInstallReturnsJSONError(t *testing.T) { + bin, _ := writeFakeLLAR(t, "not-json") + _, err := New(bin, false).Install(Module{Path: "owner/root"}, Config{}) + if err == nil || !strings.Contains(err.Error(), "decode result") { + t.Fatalf("error = %v", err) + } +} + +func TestResultJSONRoundTrip(t *testing.T) { + want := Result{ + Path: "owner/root", Version: "v1.0.0", Dir: "/tmp/root", + Deps: []Dependency{{Path: "owner/dep", Version: "v1.0.0", Dir: "/tmp/dep"}}, + Metadata: "-lroot", + } + data, err := json.Marshal(want) + if err != nil { + t.Fatal(err) + } + var got Result + if err := json.Unmarshal(data, &got); err != nil { + t.Fatal(err) + } + if got.Path != want.Path || got.Version != want.Version || got.Dir != want.Dir || got.Metadata != want.Metadata || len(got.Deps) != 1 { + t.Fatalf("round trip = %+v, want %+v", got, want) + } +} From 520740fdc267c3635cb30f44c6530bf760772397 Mon Sep 17 00:00:00 2001 From: Rick Guo Date: Tue, 25 Aug 2026 15:18:04 +0800 Subject: [PATCH 2/5] refactor(llar): place wrapper under xtool --- {llar => xtool/llar}/llar.go | 0 {llar => xtool/llar}/llar_test.go | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename {llar => xtool/llar}/llar.go (100%) rename {llar => xtool/llar}/llar_test.go (100%) diff --git a/llar/llar.go b/xtool/llar/llar.go similarity index 100% rename from llar/llar.go rename to xtool/llar/llar.go diff --git a/llar/llar_test.go b/xtool/llar/llar_test.go similarity index 100% rename from llar/llar_test.go rename to xtool/llar/llar_test.go From 1233898bca4519ef1a09c31ab7ea987dd2f90187 Mon Sep 17 00:00:00 2001 From: Rick Guo Date: Tue, 25 Aug 2026 15:25:38 +0800 Subject: [PATCH 3/5] refactor(llar): expose command output writers --- xtool/llar/llar.go | 25 ++++++++++++++----------- xtool/llar/llar_test.go | 20 ++++++++++++++++---- 2 files changed, 30 insertions(+), 15 deletions(-) diff --git a/xtool/llar/llar.go b/xtool/llar/llar.go index bda815c63b..d8fdcc9767 100644 --- a/xtool/llar/llar.go +++ b/xtool/llar/llar.go @@ -10,7 +10,6 @@ import ( "encoding/json" "fmt" "io" - "os" "os/exec" "sort" "strings" @@ -18,16 +17,18 @@ import ( // Cmd represents an llar command. type Cmd struct { - bin string - verbose bool + bin string + + Stdout io.Writer + Stderr io.Writer } // New creates an llar command wrapper. An empty bin uses llar from PATH. -func New(bin string, verbose bool) *Cmd { +func New(bin string) *Cmd { if bin == "" { bin = "llar" } - return &Cmd{bin: bin, verbose: verbose} + return &Cmd{bin: bin} } // Module identifies an llar module to install. @@ -67,7 +68,7 @@ func (p *Cmd) Install(mod Module, config Config) (Result, error) { if config.To != "" { args = append(args, "--output", config.To) } - if p.verbose { + if p.Stderr != nil { args = append(args, "--verbose") } if config.OS != "" { @@ -98,12 +99,14 @@ func (p *Cmd) Install(mod Module, config Config) (Result, error) { var stdout, stderr bytes.Buffer cmd := exec.Command(p.bin, args...) - cmd.Stdout = &stdout - var stderrWriter io.Writer = &stderr - if p.verbose { - stderrWriter = io.MultiWriter(&stderr, os.Stderr) + cmd.Stdout = io.Writer(&stdout) + if p.Stdout != nil { + cmd.Stdout = io.MultiWriter(&stdout, p.Stdout) + } + cmd.Stderr = io.Writer(&stderr) + if p.Stderr != nil { + cmd.Stderr = io.MultiWriter(&stderr, p.Stderr) } - cmd.Stderr = stderrWriter if err := cmd.Run(); err != nil { if message := strings.TrimSpace(stderr.String()); message != "" { return Result{}, fmt.Errorf("llar install: %w: %s", err, message) diff --git a/xtool/llar/llar_test.go b/xtool/llar/llar_test.go index d3dd1bb149..6bc2550d5f 100644 --- a/xtool/llar/llar_test.go +++ b/xtool/llar/llar_test.go @@ -1,6 +1,7 @@ package llar import ( + "bytes" "encoding/json" "os" "path/filepath" @@ -15,6 +16,7 @@ func writeFakeLLAR(t *testing.T, output string) (bin, argsFile string) { argsFile = filepath.Join(dir, "args") script := "#!/bin/sh\n" + "printf '%s\\n' \"$@\" > \"$LLAR_TEST_ARGS\"\n" + + "printf '%s\\n' 'progress' >&2\n" + "printf '%s\\n' '" + output + "'\n" if err := os.WriteFile(bin, []byte(script), 0o755); err != nil { t.Fatal(err) @@ -35,7 +37,11 @@ func readArgs(t *testing.T, path string) []string { func TestInstall(t *testing.T) { bin, argsFile := writeFakeLLAR(t, `{"path":"owner/root","version":"v1.2.3","dir":"/tmp/root","deps":[{"path":"owner/dep","version":"v1.0.0","dir":"/tmp/dep"}],"metadata":"-L/tmp/root/lib -lroot"}`) - result, err := New(bin, true).Install(Module{Path: "owner/root", Version: "v1.2.3"}, Config{ + var rawStdout, rawStderr bytes.Buffer + cmd := New(bin) + cmd.Stdout = &rawStdout + cmd.Stderr = &rawStderr + result, err := cmd.Install(Module{Path: "owner/root", Version: "v1.2.3"}, Config{ To: "/tmp/root", OS: "linux", Arch: "amd64", @@ -57,6 +63,12 @@ func TestInstall(t *testing.T) { if result.Metadata != "-L/tmp/root/lib -lroot" { t.Fatalf("metadata = %q", result.Metadata) } + if !strings.Contains(rawStdout.String(), `"path":"owner/root"`) { + t.Fatalf("stdout = %q, want JSON result", rawStdout.String()) + } + if rawStderr.String() != "progress\n" { + t.Fatalf("stderr = %q, want progress output", rawStderr.String()) + } wantArgs := []string{ "install", "--json", "--output", "/tmp/root", @@ -80,7 +92,7 @@ func TestInstall(t *testing.T) { func TestInstallWithoutOutput(t *testing.T) { bin, argsFile := writeFakeLLAR(t, `{"path":"owner/root","version":"v1.0.0","metadata":"-lroot"}`) - if _, err := New(bin, false).Install(Module{Path: "owner/root", Version: "v1.0.0"}, Config{}); err != nil { + if _, err := New(bin).Install(Module{Path: "owner/root", Version: "v1.0.0"}, Config{}); err != nil { t.Fatalf("Install() error = %v", err) } @@ -104,7 +116,7 @@ func TestInstallReturnsCommandError(t *testing.T) { t.Fatal(err) } - _, err := New(bin, false).Install(Module{Path: "owner/root"}, Config{To: t.TempDir()}) + _, err := New(bin).Install(Module{Path: "owner/root"}, Config{To: t.TempDir()}) if err == nil || !strings.Contains(err.Error(), "install failed") || !strings.Contains(err.Error(), "exit status 7") { t.Fatalf("error = %v", err) } @@ -112,7 +124,7 @@ func TestInstallReturnsCommandError(t *testing.T) { func TestInstallReturnsJSONError(t *testing.T) { bin, _ := writeFakeLLAR(t, "not-json") - _, err := New(bin, false).Install(Module{Path: "owner/root"}, Config{}) + _, err := New(bin).Install(Module{Path: "owner/root"}, Config{}) if err == nil || !strings.Contains(err.Error(), "decode result") { t.Fatalf("error = %v", err) } From f049cb72bd2dcd6267a778f52b59bab9f50ebd29 Mon Sep 17 00:00:00 2001 From: Rick Guo Date: Tue, 25 Aug 2026 15:33:45 +0800 Subject: [PATCH 4/5] refactor(llar): name pkg-config result field --- xtool/llar/llar.go | 10 +++++----- xtool/llar/llar_test.go | 10 +++++----- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/xtool/llar/llar.go b/xtool/llar/llar.go index d8fdcc9767..cbcf005742 100644 --- a/xtool/llar/llar.go +++ b/xtool/llar/llar.go @@ -55,11 +55,11 @@ type Dependency struct { // Result is the JSON result returned by llar install. type Result struct { - Path string `json:"path"` - Version string `json:"version"` - Dir string `json:"dir"` - Deps []Dependency `json:"deps,omitempty"` - Metadata string `json:"metadata"` + Path string `json:"path"` + Version string `json:"version"` + Dir string `json:"dir"` + Deps []Dependency `json:"deps,omitempty"` + PkgConfig string `json:"metadata"` } // Install runs llar install for mod and decodes the command's JSON result. diff --git a/xtool/llar/llar_test.go b/xtool/llar/llar_test.go index 6bc2550d5f..521c68c7a9 100644 --- a/xtool/llar/llar_test.go +++ b/xtool/llar/llar_test.go @@ -60,8 +60,8 @@ func TestInstall(t *testing.T) { if len(result.Deps) != 1 || result.Deps[0].Path != "owner/dep" { t.Fatalf("deps = %+v", result.Deps) } - if result.Metadata != "-L/tmp/root/lib -lroot" { - t.Fatalf("metadata = %q", result.Metadata) + if result.PkgConfig != "-L/tmp/root/lib -lroot" { + t.Fatalf("pkg-config = %q", result.PkgConfig) } if !strings.Contains(rawStdout.String(), `"path":"owner/root"`) { t.Fatalf("stdout = %q, want JSON result", rawStdout.String()) @@ -133,8 +133,8 @@ func TestInstallReturnsJSONError(t *testing.T) { func TestResultJSONRoundTrip(t *testing.T) { want := Result{ Path: "owner/root", Version: "v1.0.0", Dir: "/tmp/root", - Deps: []Dependency{{Path: "owner/dep", Version: "v1.0.0", Dir: "/tmp/dep"}}, - Metadata: "-lroot", + Deps: []Dependency{{Path: "owner/dep", Version: "v1.0.0", Dir: "/tmp/dep"}}, + PkgConfig: "-lroot", } data, err := json.Marshal(want) if err != nil { @@ -144,7 +144,7 @@ func TestResultJSONRoundTrip(t *testing.T) { if err := json.Unmarshal(data, &got); err != nil { t.Fatal(err) } - if got.Path != want.Path || got.Version != want.Version || got.Dir != want.Dir || got.Metadata != want.Metadata || len(got.Deps) != 1 { + if got.Path != want.Path || got.Version != want.Version || got.Dir != want.Dir || got.PkgConfig != want.PkgConfig || len(got.Deps) != 1 { t.Fatalf("round trip = %+v, want %+v", got, want) } } From 5de100a51be0059c9ab492c07e9ed65826c4f56d Mon Sep 17 00:00:00 2001 From: Rick Guo Date: Tue, 25 Aug 2026 15:48:52 +0800 Subject: [PATCH 5/5] refactor(llar): name result build flags --- xtool/llar/llar.go | 10 +++++----- xtool/llar/llar_test.go | 10 +++++----- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/xtool/llar/llar.go b/xtool/llar/llar.go index cbcf005742..b49605ef75 100644 --- a/xtool/llar/llar.go +++ b/xtool/llar/llar.go @@ -55,11 +55,11 @@ type Dependency struct { // Result is the JSON result returned by llar install. type Result struct { - Path string `json:"path"` - Version string `json:"version"` - Dir string `json:"dir"` - Deps []Dependency `json:"deps,omitempty"` - PkgConfig string `json:"metadata"` + Path string `json:"path"` + Version string `json:"version"` + Dir string `json:"dir"` + Deps []Dependency `json:"deps,omitempty"` + BuildFlags string `json:"metadata"` } // Install runs llar install for mod and decodes the command's JSON result. diff --git a/xtool/llar/llar_test.go b/xtool/llar/llar_test.go index 521c68c7a9..54251974c8 100644 --- a/xtool/llar/llar_test.go +++ b/xtool/llar/llar_test.go @@ -60,8 +60,8 @@ func TestInstall(t *testing.T) { if len(result.Deps) != 1 || result.Deps[0].Path != "owner/dep" { t.Fatalf("deps = %+v", result.Deps) } - if result.PkgConfig != "-L/tmp/root/lib -lroot" { - t.Fatalf("pkg-config = %q", result.PkgConfig) + if result.BuildFlags != "-L/tmp/root/lib -lroot" { + t.Fatalf("build flags = %q", result.BuildFlags) } if !strings.Contains(rawStdout.String(), `"path":"owner/root"`) { t.Fatalf("stdout = %q, want JSON result", rawStdout.String()) @@ -133,8 +133,8 @@ func TestInstallReturnsJSONError(t *testing.T) { func TestResultJSONRoundTrip(t *testing.T) { want := Result{ Path: "owner/root", Version: "v1.0.0", Dir: "/tmp/root", - Deps: []Dependency{{Path: "owner/dep", Version: "v1.0.0", Dir: "/tmp/dep"}}, - PkgConfig: "-lroot", + Deps: []Dependency{{Path: "owner/dep", Version: "v1.0.0", Dir: "/tmp/dep"}}, + BuildFlags: "-lroot", } data, err := json.Marshal(want) if err != nil { @@ -144,7 +144,7 @@ func TestResultJSONRoundTrip(t *testing.T) { if err := json.Unmarshal(data, &got); err != nil { t.Fatal(err) } - if got.Path != want.Path || got.Version != want.Version || got.Dir != want.Dir || got.PkgConfig != want.PkgConfig || len(got.Deps) != 1 { + if got.Path != want.Path || got.Version != want.Version || got.Dir != want.Dir || got.BuildFlags != want.BuildFlags || len(got.Deps) != 1 { t.Fatalf("round trip = %+v, want %+v", got, want) } }