mirror of
https://github.com/photoprism/photoprism.git
synced 2025-12-12 00:34:13 +01:00
CLI: Add "photoprism show commands" command to generate CLI docs #5220
Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
340
internal/commands/catalog/catalog.go
Normal file
340
internal/commands/catalog/catalog.go
Normal file
@@ -0,0 +1,340 @@
|
||||
/*
|
||||
Package catalog provides DTOs, builders, and a templated renderer to export the PhotoPrism CLI command tree (and flags) as Markdown or JSON for documentation purposes.
|
||||
|
||||
Copyright (c) 2018 - 2025 PhotoPrism UG. All rights reserved.
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under Version 3 of the GNU Affero General Public License (the "AGPL"):
|
||||
<https://docs.photoprism.app/license/agpl>
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
The AGPL is supplemented by our Trademark and Brand Guidelines,
|
||||
which describe how our Brand Assets may be used:
|
||||
<https://www.photoprism.app/trademark>
|
||||
|
||||
Feel free to send an email to hello@photoprism.app if you have questions,
|
||||
want to support our work, or just want to say hello.
|
||||
|
||||
Additional information can be found in our Developer Guide:
|
||||
<https://docs.photoprism.app/developer-guide/>
|
||||
*/
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"sort"
|
||||
"strings"
|
||||
"text/template"
|
||||
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
// Flag describes a CLI flag.
|
||||
type Flag struct {
|
||||
Name string `json:"name"`
|
||||
Aliases []string `json:"aliases,omitempty"`
|
||||
Type string `json:"type"`
|
||||
Required bool `json:"required,omitempty"`
|
||||
Default string `json:"default,omitempty"`
|
||||
Env []string `json:"env,omitempty"`
|
||||
Category string `json:"category,omitempty"`
|
||||
Usage string `json:"usage,omitempty"`
|
||||
Hidden bool `json:"hidden"`
|
||||
}
|
||||
|
||||
// Command describes a CLI command (flat form).
|
||||
type Command struct {
|
||||
Name string `json:"name"`
|
||||
FullName string `json:"full_name"`
|
||||
Parent string `json:"parent,omitempty"`
|
||||
Depth int `json:"depth"`
|
||||
Usage string `json:"usage,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Category string `json:"category,omitempty"`
|
||||
Aliases []string `json:"aliases,omitempty"`
|
||||
ArgsUsage string `json:"args_usage,omitempty"`
|
||||
Hidden bool `json:"hidden"`
|
||||
Flags []Flag `json:"flags,omitempty"`
|
||||
}
|
||||
|
||||
// Node is a nested representation of commands.
|
||||
type Node struct {
|
||||
Command
|
||||
Subcommands []Node `json:"subcommands,omitempty"`
|
||||
}
|
||||
|
||||
// App carries app metadata for top-level JSON/MD.
|
||||
type App struct {
|
||||
Name string `json:"name"`
|
||||
Edition string `json:"edition"`
|
||||
Version string `json:"version"`
|
||||
Build string `json:"build,omitempty"`
|
||||
}
|
||||
|
||||
// MarkdownData is the data model used by the Markdown template.
|
||||
type MarkdownData struct {
|
||||
App App
|
||||
GeneratedAt string
|
||||
BaseHeading int
|
||||
Short bool
|
||||
All bool
|
||||
Commands []Command
|
||||
}
|
||||
|
||||
// BuildFlat returns a depth-first flat list of commands starting at c.
|
||||
func BuildFlat(c *cli.Command, depth int, parentFull string, includeHidden bool, global []Flag) []Command {
|
||||
var out []Command
|
||||
info := CommandInfo(c, depth, parentFull, includeHidden, global)
|
||||
out = append(out, info)
|
||||
for _, sub := range c.Subcommands {
|
||||
if sub == nil || (sub.Hidden && !includeHidden) {
|
||||
continue
|
||||
}
|
||||
out = append(out, BuildFlat(sub, depth+1, info.FullName, includeHidden, global)...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// BuildNode returns a nested representation of c and its subcommands.
|
||||
func BuildNode(c *cli.Command, depth int, parentFull string, includeHidden bool, global []Flag) Node {
|
||||
info := CommandInfo(c, depth, parentFull, includeHidden, global)
|
||||
node := Node{Command: info}
|
||||
for _, sub := range c.Subcommands {
|
||||
if sub == nil || (sub.Hidden && !includeHidden) {
|
||||
continue
|
||||
}
|
||||
node.Subcommands = append(node.Subcommands, BuildNode(sub, depth+1, info.FullName, includeHidden, global))
|
||||
}
|
||||
return node
|
||||
}
|
||||
|
||||
// CommandInfo converts a cli.Command to a Command DTO.
|
||||
func CommandInfo(c *cli.Command, depth int, parentFull string, includeHidden bool, global []Flag) Command {
|
||||
pathName := c.Name
|
||||
fullName := strings.TrimSpace(parentFull + " " + pathName)
|
||||
parent := parentFull
|
||||
|
||||
cmd := Command{
|
||||
Name: pathName,
|
||||
FullName: fullName,
|
||||
Parent: parent,
|
||||
Depth: depth,
|
||||
Usage: c.Usage,
|
||||
Description: strings.TrimSpace(c.Description),
|
||||
Category: c.Category,
|
||||
Aliases: c.Aliases,
|
||||
ArgsUsage: c.ArgsUsage,
|
||||
Hidden: c.Hidden,
|
||||
}
|
||||
|
||||
// Build set of canonical global flag names to exclude from per-command flags
|
||||
globalSet := map[string]struct{}{}
|
||||
for _, gf := range global {
|
||||
globalSet[strings.TrimLeft(gf.Name, "-")] = struct{}{}
|
||||
}
|
||||
|
||||
// Convert flags and optionally filter hidden/global
|
||||
flags := FlagsToCatalog(c.Flags, includeHidden)
|
||||
keep := make([]Flag, 0, len(flags))
|
||||
for _, f := range flags {
|
||||
name := strings.TrimLeft(f.Name, "-")
|
||||
if _, isGlobal := globalSet[name]; isGlobal {
|
||||
continue
|
||||
}
|
||||
if !includeHidden && f.Hidden {
|
||||
continue
|
||||
}
|
||||
keep = append(keep, f)
|
||||
}
|
||||
sort.Slice(keep, func(i, j int) bool { return keep[i].Name < keep[j].Name })
|
||||
cmd.Flags = keep
|
||||
return cmd
|
||||
}
|
||||
|
||||
// FlagsToCatalog converts cli flags to Flag DTOs, filtering hidden if needed.
|
||||
func FlagsToCatalog(flags []cli.Flag, includeHidden bool) []Flag {
|
||||
out := make([]Flag, 0, len(flags))
|
||||
for _, f := range flags {
|
||||
if f == nil {
|
||||
continue
|
||||
}
|
||||
cf := DescribeFlag(f)
|
||||
if !includeHidden && cf.Hidden {
|
||||
continue
|
||||
}
|
||||
out = append(out, cf)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||
return out
|
||||
}
|
||||
|
||||
// DescribeFlag inspects a cli.Flag and returns a Flag with metadata.
|
||||
func DescribeFlag(f cli.Flag) Flag {
|
||||
// Names and aliases
|
||||
names := f.Names()
|
||||
primary := ""
|
||||
aliases := make([]string, 0, len(names))
|
||||
for i, n := range names {
|
||||
if i == 0 {
|
||||
primary = n
|
||||
}
|
||||
if primary == "" || (len(n) > 1 && primary != n) {
|
||||
primary = n
|
||||
}
|
||||
}
|
||||
for _, n := range names {
|
||||
if n == primary {
|
||||
continue
|
||||
}
|
||||
if len(n) == 1 {
|
||||
aliases = append(aliases, "-"+n)
|
||||
} else {
|
||||
aliases = append(aliases, "--"+n)
|
||||
}
|
||||
}
|
||||
|
||||
type hasUsage interface{ GetUsage() string }
|
||||
type hasCategory interface{ GetCategory() string }
|
||||
type hasEnv interface{ GetEnvVars() []string }
|
||||
type hasDefault interface{ GetDefaultText() string }
|
||||
type hasRequired interface{ IsRequired() bool }
|
||||
type hasVisible interface{ IsVisible() bool }
|
||||
|
||||
usage, category, def := "", "", ""
|
||||
env := []string{}
|
||||
required, hidden := false, false
|
||||
if hf, ok := f.(hasUsage); ok {
|
||||
usage = hf.GetUsage()
|
||||
}
|
||||
if hf, ok := f.(hasCategory); ok {
|
||||
category = hf.GetCategory()
|
||||
}
|
||||
if hf, ok := f.(hasEnv); ok {
|
||||
env = append(env, hf.GetEnvVars()...)
|
||||
}
|
||||
if hf, ok := f.(hasDefault); ok {
|
||||
def = hf.GetDefaultText()
|
||||
}
|
||||
if hf, ok := f.(hasRequired); ok {
|
||||
required = hf.IsRequired()
|
||||
}
|
||||
if hv, ok := f.(hasVisible); ok {
|
||||
hidden = !hv.IsVisible()
|
||||
}
|
||||
|
||||
t := flagTypeString(f)
|
||||
name := primary
|
||||
if len(name) == 1 {
|
||||
name = "-" + name
|
||||
} else {
|
||||
name = "--" + name
|
||||
}
|
||||
|
||||
return Flag{
|
||||
Name: name, Aliases: aliases, Type: t, Required: required, Default: def,
|
||||
Env: env, Category: category, Usage: usage, Hidden: hidden,
|
||||
}
|
||||
}
|
||||
|
||||
func flagTypeString(f cli.Flag) string {
|
||||
switch f.(type) {
|
||||
case *cli.BoolFlag:
|
||||
return "bool"
|
||||
case *cli.StringFlag:
|
||||
return "string"
|
||||
case *cli.IntFlag:
|
||||
return "int"
|
||||
case *cli.Int64Flag:
|
||||
return "int64"
|
||||
case *cli.UintFlag:
|
||||
return "uint"
|
||||
case *cli.Uint64Flag:
|
||||
return "uint64"
|
||||
case *cli.Float64Flag:
|
||||
return "float64"
|
||||
case *cli.DurationFlag:
|
||||
return "duration"
|
||||
case *cli.TimestampFlag:
|
||||
return "timestamp"
|
||||
case *cli.PathFlag:
|
||||
return "path"
|
||||
case *cli.StringSliceFlag:
|
||||
return "stringSlice"
|
||||
case *cli.IntSliceFlag:
|
||||
return "intSlice"
|
||||
case *cli.Int64SliceFlag:
|
||||
return "int64Slice"
|
||||
case *cli.Float64SliceFlag:
|
||||
return "float64Slice"
|
||||
case *cli.GenericFlag:
|
||||
return "generic"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// Default Markdown template (adjustable in source via rebuild).
|
||||
var commandsMDTemplate = `# {{ .App.Name }} CLI Commands ({{ .App.Edition }}) — {{ .App.Version }}
|
||||
_Generated: {{ .GeneratedAt }}_
|
||||
|
||||
{{- $base := .BaseHeading -}}
|
||||
{{- range .Commands }}
|
||||
{{ heading (add $base (dec .Depth)) }} {{ .FullName }}
|
||||
|
||||
**Usage:** {{ .Usage }}
|
||||
{{- if .Description }}
|
||||
|
||||
**Description:** {{ .Description }}
|
||||
{{- end }}
|
||||
{{- if .Aliases }}
|
||||
|
||||
**Aliases:** {{ join .Aliases ", " }}
|
||||
{{- end }}
|
||||
{{- if .ArgsUsage }}
|
||||
|
||||
**Args:** ` + "`" + `{{ .ArgsUsage }}` + "`" + `
|
||||
{{- end }}
|
||||
{{- if and (not $.Short) .Flags }}
|
||||
|
||||
| Flag | Aliases | Type | Default | Env | Required |{{ if $.All }} Hidden |{{ end }} Usage |
|
||||
|:-----|:--------|:-----|:--------|:----|:---------|{{ if $.All }}:------:|{{ end }}:------|
|
||||
{{- range .Flags }}
|
||||
| ` + "`" + `{{ .Name }}` + "`" + ` | {{ join .Aliases ", " }} | {{ .Type }} | {{ .Default }} | {{ join .Env ", " }} | {{ .Required }} |{{ if $.All }} {{ .Hidden }} |{{ end }} {{ .Usage }} |
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{- end }}`
|
||||
|
||||
// RenderMarkdown renders the catalog to Markdown using the embedded template.
|
||||
func RenderMarkdown(data MarkdownData) (string, error) {
|
||||
tmpl, err := template.New("commands").Funcs(template.FuncMap{
|
||||
"heading": func(n int) string {
|
||||
if n < 1 {
|
||||
n = 1
|
||||
} else if n > 6 {
|
||||
n = 6
|
||||
}
|
||||
return strings.Repeat("#", n)
|
||||
},
|
||||
"join": strings.Join,
|
||||
"add": func(a, b int) int { return a + b },
|
||||
"dec": func(a int) int {
|
||||
if a > 0 {
|
||||
return a - 1
|
||||
}
|
||||
return 0
|
||||
},
|
||||
}).Parse(commandsMDTemplate)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := tmpl.Execute(&buf, data); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return buf.String(), nil
|
||||
}
|
||||
169
internal/commands/catalog/catalog_test.go
Normal file
169
internal/commands/catalog/catalog_test.go
Normal file
@@ -0,0 +1,169 @@
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
func TestFlagsToCatalog_Visibility(t *testing.T) {
|
||||
flags := []cli.Flag{
|
||||
&cli.StringFlag{Name: "config-path", Aliases: []string{"c"}, Usage: "config path", EnvVars: []string{"PHOTOPRISM_CONFIG_PATH"}},
|
||||
&cli.BoolFlag{Name: "trace", Usage: "enable trace", Hidden: true},
|
||||
&cli.IntFlag{Name: "count", Usage: "max results", Value: 5, Required: true},
|
||||
}
|
||||
|
||||
vis := FlagsToCatalog(flags, false)
|
||||
if len(vis) != 2 {
|
||||
t.Fatalf("expected 2 visible flags, got %d", len(vis))
|
||||
}
|
||||
all := FlagsToCatalog(flags, true)
|
||||
if len(all) != 3 {
|
||||
t.Fatalf("expected 3 flags with --all, got %d", len(all))
|
||||
}
|
||||
// Check hidden is marked correctly when included
|
||||
var hiddenOK bool
|
||||
for _, f := range all {
|
||||
if f.Name == "--trace" && f.Hidden {
|
||||
hiddenOK = true
|
||||
}
|
||||
}
|
||||
if !hiddenOK {
|
||||
t.Fatalf("expected hidden flag '--trace' with hidden=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandInfo_GlobalFlagElimination(t *testing.T) {
|
||||
// Define a command with a global-like flag and a local one
|
||||
cmd := &cli.Command{
|
||||
Name: "auth",
|
||||
Flags: []cli.Flag{
|
||||
&cli.BoolFlag{Name: "json"}, // should be filtered out as global
|
||||
&cli.BoolFlag{Name: "force"},
|
||||
},
|
||||
}
|
||||
globals := FlagsToCatalog([]cli.Flag{&cli.BoolFlag{Name: "json"}}, false)
|
||||
info := CommandInfo(cmd, 1, "photoprism", false, globals)
|
||||
if len(info.Flags) != 1 || info.Flags[0].Name != "--force" {
|
||||
t.Fatalf("expected only '--force' flag, got %+v", info.Flags)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFlatAndNode(t *testing.T) {
|
||||
add := &cli.Command{Name: "add"}
|
||||
rmHidden := &cli.Command{Name: "rm", Hidden: true}
|
||||
auth := &cli.Command{Name: "auth", Subcommands: []*cli.Command{add, rmHidden}}
|
||||
|
||||
globals := FlagsToCatalog(nil, false)
|
||||
|
||||
// Flat without hidden
|
||||
flat := BuildFlat(auth, 1, "photoprism", false, globals)
|
||||
if len(flat) != 2 { // auth + add
|
||||
t.Fatalf("expected 2 commands (auth, add), got %d", len(flat))
|
||||
}
|
||||
if flat[0].FullName != "photoprism auth" || flat[0].Depth != 1 {
|
||||
t.Fatalf("unexpected root entry: %+v", flat[0])
|
||||
}
|
||||
if flat[1].FullName != "photoprism auth add" || flat[1].Depth != 2 {
|
||||
t.Fatalf("unexpected child entry: %+v", flat[1])
|
||||
}
|
||||
|
||||
// Nested with hidden
|
||||
node := BuildNode(auth, 1, "photoprism", true, globals)
|
||||
if len(node.Subcommands) != 2 {
|
||||
t.Fatalf("expected 2 subcommands when including hidden, got %d", len(node.Subcommands))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderMarkdown_Headings(t *testing.T) {
|
||||
data := MarkdownData{
|
||||
App: App{Name: "PhotoPrism", Edition: "ce", Version: "test"},
|
||||
GeneratedAt: "",
|
||||
BaseHeading: 2,
|
||||
Short: true, // hide flags table to focus on headings
|
||||
All: false,
|
||||
Commands: []Command{
|
||||
{Name: "auth", FullName: "photoprism auth", Depth: 1},
|
||||
{Name: "auth add", FullName: "photoprism auth add", Depth: 2},
|
||||
},
|
||||
}
|
||||
out, err := RenderMarkdown(data)
|
||||
if err != nil {
|
||||
t.Fatalf("render failed: %v", err)
|
||||
}
|
||||
if !strings.Contains(out, "## photoprism auth") {
|
||||
t.Fatalf("expected '## photoprism auth' heading, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "### photoprism auth add") {
|
||||
t.Fatalf("expected '### photoprism auth add' heading, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderMarkdown_HiddenColumn(t *testing.T) {
|
||||
cmd := Command{
|
||||
Name: "auth",
|
||||
FullName: "photoprism auth",
|
||||
Depth: 1,
|
||||
Flags: []Flag{
|
||||
{Name: "--visible", Type: "bool", Hidden: false, Usage: "visible flag"},
|
||||
{Name: "--secret", Type: "bool", Hidden: true, Usage: "hidden flag"},
|
||||
},
|
||||
}
|
||||
base := MarkdownData{
|
||||
App: App{Name: "PhotoPrism", Edition: "ce", Version: "test"},
|
||||
GeneratedAt: "",
|
||||
BaseHeading: 2,
|
||||
Short: false,
|
||||
Commands: []Command{cmd},
|
||||
}
|
||||
|
||||
// Default: no Hidden column
|
||||
base.All = false
|
||||
out, err := RenderMarkdown(base)
|
||||
if err != nil {
|
||||
t.Fatalf("render failed: %v", err)
|
||||
}
|
||||
if strings.Contains(out, " Hidden ") {
|
||||
t.Fatalf("did not expect 'Hidden' column without --all:\n%s", out)
|
||||
}
|
||||
|
||||
// With --all: Hidden column and boolean value present
|
||||
base.All = true
|
||||
out, err = RenderMarkdown(base)
|
||||
if err != nil {
|
||||
t.Fatalf("render failed: %v", err)
|
||||
}
|
||||
if !strings.Contains(out, " Hidden ") {
|
||||
t.Fatalf("expected 'Hidden' column with --all:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "hidden flag") || !strings.Contains(out, " true ") {
|
||||
t.Fatalf("expected hidden flag row to include 'true':\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderMarkdown_ShortOmitsFlags(t *testing.T) {
|
||||
cmd := Command{
|
||||
Name: "auth",
|
||||
FullName: "photoprism auth",
|
||||
Depth: 1,
|
||||
Flags: []Flag{
|
||||
{Name: "--json", Type: "bool", Hidden: false, Usage: "json output"},
|
||||
},
|
||||
}
|
||||
data := MarkdownData{
|
||||
App: App{Name: "PhotoPrism", Edition: "ce", Version: "test"},
|
||||
GeneratedAt: "",
|
||||
BaseHeading: 2,
|
||||
Short: true,
|
||||
All: false,
|
||||
Commands: []Command{cmd},
|
||||
}
|
||||
out, err := RenderMarkdown(data)
|
||||
if err != nil {
|
||||
t.Fatalf("render failed: %v", err)
|
||||
}
|
||||
if strings.Contains(out, "| Flag | Aliases | Type |") {
|
||||
t.Fatalf("did not expect flags table when Short=true:\n%s", out)
|
||||
}
|
||||
}
|
||||
@@ -18,5 +18,6 @@ var ShowCommands = &cli.Command{
|
||||
ShowThumbSizesCommand,
|
||||
ShowVideoSizesCommand,
|
||||
ShowMetadataCommand,
|
||||
ShowCommandsCommand,
|
||||
},
|
||||
}
|
||||
|
||||
143
internal/commands/show_commands.go
Normal file
143
internal/commands/show_commands.go
Normal file
@@ -0,0 +1,143 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/urfave/cli/v2"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/commands/catalog"
|
||||
"github.com/photoprism/photoprism/internal/config"
|
||||
)
|
||||
|
||||
// ShowCommandsCommand configures the command name, flags, and action.
|
||||
var ShowCommandsCommand = &cli.Command{
|
||||
Name: "commands",
|
||||
Usage: "Displays a structured catalog of CLI commands",
|
||||
Flags: []cli.Flag{
|
||||
&cli.BoolFlag{Name: "json", Aliases: []string{"j"}, Usage: "print machine-readable JSON"},
|
||||
&cli.BoolFlag{Name: "all", Usage: "include hidden commands and flags"},
|
||||
&cli.BoolFlag{Name: "short", Usage: "omit flags in Markdown output"},
|
||||
&cli.IntFlag{Name: "base-heading", Value: 2, Usage: "base Markdown heading level"},
|
||||
&cli.BoolFlag{Name: "nested", Usage: "emit nested JSON structure instead of a flat array"},
|
||||
},
|
||||
Action: showCommandsAction,
|
||||
}
|
||||
|
||||
type showCommandsOut struct {
|
||||
App catalog.App `json:"app"`
|
||||
GeneratedAt string `json:"generated_at"`
|
||||
GlobalFlags []catalog.Flag `json:"global_flags,omitempty"`
|
||||
Commands json.RawMessage `json:"commands"`
|
||||
}
|
||||
|
||||
// showCommandsAction displays a structured catalog of CLI commands.
|
||||
func showCommandsAction(ctx *cli.Context) error {
|
||||
// Prefer fast app metadata from the running app; avoid heavy config init in tests
|
||||
includeHidden := ctx.Bool("all")
|
||||
wantJSON := ctx.Bool("json")
|
||||
nested := ctx.Bool("nested")
|
||||
baseHeading := ctx.Int("base-heading")
|
||||
if baseHeading < 1 {
|
||||
baseHeading = 1
|
||||
}
|
||||
|
||||
// Collect the app metadata to be included in the output.
|
||||
app := catalog.App{}
|
||||
|
||||
if ctx != nil && ctx.App != nil && ctx.App.Metadata != nil {
|
||||
if n, ok := ctx.App.Metadata["Name"].(string); ok {
|
||||
app.Name = n
|
||||
}
|
||||
if e, ok := ctx.App.Metadata["Edition"].(string); ok {
|
||||
app.Edition = e
|
||||
}
|
||||
if v, ok := ctx.App.Metadata["Version"].(string); ok {
|
||||
app.Version = v
|
||||
app.Build = v
|
||||
}
|
||||
}
|
||||
|
||||
if app.Name == "" || app.Version == "" {
|
||||
conf := config.NewConfig(ctx)
|
||||
app.Name = conf.Name()
|
||||
app.Edition = conf.Edition()
|
||||
app.Version = conf.Version()
|
||||
app.Build = conf.Version()
|
||||
}
|
||||
|
||||
// Collect global flags from the running app.
|
||||
var globalFlags []catalog.Flag
|
||||
if ctx != nil && ctx.App != nil {
|
||||
globalFlags = catalog.FlagsToCatalog(ctx.App.Flags, includeHidden)
|
||||
} else {
|
||||
globalFlags = catalog.FlagsToCatalog(config.Flags.Cli(), includeHidden)
|
||||
}
|
||||
|
||||
// Traverse commands registry using runtime app commands to avoid init cycles.
|
||||
var flat []catalog.Command
|
||||
var tree []catalog.Node
|
||||
|
||||
var roots []*cli.Command
|
||||
if ctx != nil && ctx.App != nil {
|
||||
roots = ctx.App.Commands
|
||||
}
|
||||
for _, c := range roots {
|
||||
if c == nil {
|
||||
continue
|
||||
}
|
||||
if c.Hidden && !includeHidden {
|
||||
continue
|
||||
}
|
||||
if nested {
|
||||
node := catalog.BuildNode(c, 1, "photoprism", includeHidden, globalFlags)
|
||||
tree = append(tree, node)
|
||||
} else {
|
||||
flat = append(flat, catalog.BuildFlat(c, 1, "photoprism", includeHidden, globalFlags)...)
|
||||
}
|
||||
}
|
||||
|
||||
// Render JSON output using json.Marshal().
|
||||
if wantJSON {
|
||||
var cmds json.RawMessage
|
||||
var err error
|
||||
if nested {
|
||||
cmds, err = json.Marshal(tree)
|
||||
} else {
|
||||
cmds, err = json.Marshal(flat)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out := showCommandsOut{
|
||||
App: app,
|
||||
GeneratedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
GlobalFlags: globalFlags,
|
||||
Commands: cmds,
|
||||
}
|
||||
b, err := json.Marshal(out)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println(string(b))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Render Markdown using embedded template.
|
||||
data := catalog.MarkdownData{
|
||||
App: app,
|
||||
GeneratedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
BaseHeading: baseHeading,
|
||||
Short: ctx.Bool("short"),
|
||||
All: includeHidden,
|
||||
Commands: flat,
|
||||
}
|
||||
|
||||
md, err := catalog.RenderMarkdown(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println(md)
|
||||
return nil
|
||||
}
|
||||
127
internal/commands/show_commands_test.go
Normal file
127
internal/commands/show_commands_test.go
Normal file
@@ -0,0 +1,127 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
catalogpkg "github.com/photoprism/photoprism/internal/commands/catalog"
|
||||
)
|
||||
|
||||
func TestShowCommands_JSON_Flat(t *testing.T) {
|
||||
// Build JSON without capturing stdout to avoid pipe blocking on large outputs
|
||||
ctx := NewTestContext([]string{"commands"})
|
||||
global := catalogpkg.FlagsToCatalog(ctx.App.Flags, false)
|
||||
var flat []catalogpkg.Command
|
||||
for _, c := range ctx.App.Commands {
|
||||
if c.Hidden {
|
||||
continue
|
||||
}
|
||||
flat = append(flat, catalogpkg.BuildFlat(c, 1, "photoprism", false, global)...)
|
||||
}
|
||||
out := struct {
|
||||
App catalogpkg.App `json:"app"`
|
||||
GeneratedAt string `json:"generated_at"`
|
||||
GlobalFlags []catalogpkg.Flag `json:"global_flags"`
|
||||
Commands []catalogpkg.Command `json:"commands"`
|
||||
}{
|
||||
App: catalogpkg.App{Name: "PhotoPrism", Edition: "ce", Version: "test"},
|
||||
GeneratedAt: "",
|
||||
GlobalFlags: global,
|
||||
Commands: flat,
|
||||
}
|
||||
b, err := json.Marshal(out)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal failed: %v", err)
|
||||
}
|
||||
// Basic structural checks via unmarshal into a light struct
|
||||
var v struct {
|
||||
Commands []struct {
|
||||
Name, FullName string
|
||||
Depth int
|
||||
} `json:"commands"`
|
||||
GlobalFlags []map[string]interface{} `json:"global_flags"`
|
||||
}
|
||||
if err := json.Unmarshal(b, &v); err != nil {
|
||||
t.Fatalf("invalid json: %v", err)
|
||||
}
|
||||
if len(v.Commands) == 0 {
|
||||
t.Fatalf("expected at least one command")
|
||||
}
|
||||
// Expect at least one top-level auth and at least one subcommand overall
|
||||
var haveAuth, haveAnyChild bool
|
||||
for _, c := range v.Commands {
|
||||
if c.Name == "auth" && c.Depth == 1 {
|
||||
haveAuth = true
|
||||
}
|
||||
if c.Depth >= 2 {
|
||||
haveAnyChild = true
|
||||
}
|
||||
}
|
||||
if !haveAuth {
|
||||
t.Fatalf("expected to find 'auth' top-level command in list")
|
||||
}
|
||||
if !haveAnyChild {
|
||||
t.Fatalf("expected to find at least one subcommand (depth >= 2)")
|
||||
}
|
||||
if len(v.GlobalFlags) == 0 {
|
||||
t.Fatalf("expected non-empty global_flags")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShowCommands_JSON_Nested(t *testing.T) {
|
||||
ctx := NewTestContext([]string{"commands"})
|
||||
global := catalogpkg.FlagsToCatalog(ctx.App.Flags, false)
|
||||
var tree []catalogpkg.Node
|
||||
for _, c := range ctx.App.Commands {
|
||||
if c.Hidden {
|
||||
continue
|
||||
}
|
||||
tree = append(tree, catalogpkg.BuildNode(c, 1, "photoprism", false, global))
|
||||
}
|
||||
b, err := json.Marshal(struct {
|
||||
Commands []catalogpkg.Node `json:"commands"`
|
||||
}{Commands: tree})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal failed: %v", err)
|
||||
}
|
||||
var v struct {
|
||||
Commands []struct {
|
||||
Name string `json:"name"`
|
||||
Depth int `json:"depth"`
|
||||
Subcommands []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"subcommands"`
|
||||
} `json:"commands"`
|
||||
}
|
||||
if err := json.Unmarshal(b, &v); err != nil {
|
||||
t.Fatalf("invalid json: %v", err)
|
||||
}
|
||||
if len(v.Commands) == 0 {
|
||||
t.Fatalf("expected top-level commands")
|
||||
}
|
||||
var hasAuthWithChild bool
|
||||
for _, c := range v.Commands {
|
||||
if c.Name == "auth" && c.Depth == 1 && len(c.Subcommands) > 0 {
|
||||
hasAuthWithChild = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasAuthWithChild {
|
||||
t.Fatalf("expected auth with at least one subcommand")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShowCommands_Markdown_Default(t *testing.T) {
|
||||
out, err := RunWithTestContext(ShowCommandsCommand, []string{"commands"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
// Expect Markdown headings for commands
|
||||
if !strings.Contains(out, "## photoprism auth") {
|
||||
t.Fatalf("expected '## photoprism auth' heading in output\n%s", out[:min(400, len(out))])
|
||||
}
|
||||
if !strings.Contains(out, "### photoprism auth ") { // subcommand headings begin with parent
|
||||
t.Fatalf("expected '### photoprism auth <sub>' heading in output\n%s", out[:min(600, len(out))])
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user