diff --git a/cmd/core/pkgcmd/cmd_manage.go b/cmd/core/pkgcmd/cmd_manage.go index bc4b0ee..22d7add 100644 --- a/cmd/core/pkgcmd/cmd_manage.go +++ b/cmd/core/pkgcmd/cmd_manage.go @@ -157,6 +157,7 @@ func runPkgList(format string) error { } var updateAll bool +var updateFormat string // addPkgUpdateCommand adds the 'pkg update' command. func addPkgUpdateCommand(parent *cobra.Command) { @@ -165,16 +166,40 @@ func addPkgUpdateCommand(parent *cobra.Command) { Short: i18n.T("cmd.pkg.update.short"), Long: i18n.T("cmd.pkg.update.long"), RunE: func(cmd *cobra.Command, args []string) error { - return runPkgUpdate(args, updateAll) + format, err := cmd.Flags().GetString("format") + if err != nil { + return err + } + return runPkgUpdate(args, updateAll, format) }, } updateCmd.Flags().BoolVar(&updateAll, "all", false, i18n.T("cmd.pkg.update.flag.all")) + updateCmd.Flags().StringVar(&updateFormat, "format", "table", "Output format: table or json") parent.AddCommand(updateCmd) } -func runPkgUpdate(packages []string, all bool) error { +type pkgUpdateEntry struct { + Name string `json:"name"` + Path string `json:"path"` + Installed bool `json:"installed"` + Status string `json:"status"` + Output string `json:"output,omitempty"` +} + +type pkgUpdateReport struct { + Format string `json:"format"` + Total int `json:"total"` + Installed int `json:"installed"` + Missing int `json:"missing"` + Updated int `json:"updated"` + UpToDate int `json:"upToDate"` + Failed int `json:"failed"` + Packages []pkgUpdateEntry `json:"packages"` +} + +func runPkgUpdate(packages []string, all bool, format string) error { regPath, err := repos.FindRegistry(coreio.Local) if err != nil { return errors.New(i18n.T("cmd.pkg.error.no_repos_yaml")) @@ -193,6 +218,7 @@ func runPkgUpdate(packages []string, all bool) error { basePath = filepath.Join(filepath.Dir(regPath), basePath) } + jsonOutput := strings.EqualFold(format, "json") var toUpdate []string if all || len(packages) == 0 { for _, r := range reg.List() { @@ -202,44 +228,117 @@ func runPkgUpdate(packages []string, all bool) error { toUpdate = packages } - fmt.Printf("%s %s\n\n", dimStyle.Render(i18n.T("cmd.pkg.update.update_label")), i18n.T("cmd.pkg.update.updating", map[string]int{"Count": len(toUpdate)})) + if !jsonOutput { + fmt.Printf("%s %s\n\n", dimStyle.Render(i18n.T("cmd.pkg.update.update_label")), i18n.T("cmd.pkg.update.updating", map[string]int{"Count": len(toUpdate)})) + } - var updated, skipped, failed int + var updated, upToDate, skipped, failed int + var entries []pkgUpdateEntry for _, name := range toUpdate { repoPath := filepath.Join(basePath, name) if _, err := coreio.Local.List(filepath.Join(repoPath, ".git")); err != nil { - fmt.Printf(" %s %s (%s)\n", dimStyle.Render("○"), name, i18n.T("cmd.pkg.update.not_installed")) + if !jsonOutput { + fmt.Printf(" %s %s (%s)\n", dimStyle.Render("○"), name, i18n.T("cmd.pkg.update.not_installed")) + } + if jsonOutput { + entries = append(entries, pkgUpdateEntry{ + Name: name, + Path: repoPath, + Installed: false, + Status: "missing", + }) + } skipped++ continue } - fmt.Printf(" %s %s... ", dimStyle.Render("↓"), name) + if !jsonOutput { + fmt.Printf(" %s %s... ", dimStyle.Render("↓"), name) + } cmd := exec.Command("git", "-C", repoPath, "pull", "--ff-only") output, err := cmd.CombinedOutput() if err != nil { - fmt.Printf("%s\n", errorStyle.Render("✗")) - fmt.Printf(" %s\n", strings.TrimSpace(string(output))) + if !jsonOutput { + fmt.Printf("%s\n", errorStyle.Render("✗")) + fmt.Printf(" %s\n", strings.TrimSpace(string(output))) + } + if jsonOutput { + entries = append(entries, pkgUpdateEntry{ + Name: name, + Path: repoPath, + Installed: true, + Status: "failed", + Output: strings.TrimSpace(string(output)), + }) + } failed++ continue } if strings.Contains(string(output), "Already up to date") { - fmt.Printf("%s\n", dimStyle.Render(i18n.T("common.status.up_to_date"))) + if !jsonOutput { + fmt.Printf("%s\n", dimStyle.Render(i18n.T("common.status.up_to_date"))) + } + if jsonOutput { + entries = append(entries, pkgUpdateEntry{ + Name: name, + Path: repoPath, + Installed: true, + Status: "up_to_date", + Output: strings.TrimSpace(string(output)), + }) + } + upToDate++ } else { - fmt.Printf("%s\n", successStyle.Render("✓")) + if !jsonOutput { + fmt.Printf("%s\n", successStyle.Render("✓")) + } + if jsonOutput { + entries = append(entries, pkgUpdateEntry{ + Name: name, + Path: repoPath, + Installed: true, + Status: "updated", + Output: strings.TrimSpace(string(output)), + }) + } + updated++ } - updated++ + } + + if jsonOutput { + report := pkgUpdateReport{ + Format: "json", + Total: len(toUpdate), + Installed: updated + upToDate + failed, + Missing: skipped, + Updated: updated, + UpToDate: upToDate, + Failed: failed, + Packages: entries, + } + return printPkgUpdateJSON(report) } fmt.Println() fmt.Printf("%s %s\n", - dimStyle.Render(i18n.T("i18n.done.update")), i18n.T("cmd.pkg.update.summary", map[string]int{"Updated": updated, "Skipped": skipped, "Failed": failed})) + dimStyle.Render(i18n.T("i18n.done.update")), i18n.T("cmd.pkg.update.summary", map[string]int{"Updated": updated + upToDate, "Skipped": skipped, "Failed": failed})) return nil } +func printPkgUpdateJSON(report pkgUpdateReport) error { + out, err := json.MarshalIndent(report, "", " ") + if err != nil { + return fmt.Errorf("%s: %w", i18n.T("i18n.fail.format", "update results"), err) + } + + fmt.Println(string(out)) + return nil +} + // addPkgOutdatedCommand adds the 'pkg outdated' command. func addPkgOutdatedCommand(parent *cobra.Command) { var format string diff --git a/cmd/core/pkgcmd/cmd_manage_test.go b/cmd/core/pkgcmd/cmd_manage_test.go index d6cda11..4adaab2 100644 --- a/cmd/core/pkgcmd/cmd_manage_test.go +++ b/cmd/core/pkgcmd/cmd_manage_test.go @@ -297,7 +297,7 @@ func TestRunPkgUpdate_NoArgs_UpdatesAll(t *testing.T) { withWorkingDir(t, tmp) out := capturePkgOutput(t, func() { - err := runPkgUpdate(nil, false) + err := runPkgUpdate(nil, false, "table") require.NoError(t, err) }) @@ -305,3 +305,46 @@ func TestRunPkgUpdate_NoArgs_UpdatesAll(t *testing.T) { assert.Contains(t, out, "core-fresh") assert.Contains(t, out, "core-stale") } + +func TestRunPkgUpdate_JSON(t *testing.T) { + tmp := setupOutdatedRegistry(t) + withWorkingDir(t, tmp) + + out := capturePkgOutput(t, func() { + err := runPkgUpdate(nil, false, "json") + require.NoError(t, err) + }) + + var report pkgUpdateReport + 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.Updated) + assert.Equal(t, 1, report.UpToDate) + assert.Equal(t, 0, report.Failed) + require.Len(t, report.Packages, 3) + + var updatedFound, upToDateFound, missingFound bool + for _, pkg := range report.Packages { + switch pkg.Name { + case "core-stale": + updatedFound = true + assert.True(t, pkg.Installed) + assert.Equal(t, "updated", pkg.Status) + case "core-fresh": + upToDateFound = true + assert.True(t, pkg.Installed) + assert.Equal(t, "up_to_date", pkg.Status) + case "core-missing": + missingFound = true + assert.False(t, pkg.Installed) + assert.Equal(t, "missing", pkg.Status) + } + } + + assert.True(t, updatedFound) + assert.True(t, upToDateFound) + assert.True(t, missingFound) +} diff --git a/docs/cmd/pkg/index.md b/docs/cmd/pkg/index.md index c609226..498c4e0 100644 --- a/docs/cmd/pkg/index.md +++ b/docs/cmd/pkg/index.md @@ -126,6 +126,7 @@ core pkg update [...] [flags] | Flag | Description | |------|-------------| | `--all` | Update all packages | +| `--format` | Output format (`table` or `json`) | ### Examples @@ -135,8 +136,15 @@ core pkg update core-php # Update all packages core pkg update --all + +# JSON output for automation +core pkg update --format json ``` +### JSON Output + +When `--format json` is set, `core pkg update` emits a structured report with per-package update status and summary totals. + --- ## pkg outdated