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
17 changes: 17 additions & 0 deletions pkg/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,23 @@ func Resolve(profileFlag string) (*ResolvedAuth, error) {
return r, nil
}

// ResolveWithOverrides resolves credentials via Resolve, then applies orgOverride/
// projectOverride on top of whatever the resolved profile/env already set. An empty
// override leaves the resolved value untouched.
func ResolveWithOverrides(profileFlag, orgOverride, projectOverride string) (*ResolvedAuth, error) {
r, err := Resolve(profileFlag)
if err != nil {
return nil, err
}
if orgOverride != "" {
r.OrgID = orgOverride
}
if projectOverride != "" {
r.ProjectID = projectOverride
}
return r, nil
}

func resolveProfile(name string) (*ResolvedAuth, error) {
cfg, err := config.LoadConfig()
if err != nil {
Expand Down
6 changes: 6 additions & 0 deletions pkg/cmdctx/cmdctx.go
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,12 @@ type Ctx struct {
// - "list-fields" bool when the flag exists (get/update commands)
// - "profile", "org", "project" string when no_auth: true (the handler owns auth resolution)
FlagValues map[string]any
// UIHistory is the --ui back-navigation stack: one UILink pushed per Hop
// (link/up/view), popped by the "b" key. Session-lifetime only.
UIHistory []UILink
// RestoreListPos is the cursor row (within the first-loaded page) to seed a
// freshly built table with when replaying a popped UILink's ListPos.
RestoreListPos int
}

// ScopedAuth returns Auth adjusted for Level: "org" clears ProjectID, "account"
Expand Down
51 changes: 51 additions & 0 deletions pkg/cmdctx/uilink.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
// Copyright © 2026 Harness Inc.
// SPDX-License-Identifier: Apache-2.0

package cmdctx

// UIScreenKind identifies which screen function should be called to redraw a
// UILink: a browse table scoped to a parent id, a browse table scoped to a
// parent id that itself came from a "get" lookup, or a detail-only overlay.
type UIScreenKind int

const (
ScreenTable UIScreenKind = iota
ScreenTableForGet
ScreenDetailForGet
)

// UILink is a replayable reference to a --ui Hop's target: enough to rebuild
// the Ctx for that screen and redraw it, without encoding anything onto a
// command line. Profile/Org/Project are the raw scope flag strings in effect
// at the Hop (not a cached resolved Auth), so replay can re-resolve auth
// instead of inheriting whatever was live in memory when the Link was pushed.
type UILink struct {
Verb, Noun string
Id string
IdParts []string
Level string

Profile, Org, Project string
FlagValues map[string]any

Screen UIScreenKind
ListPos int
}

// PushUILink appends link to the back-navigation stack (LIFO — PopUILink pops
// from the end).
func (c *Ctx) PushUILink(link UILink) {
c.UIHistory = append(c.UIHistory, link)
}

// PopUILink removes and returns the most recently pushed UILink. ok is false
// when the stack is empty, in which case link is the zero value.
func (c *Ctx) PopUILink() (link UILink, ok bool) {
n := len(c.UIHistory)
if n == 0 {
return UILink{}, false
}
link = c.UIHistory[n-1]
c.UIHistory = c.UIHistory[:n-1]
return link, true
}
54 changes: 54 additions & 0 deletions pkg/cmdctx/uilink_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
// Copyright © 2026 Harness Inc.
// SPDX-License-Identifier: Apache-2.0

package cmdctx

import "testing"

func TestPushPopUILink_RoundTrip(t *testing.T) {
c := &Ctx{}
c.PushUILink(UILink{Verb: "get", Noun: "pr", Id: "1"})

link, ok := c.PopUILink()
if !ok {
t.Fatal("PopUILink: ok = false, want true")
}
if link.Verb != "get" || link.Noun != "pr" || link.Id != "1" {
t.Fatalf("PopUILink = %+v, want Verb=get Noun=pr Id=1", link)
}
if len(c.UIHistory) != 0 {
t.Fatalf("UIHistory len = %d, want 0 after pop", len(c.UIHistory))
}
}

func TestPopUILink_Empty(t *testing.T) {
c := &Ctx{}
link, ok := c.PopUILink()
if ok {
t.Fatal("PopUILink on empty stack: ok = true, want false")
}
if link.Verb != "" || link.Noun != "" || link.Id != "" {
t.Fatalf("PopUILink on empty stack: link = %+v, want zero value", link)
}
}

func TestPushPopUILink_LIFOOrder(t *testing.T) {
c := &Ctx{}
c.PushUILink(UILink{Noun: "first"})
c.PushUILink(UILink{Noun: "second"})
c.PushUILink(UILink{Noun: "third"})

want := []string{"third", "second", "first"}
for _, w := range want {
link, ok := c.PopUILink()
if !ok {
t.Fatalf("PopUILink: ok = false, want true (expected %q)", w)
}
if link.Noun != w {
t.Fatalf("PopUILink.Noun = %q, want %q", link.Noun, w)
}
}
if _, ok := c.PopUILink(); ok {
t.Fatal("PopUILink after draining stack: ok = true, want false")
}
}
55 changes: 49 additions & 6 deletions pkg/registry/buildctx.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,12 +62,10 @@ func (r *Registry) buildCompletionCtx(cmd *cobra.Command, verb, noun, parentId s
profileFlag, _ := cmd.Flags().GetString("profile")
orgFlag, _ := cmd.Flags().GetString("org")
projectFlag, _ := cmd.Flags().GetString("project")
resolved, err := auth.Resolve(profileFlag)
resolved, err := auth.ResolveWithOverrides(profileFlag, orgFlag, projectFlag)
if err != nil {
return nil, err
}
resolved.OrgID = firstNonEmpty(orgFlag, resolved.OrgID)
resolved.ProjectID = firstNonEmpty(projectFlag, resolved.ProjectID)
ctx, cancel := context.WithCancelCause(context.Background())
go runTimeout(completionTimeout, cancel)

Expand Down Expand Up @@ -247,12 +245,10 @@ func buildCtx(cmd *cobra.Command, cs *spec.CommandSpec, args []string, r *Regist
profileFlag, _ := cmd.Flags().GetString("profile")
orgFlag, _ := cmd.Flags().GetString("org")
projectFlag, _ := cmd.Flags().GetString("project")
resolved, err := auth.Resolve(profileFlag)
resolved, err := auth.ResolveWithOverrides(profileFlag, orgFlag, projectFlag)
if err != nil {
return nil, err
}
resolved.OrgID = firstNonEmpty(orgFlag, resolved.OrgID)
resolved.ProjectID = firstNonEmpty(projectFlag, resolved.ProjectID)
ctx.Auth = resolved
}
if cs.HasArgs {
Expand Down Expand Up @@ -322,6 +318,7 @@ func buildDetailCtx(parent *cmdctx.Ctx, cs *spec.CommandSpec, id string) *cmdctx
Resolver: parent.Resolver,
FormatFlags: cmdctx.FormatFlags{Format: "text"},
FlagValues: map[string]any{},
UIHistory: parent.UIHistory,
}
// Endpoint path templates split ctx.Id into idParts on the fly (see exprenv.Make), but
// workflow handlers that read the ctx.IdParts struct field directly (e.g. a multi-part
Expand All @@ -332,6 +329,52 @@ func buildDetailCtx(parent *cmdctx.Ctx, cs *spec.CommandSpec, id string) *cmdctx
return ctx
}

// buildLinkCtx constructs the Ctx for a replayed UILink — either a forward hop
// (link/up/view) or a popped History entry. Unlike buildDetailCtx/buildPickerCtx, which
// inherit the caller's already-resolved Auth verbatim, this re-resolves auth from the
// Link's raw profile/org/project strings, so a Link that captured a different scope
// replays with that scope instead of whatever happens to be live in the caller's ctx.
func buildLinkCtx(ctx *cmdctx.Ctx, link *cmdctx.UILink, targetCs *spec.CommandSpec) (*cmdctx.Ctx, error) {
var resolved *auth.ResolvedAuth
if !targetCs.NoAuth {
var err error
resolved, err = auth.ResolveWithOverrides(link.Profile, link.Org, link.Project)
if err != nil {
return nil, err
}
}
fv := link.FlagValues
if fv == nil {
fv = map[string]any{}
}
goCtx, cancel := context.WithCancelCause(ctx.Context)
newCtx := &cmdctx.Ctx{
Context: goCtx,
CancelFn: cancel,
Auth: resolved,
Verb: targetCs.Verb,
VerbHandler: targetCs.VerbHandler,
Noun: targetCs.Noun,
FieldsNoun: targetCs.FieldsNoun,
Level: link.Level,
IsPty: ctx.IsPty,
Resolver: ctx.Resolver,
FormatFlags: cmdctx.FormatFlags{Format: "text"},
FlagValues: fv,
UIHistory: ctx.UIHistory,
}
if link.Screen == cmdctx.ScreenDetailForGet {
newCtx.Id = link.Id
if targetCs.IdParts > 1 {
newCtx.IdParts = strings.SplitN(link.Id, "/", targetCs.IdParts)
}
} else {
newCtx.ParentId = link.Id
newCtx.RestoreListPos = link.ListPos
}
return newCtx, nil
}

// resolveFlagValues runs any flag_resolve_fn declared on spec flags, overwriting
// the raw string value in ctx.FlagValues with the resolved result. Skips flags
// whose value is empty. Called after buildFlagValues and auth resolution.
Expand Down
4 changes: 4 additions & 0 deletions pkg/registry/buildctx_workflow_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -786,6 +786,7 @@ func TestBuildDetailCtx(t *testing.T) {
t.Fatalf("setup buildCtx: %v", err)
}
parent.Level = "org"
parent.UIHistory = []cmdctx.UILink{{Verb: VerbGet, Noun: "detailnoun", Id: "grandparent-id"}}

detailCS := &spec.CommandSpec{
Verb: VerbGet, VerbHandler: VerbGet, Noun: "detailnoun",
Expand All @@ -807,6 +808,9 @@ func TestBuildDetailCtx(t *testing.T) {
if detail.Context == nil {
t.Fatal("detail.Context is nil")
}
if len(detail.UIHistory) != 1 || detail.UIHistory[0].Id != "grandparent-id" {
t.Fatalf("detail.UIHistory = %+v, want parent's UIHistory carried forward", detail.UIHistory)
}
}

// ---- validateIdParts via AllowsParentId ----
Expand Down
2 changes: 1 addition & 1 deletion pkg/registry/checks.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ func (r *Registry) CheckFunctions() error {
var reservedUIKeys = map[string]bool{
"p": true, "q": true, "ctrl+c": true, "esc": true, "backspace": true,
"up": true, "down": true, "k": true, "j": true, "pgup": true, "pgdown": true,
"home": true, "end": true,
"home": true, "end": true, "b": true,
}

// checkUICommands validates a noun's ui_commands list: unique non-reserved keys,
Expand Down
25 changes: 25 additions & 0 deletions pkg/registry/checks_workflow_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,31 @@ func TestCheckFunctions_TextFormatterMissing(t *testing.T) {
}
}

func TestCheckUICommands_BReserved(t *testing.T) {
r := New()
r.specs[VerbGet] = append(r.specs[VerbGet], &spec.CommandSpec{
Command: "get thing",
Verb: VerbGet,
Noun: "thing",
Module: "test",
})
if err := r.RegisterNoun(spec.NounDef{
Noun: "thing",
UICommands: []spec.UICommand{
{Key: "b", UICommandType: spec.UICommandText, Default: true, Noun: "thing"},
},
}); err != nil {
t.Fatalf("RegisterNoun: %v", err)
}
err := r.CheckFunctions()
if err == nil {
t.Fatal(`expected error for reserved key "b"`)
}
if !strings.Contains(err.Error(), `key "b" is reserved`) {
t.Fatalf("error = %q, want reserved key mention", err)
}
}

func TestCheckFunctions_BodyFnMissing(t *testing.T) {
r := New()
r.RegisterNoun(spec.NounDef{Noun: "thing2"})
Expand Down
1 change: 1 addition & 0 deletions pkg/registry/uipicker.go
Original file line number Diff line number Diff line change
Expand Up @@ -161,5 +161,6 @@ func buildPickerCtx(getCtx *cmdctx.Ctx, listCs *spec.CommandSpec) *cmdctx.Ctx {
Resolver: getCtx.Resolver,
FormatFlags: cmdctx.FormatFlags{},
FlagValues: fv,
UIHistory: getCtx.UIHistory,
}
}
Loading