repos / dbin

📦 Poor man's package manager.
git clone https://github.com/xplshn/dbin.git

xplshn  ·  2025-08-13

config.go

Go
  1package main
  2
  3import (
  4	"context"
  5	"fmt"
  6	"os"
  7	"os/exec"
  8	"path/filepath"
  9	"reflect"
 10	"runtime"
 11	"strconv"
 12	"strings"
 13	"time"
 14
 15	"github.com/adrg/xdg"
 16	"github.com/goccy/go-yaml"
 17	"github.com/urfave/cli/v3"
 18	"github.com/zeebo/errs"
 19)
 20
 21var (
 22	errConfigLoad       = errs.Class("config load error")
 23	errConfigCreate     = errs.Class("config create error")
 24	errConfigFileAccess = errs.Class("config file access error")
 25	errCommandExecution = errs.Class("command execution error")
 26	errSplitArgs        = errs.Class("split args error")
 27	arch                = runtime.GOARCH + "_" + runtime.GOOS
 28)
 29
 30type repository struct {
 31	Name         string            `yaml:"Name,omitempty"`
 32	URL          string            `yaml:"URL" description:"URL of the repository."`
 33	PubKeys      map[string]string `yaml:"pubKeys" description:"URLs to the public keys for signature verification."`
 34	SyncInterval time.Duration     `yaml:"syncInterval" description:"Interval for syncing this repository."`
 35	FallbackURLs []string          `yaml:"fallbackURLs,omitempty" description:"Fallback URLs for the repository."`
 36}
 37
 38type config struct {
 39	Repositories        []repository `yaml:"Repositories" env:"DBIN_REPO_URLS" description:"List of repositories to fetch binaries from."`
 40	InstallDir          string       `yaml:"InstallDir" env:"DBIN_INSTALL_DIR XDG_BIN_HOME" description:"Directory where binaries will be installed."`
 41	CacheDir            string       `yaml:"CacheDir" env:"DBIN_CACHE_DIR" description:"Directory where cached binaries will be stored."`
 42	LicenseDir          string       `yaml:"LicenseDir" env:"DBIN_LICENSE_DIR" description:"Directory where license files will be stored."`
 43	CreateLicenses      bool         `yaml:"CreateLicenses" env:"DBIN_CREATE_LICENSES" description:"Enable saving of license files from OCI downloads."`
 44	Limit               uint         `yaml:"SearchResultsLimit" env:"DBIN_SEARCH_LIMIT" description:"Limit the number of search results displayed."`
 45	ProgressbarStyle    int          `yaml:"PbarStyle,omitempty" env:"DBIN_PB_STYLE" description:"Style of the progress bar."`
 46	DisableTruncation   bool         `yaml:"Truncation" env:"DBIN_NOTRUNCATION" description:"Disable truncation of output."`
 47	RetakeOwnership     bool         `yaml:"RetakeOwnership" env:"DBIN_REOWN" description:"Retake ownership of installed binaries."`
 48	UseIntegrationHooks bool         `yaml:"IntegrationHooks" env:"DBIN_USEHOOKS" description:"Use integration hooks for binaries."`
 49	DisableProgressbar  bool         `yaml:"DisablePbar,omitempty" env:"DBIN_NOPBAR" description:"Disable the progress bar."`
 50	NoConfig            bool         `yaml:"-" env:"DBIN_NOCONFIG" description:"Disable configuration file usage."`
 51	ProgressbarFIFO     bool         `yaml:"-" env:"DBIN_PB_FIFO" description:"Use FIFO for progress bar."`
 52	Hooks               hooks        `yaml:"Hooks,omitempty"`
 53}
 54
 55type hooks struct {
 56	Commands map[string]hookCommands `yaml:"commands" description:"Commands for hooks."`
 57}
 58
 59type hookCommands struct {
 60	IntegrationCommand   string `yaml:"integrationCommand" description:"Command to run for integration."`
 61	DeintegrationCommand string `yaml:"deintegrationCommand" description:"Command to run for deintegration."`
 62	UseRunFromCache      bool   `yaml:"runFromCache" description:"Use run from cache for hooks."`
 63	NoOp                 bool   `yaml:"nop" description:"No operation flag for hooks."`
 64	Silent               bool   `yaml:"silent" description:"Do not notify user about the hook, at all"`
 65}
 66
 67func configCommand() *cli.Command {
 68	return &cli.Command{
 69		Name:  "config",
 70		Usage: "Manage configuration options",
 71		Flags: []cli.Flag{
 72			&cli.BoolFlag{
 73				Name:  "new",
 74				Usage: "Create a new configuration file",
 75			},
 76			&cli.BoolFlag{
 77				Name:  "show",
 78				Usage: "Show the current configuration",
 79			},
 80		},
 81		Action: func(_ context.Context, c *cli.Command) error {
 82			if c.Bool("new") {
 83				configFilePath := os.Getenv("DBIN_CONFIG_FILE")
 84				if configFilePath == "" {
 85					configFilePath = filepath.Join(xdg.ConfigHome, "dbin", "dbin.yaml")
 86				}
 87				return createDefaultConfigAt(configFilePath)
 88			} else if c.Bool("show") {
 89				config, err := loadConfig()
 90				if err != nil {
 91					return errConfigLoad.Wrap(err)
 92				}
 93				printConfig(config)
 94				return nil
 95			}
 96
 97			return cli.ShowSubcommandHelp(c)
 98		},
 99	}
100}
101
102func printConfig(config *config) {
103	v := reflect.ValueOf(config).Elem()
104	t := v.Type()
105
106	for i := 0; i < v.NumField(); i++ {
107		field := v.Field(i)
108		fieldType := t.Field(i)
109		description := fieldType.Tag.Get("description")
110		if description != "" {
111			fmt.Printf("%s: %v\nDescription: %s\n\n", fieldType.Name, field.Interface(), description)
112		}
113	}
114}
115
116func splitArgs(cmd string) ([]string, error) {
117	var args []string
118	var arg []rune
119	var inQuote rune
120	escaped := false
121	for _, c := range cmd {
122		switch {
123		case escaped:
124			arg = append(arg, c)
125			escaped = false
126		case c == '\\':
127			escaped = true
128		case c == '"' || c == '\'':
129			if inQuote == 0 {
130				inQuote = c
131			} else if inQuote == c {
132				inQuote = 0
133			} else {
134				arg = append(arg, c)
135			}
136		case c == ' ' || c == '\t':
137			if inQuote != 0 {
138				arg = append(arg, c)
139			} else if len(arg) > 0 {
140				args = append(args, string(arg))
141				arg = nil
142			}
143		default:
144			arg = append(arg, c)
145		}
146	}
147	if len(arg) > 0 {
148		args = append(args, string(arg))
149	}
150	if inQuote != 0 {
151		return nil, errSplitArgs.New("unterminated quote")
152	}
153	return args, nil
154}
155
156func executeHookCommand(config *config, hookCommands *hookCommands, ext, bEntryPath string, isIntegration bool) error {
157	if hookCommands.NoOp {
158		return nil
159	}
160
161	commandParts, err := splitArgs(hookCommands.IntegrationCommand)
162	if err != nil {
163		return errCommandExecution.Wrap(err)
164	}
165	if len(commandParts) == 0 {
166		return nil
167	}
168
169	command := commandParts[0]
170	args := commandParts[1:]
171
172	env := os.Environ()
173	env = append(env, fmt.Sprintf("DBIN_INSTALL_DIR=%s", config.InstallDir))
174	env = append(env, fmt.Sprintf("DBIN_CACHE_DIR=%s", config.CacheDir))
175	env = append(env, fmt.Sprintf("DBIN=%s", os.Args[0]))
176	env = append(env, fmt.Sprintf("DBIN_HOOK_BINARY=%s", bEntryPath))
177	env = append(env, fmt.Sprintf("DBIN_HOOK_BINARY_EXT=%s", ext))
178	if isIntegration {
179		env = append(env, "DBIN_HOOK_TYPE=install")
180	} else {
181		env = append(env, "DBIN_HOOK_TYPE=remove")
182	}
183
184	if hookCommands.Silent {
185		verbosityLevel = silentVerbosityWithErrors
186	}
187
188	if hookCommands.UseRunFromCache {
189		return runFromCache(config, stringToBinaryEntry(command), args, true, env)
190	}
191
192	cmdExec := exec.Command(command, args...)
193	cmdExec.Env = env
194	cmdExec.Stdout = os.Stdout
195	cmdExec.Stderr = os.Stderr
196	if err := cmdExec.Run(); err != nil {
197		return errCommandExecution.Wrap(err)
198	}
199	return nil
200}
201
202func loadConfig() (*config, error) {
203	cfg := config{}
204	setDefaultValues(&cfg)
205
206	if nocfg, ok := os.LookupEnv("DBIN_NOCONFIG"); ok && (nocfg == "1" || strings.ToLower(nocfg) == "true" || nocfg == "yes") {
207		cfg.NoConfig = true
208		overrideWithEnv(&cfg)
209		return &cfg, nil
210	}
211
212	configFilePath := os.Getenv("DBIN_CONFIG_FILE")
213	if configFilePath == "" {
214		configFilePath = filepath.Join(xdg.ConfigHome, "dbin", "dbin.yaml")
215	}
216
217	if _, err := os.Stat(configFilePath); os.IsNotExist(err) {
218		if err := createDefaultConfigAt(configFilePath); err != nil {
219			return nil, errConfigCreate.Wrap(err)
220		}
221	}
222
223	if err := loadYAML(configFilePath, &cfg); err != nil {
224		return nil, errConfigLoad.Wrap(err)
225	}
226
227	for v := version - 0.1; v >= version-0.3; v -= 0.1 {
228		main := fmt.Sprintf("https://d.xplshn.com.ar/misc/cmd/%.1f/%s.nlite.cbor.zst", v, arch)
229		fallback := fmt.Sprintf("https://github.com/xplshn/dbin-metadata/raw/refs/heads/master/misc/cmd/%.1f/%s.nlite.cbor.zst", v, arch)
230	
231		for _, repo := range cfg.Repositories {
232			if repo.URL == main {
233				fmt.Printf("Warning: One of your repository URLs points to version %.1f, which may be outdated. Current version is %.1f\n", v, version)
234			}
235			for _, fb := range repo.FallbackURLs {
236				if fb == fallback {
237					fmt.Printf("Warning: One of your fallback URLs points to version %.1f, which may be outdated. Current version is %.1f\n", v, version)
238				}
239			}
240		}
241	}
242
243	overrideWithEnv(&cfg)
244
245	return &cfg, nil
246}
247
248func loadYAML(filePath string, cfg *config) error {
249	file, err := os.Open(filePath)
250	if err != nil {
251		return errConfigFileAccess.Wrap(err)
252	}
253	defer file.Close()
254	return yaml.NewDecoder(file).Decode(cfg)
255}
256
257func overrideWithEnv(cfg *config) {
258	v := reflect.ValueOf(cfg).Elem()
259	t := v.Type()
260
261	setFieldFromEnv := func(field reflect.Value, envVars []string) bool {
262		for _, envVar := range envVars {
263			if value, exists := os.LookupEnv(envVar); exists && value != "" {
264				switch field.Kind() {
265				case reflect.String:
266					field.SetString(value)
267				case reflect.Slice:
268					if field.Type() == reflect.TypeOf([]repository{}) {
269						urls := strings.Split(value, ",")
270						var repos []repository
271						for _, url := range urls {
272							repos = append(repos, repository{
273								URL: strings.TrimSpace(url),
274							})
275						}
276						field.Set(reflect.ValueOf(repos))
277					} else {
278						field.Set(reflect.ValueOf(strings.Split(value, ",")))
279					}
280				case reflect.Bool:
281					if val, err := strconv.ParseBool(value); err == nil {
282						field.SetBool(val)
283					}
284				case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
285					if val, err := strconv.Atoi(value); err == nil {
286						field.SetInt(int64(val))
287					}
288				case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
289					if val, err := strconv.ParseUint(value, 10, 64); err == nil {
290						field.SetUint(val)
291					}
292				}
293				return true
294			}
295		}
296		return false
297	}
298
299	for i := 0; i < v.NumField(); i++ {
300		field := v.Field(i)
301		envTags := strings.Fields(t.Field(i).Tag.Get("env"))
302
303		if len(envTags) > 0 {
304			setFieldFromEnv(field, envTags)
305		}
306	}
307}
308
309func setDefaultValues(config *config) {
310	config.InstallDir = filepath.Join(xdg.BinHome)
311	config.CacheDir = filepath.Join(xdg.CacheHome, "dbin_cache")
312	config.LicenseDir = filepath.Join(xdg.ConfigHome, "dbin", "licenses")
313	config.CreateLicenses = true
314
315	config.Repositories = []repository{
316		{
317			URL: fmt.Sprintf("https://d.xplshn.com.ar/misc/cmd/%.1f/%s%s", version, arch, ".nlite.cbor.zst"),
318			FallbackURLs: []string{
319				fmt.Sprintf("https://github.com/xplshn/dbin-metadata/raw/refs/heads/master/misc/cmd/%.1f/%s%s", version, arch, ".nlite.cbor.zst"),
320			},
321			PubKeys: map[string]string{
322				"bincache": "https://meta.pkgforge.dev/bincache/minisign.pub",
323				"pkgcache": "https://meta.pkgforge.dev/pkgcache/minisign.pub",
324			},
325			SyncInterval: 6 * time.Hour,
326		},
327	}
328
329	config.DisableTruncation = false
330	config.Limit = 999999
331	config.UseIntegrationHooks = true
332	config.RetakeOwnership = false
333	config.ProgressbarStyle = 1
334	config.DisableProgressbar = false
335	config.NoConfig = false
336}
337
338func createDefaultConfigAt(configFilePath string) error {
339	cfg := config{}
340	setDefaultValues(&cfg)
341	overrideWithEnv(&cfg)
342
343	cfg.Hooks = hooks{
344		Commands: map[string]hookCommands{
345			"*": {
346				IntegrationCommand:   "sh -c \"$DBIN info > ${DBIN_CACHE_DIR}/.info\"",
347				DeintegrationCommand: "sh -c \"$DBIN info > ${DBIN_CACHE_DIR}/.info\"",
348				UseRunFromCache:      true,
349				Silent:               true,
350				NoOp:                 false,
351			},
352		},
353	}
354
355	dir := filepath.Dir(configFilePath)
356	if err := os.MkdirAll(dir, 0755); err != nil {
357		return errConfigCreate.Wrap(err)
358	}
359	configYAML, err := yaml.Marshal(cfg)
360	if err != nil {
361		return errConfigCreate.Wrap(err)
362	}
363
364	if err := os.WriteFile(configFilePath, configYAML, 0644); err != nil {
365		return errConfigCreate.Wrap(err)
366	}
367
368	fmt.Printf("Default config file created at: %s\n", configFilePath)
369	return nil
370}