Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions internal/app/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
v2addtarget "github.com/shpoont/dotfiles-manager/internal/v2/addtarget"
v2appauthor "github.com/shpoont/dotfiles-manager/internal/v2/appauthor"
v2appdiscovery "github.com/shpoont/dotfiles-manager/internal/v2/appdiscovery"
v2catalogdiscovery "github.com/shpoont/dotfiles-manager/internal/v2/catalogdiscovery"
v2initcmd "github.com/shpoont/dotfiles-manager/internal/v2/initcmd"
v2ledger "github.com/shpoont/dotfiles-manager/internal/v2/ledger"
v2lifecycle "github.com/shpoont/dotfiles-manager/internal/v2/lifecycle"
Expand Down Expand Up @@ -91,6 +92,7 @@ optional.`,
rootCmd.AddCommand(newSyncCmd(opts))
rootCmd.AddCommand(newSaveCmd(opts))
rootCmd.AddCommand(newApplyCmd(opts))
rootCmd.AddCommand(newCatalogCmd(opts))
rootCmd.AddCommand(newRecipeCmd(opts))
rootCmd.AddCommand(newAppCmd(opts))
rootCmd.AddCommand(newDeployCmd(opts))
Expand Down Expand Up @@ -618,6 +620,40 @@ func newExplainCmd(opts *rootOptions) *cobra.Command {
return cmd
}

func newCatalogCmd(opts *rootOptions) *cobra.Command {
cmd := &cobra.Command{
Use: "catalog",
Short: "Inspect app/tool support catalogs",
Args: func(cmd *cobra.Command, args []string) error {
if len(args) == 0 {
return nil
}
return fmt.Errorf("unknown command %q for %q", args[0], cmd.CommandPath())
},
RunE: func(cmd *cobra.Command, args []string) error {
return cmd.Help()
},
}
cmd.AddCommand(newCatalogListCmd(opts))
return cmd
}

func newCatalogListCmd(opts *rootOptions) *cobra.Command {
var jsonOutput bool

cmd := &cobra.Command{
Use: "list",
Short: "List configured support catalogs",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
return runCatalogListCommand(cmd, opts, jsonOutput)
},
}

cmd.Flags().BoolVar(&jsonOutput, "json", false, "Emit machine-readable JSON output")
return cmd
}

func newRecipeCmd(opts *rootOptions) *cobra.Command {
cmd := &cobra.Command{
Use: "recipe",
Expand Down Expand Up @@ -1229,6 +1265,11 @@ func runAppExplainCommand(cmd *cobra.Command, opts *rootOptions, appOpts v2appdi
return err
}

func runCatalogListCommand(cmd *cobra.Command, opts *rootOptions, jsonOutput bool) error {
report := v2catalogdiscovery.List()
return emitCatalogDiscoveryReport(cmd.OutOrStdout(), report, jsonOutput)
}

func appDiscoveryRepoRoot(opts *rootOptions, operation string) (string, bool, error) {
if opts != nil && strings.TrimSpace(opts.configPath) != "" {
if !isExplicitV2Config(opts.configPath) {
Expand Down Expand Up @@ -1407,6 +1448,19 @@ func emitAppExplainReport(stdout io.Writer, report *v2appdiscovery.ExplainReport
return err
}

func emitCatalogDiscoveryReport(stdout io.Writer, report *v2catalogdiscovery.Report, jsonOutput bool) error {
if jsonOutput {
payload, err := v2catalogdiscovery.JSON(report)
if err != nil {
return err
}
_, err = fmt.Fprint(stdout, payload)
return err
}
_, err := fmt.Fprintln(stdout, v2catalogdiscovery.Text(report))
return err
}

func v2initcmdErrorReport(code string, message string) *v2initcmd.Report {
report := &v2initcmd.Report{
Schema: v2initcmd.Schema,
Expand Down
138 changes: 119 additions & 19 deletions internal/app/cli_discovery_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,13 @@ func TestDiscoveryListBeforeInitIsSupportedAppsAndReadOnly(t *testing.T) {
require.Empty(t, stderr)
require.Contains(t, stdout, "Supported apps")
require.Contains(t, stdout, "git")
require.Contains(t, stdout, "built-in")
require.Contains(t, stdout, "official")
require.Contains(t, stdout, "not managed")
require.Contains(t, stdout, "dotfiles-manager explain <app>")
require.Contains(t, stdout, "No live settings were read or changed.")
require.Contains(t, stdout, "No stored settings were changed.")
require.NotContains(t, stdout, "built-in")
require.NotContains(t, stdout, "custom.files")
require.NotContains(t, stdout, "No live settings were read or changed.")
require.NotContains(t, stdout, "No stored settings were changed.")
requireNoDiscoveryState(t, projectDir)
}

Expand All @@ -50,17 +52,19 @@ selections:
require.Equal(t, "list", payload["command"])
summary := payload["summary"].(map[string]any)
require.Equal(t, "ok", summary["status"])
require.Equal(t, float64(7), summary["apps"])
require.Equal(t, float64(6), summary["apps"])
require.Equal(t, float64(1), summary["managed"])

git := requireDiscoveryApp(t, payload, "git")
require.Equal(t, "official", git["source"])
require.Equal(t, "managed", git["state"])
require.Equal(t, float64(2), git["selectedSettings"])
require.Equal(t, "recipe://bundled/git", git["recipeRef"])

zsh := requireDiscoveryApp(t, payload, "zsh")
require.Equal(t, "not-managed", zsh["state"])
require.Equal(t, float64(0), zsh["selectedSettings"])
requireNoDiscoveryApp(t, payload, "custom.files")
}

func TestListSettingsCompatibilityKeepsPreviousSelectedSettingsOutput(t *testing.T) {
Expand Down Expand Up @@ -97,21 +101,27 @@ func TestDiscoverySearchMatchesAndNoMatches(t *testing.T) {
require.NoError(t, err)
require.Empty(t, stderr)
require.Contains(t, stdout, `Search results for "git"`)
require.Contains(t, stdout, " APP CATALOG STATE\n git official not managed")
require.Contains(t, stdout, "git")
require.Contains(t, stdout, "official")
require.Contains(t, stdout, "not managed")
require.Contains(t, stdout, "dotfiles-manager explain git")
require.Contains(t, stdout, "No live settings were read or changed.")
require.Contains(t, stdout, "No stored settings were changed.")
require.NotContains(t, stdout, "No live settings were read or changed.")
require.NotContains(t, stdout, "No stored settings were changed.")

stdout, stderr, err = runDiscoveryTextCLI(t, []string{"search", "shell"})
stdout, stderr, err = runDiscoveryTextCLI(t, []string{"search", "wezterm"})
require.NoError(t, err)
require.Empty(t, stderr)
require.Contains(t, stdout, `No supported apps found for "shell".`)
require.Contains(t, stdout, "dotfiles-manager list")
require.Contains(t, stdout, "No live settings were read or changed.")
require.Contains(t, stdout, "No stored settings were changed.")
require.Contains(t, stdout, `No supported apps found for "wezterm".`)
require.Contains(t, stdout, "The current official catalog supports:")
require.Contains(t, stdout, "git, nvim, ssh, starship, tmux, zsh")
require.Contains(t, stdout, "Future versions may refresh official support data or add remote catalogs.")
require.NotContains(t, stdout, "catalog add")
require.NotContains(t, stdout, "local catalog")
require.NotContains(t, stdout, "No live settings were read or changed.")
require.NotContains(t, stdout, "No stored settings were changed.")

payload, _, stderr, err := runDiscoveryJSONCLI(t, []string{"search", "shell", "--json"})
payload, _, stderr, err := runDiscoveryJSONCLI(t, []string{"search", "wezterm", "--json"})
require.NoError(t, err)
require.Empty(t, stderr)
require.Equal(t, "dotfiles-manager.v2.apps", payload["schema"])
Expand Down Expand Up @@ -153,15 +163,17 @@ func TestTopLevelExplainIsAppOrientedAndUnknownAppIsStable(t *testing.T) {
require.Empty(t, stderr)
require.Contains(t, stdout, "Git is supported.")
require.Contains(t, stdout, "App ID: git")
require.Contains(t, stdout, "Source: built-in support from dotfiles-manager")
require.Contains(t, stdout, "Catalog: official")
require.Contains(t, stdout, "State: not managed")
require.Contains(t, stdout, "Can manage:")
require.Contains(t, stdout, "git:user.email")
require.Contains(t, stdout, "Does not manage:")
require.Contains(t, stdout, "credential.helper")
require.Contains(t, stdout, "No live values were printed.")
require.Contains(t, stdout, "No live settings were changed.")
require.Contains(t, stdout, "No stored settings were changed.")
require.NotContains(t, stdout, "Source: built-in")
require.NotContains(t, stdout, "Why this source is used:")
require.NotContains(t, stdout, "No live values were printed.")
require.NotContains(t, stdout, "No live settings were changed.")
require.NotContains(t, stdout, "No stored settings were changed.")
require.NotContains(t, stdout, "Git recipe")
require.NotContains(t, stdout, "recipe explain")

Expand All @@ -172,15 +184,15 @@ func TestTopLevelExplainIsAppOrientedAndUnknownAppIsStable(t *testing.T) {
require.Equal(t, "explain", payload["command"])
app := payload["app"].(map[string]any)
require.Equal(t, "git", app["id"])
require.Equal(t, "built-in", app["source"])
require.Equal(t, "official", app["source"])

stdout, stderr, err = runDiscoveryTextCLI(t, []string{"explain", "missing"})
require.Error(t, err)
require.Empty(t, stderr)
require.Contains(t, stdout, "App not supported: missing")
require.Contains(t, stdout, "dotfiles-manager search missing")
require.Contains(t, stdout, "No live settings were read or changed.")
require.Contains(t, stdout, "No stored settings were changed.")
require.NotContains(t, stdout, "No live settings were read or changed.")
require.NotContains(t, stdout, "No stored settings were changed.")

payload, _, stderr, err = runDiscoveryJSONCLI(t, []string{"explain", "missing", "--json"})
require.Error(t, err)
Expand All @@ -189,6 +201,19 @@ func TestTopLevelExplainIsAppOrientedAndUnknownAppIsStable(t *testing.T) {
require.Equal(t, "explain", payload["command"])
require.Equal(t, "error", payload["summary"].(map[string]any)["status"])
require.Equal(t, "explain.app.notSupported", payload["error"].(map[string]any)["code"])

stdout, stderr, err = runDiscoveryTextCLI(t, []string{"explain", "custom.files"})
require.Error(t, err)
require.Empty(t, stderr)
require.Contains(t, stdout, "App not supported: custom.files")
require.NotContains(t, stdout, "Custom files is supported.")

stdout, stderr, err = runDiscoveryTextCLI(t, []string{"explain", "custom-files"})
require.Error(t, err)
require.Empty(t, stderr)
require.Contains(t, stdout, "App not supported: custom-files")
require.NotContains(t, stdout, "Custom files is supported.")

requireNoDiscoveryState(t, projectDir)
}

Expand All @@ -199,11 +224,77 @@ func TestRootHelpSurfacesFlattenedDiscoveryBeforeRecipeNamespace(t *testing.T) {
require.Contains(t, stdout, "list")
require.Contains(t, stdout, "search")
require.Contains(t, stdout, "explain")
require.Contains(t, stdout, "catalog")
require.Contains(t, stdout, "recipe")
require.Less(t, strings.Index(stdout, "list"), strings.Index(stdout, "recipe"))
require.NotContains(t, stdout, "recipe list")
}

func TestCatalogListShowsOfficialCatalogState(t *testing.T) {
projectDir := t.TempDir()
setCWD(t, projectDir)
setTempHome(t)

stdout, stderr, err := runDiscoveryTextCLI(t, []string{"catalog", "list"})
require.NoError(t, err)
require.Empty(t, stderr)
require.Contains(t, stdout, "Catalogs")
require.Contains(t, stdout, "Catalogs define app/tool support; they do not store your settings.")
require.Contains(t, stdout, "dotfiles-manager/official active for discovery")
require.Contains(t, stdout, "Catalog version: 9f2c7a1")
require.Contains(t, stdout, "Catalog updated: 2026-06-30 18:00 UTC")
require.NotContains(t, stdout, "Source:")
require.NotContains(t, stdout, "Local copy:")
require.NotContains(t, stdout, "Offline use:")
require.NotContains(t, stdout, "Updates:")
require.NotContains(t, stdout, "Removable:")
require.NotContains(t, stdout, "catalog update")
require.NotContains(t, stdout, "catalog add")
requireNoDiscoveryState(t, projectDir)

payload, _, stderr, err := runDiscoveryJSONCLI(t, []string{"catalog", "list", "--json"})
require.NoError(t, err)
require.Empty(t, stderr)
require.Equal(t, "dotfiles-manager.v2.catalogs", payload["schema"])
require.Equal(t, "catalog.list", payload["command"])
catalogs := payload["catalogs"].([]any)
require.Len(t, catalogs, 1)
official := catalogs[0].(map[string]any)
require.Equal(t, "dotfiles-manager/official", official["id"])
require.Equal(t, "active for discovery", official["state"])
require.Equal(t, "9f2c7a1", official["version"])
require.Equal(t, "2026-06-30 18:00 UTC", official["updated"])

stdout, stderr, err = runDiscoveryTextCLI(t, []string{"catalog", "list", "--help"})
require.NoError(t, err)
require.Empty(t, stderr)
require.NotContains(t, stdout, "--verbose")

stdout, stderr, err = runDiscoveryTextCLI(t, []string{"catalog", "list", "--verbose"})
require.Error(t, err)
require.Empty(t, stdout)
require.Empty(t, stderr)
require.Contains(t, err.Error(), "unknown flag: --verbose")
}

func TestCatalogLifecycleCommandsAreUnsupportedIn228(t *testing.T) {
projectDir := t.TempDir()
setCWD(t, projectDir)
setTempHome(t)

for _, args := range [][]string{
{"catalog", "add", "shpoont/custom-recipes"},
{"catalog", "update"},
} {
stdout, stderr, err := runDiscoveryTextCLI(t, args)
require.Error(t, err, args)
require.Empty(t, stdout, args)
require.Empty(t, stderr, args)
require.Contains(t, err.Error(), "unknown command", args)
}
requireNoDiscoveryState(t, projectDir)
}

func runDiscoveryTextCLI(t *testing.T, args []string) (string, string, error) {
t.Helper()
cmd := NewRootCmd()
Expand Down Expand Up @@ -237,6 +328,15 @@ func requireDiscoveryApp(t *testing.T, payload map[string]any, id string) map[st
return nil
}

func requireNoDiscoveryApp(t *testing.T, payload map[string]any, id string) {
t.Helper()
apps := payload["apps"].([]any)
for _, raw := range apps {
app := raw.(map[string]any)
require.NotEqual(t, id, app["id"])
}
}

func requireNoDiscoveryState(t *testing.T, root string) {
t.Helper()
require.NoFileExists(t, filepath.Join(root, "dotfiles-manager.v2.yaml"))
Expand Down
Loading
Loading