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/init.go b/packages/cmd/init.go index 179042d59..c016af140 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,25 +25,40 @@ 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 + } } } + // 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) if err != nil { util.HandleError(err, "Unable to get your login details") @@ -52,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") @@ -112,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)) } @@ -165,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. @@ -201,6 +239,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/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 { 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..7aa2353b2 --- /dev/null +++ b/packages/cmd/projects.go @@ -0,0 +1,176 @@ +/* +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, util.LoggedInUserDetails, error) { + userCreds := requireUserSession() + + httpClient, err := util.GetRestyClientWithCustomHeaders() + if err != nil { + return nil, userCreds, err + } + httpClient.SetAuthToken(userCreds.UserCredentials.JTWToken) + 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") + } + + resp, err := api.CallGetAllWorkSpacesUserBelongsTo(httpClient) + if err != nil { + util.HandleError(err, "Unable to list projects") + } + + // 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") + } + util.PrintlnStdout(string(out)) + return + } + + if len(projects) == 0 { + 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)) +} + +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 { + 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.") + } + + 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) +}