package cli import ( "context" "errors" "flag" "fmt" "io" "strings" "gemcms/internal/cms" ) const usage = `gemcms is a CLI CMS for Gemini capsules. Usage: gemcms add [--type article|page] [--section name] [--tags a,b] [--draft] [--menu name] gemcms create capsule [--title title] [--host host] [--import path] [--section name] [--tags a,b] [--menu name] [--build] gemcms create site [--title title] [--host host] gemcms create page [--title title] gemcms create article [--section name] [--tags a,b] [--draft] gemcms create section <name> [--title title] gemcms create menu <name> gemcms create widget <type> [--name name] [--section name] [--limit n] gemcms menu add <menu> <home|page|section|article|custom> [target] [--label label] [--url url] gemcms list <pages|articles|menus|widgets> gemcms status gemcms health gemcms build gemcms check ` func Run(ctx context.Context, args []string, out, errOut io.Writer) error { _ = ctx if len(args) == 0 { fmt.Fprint(out, usage) return nil } switch args[0] { case "help", "-h", "--help": fmt.Fprint(out, usage) return nil case "add": return runAdd(args[1:], out, errOut) case "create": return runCreate(args[1:], out, errOut) case "menu": return runMenu(args[1:], out, errOut) case "list": return runList(args[1:], out) case "status": return runStatus(args[1:], out, false) case "health": return runStatus(args[1:], out, true) case "build": root, err := cms.FindRoot(".") if err != nil { return err } report, err := cms.Build(root) if err != nil { return err } fmt.Fprintf(out, "built %d files into %s\n", report.FilesWritten, report.OutputDir) return nil case "check": root, err := cms.FindRoot(".") if err != nil { return err } report, err := cms.Check(root) if err != nil { return err } for _, issue := range report.Issues { fmt.Fprintf(out, "%s: %s: %s\n", issue.Level, issue.Path, issue.Message) } if report.HasErrors() { return errors.New("check failed") } if len(report.Issues) == 0 { fmt.Fprintln(out, "OK") } return nil default: return fmt.Errorf("unknown command %q\n\n%s", args[0], usage) } } func runAdd(args []string, out, errOut io.Writer) error { fs := newFlagSet("add", errOut) docType := fs.String("type", "", "content type: article or page") title := fs.String("title", "", "content title") slug := fs.String("slug", "", "content slug") section := fs.String("section", "articles", "article section") tags := fs.String("tags", "", "comma-separated tags") draft := fs.Bool("draft", false, "mark content as draft") menu := fs.String("menu", "", "menu to add the content to") positionals, err := parseInterspersed(fs, args) if err != nil { return err } if len(positionals) != 1 { return errors.New("usage: gemcms add <file-or-directory> [--type article|page] [--section name] [--tags a,b] [--draft] [--menu name]") } root, err := cms.FindRoot(".") if err != nil { return err } report, err := cms.AddPath(root, cms.AddOptions{ Path: positionals[0], Type: *docType, Title: *title, Slug: *slug, Section: *section, Tags: splitCSV(*tags), Draft: *draft, Menu: *menu, }) if err != nil { return err } for _, doc := range report.Documents { fmt.Fprintf(out, "added %s %s\n", doc.Type, doc.SourcePath) } return nil } func runCreate(args []string, out, errOut io.Writer) error { if len(args) == 0 { return fmt.Errorf("missing create target\n\n%s", usage) } switch args[0] { case "site", "capsule": fs := newFlagSet("create site", errOut) title := fs.String("title", "", "capsule title") host := fs.String("host", "", "Gemini host") author := fs.String("author", "", "default author") language := fs.String("language", "en", "content language") importPath := fs.String("import", "", "file or directory to import after creating the capsule") importType := fs.String("type", "", "imported content type") section := fs.String("section", "articles", "section for imported articles") tags := fs.String("tags", "", "comma-separated tags for imported content") draft := fs.Bool("draft", false, "mark imported content as draft") menu := fs.String("menu", "", "menu to add imported content to") build := fs.Bool("build", false, "build public output after creation") positionals, err := parseInterspersed(fs, args[1:]) if err != nil { return err } if len(positionals) != 1 { return errors.New("usage: gemcms create site <name> [--title title] [--host host]") } root, err := cms.CreateSite(".", cms.SiteOptions{ Name: positionals[0], Title: *title, Host: *host, Author: *author, Language: *language, }) if err != nil { return err } fmt.Fprintf(out, "created site %s\n", root) if *importPath != "" { if *section != "articles" { if _, err := cms.CreateSection(root, cms.SectionOptions{Name: *section}); err != nil { return err } if _, err := cms.UpsertWidget(root, cms.WidgetOptions{Name: "recent-articles", Type: "recent", Section: *section, Limit: 5}); err != nil { return err } } report, err := cms.AddPath(root, cms.AddOptions{ Path: *importPath, Type: *importType, Section: *section, Tags: splitCSV(*tags), Draft: *draft, Menu: *menu, }) if err != nil { return err } for _, doc := range report.Documents { fmt.Fprintf(out, "added %s %s\n", doc.Type, doc.SourcePath) } } if *build { report, err := cms.Build(root) if err != nil { return err } fmt.Fprintf(out, "built %d files into %s\n", report.FilesWritten, report.OutputDir) } return nil case "page": fs := newFlagSet("create page", errOut) title := fs.String("title", "", "page title") menu := fs.String("menu", "", "menu to add the page to") positionals, err := parseInterspersed(fs, args[1:]) if err != nil { return err } if len(positionals) != 1 { return errors.New("usage: gemcms create page <slug> [--title title]") } root, err := cms.FindRoot(".") if err != nil { return err } doc, err := cms.CreatePage(root, cms.PageOptions{ Slug: positionals[0], Title: *title, Menu: *menu, }) if err != nil { return err } fmt.Fprintf(out, "created page %s\n", doc.SourcePath) return nil case "article": fs := newFlagSet("create article", errOut) section := fs.String("section", "articles", "article section") tags := fs.String("tags", "", "comma-separated tags") draft := fs.Bool("draft", false, "mark article as draft") menu := fs.String("menu", "", "menu to add the article to") positionals, err := parseInterspersed(fs, args[1:]) if err != nil { return err } if len(positionals) < 1 { return errors.New("usage: gemcms create article <title> [--section name] [--tags a,b] [--draft]") } root, err := cms.FindRoot(".") if err != nil { return err } doc, err := cms.CreateArticle(root, cms.ArticleOptions{ Title: strings.Join(positionals, " "), Section: *section, Tags: splitCSV(*tags), Draft: *draft, Menu: *menu, }) if err != nil { return err } fmt.Fprintf(out, "created article %s\n", doc.SourcePath) return nil case "section": fs := newFlagSet("create section", errOut) title := fs.String("title", "", "section title") positionals, err := parseInterspersed(fs, args[1:]) if err != nil { return err } if len(positionals) != 1 { return errors.New("usage: gemcms create section <name> [--title title]") } root, err := cms.FindRoot(".") if err != nil { return err } path, err := cms.CreateSection(root, cms.SectionOptions{Name: positionals[0], Title: *title}) if err != nil { return err } fmt.Fprintf(out, "created section %s\n", path) return nil case "menu": fs := newFlagSet("create menu", errOut) positionals, err := parseInterspersed(fs, args[1:]) if err != nil { return err } if len(positionals) != 1 { return errors.New("usage: gemcms create menu <name>") } root, err := cms.FindRoot(".") if err != nil { return err } path, err := cms.CreateMenu(root, positionals[0]) if err != nil { return err } fmt.Fprintf(out, "created menu %s\n", path) return nil case "widget": fs := newFlagSet("create widget", errOut) name := fs.String("name", "", "widget config name") section := fs.String("section", "articles", "section for recent widgets") limit := fs.Int("limit", 5, "item limit") positionals, err := parseInterspersed(fs, args[1:]) if err != nil { return err } if len(positionals) != 1 { return errors.New("usage: gemcms create widget <type> [--name name] [--section name] [--limit n]") } root, err := cms.FindRoot(".") if err != nil { return err } path, err := cms.CreateWidget(root, cms.WidgetOptions{ Type: positionals[0], Name: *name, Section: *section, Limit: *limit, }) if err != nil { return err } fmt.Fprintf(out, "created widget %s\n", path) return nil default: return fmt.Errorf("unknown create target %q", args[0]) } } func runMenu(args []string, out, errOut io.Writer) error { if len(args) == 0 || args[0] != "add" { return errors.New("usage: gemcms menu add <menu> <home|page|section|article|custom> [target] [--label label] [--url url]") } if len(args) < 3 { return errors.New("usage: gemcms menu add <menu> <home|page|section|article|custom> [target] [--label label] [--url url]") } menuName := args[1] kind := args[2] target := "" flagStart := 3 if requiresTarget(kind) { if len(args) < 4 || strings.HasPrefix(args[3], "-") { return fmt.Errorf("menu item %q requires a target", kind) } target = args[3] flagStart = 4 } fs := newFlagSet("menu add", errOut) label := fs.String("label", "", "menu item label") url := fs.String("url", "", "menu item URL") positionals, err := parseInterspersed(fs, args[flagStart:]) if err != nil { return err } if len(positionals) != 0 { return fmt.Errorf("unexpected argument %q", positionals[0]) } root, err := cms.FindRoot(".") if err != nil { return err } if err := cms.AddMenuItem(root, cms.MenuItemOptions{ Menu: menuName, Kind: kind, Target: target, Label: *label, URL: *url, }); err != nil { return err } fmt.Fprintf(out, "added item to menu %s\n", menuName) return nil } func runList(args []string, out io.Writer) error { if len(args) != 1 { return errors.New("usage: gemcms list <pages|articles|menus|widgets>") } root, err := cms.FindRoot(".") if err != nil { return err } items, err := cms.List(root, args[0]) if err != nil { return err } for _, item := range items { fmt.Fprintln(out, item) } return nil } func runStatus(args []string, out io.Writer, healthOnly bool) error { if len(args) != 0 { if healthOnly { return errors.New("usage: gemcms health") } return errors.New("usage: gemcms status") } root, err := cms.FindRoot(".") if err != nil { return err } report, err := cms.Status(root) if err != nil { return err } if healthOnly { fmt.Fprintf(out, "%s (%d errors, %d warnings)\n", report.Health(), report.ErrorCount(), report.WarningCount()) for _, issue := range report.Issues { fmt.Fprintf(out, "%s: %s: %s\n", issue.Level, issue.Path, issue.Message) } if report.ErrorCount() > 0 { return errors.New("health check failed") } return nil } fmt.Fprintf(out, "root: %s\n", report.Root) fmt.Fprintf(out, "title: %s\n", report.Config.Title) fmt.Fprintf(out, "host: %s\n", report.Config.Host) fmt.Fprintf(out, "content: %d pages, %d articles, %d drafts\n", report.Pages, report.Articles, report.Drafts) fmt.Fprintf(out, "site: %d menus, %d sections, %d widgets\n", report.Menus, report.Sections, report.Widgets) fmt.Fprintf(out, "public: %d files\n", report.PublicFiles) fmt.Fprintf(out, "health: %s (%d errors, %d warnings)\n", report.Health(), report.ErrorCount(), report.WarningCount()) return nil } func newFlagSet(name string, errOut io.Writer) *flag.FlagSet { fs := flag.NewFlagSet(name, flag.ContinueOnError) fs.SetOutput(errOut) return fs } func parseInterspersed(fs *flag.FlagSet, args []string) ([]string, error) { var flagArgs []string var positionals []string for i := 0; i < len(args); i++ { arg := args[i] if !strings.HasPrefix(arg, "-") || arg == "-" { positionals = append(positionals, arg) continue } flagArgs = append(flagArgs, arg) name := strings.TrimLeft(arg, "-") if idx := strings.Index(name, "="); idx >= 0 { name = name[:idx] } f := fs.Lookup(name) if f == nil { return nil, fmt.Errorf("unknown flag %s", arg) } if isBoolFlag(f) || strings.Contains(arg, "=") { continue } if i+1 >= len(args) { return nil, fmt.Errorf("flag %s requires a value", arg) } i++ flagArgs = append(flagArgs, args[i]) } if err := fs.Parse(flagArgs); err != nil { return nil, err } return positionals, nil } func isBoolFlag(f *flag.Flag) bool { type boolFlag interface { IsBoolFlag() bool } value, ok := f.Value.(boolFlag) return ok && value.IsBoolFlag() } func splitCSV(value string) []string { if value == "" { return nil } var out []string for _, part := range strings.Split(value, ",") { part = strings.TrimSpace(part) if part != "" { out = append(out, part) } } return out } func requiresTarget(kind string) bool { switch kind { case "page", "section", "article", "custom": return true default: return false } }