summaryrefslogtreecommitdiffstats
path: root/internal/cli/cli.go
diff options
context:
space:
mode:
authorGab Virebent <gabriel1@virebent.art>2026-07-07 16:24:28 +0200
committerGab Virebent <gabriel1@virebent.art>2026-07-07 16:24:28 +0200
commit37f156c30e2a2b01c840d39f0dd2bdbf811732d6 (patch)
treee2ce84dc7f88ce0eb312899448f91b7bb93ca8a3 /internal/cli/cli.go
downloadgemcms-main.tar.gz
gemcms-main.tar.xz
gemcms-main.zip
Initial commitHEADmain
Diffstat (limited to 'internal/cli/cli.go')
-rw-r--r--internal/cli/cli.go497
1 files changed, 497 insertions, 0 deletions
diff --git a/internal/cli/cli.go b/internal/cli/cli.go
new file mode 100644
index 0000000..4df575c
--- /dev/null
+++ b/internal/cli/cli.go
@@ -0,0 +1,497 @@
+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 <file-or-directory> [--type article|page] [--section name] [--tags a,b] [--draft] [--menu name]
+ gemcms create capsule <name> [--title title] [--host host] [--import path] [--section name] [--tags a,b] [--menu name] [--build]
+ gemcms create site <name> [--title title] [--host host]
+ gemcms create page <slug> [--title title]
+ gemcms create article <title> [--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
+ }
+}