From 26ec677a2bee8344675eb3b4afc24b3ae85f512a Mon Sep 17 00:00:00 2001 From: Sam Anthony Date: Thu, 14 May 2026 11:17:55 -0400 Subject: cli: validate interior --- cmd/lulu/creds.go | 41 +++++++++++++ cmd/lulu/flag.go | 138 ++++++++++++++++++++++++++++++++++++++++++ cmd/lulu/main.go | 109 +++++++++++++++++++++++++++++++++ cmd/lulu/mfg.go | 74 ++++++++++++++++++++++ cmd/lulu/validate_cover.go | 9 +++ cmd/lulu/validate_interior.go | 59 ++++++++++++++++++ 6 files changed, 430 insertions(+) create mode 100644 cmd/lulu/creds.go create mode 100644 cmd/lulu/flag.go create mode 100644 cmd/lulu/main.go create mode 100644 cmd/lulu/mfg.go create mode 100644 cmd/lulu/validate_cover.go create mode 100644 cmd/lulu/validate_interior.go (limited to 'cmd') diff --git a/cmd/lulu/creds.go b/cmd/lulu/creds.go new file mode 100644 index 0000000..5d80018 --- /dev/null +++ b/cmd/lulu/creds.go @@ -0,0 +1,41 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/adrg/xdg" +) + +var ( + configDir = filepath.Join(xdg.ConfigHome, "lulu") + keyFile = filepath.Join(configDir, "key") + secretFile = filepath.Join(configDir, "secret") + sandboxKeyFile = filepath.Join(configDir, "sandbox", "key") + sandboxSecretFile = filepath.Join(configDir, "sandbox", "secret") +) + +// Credentials contains the client-key and client-secret used to +// authenticate the client (us) to the API. +type Credentials struct { + key, secret string +} + +func readCreds() (Credentials, error) { + key, err := readFile(keyFile) + if err != nil { + return Credentials{}, fmt.Errorf("error reading client-key file: %w", err) + } + secret, err := readFile(secretFile) + if err != nil { + return Credentials{}, fmt.Errorf("error reading client-secret file: %w", err) + } + return Credentials{key, secret}, nil +} + +func readFile(path string) (string, error) { + data, err := os.ReadFile(path) + return strings.TrimSpace(string(data)), err +} diff --git a/cmd/lulu/flag.go b/cmd/lulu/flag.go new file mode 100644 index 0000000..cd7773e --- /dev/null +++ b/cmd/lulu/flag.go @@ -0,0 +1,138 @@ +package main + +import ( + "encoding" + "flag" + "fmt" + "net/url" + "os" + "strconv" + "strings" + "unicode" +) + +type Flag struct { + val flag.Value + name string + usage string +} + +func (f Flag) Synopsis() string { + s := fmt.Sprintf("-%s", f.name) + if bf, ok := f.val.(boolFlag); !ok || !bf.IsBoolFlag() { + s += " " + strings.ToUpper(firstWord(f.usage)) + } + return s +} + +func (f Flag) AddTo(fs *flag.FlagSet) { + fs.Var(f.val, f.name, f.usage) +} + +func (f Flag) IsSet() bool { + return f.val.String() != "" +} + +func (f Flag) MustBeSet(fs *flag.FlagSet) { + if !f.IsSet() { + errMissingFlag(fs, f) + } +} + +// boolValue implements flag.Value. +type boolValue struct { + p *bool +} + +func (bv boolValue) String() string { + if bv.p != nil { + return fmt.Sprint(*bv.p) + } + return fmt.Sprint(false) +} + +func (bv boolValue) Set(s string) error { + b, err := strconv.ParseBool(s) + if err != nil { + return err + } + *bv.p = b + return nil +} + +func (bv boolValue) IsBoolFlag() bool { return true } + +type boolFlag interface { + flag.Value + IsBoolFlag() bool +} + +// textValue implements flag.Value for string types that implement +// TextUnmarshaler. +type textValue[T ~string, PT interface { + *T + encoding.TextUnmarshaler +}] struct { + p PT +} + +func newTextValue[T ~string, PT interface { + *T + encoding.TextUnmarshaler +}](p PT) textValue[T, PT] { + return textValue[T, PT]{p} +} + +func (tv textValue[T, PT]) String() string { + if tv.p != nil { + return string(*(*T)(tv.p)) + } + return "" +} + +func (tv textValue[T, PT]) Set(s string) error { + return tv.p.UnmarshalText([]byte(s)) +} + +type urlValue struct { + *url.URL +} + +func (v urlValue) String() string { + if v.URL != nil { + return v.URL.String() + } + return "" +} + +func (v urlValue) Set(s string) error { + u, err := url.Parse(s) + if err != nil { + return err + } + *v.URL = *u + return nil +} + +func mutexFlags(fs *flag.FlagSet, a, b Flag) { + if a.IsSet() && b.IsSet() { + lg.Printf("lulu: %s: -%s is incompatible with -%s\n", fs.Name(), a.name, b.name) + fs.Usage() + os.Exit(1) + } +} + +func errMissingFlag(fs *flag.FlagSet, f Flag) { + lg.Printf("lulu %s: missing -%s flag\n", fs.Name(), f.name) + fs.Usage() + os.Exit(1) +} + +func firstWord(s string) string { + if i := strings.IndexFunc(s, func(r rune) bool { + return unicode.IsSpace(r) || unicode.IsPunct(r) + }); i >= 0 { + return s[:i] + } + return s +} diff --git a/cmd/lulu/main.go b/cmd/lulu/main.go new file mode 100644 index 0000000..b664644 --- /dev/null +++ b/cmd/lulu/main.go @@ -0,0 +1,109 @@ +package main + +import ( + "context" + "flag" + "fmt" + "log" + "os" + "time" + + "git.samanthony.xyz/lulu" +) + +const defaultTimeout = 15 * time.Second + +var lg = log.New(os.Stderr, "", 0) + +type command struct { + longhand, shorthand string + f func(clnt *lulu.Client, args []string) + usage string +} + +var commands = []command{ + { + "validate-interior", "vi", + validateInterior, + "Validate an interior file", + }, { + "validate-cover", "vc", + validateCover, + "Validate a cover file", + }, +} + +var ( + sandbox = false + ask = true +) + +func usage() { + progName := os.Args[0] + out := flag.CommandLine.Output() + fmt.Fprintf(out, "Usage of %s:\n", progName) + fmt.Fprintf(out, "%s [global flags] [command] [command flags]\n", progName) + fmt.Fprintf(out, "Global Flags:\n") + flag.PrintDefaults() + fmt.Fprintf(out, "Commands:\n") + for _, cmd := range commands { + fmt.Fprintf(out, " %s / %s\n", cmd.longhand, cmd.shorthand) + fmt.Fprintf(out, " \t%s\n", cmd.usage) + } +} + +func main() { + flag.Usage = usage + flag.BoolVar(&sandbox, "s", false, "Use the sandbox API environment") + flagBoolFunc("y", "Do not ask for confirmation", func() { ask = false }) + flag.BoolVar(&lulu.Debug, "d", false, "Print debug info to stdout") + flag.Parse() + + if sandbox { + lg.Println("lulu: using sandbox environment") + lulu.Sandbox() + keyFile = sandboxKeyFile + secretFile = sandboxSecretFile + } else { + lulu.Production() + } + + creds, err := readCreds() + if err != nil { + lg.Fatalf("%v\nCopy and paste your \"client key\" and \"client secret\" from the API Keys page into %s and %s\n%s\n", + err, keyFile, secretFile, lulu.ApiKeyPage()) + } + + clnt, err := lulu.NewClient(context.Background(), creds.key, creds.secret) + if err != nil { + lg.Fatal(err) + } + + args := os.Args[1+flag.NFlag():] // drop program name and flags + if len(args) < 1 { + flag.Usage() + os.Exit(1) + } + cmdStr := args[0] + subArgs := args[1:] + for _, cmd := range commands { + switch cmdStr { + case cmd.longhand, cmd.shorthand: + cmd.f(clnt, subArgs) + return + } + } + lg.Printf("lulu: unknown command %q\n", cmdStr) + flag.Usage() + os.Exit(1) +} + +func flagBoolFunc(name, usage string, f func()) { + flag.BoolFunc(name, usage, func(s string) error { + if s != "" { + return fmt.Errorf("no value allowed") + } + f() + return nil + }) +} diff --git a/cmd/lulu/mfg.go b/cmd/lulu/mfg.go new file mode 100644 index 0000000..c94c42a --- /dev/null +++ b/cmd/lulu/mfg.go @@ -0,0 +1,74 @@ +package main + +import ( + "flag" + "fmt" + "strings" + + "git.samanthony.xyz/lulu" +) + +// mfgFlags is a set of flags that specify a PkgId. +type mfgFlagSet struct { + size lulu.TrimSize + color lulu.ColorType + quality lulu.Quality + binding lulu.Binding + paper lulu.Paper + finish lulu.Finish + linen lulu.Linen + foil lulu.Foil + + flags []Flag +} + +func newMfgFlagSet() *mfgFlagSet { + mfg := new(mfgFlagSet) + mfg.flags = []Flag{ + {newTextValue(&mfg.size), "s", fmt.Sprintf("Trim size %v", lulu.TrimSizeValues())}, + {newTextValue(&mfg.color), "c", fmt.Sprintf("Color mode %v", lulu.ColorTypeValues())}, + {newTextValue(&mfg.quality), "q", fmt.Sprintf("Quality %v", lulu.QualityValues())}, + {newTextValue(&mfg.binding), "b", fmt.Sprintf("Binding %v", lulu.BindingValues())}, + {newTextValue(&mfg.paper), "p", fmt.Sprintf("Paper %v", lulu.PaperValues())}, + {newTextValue(&mfg.finish), "f", fmt.Sprintf("Cover finish %v", lulu.FinishValues())}, + {newTextValue(&mfg.linen), "l", fmt.Sprintf("Linen wrap color %v", lulu.LinenValues())}, + {newTextValue(&mfg.foil), "o", fmt.Sprintf("Foil color %v", lulu.FoilValues())}, + } + return mfg +} + +func (m mfgFlagSet) PkgId() lulu.PkgId { + return lulu.PkgId{ + m.size, + m.color, + m.quality, + m.binding, + m.paper, + m.finish, + m.linen, + m.foil, + } +} + +func (m mfgFlagSet) Synopsis() string { + s := new(strings.Builder) + for i, f := range m.flags { + if i != 0 { + fmt.Fprint(s, " ") + } + fmt.Fprint(s, f.Synopsis()) + } + return s.String() +} + +func (m mfgFlagSet) AddTo(fs *flag.FlagSet) { + for _, f := range m.flags { + f.AddTo(fs) + } +} + +func (m mfgFlagSet) MustBeSet(fs *flag.FlagSet) { + for _, f := range m.flags { + f.MustBeSet(fs) + } +} diff --git a/cmd/lulu/validate_cover.go b/cmd/lulu/validate_cover.go new file mode 100644 index 0000000..25afbfd --- /dev/null +++ b/cmd/lulu/validate_cover.go @@ -0,0 +1,9 @@ +package main + +import ( + "git.samanthony.xyz/lulu" +) + +func validateCover(clnt *lulu.Client, args []string) { + lg.Fatal("not implemented") // TODO +} diff --git a/cmd/lulu/validate_interior.go b/cmd/lulu/validate_interior.go new file mode 100644 index 0000000..f7c4bdb --- /dev/null +++ b/cmd/lulu/validate_interior.go @@ -0,0 +1,59 @@ +package main + +import ( + "context" + "flag" + "fmt" + "net/url" + "time" + + "git.samanthony.xyz/lulu" +) + +func validateInterior(clnt *lulu.Client, args []string) { + var ( + basic bool + url url.URL + timeout time.Duration + ) + basicFlag := Flag{boolValue{&basic}, "basic", "Basic validation without pod_package_id manufacturing options"} + urlFlag := Flag{urlValue{&url}, "url", "URL of interior file for Lulu to download"} + mfgFlags := newMfgFlagSet() + fs := flag.NewFlagSet("validate-interior", flag.ExitOnError) + basicFlag.AddTo(fs) + urlFlag.AddTo(fs) + mfgFlags.AddTo(fs) + fs.DurationVar(&timeout, "t", defaultTimeout, "Timeout") + fs.Usage = func() { + out := fs.Output() + name := "validate-interior" + fmt.Fprintf(out, "Usage of %s:\n", name) + fmt.Fprintf(out, " %s %s %s [flags]\n", name, urlFlag.Synopsis(), mfgFlags.Synopsis()) + fmt.Fprintf(out, " %s %s %s [flags]\n", name, basicFlag.Synopsis(), urlFlag.Synopsis()) + fmt.Fprintf(out, "Flags:\n") + fs.PrintDefaults() + } + if err := fs.Parse(args); err != nil { + lg.Fatalf("lulu: %v", err) + } + + var val lulu.InteriorValidation + var err error + ctx, cancel := context.WithTimeout(clnt.Context(), timeout) + defer cancel() + if basic { + urlFlag.MustBeSet(fs) + for _, f := range mfgFlags.flags { + mutexFlags(fs, f, basicFlag) + } + val, err = clnt.ValidateInteriorBasic(ctx, url.String()) + } else { + urlFlag.MustBeSet(fs) + mfgFlags.MustBeSet(fs) + val, err = clnt.ValidateInterior(ctx, url.String(), mfgFlags.PkgId()) + } + if err != nil { + lg.Fatal(err) + } + fmt.Println(val) // TODO: output format? +} -- cgit v1.2.3