From f3ad48428b9ff5ae3e349ff636a17d4916cd95d2 Mon Sep 17 00:00:00 2001 From: Adrian Kahali Date: Wed, 9 Sep 2026 15:36:25 -0400 Subject: [PATCH 01/13] fix(cli/login): make browser flow subprocess-safe browserCliLogin called term.GetState(os.Stdin.Fd()) unconditionally, which returns ENOTSUP ("operation not supported by device") when stdin is a pipe rather than a TTY. The CLI treated that as fatal, printed "Login via browser failed", and fell back to the interactive email prompt, which also failed because there was no stdin. Net effect: an AI agent spawning `infisical login` as a subprocess could not complete browser login at all. Guard both term.GetState/restoreTerminal and the askToPasteJwtToken paste-fallback goroutine behind an isatty check. Neither is useful in non-TTY contexts, and the browser callback flow itself requires no terminal. Interactive terminal behavior is unchanged. Co-Authored-By: Claude Opus 4.7 --- packages/cmd/login.go | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/packages/cmd/login.go b/packages/cmd/login.go index e762c08a2..3e75e309b 100644 --- a/packages/cmd/login.go +++ b/packages/cmd/login.go @@ -30,6 +30,7 @@ import ( "github.com/Infisical/infisical-merge/packages/util" "github.com/fatih/color" "github.com/manifoldco/promptui" + "github.com/mattn/go-isatty" "github.com/posthog/posthog-go" "github.com/rs/cors" "github.com/rs/zerolog/log" @@ -1094,14 +1095,16 @@ func browserCliLogin() (models.UserCredentials, error) { failure := make(chan error) timeout := time.After(time.Second * time.Duration(SERVER_TIMEOUT)) - //terminal state - oldState, err := term.GetState(int(os.Stdin.Fd())) - if err != nil { - return models.UserCredentials{}, err + // Skip in non-TTY (like an agent) so term.GetState doesn't fail + stdinIsTTY := isatty.IsTerminal(os.Stdin.Fd()) + if stdinIsTTY { + oldState, err := term.GetState(int(os.Stdin.Fd())) + if err != nil { + return models.UserCredentials{}, err + } + defer restoreTerminal(oldState) } - defer restoreTerminal(oldState) - //create handler c := cors.New(cors.Options{ AllowedOrigins: []string{strings.ReplaceAll(config.INFISICAL_LOGIN_URL, "/login", "")}, @@ -1115,7 +1118,10 @@ func browserCliLogin() (models.UserCredentials, error) { log.Debug().Msgf("Callback server listening on port %d", callbackPort) go http.Serve(listener, corsHandler) - go askToPasteJwtToken(success, failure) + // Skip in non-TTY (like an agent) + if stdinIsTTY { + go askToPasteJwtToken(success, failure) + } for { select { From e2c9c96206e7b51a6d17591b3d946deb46905844 Mon Sep 17 00:00:00 2001 From: Adrian Kahali Date: Wed, 9 Sep 2026 15:36:42 -0400 Subject: [PATCH 02/13] feat(cli): add bootstrap verbs for AI-agent quickstart Adds the five CLI verbs an AI-agent quickstart prompt needs to drive end to end without dashboard clicks, backed by the existing REST API: - `infisical init --yes --project-id `: non-interactive .infisical.json write; skips the org/project pickers so an agent can link a directory once it already has a project id. - `infisical projects list [--json]` and `infisical projects create --name [--description] [--slug] [--json]`: wraps GET /v1/workspace and POST /v2/workspace with machine-readable output. Create returns id + name + slug + orgId + environments and defaults shouldCreateDefaultEnvs to true. - `infisical org list [--json]`: enumerates the user's org memberships so an agent can pick one before creating a project. - `infisical import [--path] [--env] [--yes] [--json] [--add-gitignore]`: discovers .env, .env.local, .env.development, .env.staging, and .env.production under --path; previews key names (never values); uploads via util.SetRawSecrets; checks .gitignore, and optionally appends missing entries; never deletes the source. The CLI owns the file parsing so the agent never has to open a .env itself. Adds packages/api/model.go request/response types for project creation and packages/api/api.go CallCreateProject. Co-Authored-By: Claude Opus 4.7 --- packages/api/api.go | 21 +++ packages/api/model.go | 28 ++++ packages/cmd/import.go | 327 +++++++++++++++++++++++++++++++++++++++ packages/cmd/init.go | 40 ++++- packages/cmd/org.go | 59 +++++-- packages/cmd/projects.go | 168 ++++++++++++++++++++ 6 files changed, 622 insertions(+), 21 deletions(-) create mode 100644 packages/cmd/import.go create mode 100644 packages/cmd/projects.go diff --git a/packages/api/api.go b/packages/api/api.go index 40ed2b755..3c1f293e5 100644 --- a/packages/api/api.go +++ b/packages/api/api.go @@ -33,6 +33,7 @@ const ( operationCallSelectOrganization = "CallSelectOrganization" operationCallGetAllWorkSpacesUserBelongsTo = "CallGetAllWorkSpacesUserBelongsTo" operationCallGetProjectById = "CallGetProjectById" + operationCallCreateProject = "CallCreateProject" operationCallIsAuthenticated = "CallIsAuthenticated" operationCallGetNewAccessTokenWithRefreshToken = "CallGetNewAccessTokenWithRefreshToken" operationCallGetFoldersV1 = "CallGetFoldersV1" @@ -338,6 +339,26 @@ func CallGetAllWorkSpacesUserBelongsTo(httpClient *resty.Client) (GetWorkSpacesR return workSpacesResponse, nil } +func CallCreateProject(httpClient *resty.Client, request CreateProjectRequest) (CreatedProject, error) { + var resp CreateProjectResponse + response, err := httpClient. + R(). + SetBody(request). + SetResult(&resp). + SetHeader("User-Agent", USER_AGENT). + Post(fmt.Sprintf("%v/v2/workspace", config.INFISICAL_URL)) + + if err != nil { + return CreatedProject{}, NewGenericRequestError(operationCallCreateProject, err) + } + + if response.IsError() { + return CreatedProject{}, NewAPIErrorWithResponse(operationCallCreateProject, response, nil) + } + + return resp.Project, nil +} + func CallGetProjectById(httpClient *resty.Client, id string) (Project, error) { var projectResponse GetProjectByIdResponse response, err := httpClient. diff --git a/packages/api/model.go b/packages/api/model.go index e0b1c5fc4..9dd9ba22f 100644 --- a/packages/api/model.go +++ b/packages/api/model.go @@ -228,6 +228,34 @@ type Project struct { Slug string `json:"slug"` } +type CreateProjectRequest struct { + ProjectName string `json:"projectName"` + ProjectDescription string `json:"projectDescription,omitempty"` + Slug string `json:"slug,omitempty"` + Template string `json:"template,omitempty"` + Type string `json:"type,omitempty"` + ShouldCreateDefaultEnvs bool `json:"shouldCreateDefaultEnvs"` + HasDeleteProtection bool `json:"hasDeleteProtection,omitempty"` +} + +type CreatedProjectEnvironment struct { + ID string `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` +} + +type CreatedProject struct { + ID string `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + OrgID string `json:"orgId,omitempty"` + Environments []CreatedProjectEnvironment `json:"environments,omitempty"` +} + +type CreateProjectResponse struct { + Project CreatedProject `json:"project"` +} + type RawSecret struct { SecretKey string `json:"secretKey,omitempty"` SecretValue string `json:"secretValue,omitempty"` diff --git a/packages/cmd/import.go b/packages/cmd/import.go new file mode 100644 index 000000000..6b0f0be98 --- /dev/null +++ b/packages/cmd/import.go @@ -0,0 +1,327 @@ +/* +Copyright (c) 2026 Infisical Inc. +*/ +package cmd + +import ( + "bufio" + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/Infisical/infisical-merge/packages/models" + "github.com/Infisical/infisical-merge/packages/util" + "github.com/manifoldco/promptui" + "github.com/posthog/posthog-go" + "github.com/spf13/cobra" +) + +// Well-known env file names an AI-agent bootstrap should surface. Nothing in +// this list is auto-mapped to a specific environment: the caller picks one env +// slug (via --env or .infisical.json) and every discovered file goes there. +// Splitting files across environments requires running `import` per file, +// which keeps the mental model of the command simple. +var envFileCandidates = []string{ + ".env", + ".env.local", + ".env.development", + ".env.staging", + ".env.production", +} + +var importCmd = &cobra.Command{ + Use: "import", + Short: "Import secrets from local .env files into an Infisical environment (never deletes the source)", + DisableFlagsInUseLine: true, + Example: "infisical import\n infisical import --yes\n infisical import --path=./api --env=staging --add-gitignore", + Args: cobra.NoArgs, + PreRun: func(cmd *cobra.Command, args []string) { + util.RequireLogin() + }, + Run: runImport, +} + +type importedFileResult struct { + File string `json:"file"` + Env string `json:"env"` + Keys []string `json:"keys"` + Uploaded int `json:"uploaded"` + Error string `json:"error,omitempty"` +} + +type importResult struct { + Files []importedFileResult `json:"files"` + GitignoreUpdated []string `json:"gitignoreUpdated,omitempty"` + GitignoreMissing []string `json:"gitignoreMissing,omitempty"` + Cancelled bool `json:"cancelled,omitempty"` +} + +func runImport(cmd *cobra.Command, args []string) { + path, _ := cmd.Flags().GetString("path") + envSlug, _ := cmd.Flags().GetString("env") + yes, _ := cmd.Flags().GetBool("yes") + jsonOut, _ := cmd.Flags().GetBool("json") + addGitignore, _ := cmd.Flags().GetBool("add-gitignore") + + if path == "" { + path = "." + } + + // --env wins, else the env from .infisical.json, else "dev". + if envSlug == "" { + if envFromWorkspace := util.GetEnvFromWorkspaceFile(); envFromWorkspace != "" { + envSlug = envFromWorkspace + } else { + envSlug = "dev" + } + } + + workspaceFile, err := util.GetWorkSpaceFromFile() + if err != nil { + util.PrintErrorMessageAndExit("Cannot resolve project. Run `infisical init` (or `infisical init --project-id `) in this directory first.") + } + projectId := workspaceFile.WorkspaceId + + // Explicit list rather than a dotfile scan: a wide scan would pick up + // .envrc and other files that people don't want their vault to swallow. + var found []string + for _, name := range envFileCandidates { + p := filepath.Join(path, name) + info, err := os.Stat(p) + if err != nil { + continue + } + if info.IsDir() { + continue + } + found = append(found, p) + } + + if len(found) == 0 { + util.PrintErrorMessageAndExit(fmt.Sprintf("No .env-style files found under %q. Looked for: %s", path, strings.Join(envFileCandidates, ", "))) + } + + type fileScan struct { + Path string + Keys []string + } + var scans []fileScan + for _, f := range found { + keys, err := extractEnvKeyNames(f) + if err != nil { + util.PrintfStderr("Skipping %s: %v\n", f, err) + continue + } + if len(keys) == 0 { + util.PrintfStderr("Skipping %s (no keys found)\n", f) + continue + } + scans = append(scans, fileScan{Path: f, Keys: keys}) + } + + if len(scans) == 0 { + util.PrintErrorMessageAndExit("Nothing to import.") + } + + if !jsonOut { + util.PrintfStdout("About to import into env %q (project %s):\n", envSlug, projectId) + for _, s := range scans { + util.PrintfStdout("\n %s (%d key(s))\n", s.Path, len(s.Keys)) + for _, k := range s.Keys { + util.PrintfStdout(" %s\n", k) + } + } + } + + result := importResult{} + if !yes { + prompt := promptui.Prompt{ + Label: "Proceed with import", + IsConfirm: true, + } + if _, err := prompt.Run(); err != nil { + result.Cancelled = true + if jsonOut { + emitImportJSON(result) + return + } + util.PrintlnStdout("Cancelled. No secrets were uploaded and no files were touched.") + return + } + } + + userCreds := requireUserSession() + tokenDetails := &models.TokenDetails{Type: "", Token: userCreds.UserCredentials.JTWToken} + + for _, s := range scans { + r := importedFileResult{File: s.Path, Env: envSlug, Keys: s.Keys} + ops, err := util.SetRawSecrets(nil, util.SECRET_TYPE_SHARED, envSlug, "/", projectId, tokenDetails, s.Path, nil) + if err != nil { + r.Error = err.Error() + result.Files = append(result.Files, r) + if !jsonOut { + util.PrintfStderr("Failed to upload %s: %v\n", s.Path, err) + } + continue + } + r.Uploaded = len(ops) + result.Files = append(result.Files, r) + if !jsonOut { + util.PrintfStdout("Uploaded %d secret(s) from %s into env %q\n", len(ops), s.Path, envSlug) + } + } + + gitignorePath := filepath.Join(path, ".gitignore") + existingLines := readGitignoreLines(gitignorePath) + var missing []string + for _, s := range scans { + rel, err := filepath.Rel(path, s.Path) + if err != nil { + rel = filepath.Base(s.Path) + } + if !gitignoreCovers(existingLines, rel) { + missing = append(missing, rel) + } + } + if len(missing) > 0 { + if addGitignore { + if err := appendToGitignore(gitignorePath, missing); err != nil { + util.PrintfStderr("Warning: failed to update %s: %v\n", gitignorePath, err) + } else { + result.GitignoreUpdated = missing + if !jsonOut { + util.PrintfStdout("Added %d entry(ies) to %s\n", len(missing), gitignorePath) + } + } + } else { + result.GitignoreMissing = missing + if !jsonOut { + util.PrintWarning(fmt.Sprintf("These files are not in .gitignore: %s. Re-run with --add-gitignore to append them.", strings.Join(missing, ", "))) + } + } + } + + if jsonOut { + emitImportJSON(result) + } else { + util.PrintlnStdout("\nSource files were left in place; they were not deleted.") + } + + Telemetry.CaptureEvent("cli-command:import", posthog.NewProperties(). + Set("version", util.CLI_VERSION). + Set("filesScanned", len(scans))) +} + +func emitImportJSON(r importResult) { + out, err := json.MarshalIndent(r, "", " ") + if err != nil { + util.HandleError(err, "Unable to encode JSON") + } + util.PrintlnStdout(string(out)) +} + +// extractEnvKeyNames returns key names only (never values) using the same +// comment and key=value rules as util.parseSecrets so the preview and the +// eventual upload agree on what constitutes a line. +func extractEnvKeyNames(file string) ([]string, error) { + f, err := os.Open(file) + if err != nil { + return nil, err + } + defer f.Close() + + var keys []string + seen := map[string]struct{}{} + scanner := bufio.NewScanner(f) + // PEM-style values run long; give the scanner room so a bad line + // doesn't cap the preview well below what SetRawSecrets will accept. + scanner.Buffer(make([]byte, 0, 64*1024), 10*1024*1024) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, "//") { + continue + } + parts := strings.SplitN(line, "=", 2) + if len(parts) != 2 { + continue + } + key := strings.TrimSpace(parts[0]) + if key == "" { + continue + } + if _, dup := seen[key]; dup { + continue + } + seen[key] = struct{}{} + keys = append(keys, key) + } + if err := scanner.Err(); err != nil { + return nil, err + } + sort.Strings(keys) + return keys, nil +} + +func readGitignoreLines(path string) []string { + f, err := os.Open(path) + if err != nil { + return nil + } + defer f.Close() + + var lines []string + scanner := bufio.NewScanner(f) + for scanner.Scan() { + lines = append(lines, strings.TrimSpace(scanner.Text())) + } + return lines +} + +// gitignoreCovers is deliberately conservative: it matches only exact entries +// and the common .env glob patterns. The narrow question this answers is +// "will git commit this file by accident?" — the answer is worth surfacing +// even without a full gitignore-pattern matcher. +func gitignoreCovers(lines []string, rel string) bool { + for _, l := range lines { + if l == "" || strings.HasPrefix(l, "#") { + continue + } + if l == rel || l == "/"+rel { + return true + } + if l == ".env*" || l == "*.env" || l == "**/.env*" { + return true + } + } + return false +} + +func appendToGitignore(path string, entries []string) error { + f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600) + if err != nil { + return err + } + defer f.Close() + // Preamble newline in case the existing file didn't end in one. + if _, err := f.WriteString("\n# Added by `infisical import`\n"); err != nil { + return err + } + for _, e := range entries { + if _, err := f.WriteString(e + "\n"); err != nil { + return err + } + } + return nil +} + +func init() { + importCmd.Flags().String("path", "", "Directory to scan for .env-style files (default: current working directory).") + importCmd.Flags().String("env", "", "Environment slug to upload into (default: env from .infisical.json, else \"dev\").") + importCmd.Flags().BoolP("yes", "y", false, "Skip the confirmation prompt. Required for non-interactive / agent runs.") + importCmd.Flags().Bool("json", false, "Emit a machine-readable summary of the import (files scanned, keys per file, upload counts, gitignore status).") + importCmd.Flags().Bool("add-gitignore", false, "Append every imported file that is not already in .gitignore to .gitignore.") + RootCmd.AddCommand(importCmd) +} diff --git a/packages/cmd/init.go b/packages/cmd/init.go index 179042d59..9ed6c5005 100644 --- a/packages/cmd/init.go +++ b/packages/cmd/init.go @@ -14,6 +14,7 @@ import ( "github.com/Infisical/infisical-merge/packages/util" "github.com/go-resty/resty/v2" "github.com/manifoldco/promptui" + "github.com/mattn/go-isatty" "github.com/posthog/posthog-go" "github.com/rs/zerolog/log" "github.com/spf13/cobra" @@ -24,23 +25,44 @@ var initCmd = &cobra.Command{ Use: "init", Short: "Used to connect your local project with Infisical project", DisableFlagsInUseLine: true, - Example: "infisical init", + Example: "infisical init\n infisical init --project-id \n infisical init --project-id --force", Args: cobra.ExactArgs(0), PreRun: func(cmd *cobra.Command, args []string) { util.RequireLogin() }, Run: func(cmd *cobra.Command, args []string) { + force, _ := cmd.Flags().GetBool("force") + projectID, _ := cmd.Flags().GetString("project-id") + if util.WorkspaceConfigFileExistsInCurrentPath() { - shouldOverride, err := shouldOverrideWorkspacePrompt() - if err != nil { - log.Error().Msg("Unable to parse your answer") - log.Debug().Err(err) - return + if force { + log.Info().Msg("A workspace config file already exists here; overwriting because --force was provided.") + } else if !isatty.IsTerminal(os.Stdin.Fd()) { + util.PrintErrorMessageAndExit("This directory is already linked to an Infisical project (.infisical.json exists). Pass --force to overwrite it.") + } else { + shouldOverride, err := shouldOverrideWorkspacePrompt() + if err != nil { + log.Error().Msg("Unable to parse your answer") + log.Debug().Err(err) + return + } + + if !shouldOverride { + return + } } + } - if !shouldOverride { - return + // Non-interactive path: we already know the project. Skip the org + // and workspace pickers and just write .infisical.json. Subsequent + // commands (secrets, run) will surface auth or org-scope errors if + // the logged-in session cannot reach this project. + if projectID != "" { + if err := writeWorkspaceFile(models.Workspace{ID: projectID}); err != nil { + util.HandleError(err) } + Telemetry.CaptureEvent("cli-command:init", posthog.NewProperties().Set("version", util.CLI_VERSION).Set("nonInteractive", true)) + return } userCreds, err := util.GetCurrentLoggedInUserDetails(true) @@ -201,6 +223,8 @@ func offerDirectoryProfileBinding(profileName string) { } func init() { + initCmd.Flags().Bool("force", false, "Overwrite an existing .infisical.json without asking.") + initCmd.Flags().String("project-id", "", "Project ID to link this directory to. When set, skips the interactive org and project pickers and writes .infisical.json directly.") RootCmd.AddCommand(initCmd) } diff --git a/packages/cmd/org.go b/packages/cmd/org.go index 3fcb01a2b..39ef415f7 100644 --- a/packages/cmd/org.go +++ b/packages/cmd/org.go @@ -4,6 +4,7 @@ Copyright (c) 2023 Infisical Inc. package cmd import ( + "encoding/json" "fmt" "text/tabwriter" @@ -29,11 +30,19 @@ The organization is a setting on your login profile, not a separate login. Use }, } +type orgListEntry struct { + ID string `json:"id"` + Name string `json:"name"` + Slug string `json:"slug,omitempty"` + Current bool `json:"current"` + SubOrganizations []orgListEntry `json:"subOrganizations,omitempty"` +} + var orgListCmd = &cobra.Command{ Use: "list", Short: "List the organizations this profile's account can use", DisableFlagsInUseLine: true, - Example: "infisical org list", + Example: "infisical org list\ninfisical org list --json", Args: cobra.NoArgs, PreRun: func(cmd *cobra.Command, args []string) { util.RequireLogin() @@ -58,24 +67,17 @@ var orgListCmd = &cobra.Command{ currentOrgID = claimOrgID } - writer := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0) - fmt.Fprintln(writer, "CURRENT\tNAME\tSLUG\tID") - - marker := func(id string) string { - if id == currentOrgID { - return "*" - } - return "" - } + var orgs []orgListEntry // The sub-org aware listing carries slugs and nested organizations. // Older instances may not have it, so fall back to the flat list. if subOrgsResp, err := api.CallGetAllOrganizationsWithSubOrgs(httpClient); err == nil && len(subOrgsResp.Organizations) > 0 { for _, org := range subOrgsResp.Organizations { - fmt.Fprintf(writer, "%s\t%s\t%s\t%s\n", marker(org.ID), util.SanitizeDisplay(org.Name), util.SanitizeDisplay(org.Slug), org.ID) + entry := orgListEntry{ID: org.ID, Name: org.Name, Slug: org.Slug, Current: org.ID == currentOrgID} for _, sub := range org.SubOrganizations { - fmt.Fprintf(writer, "%s\t └─ %s\t%s\t%s\n", marker(sub.ID), util.SanitizeDisplay(sub.Name), util.SanitizeDisplay(sub.Slug), sub.ID) + entry.SubOrganizations = append(entry.SubOrganizations, orgListEntry{ID: sub.ID, Name: sub.Name, Slug: sub.Slug, Current: sub.ID == currentOrgID}) } + orgs = append(orgs, entry) } } else { orgResp, err := api.CallGetAllOrganizations(httpClient) @@ -83,7 +85,37 @@ var orgListCmd = &cobra.Command{ util.HandleError(err, "Unable to list your organizations") } for _, org := range orgResp.Organizations { - fmt.Fprintf(writer, "%s\t%s\t%s\t%s\n", marker(org.ID), util.SanitizeDisplay(org.Name), "", org.ID) + orgs = append(orgs, orgListEntry{ID: org.ID, Name: org.Name, Current: org.ID == currentOrgID}) + } + } + + if jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput { + if orgs == nil { + orgs = []orgListEntry{} + } + out, err := json.MarshalIndent(orgs, "", " ") + if err != nil { + util.HandleError(err, "Unable to encode JSON") + } + fmt.Fprintln(cmd.OutOrStdout(), string(out)) + Telemetry.CaptureEvent("cli-command:org list", posthog.NewProperties().Set("version", util.CLI_VERSION)) + return + } + + writer := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0) + fmt.Fprintln(writer, "CURRENT\tNAME\tSLUG\tID") + + marker := func(current bool) string { + if current { + return "*" + } + return "" + } + + for _, org := range orgs { + fmt.Fprintf(writer, "%s\t%s\t%s\t%s\n", marker(org.Current), util.SanitizeDisplay(org.Name), util.SanitizeDisplay(org.Slug), org.ID) + for _, sub := range org.SubOrganizations { + fmt.Fprintf(writer, "%s\t └─ %s\t%s\t%s\n", marker(sub.Current), util.SanitizeDisplay(sub.Name), util.SanitizeDisplay(sub.Slug), sub.ID) } } writer.Flush() @@ -290,6 +322,7 @@ func selectOrganizationToken(sessionToken string, email string, orgID string) (s } func init() { + orgListCmd.Flags().Bool("json", false, "Output the organizations as JSON (id, name, slug, current, and subOrganizations per entry)") orgCmd.AddCommand(orgListCmd) orgCmd.AddCommand(newSetOrgCommand("switch [org]", "org switch", "Change the organization this profile uses (same as [infisical profile set-org])")) RootCmd.AddCommand(orgCmd) diff --git a/packages/cmd/projects.go b/packages/cmd/projects.go new file mode 100644 index 000000000..816549c40 --- /dev/null +++ b/packages/cmd/projects.go @@ -0,0 +1,168 @@ +/* +Copyright (c) 2026 Infisical Inc. +*/ +package cmd + +import ( + "encoding/json" + "fmt" + + "github.com/Infisical/infisical-merge/packages/api" + "github.com/Infisical/infisical-merge/packages/util" + "github.com/go-resty/resty/v2" + "github.com/posthog/posthog-go" + "github.com/spf13/cobra" +) + +// projectsCmd groups CLI verbs for managing Infisical projects (workspaces). +// The subcommands emit machine-readable JSON when --json is set so that +// scripts and AI-agent bootstrap prompts can chain them without parsing +// human-oriented output. +var projectsCmd = &cobra.Command{ + Use: "projects", + Short: "Manage Infisical projects (list and create)", + DisableFlagsInUseLine: true, + Example: "infisical projects list --json\n infisical projects create --name my-app --json", + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + _ = cmd.Help() + }, +} + +var projectsListCmd = &cobra.Command{ + Use: "list", + Short: "List projects the current user belongs to in the currently selected organization", + DisableFlagsInUseLine: true, + Example: "infisical projects list\n infisical projects list --json", + Args: cobra.NoArgs, + PreRun: func(cmd *cobra.Command, args []string) { + util.RequireLogin() + }, + Run: runProjectsList, +} + +var projectsCreateCmd = &cobra.Command{ + Use: "create", + Short: "Create a new project in the currently selected organization", + DisableFlagsInUseLine: true, + Example: "infisical projects create --name my-app\n infisical projects create --name my-app --description \"backend service\" --json", + Args: cobra.NoArgs, + PreRun: func(cmd *cobra.Command, args []string) { + util.RequireLogin() + }, + Run: runProjectsCreate, +} + +func projectsAuthedClient() (*resty.Client, error) { + userCreds := requireUserSession() + + httpClient, err := util.GetRestyClientWithCustomHeaders() + if err != nil { + return nil, err + } + httpClient.SetAuthToken(userCreds.UserCredentials.JTWToken) + return httpClient, nil +} + +type projectListEntry struct { + ID string `json:"id"` + Name string `json:"name"` + OrgID string `json:"orgId"` +} + +func runProjectsList(cmd *cobra.Command, args []string) { + jsonOut, _ := cmd.Flags().GetBool("json") + + httpClient, err := projectsAuthedClient() + if err != nil { + util.HandleError(err, "Unable to build authenticated HTTP client") + } + + resp, err := api.CallGetAllWorkSpacesUserBelongsTo(httpClient) + if err != nil { + util.HandleError(err, "Unable to list projects") + } + + if jsonOut { + // Workspace's JSON tags mirror the API (_id, __v); emit the same field + // names as `projects create --json` so agents can chain either command. + projects := make([]projectListEntry, 0, len(resp.Workspaces)) + for _, w := range resp.Workspaces { + projects = append(projects, projectListEntry{ID: w.ID, Name: w.Name, OrgID: w.OrganizationId}) + } + out, err := json.MarshalIndent(projects, "", " ") + if err != nil { + util.HandleError(err, "Unable to encode JSON") + } + util.PrintlnStdout(string(out)) + return + } + + if len(resp.Workspaces) == 0 { + util.PrintlnStdout("No projects found for the currently selected organization.") + return + } + util.PrintlnStdout("ID\tNAME\tORG ID") + for _, w := range resp.Workspaces { + util.PrintfStdout("%s\t%s\t%s\n", w.ID, w.Name, w.OrganizationId) + } + Telemetry.CaptureEvent("cli-command:projects list", posthog.NewProperties().Set("version", util.CLI_VERSION)) +} + +func runProjectsCreate(cmd *cobra.Command, args []string) { + name, _ := cmd.Flags().GetString("name") + description, _ := cmd.Flags().GetString("description") + slug, _ := cmd.Flags().GetString("slug") + jsonOut, _ := cmd.Flags().GetBool("json") + + if name == "" { + util.PrintErrorMessageAndExit("--name is required") + } + + httpClient, err := projectsAuthedClient() + if err != nil { + util.HandleError(err, "Unable to build authenticated HTTP client") + } + + project, err := api.CallCreateProject(httpClient, api.CreateProjectRequest{ + ProjectName: name, + ProjectDescription: description, + Slug: slug, + ShouldCreateDefaultEnvs: true, + }) + if err != nil { + util.HandleError(err, "Unable to create project") + } + + if jsonOut { + out, err := json.MarshalIndent(project, "", " ") + if err != nil { + util.HandleError(err, "Unable to encode JSON") + } + util.PrintlnStdout(string(out)) + } else { + util.PrintSuccessMessage(fmt.Sprintf("Created project %q (id: %s)", project.Name, project.ID)) + if len(project.Environments) > 0 { + util.PrintlnStdout("Environments:") + for _, e := range project.Environments { + util.PrintfStdout(" - %s (%s)\n", e.Name, e.Slug) + } + } + util.PrintlnStdout("\nRun `infisical init --project-id " + project.ID + "` to link this directory.") + } + + Telemetry.CaptureEvent("cli-command:projects create", posthog.NewProperties().Set("version", util.CLI_VERSION)) +} + +func init() { + projectsListCmd.Flags().Bool("json", false, "Output the project list as JSON (id, name, orgId per entry). Useful for scripting and AI-agent bootstrap flows.") + + projectsCreateCmd.Flags().String("name", "", "Name of the project to create (required, 1-64 characters).") + projectsCreateCmd.Flags().String("description", "", "Optional description of the project (up to 1024 characters).") + projectsCreateCmd.Flags().String("slug", "", "Optional slug for the project (5-64 characters; auto-generated from the name when omitted).") + projectsCreateCmd.Flags().Bool("json", false, "Output the created project as JSON. Useful for scripting and AI-agent bootstrap flows.") + + projectsCmd.AddCommand(projectsListCmd) + projectsCmd.AddCommand(projectsCreateCmd) + RootCmd.AddCommand(projectsCmd) +} From 330095c2ac950b8ece8fe50b31e0a1e0b389cb48 Mon Sep 17 00:00:00 2001 From: Adrian Kahali Date: Thu, 24 Sep 2026 19:18:17 -0400 Subject: [PATCH 03/13] address review comments --- packages/cmd/import.go | 212 ++++++++++++++++++++++++++++++--------- packages/cmd/init.go | 76 ++++++++------ packages/cmd/projects.go | 40 +++++--- 3 files changed, 234 insertions(+), 94 deletions(-) diff --git a/packages/cmd/import.go b/packages/cmd/import.go index 6b0f0be98..337be69e2 100644 --- a/packages/cmd/import.go +++ b/packages/cmd/import.go @@ -8,9 +8,11 @@ import ( "encoding/json" "fmt" "os" + "path" "path/filepath" "sort" "strings" + "unicode" "github.com/Infisical/infisical-merge/packages/models" "github.com/Infisical/infisical-merge/packages/util" @@ -19,17 +21,21 @@ import ( "github.com/spf13/cobra" ) -// Well-known env file names an AI-agent bootstrap should surface. Nothing in -// this list is auto-mapped to a specific environment: the caller picks one env -// slug (via --env or .infisical.json) and every discovered file goes there. -// Splitting files across environments requires running `import` per file, -// which keeps the mental model of the command simple. -var envFileCandidates = []string{ - ".env", - ".env.local", - ".env.development", - ".env.staging", - ".env.production", +// Well-known env file names an AI-agent bootstrap should surface. Files +// without a stage suffix go to whichever env slug the caller picks (via --env +// or .infisical.json). A stage-specific file is only imported when its stage +// matches that env, so a default of "dev" never swallows .env.production. +// Importing another stage means running `import --env=`, which keeps +// the mental model of the command simple. +var envFileCandidates = []struct { + Name string + Stages []string // env slugs this file belongs to; empty means any +}{ + {Name: ".env"}, + {Name: ".env.local"}, + {Name: ".env.development", Stages: []string{"dev", "development"}}, + {Name: ".env.staging", Stages: []string{"staging", "stage"}}, + {Name: ".env.production", Stages: []string{"prod", "production"}}, } var importCmd = &cobra.Command{ @@ -52,56 +58,81 @@ type importedFileResult struct { Error string `json:"error,omitempty"` } +type skippedFile struct { + File string `json:"file"` + Reason string `json:"reason"` +} + type importResult struct { Files []importedFileResult `json:"files"` + Skipped []skippedFile `json:"skipped,omitempty"` GitignoreUpdated []string `json:"gitignoreUpdated,omitempty"` GitignoreMissing []string `json:"gitignoreMissing,omitempty"` + GitignoreError string `json:"gitignoreError,omitempty"` Cancelled bool `json:"cancelled,omitempty"` } func runImport(cmd *cobra.Command, args []string) { - path, _ := cmd.Flags().GetString("path") + dir, _ := cmd.Flags().GetString("path") envSlug, _ := cmd.Flags().GetString("env") yes, _ := cmd.Flags().GetBool("yes") jsonOut, _ := cmd.Flags().GetBool("json") addGitignore, _ := cmd.Flags().GetBool("add-gitignore") - if path == "" { - path = "." + if dir == "" { + dir = "." } - // --env wins, else the env from .infisical.json, else "dev". + // Resolve the project from the scanned directory, not the working + // directory: in a monorepo --path may point at an app linked to a + // different project than the root. + workspaceFile, err := findWorkspaceFileFrom(dir) + if err != nil { + util.PrintErrorMessageAndExit(fmt.Sprintf("Cannot resolve project for %q. Run `infisical init` (or `infisical init --project-id `) in that directory first.", dir)) + } + projectId := workspaceFile.WorkspaceId + + // --env wins, else the env from that .infisical.json, else "dev". if envSlug == "" { - if envFromWorkspace := util.GetEnvFromWorkspaceFile(); envFromWorkspace != "" { - envSlug = envFromWorkspace + if env := util.GetEnvelopmentBasedOnGitBranch(workspaceFile); env != "" { + envSlug = env + } else if workspaceFile.DefaultEnvironment != "" { + envSlug = workspaceFile.DefaultEnvironment } else { envSlug = "dev" } } - workspaceFile, err := util.GetWorkSpaceFromFile() - if err != nil { - util.PrintErrorMessageAndExit("Cannot resolve project. Run `infisical init` (or `infisical init --project-id `) in this directory first.") - } - projectId := workspaceFile.WorkspaceId + result := importResult{} // Explicit list rather than a dotfile scan: a wide scan would pick up // .envrc and other files that people don't want their vault to swallow. var found []string - for _, name := range envFileCandidates { - p := filepath.Join(path, name) + var candidateNames []string + for _, c := range envFileCandidates { + candidateNames = append(candidateNames, c.Name) + p := filepath.Join(dir, c.Name) info, err := os.Stat(p) - if err != nil { + if err != nil || info.IsDir() { continue } - if info.IsDir() { + if len(c.Stages) > 0 && !containsFold(c.Stages, envSlug) { + reason := fmt.Sprintf("belongs to %s, not %q; run with --env=%s to import it", c.Stages[0], envSlug, c.Stages[0]) + result.Skipped = append(result.Skipped, skippedFile{File: p, Reason: reason}) + if !jsonOut { + util.PrintfStderr("Skipping %s: %s\n", p, reason) + } continue } found = append(found, p) } if len(found) == 0 { - util.PrintErrorMessageAndExit(fmt.Sprintf("No .env-style files found under %q. Looked for: %s", path, strings.Join(envFileCandidates, ", "))) + if jsonOut && len(result.Skipped) > 0 { + emitImportJSON(result) + os.Exit(1) + } + util.PrintErrorMessageAndExit(fmt.Sprintf("No .env-style files to import into %q under %q. Looked for: %s", envSlug, dir, strings.Join(candidateNames, ", "))) } type fileScan struct { @@ -109,19 +140,37 @@ func runImport(cmd *cobra.Command, args []string) { Keys []string } var scans []fileScan + invalid := false for _, f := range found { keys, err := extractEnvKeyNames(f) if err != nil { - util.PrintfStderr("Skipping %s: %v\n", f, err) + // Reject the whole import before anything is uploaded: the upload + // parser would exit partway through otherwise. + invalid = true + result.Files = append(result.Files, importedFileResult{File: f, Env: envSlug, Error: err.Error()}) + if !jsonOut { + util.PrintfStderr("Cannot import %s: %v\n", f, err) + } continue } if len(keys) == 0 { - util.PrintfStderr("Skipping %s (no keys found)\n", f) + result.Skipped = append(result.Skipped, skippedFile{File: f, Reason: "no keys found"}) + if !jsonOut { + util.PrintfStderr("Skipping %s (no keys found)\n", f) + } continue } scans = append(scans, fileScan{Path: f, Keys: keys}) } + if invalid { + if jsonOut { + emitImportJSON(result) + os.Exit(1) + } + util.PrintErrorMessageAndExit("Fix the files above and re-run. Nothing was uploaded.") + } + if len(scans) == 0 { util.PrintErrorMessageAndExit("Nothing to import.") } @@ -136,7 +185,6 @@ func runImport(cmd *cobra.Command, args []string) { } } - result := importResult{} if !yes { prompt := promptui.Prompt{ Label: "Proceed with import", @@ -156,10 +204,12 @@ func runImport(cmd *cobra.Command, args []string) { userCreds := requireUserSession() tokenDetails := &models.TokenDetails{Type: "", Token: userCreds.UserCredentials.JTWToken} + uploadFailed := false for _, s := range scans { r := importedFileResult{File: s.Path, Env: envSlug, Keys: s.Keys} ops, err := util.SetRawSecrets(nil, util.SECRET_TYPE_SHARED, envSlug, "/", projectId, tokenDetails, s.Path, nil) if err != nil { + uploadFailed = true r.Error = err.Error() result.Files = append(result.Files, r) if !jsonOut { @@ -174,11 +224,11 @@ func runImport(cmd *cobra.Command, args []string) { } } - gitignorePath := filepath.Join(path, ".gitignore") + gitignorePath := filepath.Join(dir, ".gitignore") existingLines := readGitignoreLines(gitignorePath) var missing []string for _, s := range scans { - rel, err := filepath.Rel(path, s.Path) + rel, err := filepath.Rel(dir, s.Path) if err != nil { rel = filepath.Base(s.Path) } @@ -189,7 +239,11 @@ func runImport(cmd *cobra.Command, args []string) { if len(missing) > 0 { if addGitignore { if err := appendToGitignore(gitignorePath, missing); err != nil { - util.PrintfStderr("Warning: failed to update %s: %v\n", gitignorePath, err) + result.GitignoreMissing = missing + result.GitignoreError = err.Error() + if !jsonOut { + util.PrintfStderr("Warning: failed to update %s: %v. These files are still not ignored: %s\n", gitignorePath, err, strings.Join(missing, ", ")) + } } else { result.GitignoreUpdated = missing if !jsonOut { @@ -213,6 +267,10 @@ func runImport(cmd *cobra.Command, args []string) { Telemetry.CaptureEvent("cli-command:import", posthog.NewProperties(). Set("version", util.CLI_VERSION). Set("filesScanned", len(scans))) + + if uploadFailed { + os.Exit(1) + } } func emitImportJSON(r importResult) { @@ -223,9 +281,39 @@ func emitImportJSON(r importResult) { util.PrintlnStdout(string(out)) } -// extractEnvKeyNames returns key names only (never values) using the same -// comment and key=value rules as util.parseSecrets so the preview and the -// eventual upload agree on what constitutes a line. +// findWorkspaceFileFrom looks for .infisical.json in dir and its parents, +// mirroring util.FindWorkspaceConfigFile but anchored at dir instead of the +// working directory. +func findWorkspaceFileFrom(dir string) (models.WorkspaceConfigFile, error) { + current, err := filepath.Abs(dir) + if err != nil { + return models.WorkspaceConfigFile{}, err + } + for { + if _, err := os.Stat(filepath.Join(current, util.INFISICAL_WORKSPACE_CONFIG_FILE_NAME)); err == nil { + return util.GetWorkSpaceFromFilePath(current) + } + parent := filepath.Dir(current) + if parent == current { + return models.WorkspaceConfigFile{}, fmt.Errorf("file not found: %s", util.INFISICAL_WORKSPACE_CONFIG_FILE_NAME) + } + current = parent + } +} + +func containsFold(values []string, target string) bool { + for _, v := range values { + if strings.EqualFold(v, target) { + return true + } + } + return false +} + +// extractEnvKeyNames returns key names only (never values) and rejects the +// file under the same rules util.SetRawSecrets applies, so the preview and the +// upload agree on what is importable. Errors cite line numbers and key names +// but never line contents, since a malformed line may hold a secret. func extractEnvKeyNames(file string) ([]string, error) { f, err := os.Open(file) if err != nil { @@ -239,18 +327,32 @@ func extractEnvKeyNames(file string) ([]string, error) { // PEM-style values run long; give the scanner room so a bad line // doesn't cap the preview well below what SetRawSecrets will accept. scanner.Buffer(make([]byte, 0, 64*1024), 10*1024*1024) + lineNo := 0 for scanner.Scan() { + lineNo++ line := strings.TrimSpace(scanner.Text()) if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, "//") { continue } parts := strings.SplitN(line, "=", 2) if len(parts) != 2 { - continue + return nil, fmt.Errorf("line %d: expected KEY=VALUE", lineNo) } - key := strings.TrimSpace(parts[0]) + key, value := strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1]) if key == "" { - continue + return nil, fmt.Errorf("line %d: key is empty", lineNo) + } + if unicode.IsNumber(rune(key[0])) { + return nil, fmt.Errorf("line %d: key %q cannot start with a number", lineNo, key) + } + if strings.Contains(key, " ") { + return nil, fmt.Errorf("line %d: key %q cannot contain spaces", lineNo, key) + } + if len(value) >= 2 && ((value[0] == '"' && value[len(value)-1] == '"') || (value[0] == '\'' && value[len(value)-1] == '\'')) { + value = value[1 : len(value)-1] + } + if strings.TrimSpace(value) == "" { + return nil, fmt.Errorf("line %d: key %q has an empty value", lineNo, key) } if _, dup := seen[key]; dup { continue @@ -280,23 +382,37 @@ func readGitignoreLines(path string) []string { return lines } -// gitignoreCovers is deliberately conservative: it matches only exact entries -// and the common .env glob patterns. The narrow question this answers is -// "will git commit this file by accident?" — the answer is worth surfacing -// even without a full gitignore-pattern matcher. +// gitignoreCovers answers the narrow question "will git commit this file by +// accident?" for a file next to the .gitignore. It handles globs, anchored +// and "**/" patterns, and negation (last match wins), and skips +// directory-only patterns. It is not a full gitignore implementation, so when +// in doubt it reports the file as uncovered. func gitignoreCovers(lines []string, rel string) bool { + rel = filepath.ToSlash(rel) + base := path.Base(rel) + covered := false for _, l := range lines { if l == "" || strings.HasPrefix(l, "#") { continue } - if l == rel || l == "/"+rel { - return true + negate := strings.HasPrefix(l, "!") + pattern := strings.TrimPrefix(l, "!") + if strings.HasSuffix(pattern, "/") { + continue } - if l == ".env*" || l == "*.env" || l == "**/.env*" { - return true + pattern = strings.TrimPrefix(pattern, "**/") + + var matched bool + if strings.Contains(pattern, "/") { + matched, _ = path.Match(strings.TrimPrefix(pattern, "/"), rel) + } else { + matched, _ = path.Match(pattern, base) + } + if matched { + covered = !negate } } - return false + return covered } func appendToGitignore(path string, entries []string) error { diff --git a/packages/cmd/init.go b/packages/cmd/init.go index 9ed6c5005..c016af140 100644 --- a/packages/cmd/init.go +++ b/packages/cmd/init.go @@ -53,16 +53,10 @@ var initCmd = &cobra.Command{ } } - // Non-interactive path: we already know the project. Skip the org - // and workspace pickers and just write .infisical.json. Subsequent - // commands (secrets, run) will surface auth or org-scope errors if - // the logged-in session cannot reach this project. - if projectID != "" { - if err := writeWorkspaceFile(models.Workspace{ID: projectID}); err != nil { - util.HandleError(err) - } - Telemetry.CaptureEvent("cli-command:init", posthog.NewProperties().Set("version", util.CLI_VERSION).Set("nonInteractive", true)) - return + // Without a terminal the org and project pickers below cannot run, so + // fail with the flag that makes this command non-interactive instead. + if projectID == "" && !isatty.IsTerminal(os.Stdin.Fd()) { + util.PrintErrorMessageAndExit("No terminal available to pick a project. Pass --project-id (see `infisical projects list`).") } userCreds, err := util.GetCurrentLoggedInUserDetails(true) @@ -74,6 +68,21 @@ var initCmd = &cobra.Command{ userCreds = util.EstablishUserLoginSession() } + // Non-interactive path: we already know the project. Skip the org + // and workspace pickers and just write .infisical.json. Subsequent + // commands (secrets, run) will surface auth errors if the logged-in + // session cannot reach this project. + if projectID != "" { + if userCreds.OrganizationID != "" { + rejectTransientOrgOverride(userCreds) + } + if err := writeWorkspaceFile(models.Workspace{ID: projectID}); err != nil { + util.HandleError(err) + } + Telemetry.CaptureEvent("cli-command:init", posthog.NewProperties().Set("version", util.CLI_VERSION).Set("nonInteractive", true)) + return + } + httpClient, err := util.GetRestyClientWithCustomHeaders() if err != nil { util.HandleError(err, "Unable to get resty client with custom headers") @@ -134,26 +143,7 @@ var initCmd = &cobra.Command{ util.HandleError(err, "Unable to store your user credentials") } } else { - orgDisplay := userCreds.OrganizationName - if orgDisplay == "" { - orgDisplay = selectedOrgID - } - - // An --org override is per command, so a project linked under it - // would not resolve on later runs that use the profile's default. - // That would surface later as an unrelated-looking "project not - // found", and a warning here can be silenced, so refuse and point - // at the two ways to make the organization stick. - if userCreds.OrganizationSource != util.OrgSourceProfileDefault && userCreds.Profile.OrganizationID != "" && userCreds.OrganizationID != userCreds.Profile.ScopedOrganizationID() { - profileOrg := userCreds.Profile.OrganizationName - if profileOrg == "" { - profileOrg = userCreds.Profile.ScopedOrganizationID() - } - util.PrintErrorMessageAndExit( - fmt.Sprintf("Profile '%s' defaults to organization %s, so a project linked here under %s (selected via %s) would not be found by later commands unless they also pass --org.", userCreds.ProfileName, profileOrg, orgDisplay, userCreds.OrganizationSource), - fmt.Sprintf("Make %s the profile's default with [infisical profile set-org %s], or keep both organizations by running [infisical profile create --org %s] and then [infisical profile bind ] in this directory.", orgDisplay, orgDisplay, orgDisplay)) - } - + orgDisplay := rejectTransientOrgOverride(userCreds) util.PrintlnStderr(fmt.Sprintf("Using organization %s from profile '%s'. Pass --org to pick a different one.", orgDisplay, userCreds.ProfileName)) } @@ -187,6 +177,32 @@ var initCmd = &cobra.Command{ }, } +// rejectTransientOrgOverride exits when the session's organization comes from +// a per-command --org override that differs from the profile's default, and +// otherwise returns the organization's display name. +// +// An --org override is per command, so a project linked under it would not +// resolve on later runs that use the profile's default. That would surface +// later as an unrelated-looking "project not found", and a warning here can be +// silenced, so refuse and point at the two ways to make the organization stick. +func rejectTransientOrgOverride(userCreds util.LoggedInUserDetails) string { + orgDisplay := userCreds.OrganizationName + if orgDisplay == "" { + orgDisplay = userCreds.OrganizationID + } + + if userCreds.OrganizationSource != util.OrgSourceProfileDefault && userCreds.Profile.OrganizationID != "" && userCreds.OrganizationID != userCreds.Profile.ScopedOrganizationID() { + profileOrg := userCreds.Profile.OrganizationName + if profileOrg == "" { + profileOrg = userCreds.Profile.ScopedOrganizationID() + } + util.PrintErrorMessageAndExit( + fmt.Sprintf("Profile '%s' defaults to organization %s, so a project linked here under %s (selected via %s) would not be found by later commands unless they also pass --org.", userCreds.ProfileName, profileOrg, orgDisplay, userCreds.OrganizationSource), + fmt.Sprintf("Make %s the profile's default with [infisical profile set-org %s], or keep both organizations by running [infisical profile create --org %s] and then [infisical profile bind ] in this directory.", orgDisplay, orgDisplay, orgDisplay)) + } + return orgDisplay +} + // offerDirectoryProfileBinding asks (only when multiple profiles exist) // whether this directory should always use the profile init just ran with, so // commands run here pick the right tenant without flags or env vars. diff --git a/packages/cmd/projects.go b/packages/cmd/projects.go index 816549c40..7aa2353b2 100644 --- a/packages/cmd/projects.go +++ b/packages/cmd/projects.go @@ -53,15 +53,15 @@ var projectsCreateCmd = &cobra.Command{ Run: runProjectsCreate, } -func projectsAuthedClient() (*resty.Client, error) { +func projectsAuthedClient() (*resty.Client, util.LoggedInUserDetails, error) { userCreds := requireUserSession() httpClient, err := util.GetRestyClientWithCustomHeaders() if err != nil { - return nil, err + return nil, userCreds, err } httpClient.SetAuthToken(userCreds.UserCredentials.JTWToken) - return httpClient, nil + return httpClient, userCreds, nil } type projectListEntry struct { @@ -73,7 +73,7 @@ type projectListEntry struct { func runProjectsList(cmd *cobra.Command, args []string) { jsonOut, _ := cmd.Flags().GetBool("json") - httpClient, err := projectsAuthedClient() + httpClient, userCreds, err := projectsAuthedClient() if err != nil { util.HandleError(err, "Unable to build authenticated HTTP client") } @@ -83,13 +83,20 @@ func runProjectsList(cmd *cobra.Command, args []string) { util.HandleError(err, "Unable to list projects") } - if jsonOut { - // Workspace's JSON tags mirror the API (_id, __v); emit the same field - // names as `projects create --json` so agents can chain either command. - projects := make([]projectListEntry, 0, len(resp.Workspaces)) - for _, w := range resp.Workspaces { + // The endpoint returns projects across every organization the user + // belongs to; keep only the session's organization, as `init` does, so a + // script never picks a project it cannot link here. + // + // Workspace's JSON tags mirror the API (_id, __v); emit the same field + // names as `projects create --json` so agents can chain either command. + projects := make([]projectListEntry, 0, len(resp.Workspaces)) + for _, w := range resp.Workspaces { + if userCreds.OrganizationID == "" || w.OrganizationId == userCreds.OrganizationID { projects = append(projects, projectListEntry{ID: w.ID, Name: w.Name, OrgID: w.OrganizationId}) } + } + + if jsonOut { out, err := json.MarshalIndent(projects, "", " ") if err != nil { util.HandleError(err, "Unable to encode JSON") @@ -98,13 +105,13 @@ func runProjectsList(cmd *cobra.Command, args []string) { return } - if len(resp.Workspaces) == 0 { + if len(projects) == 0 { util.PrintlnStdout("No projects found for the currently selected organization.") return } util.PrintlnStdout("ID\tNAME\tORG ID") - for _, w := range resp.Workspaces { - util.PrintfStdout("%s\t%s\t%s\n", w.ID, w.Name, w.OrganizationId) + for _, p := range projects { + util.PrintfStdout("%s\t%s\t%s\n", util.SanitizeDisplay(p.ID), util.SanitizeDisplay(p.Name), util.SanitizeDisplay(p.OrgID)) } Telemetry.CaptureEvent("cli-command:projects list", posthog.NewProperties().Set("version", util.CLI_VERSION)) } @@ -119,7 +126,7 @@ func runProjectsCreate(cmd *cobra.Command, args []string) { util.PrintErrorMessageAndExit("--name is required") } - httpClient, err := projectsAuthedClient() + httpClient, _, err := projectsAuthedClient() if err != nil { util.HandleError(err, "Unable to build authenticated HTTP client") } @@ -141,14 +148,15 @@ func runProjectsCreate(cmd *cobra.Command, args []string) { } util.PrintlnStdout(string(out)) } else { - util.PrintSuccessMessage(fmt.Sprintf("Created project %q (id: %s)", project.Name, project.ID)) + projectID := util.SanitizeDisplay(project.ID) + util.PrintSuccessMessage(fmt.Sprintf("Created project %q (id: %s)", util.SanitizeDisplay(project.Name), projectID)) if len(project.Environments) > 0 { util.PrintlnStdout("Environments:") for _, e := range project.Environments { - util.PrintfStdout(" - %s (%s)\n", e.Name, e.Slug) + util.PrintfStdout(" - %s (%s)\n", util.SanitizeDisplay(e.Name), util.SanitizeDisplay(e.Slug)) } } - util.PrintlnStdout("\nRun `infisical init --project-id " + project.ID + "` to link this directory.") + util.PrintlnStdout("\nRun `infisical init --project-id " + projectID + "` to link this directory.") } Telemetry.CaptureEvent("cli-command:projects create", posthog.NewProperties().Set("version", util.CLI_VERSION)) From de5bab13bffdc163836641802dbe98ec5efbad2d Mon Sep 17 00:00:00 2001 From: Adrian Kahali Date: Thu, 24 Sep 2026 19:31:54 -0400 Subject: [PATCH 04/13] address greptile again --- packages/cmd/import.go | 69 +++++++++++++++++++++++++++++----------- packages/util/helper.go | 7 ++++ packages/util/secrets.go | 9 +++++- 3 files changed, 66 insertions(+), 19 deletions(-) diff --git a/packages/cmd/import.go b/packages/cmd/import.go index 337be69e2..f602555f5 100644 --- a/packages/cmd/import.go +++ b/packages/cmd/import.go @@ -25,8 +25,9 @@ import ( // without a stage suffix go to whichever env slug the caller picks (via --env // or .infisical.json). A stage-specific file is only imported when its stage // matches that env, so a default of "dev" never swallows .env.production. -// Importing another stage means running `import --env=`, which keeps -// the mental model of the command simple. +// Stage matching only knows the conventional slugs; for a project whose +// production env is called something else (say "live"), --file names the +// files explicitly and --env the target, with no guessing. var envFileCandidates = []struct { Name string Stages []string // env slugs this file belongs to; empty means any @@ -42,7 +43,7 @@ var importCmd = &cobra.Command{ Use: "import", Short: "Import secrets from local .env files into an Infisical environment (never deletes the source)", DisableFlagsInUseLine: true, - Example: "infisical import\n infisical import --yes\n infisical import --path=./api --env=staging --add-gitignore", + Example: "infisical import\n infisical import --yes\n infisical import --path=./api --env=staging --add-gitignore\n infisical import --file=.env.production --env=live", Args: cobra.NoArgs, PreRun: func(cmd *cobra.Command, args []string) { util.RequireLogin() @@ -78,6 +79,13 @@ func runImport(cmd *cobra.Command, args []string) { yes, _ := cmd.Flags().GetBool("yes") jsonOut, _ := cmd.Flags().GetBool("json") addGitignore, _ := cmd.Flags().GetBool("add-gitignore") + explicitFiles, _ := cmd.Flags().GetStringSlice("file") + + // Named files skip stage matching, so the target env must be named too; + // otherwise a default of "dev" could receive .env.production after all. + if len(explicitFiles) > 0 && envSlug == "" { + util.PrintErrorMessageAndExit("--file requires --env, so the files go to an environment you chose explicitly.") + } if dir == "" { dir = "." @@ -92,9 +100,11 @@ func runImport(cmd *cobra.Command, args []string) { } projectId := workspaceFile.WorkspaceId - // --env wins, else the env from that .infisical.json, else "dev". + // --env wins, else the env from that .infisical.json, else "dev". The + // branch mapping is read from the repository --path lives in, which may + // not be the one the command runs from. if envSlug == "" { - if env := util.GetEnvelopmentBasedOnGitBranch(workspaceFile); env != "" { + if env := util.GetEnvironmentBasedOnGitBranchIn(workspaceFile, dir); env != "" { envSlug = env } else if workspaceFile.DefaultEnvironment != "" { envSlug = workspaceFile.DefaultEnvironment @@ -109,22 +119,44 @@ func runImport(cmd *cobra.Command, args []string) { // .envrc and other files that people don't want their vault to swallow. var found []string var candidateNames []string - for _, c := range envFileCandidates { - candidateNames = append(candidateNames, c.Name) - p := filepath.Join(dir, c.Name) - info, err := os.Stat(p) - if err != nil || info.IsDir() { - continue + if len(explicitFiles) > 0 { + for _, name := range explicitFiles { + p := name + if !filepath.IsAbs(p) { + p = filepath.Join(dir, name) + } + // Keep files under --path so the .gitignore check and entries + // written there refer to the right file. + if rel, err := filepath.Rel(dir, p); err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + util.PrintErrorMessageAndExit(fmt.Sprintf("--file %q is outside --path %q.", name, dir)) + } + info, err := os.Stat(p) + if err != nil || info.IsDir() { + util.PrintErrorMessageAndExit(fmt.Sprintf("--file %q is not a readable file.", name)) + } + candidateNames = append(candidateNames, name) + found = append(found, p) } - if len(c.Stages) > 0 && !containsFold(c.Stages, envSlug) { - reason := fmt.Sprintf("belongs to %s, not %q; run with --env=%s to import it", c.Stages[0], envSlug, c.Stages[0]) - result.Skipped = append(result.Skipped, skippedFile{File: p, Reason: reason}) - if !jsonOut { - util.PrintfStderr("Skipping %s: %s\n", p, reason) + } else { + for _, c := range envFileCandidates { + candidateNames = append(candidateNames, c.Name) + p := filepath.Join(dir, c.Name) + info, err := os.Stat(p) + if err != nil || info.IsDir() { + continue } - continue + if len(c.Stages) > 0 && !containsFold(c.Stages, envSlug) { + // Don't suggest a slug: the project's env for this stage may + // not be called any of the conventional names. + reason := fmt.Sprintf("looks like a %s file but the target env is %q; to import it, re-run with --file=%s --env=", c.Stages[0], envSlug, c.Name) + result.Skipped = append(result.Skipped, skippedFile{File: p, Reason: reason}) + if !jsonOut { + util.PrintfStderr("Skipping %s: %s\n", p, reason) + } + continue + } + found = append(found, p) } - found = append(found, p) } if len(found) == 0 { @@ -438,6 +470,7 @@ func init() { importCmd.Flags().String("env", "", "Environment slug to upload into (default: env from .infisical.json, else \"dev\").") importCmd.Flags().BoolP("yes", "y", false, "Skip the confirmation prompt. Required for non-interactive / agent runs.") importCmd.Flags().Bool("json", false, "Emit a machine-readable summary of the import (files scanned, keys per file, upload counts, gitignore status).") + importCmd.Flags().StringSlice("file", nil, "Import exactly these files (relative to --path) instead of scanning for well-known names. Skips stage matching, so --env is required. Repeatable.") importCmd.Flags().Bool("add-gitignore", false, "Append every imported file that is not already in .gitignore to .gitignore.") RootCmd.AddCommand(importCmd) } diff --git a/packages/util/helper.go b/packages/util/helper.go index 3e518939c..ee9ce6a07 100644 --- a/packages/util/helper.go +++ b/packages/util/helper.go @@ -447,7 +447,14 @@ var getCurrentBranchCmd = execCmd{ } func getCurrentBranch() (string, error) { + return getCurrentBranchIn("") +} + +// getCurrentBranchIn reads the branch of the repository containing dir, or of +// the working directory when dir is empty. +func getCurrentBranchIn(dir string) (string, error) { cmd := exec.Command(getCurrentBranchCmd.cmd, getCurrentBranchCmd.args...) + cmd.Dir = dir var out bytes.Buffer cmd.Stdout = &out err := cmd.Run() diff --git a/packages/util/secrets.go b/packages/util/secrets.go index c1d458ee6..8486e5498 100644 --- a/packages/util/secrets.go +++ b/packages/util/secrets.go @@ -512,7 +512,14 @@ func GetSecretPathFromWorkspaceFile() string { } func GetEnvelopmentBasedOnGitBranch(workspaceFile models.WorkspaceConfigFile) string { - branch, err := getCurrentBranch() + return GetEnvironmentBasedOnGitBranchIn(workspaceFile, "") +} + +// GetEnvironmentBasedOnGitBranchIn is GetEnvelopmentBasedOnGitBranch for the +// repository containing dir rather than the working directory, for commands +// that read a workspace file from somewhere other than where they run. +func GetEnvironmentBasedOnGitBranchIn(workspaceFile models.WorkspaceConfigFile, dir string) string { + branch, err := getCurrentBranchIn(dir) if err != nil { log.Debug().Msgf("getEnvelopmentBasedOnGitBranch: [err=%s]", err) } From f05720763d54e91db0ddb151a2ec09b5daa257ab Mon Sep 17 00:00:00 2001 From: Adrian Kahali Date: Thu, 24 Sep 2026 19:41:23 -0400 Subject: [PATCH 05/13] address even more comments --- packages/cmd/import.go | 34 ++++++++++++++++++++++++++++------ 1 file changed, 28 insertions(+), 6 deletions(-) diff --git a/packages/cmd/import.go b/packages/cmd/import.go index f602555f5..b1b621721 100644 --- a/packages/cmd/import.go +++ b/packages/cmd/import.go @@ -120,20 +120,31 @@ func runImport(cmd *cobra.Command, args []string) { var found []string var candidateNames []string if len(explicitFiles) > 0 { + realDir, err := resolveRealPath(dir) + if err != nil { + util.PrintErrorMessageAndExit(fmt.Sprintf("Cannot resolve --path %q: %v", dir, err)) + } for _, name := range explicitFiles { p := name if !filepath.IsAbs(p) { p = filepath.Join(dir, name) } - // Keep files under --path so the .gitignore check and entries - // written there refer to the right file. - if rel, err := filepath.Rel(dir, p); err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { - util.PrintErrorMessageAndExit(fmt.Sprintf("--file %q is outside --path %q.", name, dir)) - } info, err := os.Stat(p) if err != nil || info.IsDir() { util.PrintErrorMessageAndExit(fmt.Sprintf("--file %q is not a readable file.", name)) } + // Keep files under --path so the .gitignore check and entries + // written there refer to the right file. Compare resolved paths: + // the upload follows symlinks, so a link inside --path must not + // reach a file outside it, and an absolute --file must compare + // cleanly against a relative --path. + realFile, err := resolveRealPath(p) + if err != nil { + util.PrintErrorMessageAndExit(fmt.Sprintf("Cannot resolve --file %q: %v", name, err)) + } + if rel, err := filepath.Rel(realDir, realFile); err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + util.PrintErrorMessageAndExit(fmt.Sprintf("--file %q is outside --path %q.", name, dir)) + } candidateNames = append(candidateNames, name) found = append(found, p) } @@ -259,8 +270,10 @@ func runImport(cmd *cobra.Command, args []string) { gitignorePath := filepath.Join(dir, ".gitignore") existingLines := readGitignoreLines(gitignorePath) var missing []string + absDir, _ := filepath.Abs(dir) for _, s := range scans { - rel, err := filepath.Rel(dir, s.Path) + absFile, _ := filepath.Abs(s.Path) + rel, err := filepath.Rel(absDir, absFile) if err != nil { rel = filepath.Base(s.Path) } @@ -333,6 +346,15 @@ func findWorkspaceFileFrom(dir string) (models.WorkspaceConfigFile, error) { } } +// resolveRealPath returns p as an absolute path with every symlink resolved. +func resolveRealPath(p string) (string, error) { + abs, err := filepath.Abs(p) + if err != nil { + return "", err + } + return filepath.EvalSymlinks(abs) +} + func containsFold(values []string, target string) bool { for _, v := range values { if strings.EqualFold(v, target) { From 6d3155db14455617cfc3d067ae15b283fa115c95 Mon Sep 17 00:00:00 2001 From: Adrian Kahali Date: Thu, 24 Sep 2026 19:47:57 -0400 Subject: [PATCH 06/13] once again --- packages/cmd/import.go | 36 +++++++++++++++++++++++++++++++----- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/packages/cmd/import.go b/packages/cmd/import.go index b1b621721..a03714f3a 100644 --- a/packages/cmd/import.go +++ b/packages/cmd/import.go @@ -142,11 +142,24 @@ func runImport(cmd *cobra.Command, args []string) { if err != nil { util.PrintErrorMessageAndExit(fmt.Sprintf("Cannot resolve --file %q: %v", name, err)) } - if rel, err := filepath.Rel(realDir, realFile); err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + if _, inside := relInside(realDir, realFile); !inside { + util.PrintErrorMessageAndExit(fmt.Sprintf("--file %q is outside --path %q.", name, dir)) + } + // The entry itself (a symlink, if it is one) must also sit under + // --path, and its location there is what .gitignore has to name. + // Resolve only its parent so the entry's own name is kept. + realParent, err := resolveRealPath(filepath.Dir(p)) + if err != nil { + util.PrintErrorMessageAndExit(fmt.Sprintf("Cannot resolve --file %q: %v", name, err)) + } + entryRel, inside := relInside(realDir, filepath.Join(realParent, filepath.Base(p))) + if !inside { util.PrintErrorMessageAndExit(fmt.Sprintf("--file %q is outside --path %q.", name, dir)) } candidateNames = append(candidateNames, name) - found = append(found, p) + // Rewrite as a path under --path so the gitignore step below + // derives the same relative entry the checks above did. + found = append(found, filepath.Join(dir, entryRel)) } } else { for _, c := range envFileCandidates { @@ -273,9 +286,12 @@ func runImport(cmd *cobra.Command, args []string) { absDir, _ := filepath.Abs(dir) for _, s := range scans { absFile, _ := filepath.Abs(s.Path) - rel, err := filepath.Rel(absDir, absFile) - if err != nil { - rel = filepath.Base(s.Path) + rel, inside := relInside(absDir, absFile) + if !inside { + // A "../" entry in this .gitignore would not ignore the file, so + // never write one or report the file as protected by it. + util.PrintfStderr("Warning: cannot check .gitignore coverage for %s: it is not under %s\n", s.Path, dir) + continue } if !gitignoreCovers(existingLines, rel) { missing = append(missing, rel) @@ -346,6 +362,16 @@ func findWorkspaceFileFrom(dir string) (models.WorkspaceConfigFile, error) { } } +// relInside returns target relative to base, and whether target is base +// itself or lies beneath it. +func relInside(base, target string) (string, bool) { + rel, err := filepath.Rel(base, target) + if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return "", false + } + return rel, true +} + // resolveRealPath returns p as an absolute path with every symlink resolved. func resolveRealPath(p string) (string, error) { abs, err := filepath.Abs(p) From 5340855cedfa3bcf90d7ad7a2617050408f5081b Mon Sep 17 00:00:00 2001 From: Adrian Kahali Date: Fri, 25 Sep 2026 16:13:12 -0400 Subject: [PATCH 07/13] remove unnecessary import command --- packages/cmd/import.go | 524 --------------------------------------- packages/util/helper.go | 7 - packages/util/secrets.go | 9 +- 3 files changed, 1 insertion(+), 539 deletions(-) delete mode 100644 packages/cmd/import.go diff --git a/packages/cmd/import.go b/packages/cmd/import.go deleted file mode 100644 index a03714f3a..000000000 --- a/packages/cmd/import.go +++ /dev/null @@ -1,524 +0,0 @@ -/* -Copyright (c) 2026 Infisical Inc. -*/ -package cmd - -import ( - "bufio" - "encoding/json" - "fmt" - "os" - "path" - "path/filepath" - "sort" - "strings" - "unicode" - - "github.com/Infisical/infisical-merge/packages/models" - "github.com/Infisical/infisical-merge/packages/util" - "github.com/manifoldco/promptui" - "github.com/posthog/posthog-go" - "github.com/spf13/cobra" -) - -// Well-known env file names an AI-agent bootstrap should surface. Files -// without a stage suffix go to whichever env slug the caller picks (via --env -// or .infisical.json). A stage-specific file is only imported when its stage -// matches that env, so a default of "dev" never swallows .env.production. -// Stage matching only knows the conventional slugs; for a project whose -// production env is called something else (say "live"), --file names the -// files explicitly and --env the target, with no guessing. -var envFileCandidates = []struct { - Name string - Stages []string // env slugs this file belongs to; empty means any -}{ - {Name: ".env"}, - {Name: ".env.local"}, - {Name: ".env.development", Stages: []string{"dev", "development"}}, - {Name: ".env.staging", Stages: []string{"staging", "stage"}}, - {Name: ".env.production", Stages: []string{"prod", "production"}}, -} - -var importCmd = &cobra.Command{ - Use: "import", - Short: "Import secrets from local .env files into an Infisical environment (never deletes the source)", - DisableFlagsInUseLine: true, - Example: "infisical import\n infisical import --yes\n infisical import --path=./api --env=staging --add-gitignore\n infisical import --file=.env.production --env=live", - Args: cobra.NoArgs, - PreRun: func(cmd *cobra.Command, args []string) { - util.RequireLogin() - }, - Run: runImport, -} - -type importedFileResult struct { - File string `json:"file"` - Env string `json:"env"` - Keys []string `json:"keys"` - Uploaded int `json:"uploaded"` - Error string `json:"error,omitempty"` -} - -type skippedFile struct { - File string `json:"file"` - Reason string `json:"reason"` -} - -type importResult struct { - Files []importedFileResult `json:"files"` - Skipped []skippedFile `json:"skipped,omitempty"` - GitignoreUpdated []string `json:"gitignoreUpdated,omitempty"` - GitignoreMissing []string `json:"gitignoreMissing,omitempty"` - GitignoreError string `json:"gitignoreError,omitempty"` - Cancelled bool `json:"cancelled,omitempty"` -} - -func runImport(cmd *cobra.Command, args []string) { - dir, _ := cmd.Flags().GetString("path") - envSlug, _ := cmd.Flags().GetString("env") - yes, _ := cmd.Flags().GetBool("yes") - jsonOut, _ := cmd.Flags().GetBool("json") - addGitignore, _ := cmd.Flags().GetBool("add-gitignore") - explicitFiles, _ := cmd.Flags().GetStringSlice("file") - - // Named files skip stage matching, so the target env must be named too; - // otherwise a default of "dev" could receive .env.production after all. - if len(explicitFiles) > 0 && envSlug == "" { - util.PrintErrorMessageAndExit("--file requires --env, so the files go to an environment you chose explicitly.") - } - - if dir == "" { - dir = "." - } - - // Resolve the project from the scanned directory, not the working - // directory: in a monorepo --path may point at an app linked to a - // different project than the root. - workspaceFile, err := findWorkspaceFileFrom(dir) - if err != nil { - util.PrintErrorMessageAndExit(fmt.Sprintf("Cannot resolve project for %q. Run `infisical init` (or `infisical init --project-id `) in that directory first.", dir)) - } - projectId := workspaceFile.WorkspaceId - - // --env wins, else the env from that .infisical.json, else "dev". The - // branch mapping is read from the repository --path lives in, which may - // not be the one the command runs from. - if envSlug == "" { - if env := util.GetEnvironmentBasedOnGitBranchIn(workspaceFile, dir); env != "" { - envSlug = env - } else if workspaceFile.DefaultEnvironment != "" { - envSlug = workspaceFile.DefaultEnvironment - } else { - envSlug = "dev" - } - } - - result := importResult{} - - // Explicit list rather than a dotfile scan: a wide scan would pick up - // .envrc and other files that people don't want their vault to swallow. - var found []string - var candidateNames []string - if len(explicitFiles) > 0 { - realDir, err := resolveRealPath(dir) - if err != nil { - util.PrintErrorMessageAndExit(fmt.Sprintf("Cannot resolve --path %q: %v", dir, err)) - } - for _, name := range explicitFiles { - p := name - if !filepath.IsAbs(p) { - p = filepath.Join(dir, name) - } - info, err := os.Stat(p) - if err != nil || info.IsDir() { - util.PrintErrorMessageAndExit(fmt.Sprintf("--file %q is not a readable file.", name)) - } - // Keep files under --path so the .gitignore check and entries - // written there refer to the right file. Compare resolved paths: - // the upload follows symlinks, so a link inside --path must not - // reach a file outside it, and an absolute --file must compare - // cleanly against a relative --path. - realFile, err := resolveRealPath(p) - if err != nil { - util.PrintErrorMessageAndExit(fmt.Sprintf("Cannot resolve --file %q: %v", name, err)) - } - if _, inside := relInside(realDir, realFile); !inside { - util.PrintErrorMessageAndExit(fmt.Sprintf("--file %q is outside --path %q.", name, dir)) - } - // The entry itself (a symlink, if it is one) must also sit under - // --path, and its location there is what .gitignore has to name. - // Resolve only its parent so the entry's own name is kept. - realParent, err := resolveRealPath(filepath.Dir(p)) - if err != nil { - util.PrintErrorMessageAndExit(fmt.Sprintf("Cannot resolve --file %q: %v", name, err)) - } - entryRel, inside := relInside(realDir, filepath.Join(realParent, filepath.Base(p))) - if !inside { - util.PrintErrorMessageAndExit(fmt.Sprintf("--file %q is outside --path %q.", name, dir)) - } - candidateNames = append(candidateNames, name) - // Rewrite as a path under --path so the gitignore step below - // derives the same relative entry the checks above did. - found = append(found, filepath.Join(dir, entryRel)) - } - } else { - for _, c := range envFileCandidates { - candidateNames = append(candidateNames, c.Name) - p := filepath.Join(dir, c.Name) - info, err := os.Stat(p) - if err != nil || info.IsDir() { - continue - } - if len(c.Stages) > 0 && !containsFold(c.Stages, envSlug) { - // Don't suggest a slug: the project's env for this stage may - // not be called any of the conventional names. - reason := fmt.Sprintf("looks like a %s file but the target env is %q; to import it, re-run with --file=%s --env=", c.Stages[0], envSlug, c.Name) - result.Skipped = append(result.Skipped, skippedFile{File: p, Reason: reason}) - if !jsonOut { - util.PrintfStderr("Skipping %s: %s\n", p, reason) - } - continue - } - found = append(found, p) - } - } - - if len(found) == 0 { - if jsonOut && len(result.Skipped) > 0 { - emitImportJSON(result) - os.Exit(1) - } - util.PrintErrorMessageAndExit(fmt.Sprintf("No .env-style files to import into %q under %q. Looked for: %s", envSlug, dir, strings.Join(candidateNames, ", "))) - } - - type fileScan struct { - Path string - Keys []string - } - var scans []fileScan - invalid := false - for _, f := range found { - keys, err := extractEnvKeyNames(f) - if err != nil { - // Reject the whole import before anything is uploaded: the upload - // parser would exit partway through otherwise. - invalid = true - result.Files = append(result.Files, importedFileResult{File: f, Env: envSlug, Error: err.Error()}) - if !jsonOut { - util.PrintfStderr("Cannot import %s: %v\n", f, err) - } - continue - } - if len(keys) == 0 { - result.Skipped = append(result.Skipped, skippedFile{File: f, Reason: "no keys found"}) - if !jsonOut { - util.PrintfStderr("Skipping %s (no keys found)\n", f) - } - continue - } - scans = append(scans, fileScan{Path: f, Keys: keys}) - } - - if invalid { - if jsonOut { - emitImportJSON(result) - os.Exit(1) - } - util.PrintErrorMessageAndExit("Fix the files above and re-run. Nothing was uploaded.") - } - - if len(scans) == 0 { - util.PrintErrorMessageAndExit("Nothing to import.") - } - - if !jsonOut { - util.PrintfStdout("About to import into env %q (project %s):\n", envSlug, projectId) - for _, s := range scans { - util.PrintfStdout("\n %s (%d key(s))\n", s.Path, len(s.Keys)) - for _, k := range s.Keys { - util.PrintfStdout(" %s\n", k) - } - } - } - - if !yes { - prompt := promptui.Prompt{ - Label: "Proceed with import", - IsConfirm: true, - } - if _, err := prompt.Run(); err != nil { - result.Cancelled = true - if jsonOut { - emitImportJSON(result) - return - } - util.PrintlnStdout("Cancelled. No secrets were uploaded and no files were touched.") - return - } - } - - userCreds := requireUserSession() - tokenDetails := &models.TokenDetails{Type: "", Token: userCreds.UserCredentials.JTWToken} - - uploadFailed := false - for _, s := range scans { - r := importedFileResult{File: s.Path, Env: envSlug, Keys: s.Keys} - ops, err := util.SetRawSecrets(nil, util.SECRET_TYPE_SHARED, envSlug, "/", projectId, tokenDetails, s.Path, nil) - if err != nil { - uploadFailed = true - r.Error = err.Error() - result.Files = append(result.Files, r) - if !jsonOut { - util.PrintfStderr("Failed to upload %s: %v\n", s.Path, err) - } - continue - } - r.Uploaded = len(ops) - result.Files = append(result.Files, r) - if !jsonOut { - util.PrintfStdout("Uploaded %d secret(s) from %s into env %q\n", len(ops), s.Path, envSlug) - } - } - - gitignorePath := filepath.Join(dir, ".gitignore") - existingLines := readGitignoreLines(gitignorePath) - var missing []string - absDir, _ := filepath.Abs(dir) - for _, s := range scans { - absFile, _ := filepath.Abs(s.Path) - rel, inside := relInside(absDir, absFile) - if !inside { - // A "../" entry in this .gitignore would not ignore the file, so - // never write one or report the file as protected by it. - util.PrintfStderr("Warning: cannot check .gitignore coverage for %s: it is not under %s\n", s.Path, dir) - continue - } - if !gitignoreCovers(existingLines, rel) { - missing = append(missing, rel) - } - } - if len(missing) > 0 { - if addGitignore { - if err := appendToGitignore(gitignorePath, missing); err != nil { - result.GitignoreMissing = missing - result.GitignoreError = err.Error() - if !jsonOut { - util.PrintfStderr("Warning: failed to update %s: %v. These files are still not ignored: %s\n", gitignorePath, err, strings.Join(missing, ", ")) - } - } else { - result.GitignoreUpdated = missing - if !jsonOut { - util.PrintfStdout("Added %d entry(ies) to %s\n", len(missing), gitignorePath) - } - } - } else { - result.GitignoreMissing = missing - if !jsonOut { - util.PrintWarning(fmt.Sprintf("These files are not in .gitignore: %s. Re-run with --add-gitignore to append them.", strings.Join(missing, ", "))) - } - } - } - - if jsonOut { - emitImportJSON(result) - } else { - util.PrintlnStdout("\nSource files were left in place; they were not deleted.") - } - - Telemetry.CaptureEvent("cli-command:import", posthog.NewProperties(). - Set("version", util.CLI_VERSION). - Set("filesScanned", len(scans))) - - if uploadFailed { - os.Exit(1) - } -} - -func emitImportJSON(r importResult) { - out, err := json.MarshalIndent(r, "", " ") - if err != nil { - util.HandleError(err, "Unable to encode JSON") - } - util.PrintlnStdout(string(out)) -} - -// findWorkspaceFileFrom looks for .infisical.json in dir and its parents, -// mirroring util.FindWorkspaceConfigFile but anchored at dir instead of the -// working directory. -func findWorkspaceFileFrom(dir string) (models.WorkspaceConfigFile, error) { - current, err := filepath.Abs(dir) - if err != nil { - return models.WorkspaceConfigFile{}, err - } - for { - if _, err := os.Stat(filepath.Join(current, util.INFISICAL_WORKSPACE_CONFIG_FILE_NAME)); err == nil { - return util.GetWorkSpaceFromFilePath(current) - } - parent := filepath.Dir(current) - if parent == current { - return models.WorkspaceConfigFile{}, fmt.Errorf("file not found: %s", util.INFISICAL_WORKSPACE_CONFIG_FILE_NAME) - } - current = parent - } -} - -// relInside returns target relative to base, and whether target is base -// itself or lies beneath it. -func relInside(base, target string) (string, bool) { - rel, err := filepath.Rel(base, target) - if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { - return "", false - } - return rel, true -} - -// resolveRealPath returns p as an absolute path with every symlink resolved. -func resolveRealPath(p string) (string, error) { - abs, err := filepath.Abs(p) - if err != nil { - return "", err - } - return filepath.EvalSymlinks(abs) -} - -func containsFold(values []string, target string) bool { - for _, v := range values { - if strings.EqualFold(v, target) { - return true - } - } - return false -} - -// extractEnvKeyNames returns key names only (never values) and rejects the -// file under the same rules util.SetRawSecrets applies, so the preview and the -// upload agree on what is importable. Errors cite line numbers and key names -// but never line contents, since a malformed line may hold a secret. -func extractEnvKeyNames(file string) ([]string, error) { - f, err := os.Open(file) - if err != nil { - return nil, err - } - defer f.Close() - - var keys []string - seen := map[string]struct{}{} - scanner := bufio.NewScanner(f) - // PEM-style values run long; give the scanner room so a bad line - // doesn't cap the preview well below what SetRawSecrets will accept. - scanner.Buffer(make([]byte, 0, 64*1024), 10*1024*1024) - lineNo := 0 - for scanner.Scan() { - lineNo++ - line := strings.TrimSpace(scanner.Text()) - if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, "//") { - continue - } - parts := strings.SplitN(line, "=", 2) - if len(parts) != 2 { - return nil, fmt.Errorf("line %d: expected KEY=VALUE", lineNo) - } - key, value := strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1]) - if key == "" { - return nil, fmt.Errorf("line %d: key is empty", lineNo) - } - if unicode.IsNumber(rune(key[0])) { - return nil, fmt.Errorf("line %d: key %q cannot start with a number", lineNo, key) - } - if strings.Contains(key, " ") { - return nil, fmt.Errorf("line %d: key %q cannot contain spaces", lineNo, key) - } - if len(value) >= 2 && ((value[0] == '"' && value[len(value)-1] == '"') || (value[0] == '\'' && value[len(value)-1] == '\'')) { - value = value[1 : len(value)-1] - } - if strings.TrimSpace(value) == "" { - return nil, fmt.Errorf("line %d: key %q has an empty value", lineNo, key) - } - if _, dup := seen[key]; dup { - continue - } - seen[key] = struct{}{} - keys = append(keys, key) - } - if err := scanner.Err(); err != nil { - return nil, err - } - sort.Strings(keys) - return keys, nil -} - -func readGitignoreLines(path string) []string { - f, err := os.Open(path) - if err != nil { - return nil - } - defer f.Close() - - var lines []string - scanner := bufio.NewScanner(f) - for scanner.Scan() { - lines = append(lines, strings.TrimSpace(scanner.Text())) - } - return lines -} - -// gitignoreCovers answers the narrow question "will git commit this file by -// accident?" for a file next to the .gitignore. It handles globs, anchored -// and "**/" patterns, and negation (last match wins), and skips -// directory-only patterns. It is not a full gitignore implementation, so when -// in doubt it reports the file as uncovered. -func gitignoreCovers(lines []string, rel string) bool { - rel = filepath.ToSlash(rel) - base := path.Base(rel) - covered := false - for _, l := range lines { - if l == "" || strings.HasPrefix(l, "#") { - continue - } - negate := strings.HasPrefix(l, "!") - pattern := strings.TrimPrefix(l, "!") - if strings.HasSuffix(pattern, "/") { - continue - } - pattern = strings.TrimPrefix(pattern, "**/") - - var matched bool - if strings.Contains(pattern, "/") { - matched, _ = path.Match(strings.TrimPrefix(pattern, "/"), rel) - } else { - matched, _ = path.Match(pattern, base) - } - if matched { - covered = !negate - } - } - return covered -} - -func appendToGitignore(path string, entries []string) error { - f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600) - if err != nil { - return err - } - defer f.Close() - // Preamble newline in case the existing file didn't end in one. - if _, err := f.WriteString("\n# Added by `infisical import`\n"); err != nil { - return err - } - for _, e := range entries { - if _, err := f.WriteString(e + "\n"); err != nil { - return err - } - } - return nil -} - -func init() { - importCmd.Flags().String("path", "", "Directory to scan for .env-style files (default: current working directory).") - importCmd.Flags().String("env", "", "Environment slug to upload into (default: env from .infisical.json, else \"dev\").") - importCmd.Flags().BoolP("yes", "y", false, "Skip the confirmation prompt. Required for non-interactive / agent runs.") - importCmd.Flags().Bool("json", false, "Emit a machine-readable summary of the import (files scanned, keys per file, upload counts, gitignore status).") - importCmd.Flags().StringSlice("file", nil, "Import exactly these files (relative to --path) instead of scanning for well-known names. Skips stage matching, so --env is required. Repeatable.") - importCmd.Flags().Bool("add-gitignore", false, "Append every imported file that is not already in .gitignore to .gitignore.") - RootCmd.AddCommand(importCmd) -} diff --git a/packages/util/helper.go b/packages/util/helper.go index ee9ce6a07..3e518939c 100644 --- a/packages/util/helper.go +++ b/packages/util/helper.go @@ -447,14 +447,7 @@ var getCurrentBranchCmd = execCmd{ } func getCurrentBranch() (string, error) { - return getCurrentBranchIn("") -} - -// getCurrentBranchIn reads the branch of the repository containing dir, or of -// the working directory when dir is empty. -func getCurrentBranchIn(dir string) (string, error) { cmd := exec.Command(getCurrentBranchCmd.cmd, getCurrentBranchCmd.args...) - cmd.Dir = dir var out bytes.Buffer cmd.Stdout = &out err := cmd.Run() diff --git a/packages/util/secrets.go b/packages/util/secrets.go index 8486e5498..c1d458ee6 100644 --- a/packages/util/secrets.go +++ b/packages/util/secrets.go @@ -512,14 +512,7 @@ func GetSecretPathFromWorkspaceFile() string { } func GetEnvelopmentBasedOnGitBranch(workspaceFile models.WorkspaceConfigFile) string { - return GetEnvironmentBasedOnGitBranchIn(workspaceFile, "") -} - -// GetEnvironmentBasedOnGitBranchIn is GetEnvelopmentBasedOnGitBranch for the -// repository containing dir rather than the working directory, for commands -// that read a workspace file from somewhere other than where they run. -func GetEnvironmentBasedOnGitBranchIn(workspaceFile models.WorkspaceConfigFile, dir string) string { - branch, err := getCurrentBranchIn(dir) + branch, err := getCurrentBranch() if err != nil { log.Debug().Msgf("getEnvelopmentBasedOnGitBranch: [err=%s]", err) } From 65724ad24f2ac0b20432c5f46e705d15f620ccc3 Mon Sep 17 00:00:00 2001 From: Adrian Kahali Date: Tue, 29 Sep 2026 19:34:49 -0400 Subject: [PATCH 08/13] remove json flag form org and projects --- packages/cmd/org.go | 59 ++++++++------------------------ packages/cmd/projects.go | 72 ++++++++++------------------------------ 2 files changed, 31 insertions(+), 100 deletions(-) diff --git a/packages/cmd/org.go b/packages/cmd/org.go index 39ef415f7..3fcb01a2b 100644 --- a/packages/cmd/org.go +++ b/packages/cmd/org.go @@ -4,7 +4,6 @@ Copyright (c) 2023 Infisical Inc. package cmd import ( - "encoding/json" "fmt" "text/tabwriter" @@ -30,19 +29,11 @@ The organization is a setting on your login profile, not a separate login. Use }, } -type orgListEntry struct { - ID string `json:"id"` - Name string `json:"name"` - Slug string `json:"slug,omitempty"` - Current bool `json:"current"` - SubOrganizations []orgListEntry `json:"subOrganizations,omitempty"` -} - var orgListCmd = &cobra.Command{ Use: "list", Short: "List the organizations this profile's account can use", DisableFlagsInUseLine: true, - Example: "infisical org list\ninfisical org list --json", + Example: "infisical org list", Args: cobra.NoArgs, PreRun: func(cmd *cobra.Command, args []string) { util.RequireLogin() @@ -67,17 +58,24 @@ var orgListCmd = &cobra.Command{ currentOrgID = claimOrgID } - var orgs []orgListEntry + writer := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0) + fmt.Fprintln(writer, "CURRENT\tNAME\tSLUG\tID") + + marker := func(id string) string { + if id == currentOrgID { + return "*" + } + return "" + } // The sub-org aware listing carries slugs and nested organizations. // Older instances may not have it, so fall back to the flat list. if subOrgsResp, err := api.CallGetAllOrganizationsWithSubOrgs(httpClient); err == nil && len(subOrgsResp.Organizations) > 0 { for _, org := range subOrgsResp.Organizations { - entry := orgListEntry{ID: org.ID, Name: org.Name, Slug: org.Slug, Current: org.ID == currentOrgID} + fmt.Fprintf(writer, "%s\t%s\t%s\t%s\n", marker(org.ID), util.SanitizeDisplay(org.Name), util.SanitizeDisplay(org.Slug), org.ID) for _, sub := range org.SubOrganizations { - entry.SubOrganizations = append(entry.SubOrganizations, orgListEntry{ID: sub.ID, Name: sub.Name, Slug: sub.Slug, Current: sub.ID == currentOrgID}) + fmt.Fprintf(writer, "%s\t └─ %s\t%s\t%s\n", marker(sub.ID), util.SanitizeDisplay(sub.Name), util.SanitizeDisplay(sub.Slug), sub.ID) } - orgs = append(orgs, entry) } } else { orgResp, err := api.CallGetAllOrganizations(httpClient) @@ -85,37 +83,7 @@ var orgListCmd = &cobra.Command{ util.HandleError(err, "Unable to list your organizations") } for _, org := range orgResp.Organizations { - orgs = append(orgs, orgListEntry{ID: org.ID, Name: org.Name, Current: org.ID == currentOrgID}) - } - } - - if jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput { - if orgs == nil { - orgs = []orgListEntry{} - } - out, err := json.MarshalIndent(orgs, "", " ") - if err != nil { - util.HandleError(err, "Unable to encode JSON") - } - fmt.Fprintln(cmd.OutOrStdout(), string(out)) - Telemetry.CaptureEvent("cli-command:org list", posthog.NewProperties().Set("version", util.CLI_VERSION)) - return - } - - writer := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0) - fmt.Fprintln(writer, "CURRENT\tNAME\tSLUG\tID") - - marker := func(current bool) string { - if current { - return "*" - } - return "" - } - - for _, org := range orgs { - fmt.Fprintf(writer, "%s\t%s\t%s\t%s\n", marker(org.Current), util.SanitizeDisplay(org.Name), util.SanitizeDisplay(org.Slug), org.ID) - for _, sub := range org.SubOrganizations { - fmt.Fprintf(writer, "%s\t └─ %s\t%s\t%s\n", marker(sub.Current), util.SanitizeDisplay(sub.Name), util.SanitizeDisplay(sub.Slug), sub.ID) + fmt.Fprintf(writer, "%s\t%s\t%s\t%s\n", marker(org.ID), util.SanitizeDisplay(org.Name), "", org.ID) } } writer.Flush() @@ -322,7 +290,6 @@ func selectOrganizationToken(sessionToken string, email string, orgID string) (s } func init() { - orgListCmd.Flags().Bool("json", false, "Output the organizations as JSON (id, name, slug, current, and subOrganizations per entry)") orgCmd.AddCommand(orgListCmd) orgCmd.AddCommand(newSetOrgCommand("switch [org]", "org switch", "Change the organization this profile uses (same as [infisical profile set-org])")) RootCmd.AddCommand(orgCmd) diff --git a/packages/cmd/projects.go b/packages/cmd/projects.go index 7aa2353b2..a4e689d40 100644 --- a/packages/cmd/projects.go +++ b/packages/cmd/projects.go @@ -4,7 +4,6 @@ Copyright (c) 2026 Infisical Inc. package cmd import ( - "encoding/json" "fmt" "github.com/Infisical/infisical-merge/packages/api" @@ -15,14 +14,11 @@ import ( ) // projectsCmd groups CLI verbs for managing Infisical projects (workspaces). -// The subcommands emit machine-readable JSON when --json is set so that -// scripts and AI-agent bootstrap prompts can chain them without parsing -// human-oriented output. var projectsCmd = &cobra.Command{ Use: "projects", Short: "Manage Infisical projects (list and create)", DisableFlagsInUseLine: true, - Example: "infisical projects list --json\n infisical projects create --name my-app --json", + Example: "infisical projects list\n infisical projects create --name my-app", Args: cobra.NoArgs, Run: func(cmd *cobra.Command, args []string) { _ = cmd.Help() @@ -33,7 +29,7 @@ var projectsListCmd = &cobra.Command{ Use: "list", Short: "List projects the current user belongs to in the currently selected organization", DisableFlagsInUseLine: true, - Example: "infisical projects list\n infisical projects list --json", + Example: "infisical projects list", Args: cobra.NoArgs, PreRun: func(cmd *cobra.Command, args []string) { util.RequireLogin() @@ -45,7 +41,7 @@ var projectsCreateCmd = &cobra.Command{ Use: "create", Short: "Create a new project in the currently selected organization", DisableFlagsInUseLine: true, - Example: "infisical projects create --name my-app\n infisical projects create --name my-app --description \"backend service\" --json", + Example: "infisical projects create --name my-app\n infisical projects create --name my-app --description \"backend service\"", Args: cobra.NoArgs, PreRun: func(cmd *cobra.Command, args []string) { util.RequireLogin() @@ -64,15 +60,7 @@ func projectsAuthedClient() (*resty.Client, util.LoggedInUserDetails, error) { return httpClient, userCreds, nil } -type projectListEntry struct { - ID string `json:"id"` - Name string `json:"name"` - OrgID string `json:"orgId"` -} - func runProjectsList(cmd *cobra.Command, args []string) { - jsonOut, _ := cmd.Flags().GetBool("json") - httpClient, userCreds, err := projectsAuthedClient() if err != nil { util.HandleError(err, "Unable to build authenticated HTTP client") @@ -86,33 +74,22 @@ func runProjectsList(cmd *cobra.Command, args []string) { // The endpoint returns projects across every organization the user // belongs to; keep only the session's organization, as `init` does, so a // script never picks a project it cannot link here. - // - // Workspace's JSON tags mirror the API (_id, __v); emit the same field - // names as `projects create --json` so agents can chain either command. - projects := make([]projectListEntry, 0, len(resp.Workspaces)) + found := false for _, w := range resp.Workspaces { - if userCreds.OrganizationID == "" || w.OrganizationId == userCreds.OrganizationID { - projects = append(projects, projectListEntry{ID: w.ID, Name: w.Name, OrgID: w.OrganizationId}) + if userCreds.OrganizationID != "" && w.OrganizationId != userCreds.OrganizationID { + continue } - } - - if jsonOut { - out, err := json.MarshalIndent(projects, "", " ") - if err != nil { - util.HandleError(err, "Unable to encode JSON") + if !found { + util.PrintlnStdout("ID\tNAME\tORG ID") + found = true } - util.PrintlnStdout(string(out)) - return + util.PrintfStdout("%s\t%s\t%s\n", util.SanitizeDisplay(w.ID), util.SanitizeDisplay(w.Name), util.SanitizeDisplay(w.OrganizationId)) } - if len(projects) == 0 { + if !found { util.PrintlnStdout("No projects found for the currently selected organization.") return } - util.PrintlnStdout("ID\tNAME\tORG ID") - for _, p := range projects { - util.PrintfStdout("%s\t%s\t%s\n", util.SanitizeDisplay(p.ID), util.SanitizeDisplay(p.Name), util.SanitizeDisplay(p.OrgID)) - } Telemetry.CaptureEvent("cli-command:projects list", posthog.NewProperties().Set("version", util.CLI_VERSION)) } @@ -120,8 +97,6 @@ func runProjectsCreate(cmd *cobra.Command, args []string) { name, _ := cmd.Flags().GetString("name") description, _ := cmd.Flags().GetString("description") slug, _ := cmd.Flags().GetString("slug") - jsonOut, _ := cmd.Flags().GetBool("json") - if name == "" { util.PrintErrorMessageAndExit("--name is required") } @@ -141,34 +116,23 @@ func runProjectsCreate(cmd *cobra.Command, args []string) { util.HandleError(err, "Unable to create project") } - if jsonOut { - out, err := json.MarshalIndent(project, "", " ") - if err != nil { - util.HandleError(err, "Unable to encode JSON") + projectID := util.SanitizeDisplay(project.ID) + util.PrintSuccessMessage(fmt.Sprintf("Created project %q (id: %s)", util.SanitizeDisplay(project.Name), projectID)) + if len(project.Environments) > 0 { + util.PrintlnStdout("Environments:") + for _, e := range project.Environments { + util.PrintfStdout(" - %s (%s)\n", util.SanitizeDisplay(e.Name), util.SanitizeDisplay(e.Slug)) } - util.PrintlnStdout(string(out)) - } else { - projectID := util.SanitizeDisplay(project.ID) - util.PrintSuccessMessage(fmt.Sprintf("Created project %q (id: %s)", util.SanitizeDisplay(project.Name), projectID)) - if len(project.Environments) > 0 { - util.PrintlnStdout("Environments:") - for _, e := range project.Environments { - util.PrintfStdout(" - %s (%s)\n", util.SanitizeDisplay(e.Name), util.SanitizeDisplay(e.Slug)) - } - } - util.PrintlnStdout("\nRun `infisical init --project-id " + projectID + "` to link this directory.") } + util.PrintlnStdout("\nRun `infisical init --project-id " + projectID + "` to link this directory.") Telemetry.CaptureEvent("cli-command:projects create", posthog.NewProperties().Set("version", util.CLI_VERSION)) } func init() { - projectsListCmd.Flags().Bool("json", false, "Output the project list as JSON (id, name, orgId per entry). Useful for scripting and AI-agent bootstrap flows.") - projectsCreateCmd.Flags().String("name", "", "Name of the project to create (required, 1-64 characters).") projectsCreateCmd.Flags().String("description", "", "Optional description of the project (up to 1024 characters).") projectsCreateCmd.Flags().String("slug", "", "Optional slug for the project (5-64 characters; auto-generated from the name when omitted).") - projectsCreateCmd.Flags().Bool("json", false, "Output the created project as JSON. Useful for scripting and AI-agent bootstrap flows.") projectsCmd.AddCommand(projectsListCmd) projectsCmd.AddCommand(projectsCreateCmd) From b57242a04fb74ae114fcda8271960d7d24596535 Mon Sep 17 00:00:00 2001 From: Adrian Kahali Date: Tue, 29 Sep 2026 20:40:31 -0400 Subject: [PATCH 09/13] remove force flag and reformat --- packages/cmd/init.go | 42 +++++++++++++++++++++--------------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/packages/cmd/init.go b/packages/cmd/init.go index c016af140..9c9646c1b 100644 --- a/packages/cmd/init.go +++ b/packages/cmd/init.go @@ -25,36 +25,37 @@ var initCmd = &cobra.Command{ Use: "init", Short: "Used to connect your local project with Infisical project", DisableFlagsInUseLine: true, - Example: "infisical init\n infisical init --project-id \n infisical init --project-id --force", - Args: cobra.ExactArgs(0), + Example: `# Pick an organization and project interactively +infisical init + +# Link this directory to a known project without prompting (see infisical projects list) +infisical init --project-id `, + Args: cobra.ExactArgs(0), PreRun: func(cmd *cobra.Command, args []string) { util.RequireLogin() }, Run: func(cmd *cobra.Command, args []string) { - force, _ := cmd.Flags().GetBool("force") projectID, _ := cmd.Flags().GetString("project-id") if util.WorkspaceConfigFileExistsInCurrentPath() { - if force { - log.Info().Msg("A workspace config file already exists here; overwriting because --force was provided.") - } else if !isatty.IsTerminal(os.Stdin.Fd()) { - util.PrintErrorMessageAndExit("This directory is already linked to an Infisical project (.infisical.json exists). Pass --force to overwrite it.") - } else { - shouldOverride, err := shouldOverrideWorkspacePrompt() - if err != nil { - log.Error().Msg("Unable to parse your answer") - log.Debug().Err(err) - return - } - - if !shouldOverride { - return - } + if !isatty.IsTerminal(os.Stdin.Fd()) { + util.PrintErrorMessageAndExit("This directory is already linked to an Infisical project (.infisical.json exists). Remove it first to link a different project.") + } + + shouldOverride, err := shouldOverrideWorkspacePrompt() + if err != nil { + log.Error().Msg("Unable to parse your answer") + log.Debug().Err(err) + return + } + + if !shouldOverride { + return } } - // Without a terminal the org and project pickers below cannot run, so - // fail with the flag that makes this command non-interactive instead. + // The org and project pickers below need a terminal. Without one, exit and + // point at --project-id instead of prompting. if projectID == "" && !isatty.IsTerminal(os.Stdin.Fd()) { util.PrintErrorMessageAndExit("No terminal available to pick a project. Pass --project-id (see `infisical projects list`).") } @@ -239,7 +240,6 @@ func offerDirectoryProfileBinding(profileName string) { } func init() { - initCmd.Flags().Bool("force", false, "Overwrite an existing .infisical.json without asking.") initCmd.Flags().String("project-id", "", "Project ID to link this directory to. When set, skips the interactive org and project pickers and writes .infisical.json directly.") RootCmd.AddCommand(initCmd) } From 587655184fa5a26cb7a5a7e6d184997aff746c96 Mon Sep 17 00:00:00 2001 From: Adrian Kahali Date: Tue, 29 Sep 2026 20:42:48 -0400 Subject: [PATCH 10/13] fix endpoint for project creation --- packages/api/api.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/api/api.go b/packages/api/api.go index 3c1f293e5..8699b9441 100644 --- a/packages/api/api.go +++ b/packages/api/api.go @@ -346,7 +346,7 @@ func CallCreateProject(httpClient *resty.Client, request CreateProjectRequest) ( SetBody(request). SetResult(&resp). SetHeader("User-Agent", USER_AGENT). - Post(fmt.Sprintf("%v/v2/workspace", config.INFISICAL_URL)) + Post(fmt.Sprintf("%v/v1/projects", config.INFISICAL_URL)) if err != nil { return CreatedProject{}, NewGenericRequestError(operationCallCreateProject, err) From 227af4f2c4d19469933600839dc4041e39d0cc8e Mon Sep 17 00:00:00 2001 From: Adrian Kahali Date: Tue, 29 Sep 2026 21:21:14 -0400 Subject: [PATCH 11/13] suggest editing workspaceId when init is already linked --- packages/cmd/init.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cmd/init.go b/packages/cmd/init.go index 9c9646c1b..8a3b6f099 100644 --- a/packages/cmd/init.go +++ b/packages/cmd/init.go @@ -39,7 +39,7 @@ infisical init --project-id `, if util.WorkspaceConfigFileExistsInCurrentPath() { if !isatty.IsTerminal(os.Stdin.Fd()) { - util.PrintErrorMessageAndExit("This directory is already linked to an Infisical project (.infisical.json exists). Remove it first to link a different project.") + util.PrintErrorMessageAndExit("This directory is already linked to an Infisical project (.infisical.json exists). To link a different project, change workspaceId in .infisical.json.") } shouldOverride, err := shouldOverrideWorkspacePrompt() From 103040e8e2e982b48535e4a8988decda88e357c9 Mon Sep 17 00:00:00 2001 From: Adrian Kahali Date: Tue, 29 Sep 2026 21:21:48 -0400 Subject: [PATCH 12/13] add hint for profile binding after non interactive init --- packages/cmd/init.go | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/packages/cmd/init.go b/packages/cmd/init.go index 8a3b6f099..fe5daee7c 100644 --- a/packages/cmd/init.go +++ b/packages/cmd/init.go @@ -80,6 +80,7 @@ infisical init --project-id `, if err := writeWorkspaceFile(models.Workspace{ID: projectID}); err != nil { util.HandleError(err) } + hintDirectoryProfileBinding(userCreds) Telemetry.CaptureEvent("cli-command:init", posthog.NewProperties().Set("version", util.CLI_VERSION).Set("nonInteractive", true)) return } @@ -239,6 +240,34 @@ func offerDirectoryProfileBinding(profileName string) { util.PrintlnStderr(fmt.Sprintf("Directory %s now uses profile '%s'. Manage bindings with [infisical profile bind] and [infisical profile unbind].", cwd, profileName)) } +// hintDirectoryProfileBinding runs after a non-interactive init. If the profile +// was chosen with --profile or INFISICAL_PROFILE and is not the default, later +// commands in this directory will not use it unless they choose it again. In +// that case, it prints a note suggesting that the user bind the directory to +// the profile. The interactive path asks the same question in +// offerDirectoryProfileBinding. +func hintDirectoryProfileBinding(userCreds util.LoggedInUserDetails) { + if userCreds.ProfileSource != util.ProfileSourceFlag && userCreds.ProfileSource != util.ProfileSourceEnv { + return + } + + configFile, err := util.GetMigratedConfigFile() + if err != nil || userCreds.ProfileName == "" || configFile.ActiveProfile == userCreds.ProfileName { + return + } + + cwd, err := os.Getwd() + if err != nil { + return + } + + if boundProfile, _, ok := util.FindGoverningDirectoryProfile(configFile, cwd); ok && boundProfile == userCreds.ProfileName { + return + } + + util.PrintlnStderr(fmt.Sprintf("This directory was linked using profile '%s', but later commands run here will use a different profile unless you choose '%s' again. To use it here automatically, run [infisical profile bind %s].", userCreds.ProfileName, userCreds.ProfileName, userCreds.ProfileName)) +} + func init() { initCmd.Flags().String("project-id", "", "Project ID to link this directory to. When set, skips the interactive org and project pickers and writes .infisical.json directly.") RootCmd.AddCommand(initCmd) From 11e2b11b1b7a9a49fd3db2f4df7126890964ec38 Mon Sep 17 00:00:00 2001 From: Adrian Kahali Date: Tue, 29 Sep 2026 21:27:21 -0400 Subject: [PATCH 13/13] address veria --- packages/cmd/projects.go | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/cmd/projects.go b/packages/cmd/projects.go index a4e689d40..06db29940 100644 --- a/packages/cmd/projects.go +++ b/packages/cmd/projects.go @@ -9,6 +9,7 @@ import ( "github.com/Infisical/infisical-merge/packages/api" "github.com/Infisical/infisical-merge/packages/util" "github.com/go-resty/resty/v2" + "github.com/google/uuid" "github.com/posthog/posthog-go" "github.com/spf13/cobra" ) @@ -116,15 +117,17 @@ func runProjectsCreate(cmd *cobra.Command, args []string) { util.HandleError(err, "Unable to create project") } - projectID := util.SanitizeDisplay(project.ID) - util.PrintSuccessMessage(fmt.Sprintf("Created project %q (id: %s)", util.SanitizeDisplay(project.Name), projectID)) + util.PrintSuccessMessage(fmt.Sprintf("Created project %q (id: %s)", util.SanitizeDisplay(project.Name), util.SanitizeDisplay(project.ID))) if len(project.Environments) > 0 { util.PrintlnStdout("Environments:") for _, e := range project.Environments { util.PrintfStdout(" - %s (%s)\n", util.SanitizeDisplay(e.Name), util.SanitizeDisplay(e.Slug)) } } - util.PrintlnStdout("\nRun `infisical init --project-id " + projectID + "` to link this directory.") + // Only print the hint after validating that it's a valid UUID + if parsedID, err := uuid.Parse(project.ID); err == nil { + util.PrintlnStdout("\nRun `infisical init --project-id " + parsedID.String() + "` to link this directory.") + } Telemetry.CaptureEvent("cli-command:projects create", posthog.NewProperties().Set("version", util.CLI_VERSION)) }