diff --git a/apps/docs/content/docs/en/env-vars.md b/apps/docs/content/docs/en/env-vars.md index 2d834b6b..6ef1910c 100644 --- a/apps/docs/content/docs/en/env-vars.md +++ b/apps/docs/content/docs/en/env-vars.md @@ -26,9 +26,12 @@ The manifest records only binding information and environment slugs. Values stay one env one env set DATABASE_URL -p api one env set API_URL=https://example.com -p web --env dev --yes +one env set SIGNING_PRIVATE_KEY -p api --env dev --stdin < /secure/private-key.pem one env list -p web --env dev ``` +Use `--stdin` for multiline PEM files and other secrets without passing their contents as arguments. It works for both project variables and `--global` shared credentials, preserves embedded line breaks, and removes only one final line terminator. Input is limited to 1 MiB; combining `--stdin` with an argument value is rejected. Existing-variable overwrites still require confirmation or `--yes`. + `set KEY` prompts for a hidden value in a terminal. Scripts pass a value explicitly. `--yes` confirms overwrites and new environment names. `list` returns names only. The CLI has no plaintext read command; use `one exec` to inject values into a child process. `-o json` or `-o yaml` provides structured output with the stable schemas `one-cli/env-set/v1` and `one-cli/env-list/v1`. `-p / --project` accepts a project name or workspace-relative path. Without it, One CLI infers the project from the current directory. At the workspace root, variables are shared; interactive `set` offers a scope selection. diff --git a/apps/docs/content/docs/zh/env-vars.md b/apps/docs/content/docs/zh/env-vars.md index d6b4cc10..03089c25 100644 --- a/apps/docs/content/docs/zh/env-vars.md +++ b/apps/docs/content/docs/zh/env-vars.md @@ -26,9 +26,12 @@ Manifest 只记录绑定信息和环境 slug。值保存在 Infisical,登录 one env one env set DATABASE_URL -p api one env set API_URL=https://example.com -p web --env dev --yes +one env set SIGNING_PRIVATE_KEY -p api --env dev --stdin < /secure/private-key.pem one env list -p web --env dev ``` +多行 PEM 和其他秘密可以通过 `--stdin` 读取,无需把内容放进命令参数。项目变量和 `--global` 共享凭据都支持该方式,保留内部换行,只去掉一个结尾换行符。输入上限为 1 MiB,不能同时提供参数值。覆盖已有变量仍需确认或显式指定 `--yes`。 + 终端中的 `set KEY` 会隐藏输入值;脚本显式传入值。`--yes` 确认覆盖已有值或新增环境名。`list` 只返回变量名。CLI 不提供明文读取命令;通过 `one exec` 将值注入子进程。`-o json` 或 `-o yaml` 输出结构化结果,保留 `one-cli/env-set/v1`、`one-cli/env-list/v1` 协议。 `-p / --project` 接受项目名或工作区相对路径。不传时按当前目录推断项目;在工作区根目录操作共享变量,交互式 `set` 会提供作用域选择。 diff --git a/packages/cli/internal/platform/i18n/locales/en-US.json b/packages/cli/internal/platform/i18n/locales/en-US.json index 63fb7f29..7544a1a5 100644 --- a/packages/cli/internal/platform/i18n/locales/en-US.json +++ b/packages/cli/internal/platform/i18n/locales/en-US.json @@ -255,7 +255,7 @@ "env.bind.select_environment": "Default browsing environment", "env.unset.short": "Delete an Infisical environment variable", "env.flag.project_selector": "Project name or path", - "env.flag.stdin": "Read the value from standard input to keep it out of shell history (shared credentials)", + "env.flag.stdin": "Read a project variable or shared credential from standard input to keep it out of shell history", "env.path_global_required": "--path is only available with --global.", "env.global_project_conflict": "--global cannot be combined with --project.", "env.stdin_value_conflict": "--stdin cannot be combined with a value argument.", diff --git a/packages/cli/internal/platform/i18n/locales/zh-CN.json b/packages/cli/internal/platform/i18n/locales/zh-CN.json index bcbfec29..f4b533aa 100644 --- a/packages/cli/internal/platform/i18n/locales/zh-CN.json +++ b/packages/cli/internal/platform/i18n/locales/zh-CN.json @@ -255,7 +255,7 @@ "env.bind.select_environment": "默认浏览环境", "env.unset.short": "删除一个 Infisical 环境变量", "env.flag.project_selector": "项目名或路径", - "env.flag.stdin": "从标准输入读取值,避免写入命令历史(共享凭据)", + "env.flag.stdin": "从标准输入读取项目变量或共享凭据,避免写入命令历史", "env.path_global_required": "--path 仅用于 --global", "env.global_project_conflict": "--global 不能与 --project 同时使用", "env.stdin_value_conflict": "--stdin 不能同时提供参数值", diff --git a/packages/cli/internal/transport/cobra/env/global.go b/packages/cli/internal/transport/cobra/env/global.go index 52de0060..ab7acede 100644 --- a/packages/cli/internal/transport/cobra/env/global.go +++ b/packages/cli/internal/transport/cobra/env/global.go @@ -1,9 +1,6 @@ package envcmd import ( - "io" - "strings" - "github.com/spf13/cobra" environmentmodule "github.com/torchstellar-team/one-cli/packages/cli/internal/modules/environment" @@ -94,10 +91,6 @@ func configureGlobal(parent *cobra.Command, deps Dependencies) { if c == bind { continue } - if c.Name() == "set" { - c.Flags().Bool("stdin", false, i18n.T("env.flag.stdin")) - i18n.MarkFlagUsage(c, "stdin", "env.flag.stdin") - } original := c.RunE if original == nil { continue @@ -137,21 +130,12 @@ func configureGlobal(parent *cobra.Command, deps Dependencies) { case "unset": result, e = remote.GlobalSecret(cmd.Context(), cmd.Name(), env, folder, args[0], "") case "set": - key, value := parseSetArgs(args) - stdin, _ := cmd.Flags().GetBool("stdin") - if stdin { - if setValueProvided(args) { - return i18n.Errorf("env.stdin_value_conflict") - } - b, err := io.ReadAll(io.LimitReader(cmd.InOrStdin(), (1<<20)+1)) - if err != nil { - return err - } - if len(b) > 1<<20 { - return i18n.Errorf("env.value_too_large") - } - value = strings.TrimSuffix(strings.TrimSuffix(string(b), "\n"), "\r") - } else if !setValueProvided(args) { + key, _ := parseSetArgs(args) + value, provided, err := readSetValue(cmd, args) + if err != nil { + return err + } + if !provided { if !output.CanPrompt() { return i18n.Errorf("env.stdin_required") } diff --git a/packages/cli/internal/transport/cobra/env/input.go b/packages/cli/internal/transport/cobra/env/input.go new file mode 100644 index 00000000..7540782d --- /dev/null +++ b/packages/cli/internal/transport/cobra/env/input.go @@ -0,0 +1,31 @@ +package envcmd + +import ( + "io" + "strings" + + "github.com/spf13/cobra" + "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/i18n" +) + +// readSetValue handles both project variables and shared credentials. Limit the +// read before planning remote writes, and remove only the final line terminator. +func readSetValue(cmd *cobra.Command, args []string) (string, bool, error) { + _, value := parseSetArgs(args) + provided := setValueProvided(args) + stdin, _ := cmd.Flags().GetBool("stdin") + if !stdin { + return value, provided, nil + } + if provided { + return "", false, i18n.Errorf("env.stdin_value_conflict") + } + b, err := io.ReadAll(io.LimitReader(cmd.InOrStdin(), (1<<20)+1)) + if err != nil { + return "", false, err + } + if len(b) > 1<<20 { + return "", false, i18n.Errorf("env.value_too_large") + } + return strings.TrimSuffix(strings.TrimSuffix(string(b), "\n"), "\r"), true, nil +} diff --git a/packages/cli/internal/transport/cobra/env/input_test.go b/packages/cli/internal/transport/cobra/env/input_test.go new file mode 100644 index 00000000..288ada5a --- /dev/null +++ b/packages/cli/internal/transport/cobra/env/input_test.go @@ -0,0 +1,85 @@ +package envcmd + +import ( + "errors" + "strings" + "testing" + + "github.com/spf13/cobra" +) + +func TestReadSetValuePreservesMultilineInput(t *testing.T) { + t.Parallel() + for _, test := range []struct{ name, input, want string }{ + {"LF", "-----BEGIN PRIVATE KEY-----\nfixture\n-----END PRIVATE KEY-----\n", "-----BEGIN PRIVATE KEY-----\nfixture\n-----END PRIVATE KEY-----"}, + {"CRLF", "header\r\nbody\r\n", "header\r\nbody"}, + {"two trailing lines", "body\n\n", "body\n"}, + {"literal escape", "header\\nbody", "header\\nbody"}, + {"empty", "", ""}, + {"limit", strings.Repeat("a", 1<<20), strings.Repeat("a", 1<<20)}, + } { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + cmd := &cobra.Command{} + cmd.Flags().Bool("stdin", true, "") + cmd.SetIn(strings.NewReader(test.input)) + got, provided, err := readSetValue(cmd, []string{"PRIVATE_KEY"}) + if err != nil || !provided || got != test.want { + t.Fatalf("stdin value was not preserved (provided=%v, error=%v)", provided, err) + } + }) + } +} + +func TestSetRejectsStdinConflictAndOversizeBeforeRemoteAccess(t *testing.T) { + for _, global := range []bool{false, true} { + for _, test := range []struct { + args []string + input string + }{ + {[]string{"PRIVATE_KEY", "private-argument-marker", "--stdin"}, ""}, + {[]string{"PRIVATE_KEY=private-argument-marker", "--stdin"}, ""}, + {[]string{"PRIVATE_KEY", "--stdin"}, strings.Repeat("private-input-marker", 60_000)}, + } { + // A nil service proves these failures cannot reach project planning or writes. + cmd := Commands(Dependencies{})[0] + cmd.SilenceUsage = true + cmd.SilenceErrors = true + args := append([]string{"set"}, test.args...) + if global { + args = append(args, "--global") + } + cmd.SetArgs(args) + cmd.SetIn(strings.NewReader(test.input)) + err := cmd.Execute() + if err == nil || strings.Contains(err.Error(), "private-argument-marker") || strings.Contains(err.Error(), "private-input-marker") { + t.Fatalf("unsafe stdin rejection (global=%v): %v", global, err) + } + } + } +} + +type brokenInput struct{ err error } + +func (input brokenInput) Read([]byte) (int, error) { return 0, input.err } + +func TestReadSetValuePreservesReadErrorAndExplicitValueBehavior(t *testing.T) { + cmd := &cobra.Command{} + cmd.Flags().Bool("stdin", true, "") + want := errors.New("stdin transport failed") + cmd.SetIn(brokenInput{want}) + if _, _, err := readSetValue(cmd, []string{"TOKEN"}); !errors.Is(err, want) { + t.Fatalf("lost stdin error: %v", err) + } + if err := cmd.Flags().Set("stdin", "false"); err != nil { + t.Fatal(err) + } + value, provided, err := readSetValue(cmd, []string{"TOKEN="}) + if err != nil || !provided || value != "" { + t.Fatal("explicit empty values changed") + } + _, provided, err = readSetValue(cmd, []string{"TOKEN"}) + if err != nil || provided { + t.Fatal("hidden-prompt selection changed") + } +} diff --git a/packages/cli/internal/transport/cobra/env/set.go b/packages/cli/internal/transport/cobra/env/set.go index a43d89b2..769c61b4 100644 --- a/packages/cli/internal/transport/cobra/env/set.go +++ b/packages/cli/internal/transport/cobra/env/set.go @@ -25,14 +25,18 @@ func newSetCmd(deps Dependencies) *cobra.Command { Long: i18n.T("env.set.tip"), Args: validateSetArgs, RunE: func(cmd *cobra.Command, args []string) error { + value, provided, err := readSetValue(cmd, args) + if err != nil { + return err + } plan, err := deps.Service.PlanSet(environmentmodule.PlanSetInput{ Scope: commandScope(cmd), Environment: environment, Project: project, }) if err != nil { return err } - key, value := parseSetArgs(args) - if !setValueProvided(args) { + key, _ := parseSetArgs(args) + if !provided { if !output.CanPrompt() { return cliErrors.New(cliErrors.ENV_SET_VALUE_REQUIRED, i18n.T("env.value_required")) } @@ -87,6 +91,8 @@ func newSetCmd(deps Dependencies) *cobra.Command { i18n.MarkFlagUsage(cmd, "env", "env.flag.environment") cmd.Flags().BoolVarP(&yes, "yes", "y", false, i18n.T("env.flag.yes")) i18n.MarkFlagUsage(cmd, "yes", "env.flag.yes") + cmd.Flags().Bool("stdin", false, i18n.T("env.flag.stdin")) + i18n.MarkFlagUsage(cmd, "stdin", "env.flag.stdin") markEnvFlagUsage(cmd, "project", "env", "yes") i18n.MarkShort(cmd, "env.set.short") i18n.MarkLong(cmd, "env.set.tip") diff --git a/packages/cli/testdata/reference/help/env_set.txt b/packages/cli/testdata/reference/help/env_set.txt index 0e610e7a..3d6a55aa 100644 --- a/packages/cli/testdata/reference/help/env_set.txt +++ b/packages/cli/testdata/reference/help/env_set.txt @@ -14,5 +14,5 @@ COMMON OPTIONS -o, --output Output format: json | yaml | text (default: terminal-friendly text, JSON when piped) --path Shared credential folder (current level only, not recursive) -p, --project Project name or relative path; defaults to the current project - --stdin Read the value from standard input to keep it out of shell history (shared credentials) + --stdin Read a project variable or shared credential from standard input to keep it out of shell history -y, --yes Confirm overwrites and new environments non-interactively