diff --git a/cmd/core/pkgcmd/cmd_manage.go b/cmd/core/pkgcmd/cmd_manage.go index 341edde..c4831a0 100644 --- a/cmd/core/pkgcmd/cmd_manage.go +++ b/cmd/core/pkgcmd/cmd_manage.go @@ -245,19 +245,43 @@ func runPkgUpdate(packages []string, all bool) error { // addPkgOutdatedCommand adds the 'pkg outdated' command. func addPkgOutdatedCommand(parent *cobra.Command) { + var format string outdatedCmd := &cobra.Command{ Use: "outdated", Short: i18n.T("cmd.pkg.outdated.short"), Long: i18n.T("cmd.pkg.outdated.long"), RunE: func(cmd *cobra.Command, args []string) error { - return runPkgOutdated() + format, err := cmd.Flags().GetString("format") + if err != nil { + return err + } + return runPkgOutdated(format) }, } + outdatedCmd.Flags().StringVar(&format, "format", "table", i18n.T("cmd.pkg.outdated.flag.format")) parent.AddCommand(outdatedCmd) } -func runPkgOutdated() error { +type pkgOutdatedEntry struct { + Name string `json:"name"` + Path string `json:"path"` + Behind int `json:"behind"` + UpToDate bool `json:"upToDate"` + Installed bool `json:"installed"` +} + +type pkgOutdatedReport struct { + Format string `json:"format"` + Total int `json:"total"` + Installed int `json:"installed"` + Missing int `json:"missing"` + Outdated int `json:"outdated"` + UpToDate int `json:"upToDate"` + Packages []pkgOutdatedEntry `json:"packages"` +} + +func runPkgOutdated(format string) error { regPath, err := repos.FindRegistry(coreio.Local) if err != nil { return errors.New(i18n.T("cmd.pkg.error.no_repos_yaml")) @@ -276,17 +300,31 @@ func runPkgOutdated() error { basePath = filepath.Join(filepath.Dir(regPath), basePath) } - fmt.Printf("%s %s\n\n", dimStyle.Render(i18n.T("cmd.pkg.outdated.outdated_label")), i18n.T("common.progress.checking_updates")) + jsonOutput := strings.EqualFold(format, "json") + if !jsonOutput { + fmt.Printf("%s %s\n\n", dimStyle.Render(i18n.T("cmd.pkg.outdated.outdated_label")), i18n.T("common.progress.checking_updates")) + } - var outdated, upToDate, notInstalled int + var installed, outdated, upToDate, notInstalled int + var entries []pkgOutdatedEntry for _, r := range reg.List() { repoPath := filepath.Join(basePath, r.Name) if !coreio.Local.Exists(filepath.Join(repoPath, ".git")) { notInstalled++ + if jsonOutput { + entries = append(entries, pkgOutdatedEntry{ + Name: r.Name, + Path: repoPath, + Behind: 0, + UpToDate: false, + Installed: false, + }) + } continue } + installed++ // Fetch updates _ = exec.Command("git", "-C", repoPath, "fetch", "--quiet").Run() @@ -299,15 +337,52 @@ func runPkgOutdated() error { } count := strings.TrimSpace(string(output)) + behind := 0 + if count != "" { + fmt.Sscanf(count, "%d", &behind) + } if count != "0" { - fmt.Printf(" %s %s (%s)\n", - errorStyle.Render("↓"), repoNameStyle.Render(r.Name), i18n.T("cmd.pkg.outdated.commits_behind", map[string]string{"Count": count})) + if !jsonOutput { + fmt.Printf(" %s %s (%s)\n", + errorStyle.Render("↓"), repoNameStyle.Render(r.Name), i18n.T("cmd.pkg.outdated.commits_behind", map[string]string{"Count": count})) + } outdated++ + if jsonOutput { + entries = append(entries, pkgOutdatedEntry{ + Name: r.Name, + Path: repoPath, + Behind: behind, + UpToDate: false, + Installed: true, + }) + } } else { upToDate++ + if jsonOutput { + entries = append(entries, pkgOutdatedEntry{ + Name: r.Name, + Path: repoPath, + Behind: 0, + UpToDate: true, + Installed: true, + }) + } } } + if jsonOutput { + report := pkgOutdatedReport{ + Format: "json", + Total: len(reg.List()), + Installed: installed, + Missing: notInstalled, + Outdated: outdated, + UpToDate: upToDate, + Packages: entries, + } + return printPkgOutdatedJSON(report) + } + fmt.Println() if outdated == 0 { fmt.Printf("%s %s\n", successStyle.Render(i18n.T("i18n.done.update")), i18n.T("cmd.pkg.outdated.all_up_to_date")) @@ -319,3 +394,13 @@ func runPkgOutdated() error { return nil } + +func printPkgOutdatedJSON(report pkgOutdatedReport) error { + out, err := json.MarshalIndent(report, "", " ") + if err != nil { + return fmt.Errorf("%s: %w", i18n.T("i18n.fail.format", "outdated results"), err) + } + + fmt.Println(string(out)) + return nil +} diff --git a/cmd/core/pkgcmd/cmd_manage_test.go b/cmd/core/pkgcmd/cmd_manage_test.go index f555036..fc0199e 100644 --- a/cmd/core/pkgcmd/cmd_manage_test.go +++ b/cmd/core/pkgcmd/cmd_manage_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "io" "os" + "os/exec" "path/filepath" "strings" "testing" @@ -67,6 +68,70 @@ repos: require.NoError(t, os.MkdirAll(filepath.Join(dir, "core-alpha", ".git"), 0755)) } +func gitCommand(t *testing.T, dir string, args ...string) string { + t.Helper() + + cmd := exec.Command("git", args...) + cmd.Dir = dir + out, err := cmd.CombinedOutput() + require.NoError(t, err, "git %v failed: %s", args, string(out)) + return string(out) +} + +func commitGitRepo(t *testing.T, dir, filename, content, message string) { + t.Helper() + + require.NoError(t, os.WriteFile(filepath.Join(dir, filename), []byte(content), 0644)) + gitCommand(t, dir, "add", filename) + gitCommand(t, dir, "commit", "-m", message) +} + +func setupOutdatedRegistry(t *testing.T) string { + t.Helper() + + tmp := t.TempDir() + + remoteDir := filepath.Join(tmp, "remote.git") + gitCommand(t, tmp, "init", "--bare", remoteDir) + + seedDir := filepath.Join(tmp, "seed") + require.NoError(t, os.MkdirAll(seedDir, 0755)) + gitCommand(t, seedDir, "init") + gitCommand(t, seedDir, "config", "user.email", "test@test.com") + gitCommand(t, seedDir, "config", "user.name", "Test") + commitGitRepo(t, seedDir, "repo.txt", "v1\n", "initial") + gitCommand(t, seedDir, "remote", "add", "origin", remoteDir) + gitCommand(t, seedDir, "push", "-u", "origin", "master") + + freshDir := filepath.Join(tmp, "core-fresh") + gitCommand(t, tmp, "clone", remoteDir, freshDir) + + staleDir := filepath.Join(tmp, "core-stale") + gitCommand(t, tmp, "clone", remoteDir, staleDir) + + commitGitRepo(t, seedDir, "repo.txt", "v2\n", "second") + gitCommand(t, seedDir, "push") + gitCommand(t, freshDir, "pull", "--ff-only") + + registry := strings.TrimSpace(` +org: host-uk +base_path: . +repos: + core-fresh: + type: foundation + description: Fresh package + core-stale: + type: module + description: Stale package + core-missing: + type: module + description: Missing package +`) + "\n" + + require.NoError(t, os.WriteFile(filepath.Join(tmp, "repos.yaml"), []byte(registry), 0644)) + return tmp +} + func TestRunPkgList_Good(t *testing.T) { tmp := t.TempDir() writeTestRegistry(t, tmp) @@ -116,6 +181,51 @@ func TestRunPkgList_UnsupportedFormat(t *testing.T) { assert.Contains(t, err.Error(), "unsupported format") } +func TestRunPkgOutdated_JSON(t *testing.T) { + tmp := setupOutdatedRegistry(t) + withWorkingDir(t, tmp) + + out := capturePkgOutput(t, func() { + err := runPkgOutdated("json") + require.NoError(t, err) + }) + + var report pkgOutdatedReport + require.NoError(t, json.Unmarshal([]byte(strings.TrimSpace(out)), &report)) + assert.Equal(t, "json", report.Format) + assert.Equal(t, 3, report.Total) + assert.Equal(t, 2, report.Installed) + assert.Equal(t, 1, report.Missing) + assert.Equal(t, 1, report.Outdated) + assert.Equal(t, 1, report.UpToDate) + require.Len(t, report.Packages, 3) + + var staleFound, freshFound, missingFound bool + for _, pkg := range report.Packages { + switch pkg.Name { + case "core-stale": + staleFound = true + assert.True(t, pkg.Installed) + assert.False(t, pkg.UpToDate) + assert.Equal(t, 1, pkg.Behind) + case "core-fresh": + freshFound = true + assert.True(t, pkg.Installed) + assert.True(t, pkg.UpToDate) + assert.Equal(t, 0, pkg.Behind) + case "core-missing": + missingFound = true + assert.False(t, pkg.Installed) + assert.False(t, pkg.UpToDate) + assert.Equal(t, 0, pkg.Behind) + } + } + + assert.True(t, staleFound) + assert.True(t, freshFound) + assert.True(t, missingFound) +} + func TestRenderPkgSearchResults_ShowsMetadata(t *testing.T) { out := capturePkgOutput(t, func() { renderPkgSearchResults([]ghRepo{ diff --git a/docs/cmd/pkg/example.md b/docs/cmd/pkg/example.md index 7904aae..b03cc67 100644 --- a/docs/cmd/pkg/example.md +++ b/docs/cmd/pkg/example.md @@ -33,4 +33,5 @@ core pkg update core-api ```bash core pkg outdated +core pkg outdated --format json ``` diff --git a/docs/cmd/pkg/index.md b/docs/cmd/pkg/index.md index c07dc02..f531964 100644 --- a/docs/cmd/pkg/index.md +++ b/docs/cmd/pkg/index.md @@ -146,6 +146,16 @@ core pkg outdated Fetches from remote and shows packages that are behind. +### Flags + +| Flag | Description | +|------|-------------| +| `--format` | Output format (`table` or `json`) | + +### JSON Output + +When `--format json` is set, `core pkg outdated` emits a structured report with package status, behind counts, and summary totals. + --- ## See Also diff --git a/pkg/cli/locales/en.json b/pkg/cli/locales/en.json index 50cc311..26ef610 100644 --- a/pkg/cli/locales/en.json +++ b/pkg/cli/locales/en.json @@ -108,7 +108,10 @@ "all_up_to_date": "All packages are up to date", "commits_behind": "{{.Count}} commits behind", "update_with": "Update with: core pkg update {{.Name}}", - "summary": "{{.Outdated}}/{{.Total}} outdated" + "summary": "{{.Outdated}}/{{.Total}} outdated", + "flag": { + "format": "Output format: table or json" + } } } },