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}