Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions packages/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ const (
operationCallSelectOrganization = "CallSelectOrganization"
operationCallGetAllWorkSpacesUserBelongsTo = "CallGetAllWorkSpacesUserBelongsTo"
operationCallGetProjectById = "CallGetProjectById"
operationCallCreateProject = "CallCreateProject"
operationCallIsAuthenticated = "CallIsAuthenticated"
operationCallGetNewAccessTokenWithRefreshToken = "CallGetNewAccessTokenWithRefreshToken"
operationCallGetFoldersV1 = "CallGetFoldersV1"
Expand Down Expand Up @@ -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.
Expand Down
28 changes: 28 additions & 0 deletions packages/api/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
98 changes: 69 additions & 29 deletions packages/cmd/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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 <project-id>\n infisical init --project-id <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 {
Comment thread
greptile-apps[bot] marked this conversation as resolved.
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 <id> (see `infisical projects list`).")
}

userCreds, err := util.GetCurrentLoggedInUserDetails(true)
if err != nil {
util.HandleError(err, "Unable to get your login details")
Expand All @@ -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")
Expand Down Expand Up @@ -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 <name> --org %s] and then [infisical profile bind <name>] 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))
}

Expand Down Expand Up @@ -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 <name> --org %s] and then [infisical profile bind <name>] 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.
Expand Down Expand Up @@ -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)
}

Expand Down
20 changes: 13 additions & 7 deletions packages/cmd/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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", "")},
Expand All @@ -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 {
Expand Down
59 changes: 46 additions & 13 deletions packages/cmd/org.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ Copyright (c) 2023 Infisical Inc.
package cmd

import (
"encoding/json"
"fmt"
"text/tabwriter"

Expand All @@ -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()
Expand All @@ -58,32 +67,55 @@ 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)
if err != nil {
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()
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading