Skip to content

refactor: restructure CLI into pkg/cmd with factory pattern#2

Merged
SergK merged 1 commit intomainfrom
refactor
Mar 20, 2026
Merged

refactor: restructure CLI into pkg/cmd with factory pattern#2
SergK merged 1 commit intomainfrom
refactor

Conversation

@zmotso
Copy link
Member

@zmotso zmotso commented Mar 18, 2026

Replaces the monolithic internal/cli package with a modular pkg/cmd layout (auth, deployment, project, root, version). Introduces a cmdutil.Factory for dependency injection and an iostreams abstraction for testable I/O.

Dependency changes:

  • Replace github.com/charmbracelet/lipgloss with charm.land/lipgloss/v2
  • Remove github.com/fatih/color (replaced by lipgloss v2 styling)
  • Update charmbracelet indirect deps (colorprofile, x/ansi, x/term)

@zmotso zmotso self-assigned this Mar 18, 2026
@SergK
Copy link
Member

SergK commented Mar 19, 2026

/review

1 similar comment
@SergK
Copy link
Member

SergK commented Mar 19, 2026

/review

@SergK
Copy link
Member

SergK commented Mar 19, 2026

/review

@github-actions
Copy link

Review Summary

Issues found: 0 critical, 7 important

Important Issues

  • internal/cmdutil/factory.go:40 — Memoized closures use unsynchronized nil-checks; concurrent callers can race to initialise cachedConfig, cachedTokenProvider, cachedK8sClient. Fix with sync.Once per cached value. (confidence: 88)
  • internal/output/output.go:33 — Exported var style globals (HeaderStyle, EvenRowStyle, OddRowStyle, BorderStyle, LabelStyle, ValueStyle) are mutable at the package level; any caller can overwrite them and corrupt rendering for all subsequent calls. Should be unexported. (confidence: 80)
  • internal/output/output.go:51StatusColor() switch hardcodes string literals "created", "in_progress", "failed" instead of referencing k8s.StatusCreated, k8s.StatusInProgress, k8s.StatusFailed constants; silent drift if constants are renamed. (confidence: 85)
  • pkg/cmd/root/root.go:31PersistentPreRunE is silently superseded by any subcommand that defines its own PersistentPreRunE; any future subcommand adding its own hook will silently skip config loading. Also fires for krci version which needs no config — config.Resolve() may fail or warn in envs with no config file. (confidence: 90)
  • pkg/cmd/version/version.go:14NewCmdVersion receives *cmdutil.Factory solely to access f.IOStreams.Out, inconsistent with the opts.IO testability pattern used by all other commands in the package; no runF injection point. (confidence: 82)
  • pkg/cmd/deployment/get/get.go:190summarizeGates silently drops gates of unknown type; if a new CRD gate type is added, it is counted in the total but excluded from the label, producing a misleading bare number in the output. (confidence: 80)

Verdict

REQUEST CHANGES — Several important issues around concurrency safety, API surface correctness, and Cobra hook inheritance should be addressed before merge.

@github-actions
Copy link

Review Metrics

Metric Value
Outcome success
Status success
Cost $2.5683582000000005
Turns 1
Permission denials 0

@zmotso zmotso force-pushed the refactor branch 2 times, most recently from 7350784 to d63c647 Compare March 19, 2026 13:42
@zmotso
Copy link
Member Author

zmotso commented Mar 19, 2026

/review

@github-actions
Copy link

Review Summary

Issues found: 2 critical, 7 important

Critical Issues

  • internal/cmdutil/factory.go:41sync.Once permanently caches any config.Resolve() error; if the first call fails transiently, every subsequent f.Config() call returns the same error for the process lifetime with no recovery path (confidence: 95)
  • internal/cmdutil/factory.go:59 — Same permanent-error pattern on onceTP: if the first f.TokenProvider() call fails, the login command cannot recover within the same process (confidence: 90)

Important Issues

  • internal/iostreams/iostreams.go:31TestIOStreams() is exported in production code (iostreams.go), not in a _test.go file; it ships in the compiled binary unnecessarily (confidence: 90)
  • pkg/cmd/root/root.go:25PersistentPreRunE unconditionally calls f.Config() for every subcommand including krci version; a missing or malformed config file breaks krci version (confidence: 88)
  • pkg/cmd/auth/status/status.go:113statusRun manually re-implements the if isTTY { PrintStyledDetailLines } else { PrintPlainDetailLines } TTY dispatch that output.RenderDetail already encapsulates, diverging from the project's own abstraction (confidence: 83)
  • pkg/cmd/auth/status/status.go:52tp.GetToken() return value is discarded; the call may trigger a real network refresh as a side-effect with no user feedback or indication (confidence: 82)
  • internal/cmdutil/factory.go:8k8s.io/client-go/dynamic is isolated in its own import group between stdlib and internal imports, violating the stdlib / external / internal three-group convention (confidence: 82)
  • pkg/cmd/auth/login/login.go:77 — After tp.Login() succeeds, the sync.Once-memoized TokenProvider instance was constructed before login; while tokenProvider re-reads the store on each GetToken, the memoized-provider-surviving-login pattern is a latent correctness risk (confidence: 80)
  • pkg/cmd/root/root.go:25 — Cobra does NOT automatically chain PersistentPreRunE; any future subcommand that defines its own PersistentPreRunE will silently skip the root config-warming hook (confidence: 85)

