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
69 changes: 69 additions & 0 deletions src/cmd/context.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package cmd

import (
"fmt"
"sort"
"strings"

"github.com/phasehq/cli/pkg/phase"
"github.com/phasehq/cli/pkg/util"
sdk "github.com/phasehq/golang-sdk/v2/phase"
)

func mapKeys(m map[string]bool) []string {
var keys []string
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}

// contextNames returns the application and environment names to display for a
// fetch. They are read off the returned secrets when possible, and resolved
// from the account's app list when the fetch came back empty โ€” otherwise a
// path that matches no secrets would report a blank app and environment and
// look like a broken .phase.json.
func contextNames(p *sdk.Phase, secrets []sdk.SecretResult, appName, envName, appID string) (string, string) {
apps := map[string]bool{}
envs := map[string]bool{}
for _, s := range secrets {
if s.Application != "" {
apps[s.Application] = true
}
if s.Environment != "" {
envs[s.Environment] = true
}
}
if len(apps) > 0 && len(envs) > 0 {
return strings.Join(mapKeys(apps), ", "), strings.Join(mapKeys(envs), ", ")
}
return phase.ResolveNames(p, appName, envName, appID)
}

// emptyResultHint explains why a fetch matched nothing. Secrets are filtered by
// exact path, so secrets kept in folders are invisible at the default '/'; a
// --tags filter can also empty the result, in which case suggesting a broader
// path alone would misdirect. Returns "" when neither filter was applied โ€”
// the environment is then genuinely empty and there is nothing to suggest.
// Set forStdout when the hint is printed to stdout so ANSI styling is gated on
// the right stream.
func emptyResultHint(path, tags, verb string, forStdout bool) string {
bold, yellow := util.BoldErr, util.BoldYellowErr
if forStdout {
bold, yellow = util.Bold, util.BoldYellow
}
switch {
case tags != "" && path != "":
return fmt.Sprintf("๐Ÿ’ก No secrets matched tag filter %s at path %s. Adjust %s, or pass %s to %s secrets from all paths.\n",
yellow(tags), yellow(path), bold("--tags"), bold(`--path ""`), verb)
case tags != "":
return fmt.Sprintf("๐Ÿ’ก No secrets matched tag filter %s. Adjust or drop %s to %s all secrets.\n",
yellow(tags), bold("--tags"), verb)
case path != "":
return fmt.Sprintf("๐Ÿ’ก No secrets found at path %s. Secrets under other paths are not included โ€” pass %s to %s secrets from all paths.\n",
yellow(path), bold(`--path ""`), verb)
default:
return ""
}
}
74 changes: 74 additions & 0 deletions src/cmd/context_names_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package cmd

import (
"strings"
"testing"

sdk "github.com/phasehq/golang-sdk/v2/phase"
)

func TestContextNamesReadsNamesFromSecrets(t *testing.T) {
secrets := []sdk.SecretResult{
{Key: "A", Application: "my-app", Environment: "Development"},
{Key: "B", Application: "my-app", Environment: "Development"},
}

// A nil client is safe here: names come off the rows, so no lookup is made.
app, env := contextNames(nil, secrets, "my", "dev", "")
if app != "my-app" {
t.Fatalf("unexpected application: got %q want %q", app, "my-app")
}
if env != "Development" {
t.Fatalf("unexpected environment: got %q want %q", env, "Development")
}
}

func TestContextNamesIgnoresBlankNamesOnRows(t *testing.T) {
secrets := []sdk.SecretResult{{Key: "A"}}

// With no usable names on the rows, contextNames falls back to a lookup.
// PHASE_SERVICE_TOKEN disables the on-disk user-data cache (see
// getCacheDir), so a developer's real cache can't leak into the test, and
// the zero-value client has no host, so the lookup fails at request time
// without any network I/O and the selectors are returned as-is.
t.Setenv("PHASE_SERVICE_TOKEN", "test")
t.Setenv("PHASE_OFFLINE", "")
app, env := contextNames(&sdk.Phase{}, secrets, "my-app", "Development", "")
if app != "my-app" || env != "Development" {
t.Fatalf("unexpected fallback: got %q/%q want %q/%q", app, env, "my-app", "Development")
}
}

func TestEmptyResultHint(t *testing.T) {
// No filters means the environment is genuinely empty โ€” nothing to suggest.
if got := emptyResultHint("", "", "inject", false); got != "" {
t.Fatalf("expected no hint without filters, got %q", got)
}

hint := emptyResultHint("/", "", "inject", false)
if !strings.Contains(hint, `--path ""`) {
t.Fatalf("path hint should suggest --path \"\", got %q", hint)
}
if !strings.Contains(hint, "inject") {
t.Fatalf("hint should use the caller's verb, got %q", hint)
}

// A tag filter is a likelier cause than the path, so it must be named; the
// path suggestion alone would misdirect.
hint = emptyResultHint("/", "backend", "inject", false)
if !strings.Contains(hint, "backend") || !strings.Contains(hint, "--tags") {
t.Fatalf("tag+path hint should name the tag filter, got %q", hint)
}
if !strings.Contains(hint, `--path ""`) {
t.Fatalf("tag+path hint should still mention the path filter, got %q", hint)
}

// Tags with no path filter: the path suggestion would be a dead end.
hint = emptyResultHint("", "backend", "list", false)
if !strings.Contains(hint, "--tags") {
t.Fatalf("tag hint should name the tag filter, got %q", hint)
}
if strings.Contains(hint, `--path ""`) {
t.Fatalf("tag-only hint should not suggest a path change, got %q", hint)
}
}
32 changes: 8 additions & 24 deletions src/cmd/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,30 +85,22 @@ func runRun(cmd *cobra.Command, args []string) error {

// Print injection stats to stderr (matches Python CLI behavior)
secretCount := len(resolvedSecrets)
apps := map[string]bool{}
envs := map[string]bool{}
for _, s := range allSecrets {
if _, ok := resolvedSecrets[s.Key]; ok {
if s.Application != "" {
apps[s.Application] = true
}
envs[s.Environment] = true
}
}
appNames := mapKeys(apps)
envNames := mapKeys(envs)
appLabel, envLabel := contextNames(p, allSecrets, appName, envName, appID)

if path != "" && path != "/" {
fmt.Fprintf(os.Stderr, "๐Ÿš€ Injected %s secrets from Application: %s, Environment: %s, Path: %s\n",
util.BoldMagentaErr(fmt.Sprintf("%d", secretCount)),
util.BoldCyanErr(strings.Join(appNames, ", ")),
util.BoldGreenErr(strings.Join(envNames, ", ")),
util.BoldCyanErr(appLabel),
util.BoldGreenErr(envLabel),
util.BoldYellowErr(path))
} else {
fmt.Fprintf(os.Stderr, "๐Ÿš€ Injected %s secrets from Application: %s, Environment: %s\n",
util.BoldMagentaErr(fmt.Sprintf("%d", secretCount)),
util.BoldCyanErr(strings.Join(appNames, ", ")),
util.BoldGreenErr(strings.Join(envNames, ", ")))
util.BoldCyanErr(appLabel),
util.BoldGreenErr(envLabel))
}
if secretCount == 0 {
fmt.Fprint(os.Stderr, emptyResultHint(path, tags, "inject", false))
}

// Build environment: inherit current env and append secrets
Expand Down Expand Up @@ -138,11 +130,3 @@ func runRun(cmd *cobra.Command, args []string) error {
}
return nil
}

func mapKeys(m map[string]bool) []string {
var keys []string
for k := range m {
keys = append(keys, k)
}
return keys
}
19 changes: 18 additions & 1 deletion src/cmd/secrets_export.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,17 @@ func runSecretsExport(cmd *cobra.Command, args []string) error {
}
}
if len(missingKeys) > 0 {
return fmt.Errorf("๐Ÿฅก failed to export โ€” the following secret(s) do not exist: %s", strings.Join(missingKeys, ", "))
missing := strings.Join(missingKeys, ", ")
switch {
case tags != "" && path != "":
return fmt.Errorf("๐Ÿฅก failed to export โ€” the following secret(s) were not found at path %s with tag filter %s: %s. Adjust --tags, or pass --path \"\" to export from all paths", path, tags, missing)
case tags != "":
return fmt.Errorf("๐Ÿฅก failed to export โ€” the following secret(s) did not match tag filter %s: %s. Adjust or drop --tags", tags, missing)
case path != "":
return fmt.Errorf("๐Ÿฅก failed to export โ€” the following secret(s) were not found at path %s: %s. Secrets under other paths are not searched โ€” pass --path \"\" to export from all paths", path, missing)
default:
return fmt.Errorf("๐Ÿฅก failed to export โ€” the following secret(s) do not exist: %s", missing)
}
}
// Export only the requested keys (in the order they were specified)
for _, key := range filterKeys {
Expand All @@ -111,6 +121,13 @@ func runSecretsExport(cmd *cobra.Command, args []string) error {
}
}

// An export that produces nothing is otherwise silent; say why on stderr so
// it can't be mistaken for a broken app or environment. stderr keeps the
// exported document on stdout intact for piping.
if len(secretsList) == 0 {
fmt.Fprint(os.Stderr, emptyResultHint(path, tags, "export", false))
}

switch format {
case "json":
util.ExportJSON(secretsList)
Expand Down
3 changes: 3 additions & 0 deletions src/cmd/secrets_get.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,9 @@ func runSecretsGet(cmd *cobra.Command, args []string) error {
}

if len(results) == 0 {
if path != "" {
return fmt.Errorf("๐Ÿ” No matching secrets found at path %s โ€” secrets under other paths are not searched. Pass --path \"\" to search all paths", path)
}
return fmt.Errorf("๐Ÿ” No matching secrets found")
}

Expand Down
3 changes: 2 additions & 1 deletion src/cmd/secrets_list.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,8 @@ func listSecrets(p *sdk.Phase, envName, appName, appID, tags, path string, show,
return err
}

display.RenderSecretsTree(secrets, show)
appLabel, envLabel := contextNames(p, secrets, appName, envName, appID)
display.RenderSecretsTree(secrets, show, appLabel, envLabel, emptyResultHint(path, tags, "list", true))
return nil
}

Expand Down
33 changes: 12 additions & 21 deletions src/cmd/shell.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import (
"fmt"
"os"
"os/exec"
"strings"

"github.com/phasehq/cli/pkg/ai"
"github.com/phasehq/cli/pkg/phase"
Expand Down Expand Up @@ -79,30 +78,19 @@ func runShell(cmd *cobra.Command, args []string) error {
}

// Collect env/app info for display
apps := map[string]bool{}
envs := map[string]bool{}
for _, s := range allSecrets {
if _, ok := resolvedSecrets[s.Key]; ok {
if s.Application != "" {
apps[s.Application] = true
}
envs[s.Environment] = true
}
}
appNames := mapKeys(apps)
envNames := mapKeys(envs)
appLabel, envLabel := contextNames(p, allSecrets, appName, envName, appID)

// Build environment: inherit current env, add secrets and shell markers
envSlice := os.Environ()
for k, v := range resolvedSecrets {
envSlice = append(envSlice, fmt.Sprintf("%s=%s", k, v))
}
envSlice = append(envSlice, "PHASE_SHELL=true")
if len(envNames) > 0 {
envSlice = append(envSlice, fmt.Sprintf("PHASE_ENV=%s", envNames[0]))
if envLabel != "" {
envSlice = append(envSlice, fmt.Sprintf("PHASE_ENV=%s", envLabel))
}
if len(appNames) > 0 {
envSlice = append(envSlice, fmt.Sprintf("PHASE_APP=%s", appNames[0]))
if appLabel != "" {
envSlice = append(envSlice, fmt.Sprintf("PHASE_APP=%s", appLabel))
}
if os.Getenv("TERM") == "" {
envSlice = append(envSlice, "TERM=xterm-256color")
Expand All @@ -128,15 +116,18 @@ func runShell(cmd *cobra.Command, args []string) error {
fmt.Fprintf(os.Stderr, "๐Ÿš Initialized %s with %s secrets from Application: %s, Environment: %s, Path: %s\n",
util.BoldGreenErr(shellName),
util.BoldMagentaErr(fmt.Sprintf("%d", secretCount)),
util.BoldCyanErr(strings.Join(appNames, ", ")),
util.BoldGreenErr(strings.Join(envNames, ", ")),
util.BoldCyanErr(appLabel),
util.BoldGreenErr(envLabel),
util.BoldYellowErr(path))
} else {
fmt.Fprintf(os.Stderr, "๐Ÿš Initialized %s with %s secrets from Application: %s, Environment: %s\n",
util.BoldGreenErr(shellName),
util.BoldMagentaErr(fmt.Sprintf("%d", secretCount)),
util.BoldCyanErr(strings.Join(appNames, ", ")),
util.BoldGreenErr(strings.Join(envNames, ", ")))
util.BoldCyanErr(appLabel),
util.BoldGreenErr(envLabel))
}
if secretCount == 0 {
fmt.Fprint(os.Stderr, emptyResultHint(path, tags, "load", false))
}
fmt.Fprintf(os.Stderr, "%s Secrets are only available in this session. Type %s or press %s to exit.\n",
util.BoldYellowErr("Remember:"),
Expand Down
22 changes: 14 additions & 8 deletions src/pkg/display/tree.go
Original file line number Diff line number Diff line change
Expand Up @@ -199,18 +199,24 @@ func renderSecretRow(pathPrefix string, s sdk.SecretResult, show bool, keyWidth,
}
}

// RenderSecretsTree renders secrets in a tree view with path hierarchy
func RenderSecretsTree(secrets []sdk.SecretResult, show bool) {
// RenderSecretsTree renders secrets in a tree view with path hierarchy.
// appName and envName are the resolved context the secrets were fetched for;
// they are rendered even when nothing matched, so an empty result is clearly a
// filter miss rather than an unresolved app or environment. emptyHint is an
// optional newline-terminated line explaining why the result is empty, printed
// only when there are no secrets; pass "" for none.
func RenderSecretsTree(secrets []sdk.SecretResult, show bool, appName, envName, emptyHint string) {
bold, cyan, green, magenta, reset := util.AnsiCodes()

if len(secrets) == 0 {
fmt.Println("No secrets to display.")
fmt.Printf(" %s No secrets found for Application: %s%s%s%s, Environment: %s%s%s%s\n",
"๐Ÿ”ฎ", bold, cyan, appName, reset, bold, green, envName, reset)
if emptyHint != "" {
fmt.Printf(" %s", emptyHint)
}
return
}

appName := secrets[0].Application
envName := secrets[0].Environment

bold, cyan, green, magenta, reset := util.AnsiCodes()

fmt.Printf(" %s Secrets for Application: %s%s%s%s, Environment: %s%s%s%s\n",
"๐Ÿ”ฎ", bold, cyan, appName, reset, bold, green, envName, reset)

Expand Down
Loading
Loading