Verdict

REQUEST CHANGES — Two sync.Once permanent-error-caching issues in the factory are critical correctness defects; the remaining important issues (version command breaking on missing config, TestIOStreams in production code) should be resolved before merge.

@github-actions
Copy link

Review Metrics

Metric Value
Outcome success
Status success
Cost $1.7166523500000002
Turns 16
Permission denials 0

Replaces the monolithic internal/cli package with a modular pkg/cmd
layout (auth, deployment, project, root, version). Introduces a
cmdutil.Factory for dependency injection and an iostreams abstraction
for testable I/O.

Dependency changes:
- Replace github.com/charmbracelet/lipgloss with charm.land/lipgloss/v2
- Remove github.com/fatih/color (replaced by lipgloss v2 styling)
- Update charmbracelet indirect deps (colorprofile, x/ansi, x/term)
@zmotso
Copy link
Member Author

zmotso commented Mar 19, 2026

/review

}

var (
muCfg sync.Mutex
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Config memoization is inconsistent with TokenProvider and K8sClient

Config uses a sync.Mutex + nil-check pattern that does not cache errors: if config.Resolve() fails, cachedConfig stays nil and every subsequent caller retries the filesystem I/O. Both TokenProvider and K8sClient use sync.Once, which correctly caches the failure on the first call and skips all retries.

Suggestion: convert Config to sync.Once for consistency:

Suggested change
muCfg sync.Mutex
onceConfig sync.Once
cachedConfig *config.Config
cachedConfigErr error

Then update the closure:

f.Config = func() (*config.Config, error) {
    onceConfig.Do(func() {
        cfg, err := config.Resolve()
        if err != nil {
            cachedConfigErr = fmt.Errorf("loading config: %w", err)
            return
        }
        cachedConfig = cfg
    })
    return cachedConfig, cachedConfigErr
}


// ResolveFormat returns the explicit format if provided, otherwise defaults to table.
// Use -o json for JSON output.
func ResolveFormat(explicit string) string {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ResolveFormat always returns "table" — piped output is no longer machine-readable

When no -o flag is passed, this unconditionally returns FormatTable regardless of whether stdout is a TTY. The previous implementation auto-detected TTY and defaulted to JSON for piped output, which is important for scripting (krci deployment list | jq ...).

Consider restoring TTY-based auto-detection:

Suggested change
func ResolveFormat(explicit string) string {
func ResolveFormat(explicit string, isTTY bool) string {
if explicit != "" {
return explicit
}
if !isTTY {
return FormatJSON
}
return FormatTable
}

Callers (RenderList, RenderDetail) already receive ios which exposes IsStdoutTTY(), so isTTY can be passed through without changes to the public API surface.

lines = append(lines, output.DetailLine{Label: "Status", Value: "Authenticated"})

if expiry := info.ExpiresAt; !expiry.IsZero() {
remaining := time.Until(expiry).Round(time.Second)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

time.Until evaluated twice — color threshold can disagree with displayed value

remaining is computed here for the display string, then computed again at line 111 inside the Styled closure. The two calls observe different wall-clock instants. In practice the delta is small, but if the token has e.g. 5m01s remaining when the display string is built and 4m59s when the closure runs, the output will show "5m1s" in the Expires line but color it yellow — a confusing inconsistency.

Fix: capture remaining once and close over it:

if expiry := info.ExpiresAt; !expiry.IsZero() {
    remaining := time.Until(expiry).Round(time.Second) // ← single evaluation
    expiresVal := fmt.Sprintf("%s (%s)", expiry.Local().Format(time.RFC822), remaining)
    lines = append(lines, output.DetailLine{Label: "Expires", Value: expiresVal})
}

Then in the Styled closure, reference the captured remaining variable directly instead of calling time.Until again.

lines = append(lines, output.DetailLine{Label: "Groups", Value: strings.Join(info.Groups, ", ")})
}

return output.RenderDetail(opts.IO, "", lines, output.DetailRenderer[[]output.DetailLine]{
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

outputFormat hardcoded to ""-o json is impossible for auth status

RenderDetail accepts an outputFormat parameter, but it is hardcoded to "" here and no -o flag is registered on the status command. This means users cannot request JSON output from krci auth status, and the outputFormat parameter of RenderDetail is permanently dead for this call site.

Either register a persistent -o flag and wire it through StatusOptions, or — if JSON output is intentionally not supported here — use a simpler renderer that does not imply format selection.

}

return output.RenderDetail(opts.IO, "", lines, output.DetailRenderer[[]output.DetailLine]{
Styled: func(w io.Writer, ls []output.DetailLine) error {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Styled closure mutates the caller's slice in-place — fragile side-effect pattern

ls[i].Styled = ... modifies the backing array of the slice passed into RenderDetail. This works today because PrintStyledDetailLines only iterates the slice once. However, if RenderDetail is ever refactored to call Styled more than once (e.g., for buffering or retry), the second invocation will see already-populated Styled fields, silently bypassing the conditional logic.

Compare with deployment/get and project/get, which build a pre-styled slice before passing it to RenderDetail — a cleaner pattern with no hidden mutation. Consider doing the same here by computing styled values eagerly before calling RenderDetail.

return err
}

svc := k8s.NewDeploymentService(dynClient, cfg.Namespace)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Service construction duplicated across 4 command packages

k8s.NewDeploymentService(dynClient, cfg.Namespace) is constructed verbatim here and again in pkg/cmd/deployment/get/get.go:75. The same pattern repeats for k8s.NewProjectService in pkg/cmd/project/list/list.go:71 and pkg/cmd/project/get/get.go:73.

Each of the four *Run functions calls opts.Config() and opts.K8sClient() solely to feed the service constructor — 8 lines of identical boilerplate. Adding a DeploymentService and ProjectService factory func to cmdutil.Factory (similar to how K8sClient is handled) would eliminate the duplication and make the constructors a single source of truth.

Version: v,
SilenceUsage: true,
SilenceErrors: true,
PersistentPreRunE: func(cmd *cobra.Command, _ []string) error {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PersistentPreRunE is not automatically chained by Cobra

Cobra does not chain PersistentPreRunE hooks. If any future subcommand (e.g., a new auth flow) defines its own PersistentPreRunE or PreRunE, this config warm-up will be silently bypassed — no compile error, no runtime warning.

Consider documenting this constraint explicitly, or switching to a pattern that is chain-safe (e.g., a middleware wrapper around each command's RunE, or calling f.Config() at the top of each RunE that needs it). At minimum, add a comment warning future contributors that adding PersistentPreRunE to a subcommand will override this hook.

@github-actions
Copy link

Review Summary

Issues found: 0 critical, 7 important

Important Issues

  • internal/cmdutil/factory.go:40 — Config() uses sync.Mutex+nil-check memoization and does not cache errors; a failed config.Resolve() leaves cachedConfig nil so every subsequent caller retries the I/O. TokenProvider and K8sClient both use sync.Once (which caches errors too) — Config should be consistent. (confidence: 85)
  • internal/output/output.go:126 — ResolveFormat always defaults to "table" with no TTY-detection fallback; piped consumers always receive human-formatted table rows instead of machine-readable JSON when no -o flag is set, silently breaking scripted usage. (confidence: 90)
  • pkg/cmd/auth/status/status.go:89 — time.Until(info.ExpiresAt) is evaluated twice: once when building the display string (~line 93) and again inside the Styled closure (~line 111) for the yellow-threshold check. The two evaluations observe different wall-clock instants, so the color threshold can disagree with the displayed remaining duration. (confidence: 83)
  • pkg/cmd/auth/status/status.go:101 — The Styled closure mutates ls[i].Styled in-place on the backing array of the slice passed into RenderDetail; if RenderDetail ever invokes r.Styled more than once, styling logic will be bypassed silently. Contrast with deployment/get and project/get which pass a pre-built slice with Styled already set. (confidence: 83)
  • pkg/cmd/auth/status/status.go:104 — output.RenderDetail is called with outputFormat hardcoded to ""; the status command never registers an -o flag, making the outputFormat parameter of RenderDetail permanently dead for this call site and leaving no way for users to request JSON output from auth status. (confidence: 80)
  • pkg/cmd/deployment/list/list.go:76 — k8s.NewDeploymentService and k8s.NewProjectService are each constructed verbatim in two separate command packages (deployment/list, deployment/get, project/list, project/get), producing four identical service-construction call sites with no shared factory helper. (confidence: 90)
  • pkg/cmd/root/root.go:25 — PersistentPreRunE on the root command is not automatically chained by Cobra; any subcommand that defines its own PersistentPreRunE or PreRunE will silently bypass the config warm-up without any compile-time or runtime warning. (confidence: 82)

Verdict

REQUEST CHANGES — The refactor introduces several important issues: broken TTY-detection for piped output format, inconsistent memoization in the factory, and duplicated service construction patterns that should be resolved before merging.

@github-actions
Copy link

Review Metrics

Metric Value
Outcome success
Status success
Cost $1.8123355499999994
Turns 23
Permission denials 1

@SergK SergK merged commit e517b04 into main Mar 20, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants