lots of work on user management, various fixes, usage guide update

This commit is contained in:
Dane Schneider
2024-03-27 00:51:37 -07:00
parent 84fe020ac7
commit 0ccdadc40e
34 changed files with 695 additions and 129 deletions

View File

@@ -11,7 +11,7 @@ import (
const (
AuthFreeTrialOption = "Start an anonymous trial on Plandex Cloud (no email required)"
AuthAccountOption = "Sign in or create an account"
AuthAccountOption = "Sign in, accept an invite, or create an account"
)
func promptInitialAuth() error {
@@ -185,9 +185,9 @@ func verifyEmail(email, host string) (bool, string, error) {
return false, "", fmt.Errorf("error creating email verification: %v", apiErr.Msg)
}
fmt.Println("✉️ You'll now receive a 6 character pin by email")
fmt.Println("✉️ You'll now receive a 6 character pin by email. It will be valid for 5 minutes.")
pin, err := term.GetUserPasswordInput("Please enter it here:")
pin, err := term.GetUserPasswordInput("Please enter your pin:")
if err != nil {
return false, "", fmt.Errorf("error prompting pin: %v", err)

View File

@@ -89,7 +89,7 @@ func promptAutoAddUsersIfValid(email string) (bool, error) {
var autoAddDomainUsers bool
var err error
if !shared.IsEmailServiceDomain(userDomain) {
fmt.Println("With domain auto-join, you can allow any user with an email ending in @"+userDomain, "to auto-join this org")
fmt.Println("With domain auto-join, you can allow any user with an email ending in @"+userDomain, "to auto-join this org.")
autoAddDomainUsers, err = term.ConfirmYesNo(fmt.Sprintf("Enable auto-join for %s?", userDomain))
if err != nil {

101
app/cli/cmd/invite.go Normal file
View File

@@ -0,0 +1,101 @@
package cmd
import (
"fmt"
"plandex/api"
"plandex/auth"
"plandex/term"
"github.com/plandex/plandex/shared"
"github.com/spf13/cobra"
)
var inviteCmd = &cobra.Command{
Use: "invite [email] [name] [org-role]",
Short: "Invite a new user to the org",
Run: invite,
Args: cobra.MaximumNArgs(3),
}
func init() {
RootCmd.AddCommand(inviteCmd)
}
func invite(cmd *cobra.Command, args []string) {
auth.MustResolveAuthWithOrg()
email, name, orgRoleName := "", "", ""
if len(args) >= 1 {
email = args[0]
}
if len(args) >= 2 {
name = args[1]
}
if len(args) == 3 {
orgRoleName = args[2]
}
term.StartSpinner("")
orgRoles, err := api.Client.ListOrgRoles()
term.StopSpinner()
if err != nil {
term.OutputErrorAndExit("Failed to list org roles: %v", err)
}
if email == "" {
var err error
email, err = term.GetUserStringInput("Email:")
if err != nil {
term.OutputErrorAndExit("Failed to get email: %v", err)
}
}
if name == "" {
var err error
name, err = term.GetUserStringInput("Name:")
if err != nil {
term.OutputErrorAndExit("Failed to get name: %v", err)
}
}
if orgRoleName == "" {
var orgRoleNames []string
for _, orgRole := range orgRoles {
orgRoleNames = append(orgRoleNames, orgRole.Label)
}
var err error
orgRoleName, err = term.SelectFromList("Org role:", orgRoleNames)
if err != nil {
term.OutputErrorAndExit("Failed to select org role: %v", err)
}
}
var orgRoleId string
for _, orgRole := range orgRoles {
if orgRole.Label == orgRoleName {
orgRoleId = orgRole.Id
break
}
}
if orgRoleId == "" {
term.OutputErrorAndExit("Org role '%s' not found", orgRoleName)
}
inviteRequest := shared.InviteRequest{
Email: email,
Name: name,
OrgRoleId: orgRoleId,
}
term.StartSpinner("")
apiErr := api.Client.InviteUser(inviteRequest)
term.StopSpinner()
if apiErr != nil {
term.OutputErrorAndExit("Failed to invite user: %s", apiErr.Msg)
}
fmt.Println("✅ Invite sent")
}

116
app/cli/cmd/revoke.go Normal file
View File

@@ -0,0 +1,116 @@
package cmd
import (
"fmt"
"plandex/api"
"plandex/auth"
"plandex/term"
"github.com/plandex/plandex/shared"
"github.com/spf13/cobra"
)
var revokeCmd = &cobra.Command{
Use: "revoke [email]",
Short: "Revoke an invite or remove a user from the org",
Run: revoke,
Args: cobra.MaximumNArgs(1),
}
func init() {
RootCmd.AddCommand(revokeCmd)
}
func revoke(cmd *cobra.Command, args []string) {
auth.MustResolveAuthWithOrg()
email := ""
if len(args) > 0 {
email = args[0]
}
var users []*shared.User
var pendingInvites []*shared.Invite
errCh := make(chan error)
term.StartSpinner("")
go func() {
var err *shared.ApiError
users, err = api.Client.ListUsers()
if err != nil {
errCh <- fmt.Errorf("error fetching users: %s", err.Msg)
return
}
errCh <- nil
}()
go func() {
var err *shared.ApiError
pendingInvites, err = api.Client.ListPendingInvites()
if err != nil {
errCh <- fmt.Errorf("error fetching pending invites: %s", err.Msg)
return
}
errCh <- nil
}()
for i := 0; i < 2; i++ {
err := <-errCh
if err != nil {
term.StopSpinner()
term.OutputErrorAndExit(err.Error())
}
}
term.StopSpinner()
type userInfo struct {
Id string
IsInvite bool
}
emailToUserMap := make(map[string]userInfo)
labelToEmail := make(map[string]string)
// Combine users and invites for selection
combinedList := make([]string, 0, len(users)+len(pendingInvites))
for _, user := range users {
label := fmt.Sprintf("%s <%s>", user.Name, user.Email)
labelToEmail[label] = user.Email
combinedList = append(combinedList, label)
emailToUserMap[user.Email] = userInfo{Id: user.Id, IsInvite: false}
}
for _, invite := range pendingInvites {
label := fmt.Sprintf("%s <%s> (invite pending)", invite.Name, invite.Email)
labelToEmail[label] = invite.Email
combinedList = append(combinedList, label)
emailToUserMap[invite.Email] = userInfo{Id: invite.Id, IsInvite: true}
}
if email == "" {
selected, err := term.SelectFromList("Select a user or invite:", combinedList)
if err != nil {
term.OutputErrorAndExit("Error selecting item to revoke: %v", err)
}
email = labelToEmail[selected]
}
// Determine if email belongs to a user or an invite and revoke accordingly
if userInfo, exists := emailToUserMap[email]; exists {
if userInfo.IsInvite {
if err := api.Client.DeleteInvite(userInfo.Id); err != nil {
term.OutputErrorAndExit("Failed to revoke invite: %v", err)
}
fmt.Println("✅ Invite revoked")
} else {
if err := api.Client.DeleteUser(userInfo.Id); err != nil {
term.OutputErrorAndExit("Failed to remove user: %v", err)
}
fmt.Println("✅ User removed")
}
} else {
term.OutputErrorAndExit("No user or pending invite found for email '%s'", email)
}
}

View File

@@ -1,8 +1,10 @@
package cmd
import (
"fmt"
"plandex/auth"
"plandex/lib"
"plandex/term"
"github.com/spf13/cobra"
)
@@ -24,5 +26,19 @@ func update(cmd *cobra.Command, args []string) {
auth.MustResolveAuthWithOrg()
lib.MustResolveProject()
term.StartSpinner("")
outdated, err := lib.CheckOutdatedContext(nil)
if err != nil {
term.StopSpinner()
term.OutputErrorAndExit("failed to check outdated context: %s", err)
}
if len(outdated.UpdatedContexts) == 0 {
term.StopSpinner()
fmt.Println("✅ Context is up to date")
return
}
lib.MustUpdateContext(nil)
}

95
app/cli/cmd/users.go Normal file
View File

@@ -0,0 +1,95 @@
package cmd
import (
"fmt"
"os"
"plandex/api"
"plandex/auth"
"plandex/term"
"github.com/olekukonko/tablewriter"
"github.com/plandex/plandex/shared"
"github.com/spf13/cobra"
)
var usersCmd = &cobra.Command{
Use: "users",
Short: "List all users and pending invites and the current org",
Run: listUsersAndInvites,
}
func init() {
RootCmd.AddCommand(usersCmd)
}
func listUsersAndInvites(cmd *cobra.Command, args []string) {
auth.MustResolveAuthWithOrg()
var users []*shared.User
var pendingInvites []*shared.Invite
var orgRoles []*shared.OrgRole
errCh := make(chan error)
term.StartSpinner("")
go func() {
var err *shared.ApiError
users, err = api.Client.ListUsers()
if err != nil {
errCh <- fmt.Errorf("error fetching users: %s", err.Msg)
return
}
errCh <- nil
}()
go func() {
var err *shared.ApiError
pendingInvites, err = api.Client.ListPendingInvites()
if err != nil {
errCh <- fmt.Errorf("error fetching pending invites: %s", err.Msg)
return
}
errCh <- nil
}()
go func() {
var err *shared.ApiError
orgRoles, err = api.Client.ListOrgRoles()
if err != nil {
errCh <- fmt.Errorf("error fetching org roles: %s", err.Msg)
return
}
errCh <- nil
}()
for i := 0; i < 3; i++ {
err := <-errCh
if err != nil {
term.StopSpinner()
term.OutputErrorAndExit("%v", err)
}
}
term.StopSpinner()
orgRolesById := make(map[string]*shared.OrgRole)
for _, role := range orgRoles {
orgRolesById[role.Id] = role
}
// Display users and pending invites in a table
table := tablewriter.NewWriter(os.Stdout)
table.SetHeader([]string{"Type", "Email", "Name", "Role", "Status"})
for _, user := range users {
table.Append([]string{"User", user.Email, user.Name, orgRolesById[user.OrgRoleId].Label, "Active"})
}
for _, invite := range pendingInvites {
table.Append([]string{"Invite", invite.Email, invite.Name, orgRolesById[invite.OrgRoleId].Label, "Pending"})
}
table.Render()
}

View File

@@ -178,9 +178,9 @@ func MustApplyPlan(planId, branch string, autoConfirm bool) {
return
} else {
if isRepo {
fmt.Println("✏️ Plandex can commit these updates with an automatically generated message.")
fmt.Println("✏️ Plandex can commit these updates with an automatically generated message.")
fmt.Println()
fmt.Println(" Only the files that Plandex is updating will be included the commit. Any other changes, staged or unstaged, will remain exactly as they are.")
fmt.Println(" Only the files that Plandex is updating will be included the commit. Any other changes, staged or unstaged, will remain exactly as they are.")
fmt.Println()
confirmed, err := term.ConfirmYesNo("Commit Plandex updates now?")

View File

@@ -144,10 +144,11 @@ func MustLoadContext(resources []string, params *types.LoadContextParams) {
}
contextCh <- &shared.LoadContextParams{
ContextType: shared.ContextDirectoryTreeType,
Name: name,
Body: body,
FilePath: inputFilePath,
ContextType: shared.ContextDirectoryTreeType,
Name: name,
Body: body,
FilePath: inputFilePath,
ForceSkipIgnore: params.ForceSkipIgnore,
}
}(inputFilePath)
}

View File

@@ -6,6 +6,7 @@ import (
"fmt"
"os"
"plandex/api"
"plandex/fs"
"plandex/term"
"plandex/types"
"plandex/url"
@@ -29,9 +30,7 @@ func MustCheckOutdatedContext(quiet bool, maybeContexts []*shared.Context) (cont
term.OutputErrorAndExit("failed to check outdated context: %s", err)
}
if !quiet {
term.StopSpinner()
}
term.StopSpinner()
if len(outdatedRes.UpdatedContexts) == 0 {
if !quiet {
@@ -155,6 +154,25 @@ func checkOutdatedAndMaybeUpdateContext(doUpdate bool, maybeContexts []*shared.C
var wg sync.WaitGroup
contextsById := map[string]*shared.Context{}
var paths *fs.ProjectPaths
var hasDirectoryTreeWithIgnoredPaths bool
for _, context := range contexts {
if context.ContextType == shared.ContextDirectoryTreeType && !context.ForceSkipIgnore {
hasDirectoryTreeWithIgnoredPaths = true
break
}
}
if hasDirectoryTreeWithIgnoredPaths {
baseDir := fs.GetBaseDirForContexts(contexts)
var err error
paths, err = fs.GetProjectPaths(baseDir)
if err != nil {
return nil, fmt.Errorf("failed to get project paths: %v", err)
}
}
for _, context := range contexts {
contextsById[context.Id] = context
@@ -198,7 +216,8 @@ func checkOutdatedAndMaybeUpdateContext(doUpdate bool, maybeContexts []*shared.C
go func(context *shared.Context) {
defer wg.Done()
flattenedPaths, err := ParseInputPaths([]string{context.FilePath}, &types.LoadContextParams{
NamesOnly: true,
NamesOnly: true,
ForceSkipIgnore: context.ForceSkipIgnore,
})
mu.Lock()
@@ -208,6 +227,22 @@ func checkOutdatedAndMaybeUpdateContext(doUpdate bool, maybeContexts []*shared.C
errs = append(errs, fmt.Errorf("failed to get the directory tree %s: %v", context.FilePath, err))
return
}
if !context.ForceSkipIgnore {
if paths == nil {
errs = append(errs, fmt.Errorf("project paths are nil"))
return
}
var filteredPaths []string
for _, path := range flattenedPaths {
if _, ok := paths.ActivePaths[path]; ok {
filteredPaths = append(filteredPaths, path)
}
}
flattenedPaths = filteredPaths
}
body := strings.Join(flattenedPaths, "\n")
bytes := []byte(body)

View File

@@ -26,11 +26,29 @@ func OutputErrorAndExit(msg string, args ...interface{}) {
displayMsg := ""
errorParts := strings.Split(msg, ": ")
addedErrors := map[string]bool{}
if len(errorParts) > 1 {
for i, part := range errorParts {
var lastPart string
i := 0
for _, part := range errorParts {
// don't repeat the same error message
if _, ok := addedErrors[strings.ToLower(part)]; ok {
continue
}
if len(lastPart) < 10 && i > 0 {
lastPart = lastPart + ": " + part
displayMsg += ": " + part
addedErrors[strings.ToLower(lastPart)] = true
addedErrors[strings.ToLower(part)] = true
continue
}
if i != 0 {
displayMsg += "\n"
}
// indent the error message
for n := 0; n < i; n++ {
displayMsg += " "
@@ -45,6 +63,10 @@ func OutputErrorAndExit(msg string, args ...interface{}) {
}
displayMsg += s
addedErrors[strings.ToLower(part)] = true
lastPart = part
i++
}
} else {
displayMsg = color.New(ColorHiRed, color.Bold).Sprint("🚨 " + msg)

View File

@@ -39,7 +39,7 @@ var CmdDesc = map[string][2]string{
"ps": {"", "list active and recently finished plan streams"},
"stop": {"", "stop an active plan stream"},
"connect": {"conn", "connect to an active plan stream"},
"sign-in": {"", "sign in to an existing account or create a new one"},
"sign-in": {"", "sign in, accept an invite, or create an account"},
}
func PrintCmds(prefix string, cmds ...string) {

View File

@@ -87,7 +87,7 @@ func ValidateEmailVerification(email, pin string) (id string, err error) {
err = Conn.QueryRow(query, pinHash, email, time.Now().Add(-emailVerificationExpirationMinutes*time.Minute)).Scan(&id, &authTokenId)
if err != nil {
if err == sql.ErrNoRows {
return "", errors.New("invalid pin")
return "", errors.New("invalid or expired pin")
}
return "", fmt.Errorf("error validating email verification: %v", err)
}

View File

@@ -123,12 +123,16 @@ func ListPlanBranches(orgId, planId string) ([]*Branch, error) {
return nil, fmt.Errorf("error listing branches: %v", err)
}
// log.Println("branches: ", spew.Sdump(branches))
gitBranches, err := GitListBranches(orgId, planId)
if err != nil {
return nil, fmt.Errorf("error listing git branches: %v", err)
}
// log.Println("gitBranches: ", spew.Sdump(gitBranches))
var nameSet = make(map[string]bool)
for _, name := range gitBranches {
nameSet[name] = true

View File

@@ -253,16 +253,17 @@ func LoadContexts(params LoadContextsParams) (*shared.LoadContextResponse, []*Co
context := Context{
// Id generated by db layer
OrgId: orgId,
OwnerId: userId,
PlanId: planId,
ContextType: params.ContextType,
Name: params.Name,
Url: params.Url,
FilePath: params.FilePath,
NumTokens: numTokensByTempId[tempId],
Sha: sha,
Body: params.Body,
OrgId: orgId,
OwnerId: userId,
PlanId: planId,
ContextType: params.ContextType,
Name: params.Name,
Url: params.Url,
FilePath: params.FilePath,
NumTokens: numTokensByTempId[tempId],
Sha: sha,
Body: params.Body,
ForceSkipIgnore: params.ForceSkipIgnore,
}
err := StoreContext(&context)

View File

@@ -298,34 +298,36 @@ type repoLock struct {
// This allows us to store them in a git repo and use git to manage history.
type Context struct {
Id string `json:"id"`
OrgId string `json:"orgId"`
OwnerId string `json:"ownerId"`
PlanId string `json:"planId"`
ContextType shared.ContextType `json:"contextType"`
Name string `json:"name"`
Url string `json:"url"`
FilePath string `json:"filePath"`
Sha string `json:"sha"`
NumTokens int `json:"numTokens"`
Body string `json:"body,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
Id string `json:"id"`
OrgId string `json:"orgId"`
OwnerId string `json:"ownerId"`
PlanId string `json:"planId"`
ContextType shared.ContextType `json:"contextType"`
Name string `json:"name"`
Url string `json:"url"`
FilePath string `json:"filePath"`
Sha string `json:"sha"`
NumTokens int `json:"numTokens"`
Body string `json:"body,omitempty"`
ForceSkipIgnore bool `json:"forceSkipIgnore"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (context *Context) ToApi() *shared.Context {
return &shared.Context{
Id: context.Id,
OwnerId: context.OwnerId,
ContextType: context.ContextType,
Name: context.Name,
Url: context.Url,
FilePath: context.FilePath,
Sha: context.Sha,
NumTokens: context.NumTokens,
Body: context.Body,
CreatedAt: context.CreatedAt,
UpdatedAt: context.UpdatedAt,
Id: context.Id,
OwnerId: context.OwnerId,
ContextType: context.ContextType,
Name: context.Name,
Url: context.Url,
FilePath: context.FilePath,
Sha: context.Sha,
NumTokens: context.NumTokens,
Body: context.Body,
ForceSkipIgnore: context.ForceSkipIgnore,
CreatedAt: context.CreatedAt,
UpdatedAt: context.UpdatedAt,
}
}

View File

@@ -120,6 +120,10 @@ func GitListBranches(orgId, planId string) ([]string, error) {
branches := strings.Split(strings.TrimSpace(out.String()), "\n")
if len(branches) == 0 || (len(branches) == 1 && branches[0] == "") {
return []string{"main"}, nil
}
return branches, nil
}

View File

@@ -7,8 +7,9 @@ import (
"strings"
)
func CreateInvite(invite *Invite) error {
_, err := Conn.NamedExec(`INSERT INTO invites (id, org_id, email, name, inviter_id) VALUES (:id, :org_id, :email, :name, :inviter_id)`, invite)
func CreateInvite(invite *Invite, tx *sql.Tx) error {
err := tx.QueryRow("INSERT INTO invites (org_id, email, name, inviter_id) RETURNING id", invite.OrgId, invite.Email, invite.Name, invite.InviterId).Scan(&invite.Id)
if err != nil {
return fmt.Errorf("error creating invite: %v", err)
}
@@ -21,6 +22,10 @@ func GetInvite(id string) (*Invite, error) {
err := Conn.Get(&invite, "SELECT * FROM invites WHERE id = $1", id)
if err != nil {
if err == sql.ErrNoRows {
return nil, nil
}
return nil, fmt.Errorf("error getting invite: %v", err)
}

View File

@@ -75,7 +75,7 @@ func CreateOrg(req *shared.CreateOrgRequest, userId string, domain *string, tx *
OwnerId: userId,
}
err := tx.QueryRow("INSERT INTO orgs (name, domain, auto_add_domain_users, owner_id) VALUES ($1, $2, $3, $4) RETURNING id", req.Name, domain, req.AutoAddDomainUsers, userId).Scan(&org.Id)
err := tx.QueryRow("INSERT INTO orgs (name, domain, auto_add_domain_users, owner_id, is_trial) VALUES ($1, $2, $3, $4, false) RETURNING id", req.Name, domain, req.AutoAddDomainUsers, userId).Scan(&org.Id)
if err != nil {
if IsNonUniqueErr(err) {

View File

@@ -60,3 +60,14 @@ func ListUsers(orgId string) ([]*User, error) {
func CreateUser(user *User, tx *sql.Tx) error {
return tx.QueryRow("INSERT INTO users (name, email, domain, is_trial) VALUES ($1, $2, $3, $4) RETURNING id", user.Name, user.Email, user.Domain, user.IsTrial).Scan(&user.Id)
}
func NumUsersWithRole(orgId, roleId string) (int, error) {
var count int
err := Conn.Get(&count, "SELECT COUNT(*) FROM orgs_users WHERE org_id = $1 AND org_role_id = $2", orgId, roleId)
if err != nil {
return 0, fmt.Errorf("error counting users with role: %v", err)
}
return count, nil
}

View File

@@ -5,46 +5,11 @@ import (
"net/smtp"
"os"
"github.com/atotto/clipboard"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/session"
"github.com/aws/aws-sdk-go/service/ses"
"github.com/gen2brain/beeep"
)
func SendVerificationEmail(email string, pin string) error {
// Check if the environment is production
if os.Getenv("GOENV") == "production" {
// Production environment - send email using AWS SES
subject := "Your Plandex Pin"
htmlBody := fmt.Sprintf("<p>Hi there,</p><p>Your pin is: <strong>%s</strong></p>", pin)
textBody := fmt.Sprintf("Hi there,\n\nYour pin is: %s", pin)
if os.Getenv("IS_CLOUD") == "" {
return sendEmailViaSMTP(email, subject, htmlBody, textBody)
} else {
return sendEmailViaSES(email, subject, htmlBody, textBody)
}
}
if os.Getenv("GOENV") == "development" {
// Development environment
// Copy pin to clipboard
if err := clipboard.WriteAll(pin); err != nil {
return fmt.Errorf("error copying pin to clipboard in dev: %v", err)
}
// Send notification
err := beeep.Notify("Verification Pin", fmt.Sprintf("Verification pin %s copied to clipboard %s", pin, email), "")
if err != nil {
return fmt.Errorf("error sending notification in dev: %v", err)
}
}
return nil
}
// sendEmailViaSES sends an email using AWS SES
func sendEmailViaSES(recipient, subject, htmlBody, textBody string) error {
sess, err := session.NewSession()
@@ -78,7 +43,7 @@ func sendEmailViaSES(recipient, subject, htmlBody, textBody string) error {
Data: aws.String(subject),
},
},
Source: aws.String("support@plandex.ai"),
Source: aws.String("Plandex <support@plandex.ai>"),
}
// Attempt to send the email.

View File

@@ -0,0 +1,34 @@
package email
import (
"fmt"
"os"
"github.com/gen2brain/beeep"
)
func SendInviteEmail(email, inviteeFirstName, inviterName, orgName string) error {
// Check if the environment is production
if os.Getenv("GOENV") == "production" {
// Production environment - send email using AWS SES
subject := fmt.Sprintf("%s, you've been invited to join %s on Plandex", inviteeFirstName, orgName)
htmlBody := fmt.Sprintf(`<p>Hi %s,</p><p>%s has invited you to join the org <strong>%s</strong> on <a href="https://plandex.ai">Plandex.</a></p><p>Plandex is a terminal-based AI programming engine for complex tasks.</p><p>To accept the invite, first <a href="https://github.com/plandex-ai/plandex?tab=readme-ov-file#install">install Plandex</a>, then open a terminal and run 'plandex sign-in'. Enter '%s' when asked for your email and follow the prompts from there.</p><p>If you have questions, feedback, or run into a problem, you can reply directly to this email, <a href="https://github.com/plandex-ai/plandex/discussions">start a discussion</a>, or <a href="https://github.com/plandex-ai/plandex/issues">open an issue.</a></p>`, inviteeFirstName, inviterName, orgName, email)
textBody := fmt.Sprintf(`Hi %s,\n\n%s has invited you to join the org %s on Plandex.\n\nPlandex is a terminal-based AI programming engine for complex tasks.\n\nTo accept the invite, first install Plandex (https://github.com/plandex-ai/plandex?tab=readme-ov-file#install), then open a terminal and run 'plandex sign-in'. Enter '%s' when asked for your email and follow the prompts from there.\n\nIf you have questions, feedback, or run into a problem, you can reply directly to this email, start a discussion (https://github.com/plandex-ai/plandex/discussions), or open an issue (https://github.com/plandex-ai/plandex/issues).`, inviteeFirstName, inviterName, orgName, email)
if os.Getenv("IS_CLOUD") == "" {
return sendEmailViaSMTP(email, subject, htmlBody, textBody)
} else {
return sendEmailViaSES(email, subject, htmlBody, textBody)
}
} else {
// Send notification
err := beeep.Notify("Invite Sent", fmt.Sprintf("Invite sent to %s (email not sent in development)", email), "")
if err != nil {
return fmt.Errorf("error sending notification in dev: %v", err)
}
}
return nil
}

View File

@@ -0,0 +1,42 @@
package email
import (
"fmt"
"os"
"github.com/atotto/clipboard"
"github.com/gen2brain/beeep"
)
func SendVerificationEmail(email string, pin string) error {
// Check if the environment is production
if os.Getenv("GOENV") == "production" {
// Production environment - send email using AWS SES
subject := "Your Plandex Pin"
htmlBody := fmt.Sprintf("<p>Hi there,</p><p>Welcome to Plandex!</p><p>Your pin is:<br><strong>%s</strong></p><p>It will be valid for the next 5 minutes. Please return to the terminal and paste in your pin.</p>", pin)
textBody := fmt.Sprintf("Hi there,\n\nWelcome to Plandex!\n\nYour pin is:\n%s\n\nIt will be valid for the next 5 minutes. Please return to the terminal and paste in your pin.", pin)
if os.Getenv("IS_CLOUD") == "" {
return sendEmailViaSMTP(email, subject, htmlBody, textBody)
} else {
return sendEmailViaSES(email, subject, htmlBody, textBody)
}
}
if os.Getenv("GOENV") == "development" {
// Development environment
// Copy pin to clipboard
if err := clipboard.WriteAll(pin); err != nil {
return fmt.Errorf("error copying pin to clipboard in dev: %v", err)
}
// Send notification
err := beeep.Notify("Verification Pin", fmt.Sprintf("Verification pin %s copied to clipboard %s", pin, email), "")
if err != nil {
return fmt.Errorf("error sending notification in dev: %v", err)
}
}
return nil
}

View File

@@ -53,7 +53,7 @@ func lockRepo(w http.ResponseWriter, r *http.Request, auth *types.ServerAuth, sc
http.Error(w, "Error locking repo: "+err.Error(), http.StatusInternalServerError)
}
log.Println("Rolling back repo if error")
// log.Println("Rolling back repo if error")
err = RollbackRepoIfErr(auth.OrgId, planId, err)
if err != nil {
log.Printf("Error rolling back repo: %v\n", err)

View File

@@ -5,6 +5,7 @@ import (
"log"
"net/http"
"plandex-server/db"
"plandex-server/email"
"plandex-server/types"
"strings"
@@ -110,13 +111,32 @@ func InviteUserHandler(w http.ResponseWriter, r *http.Request) {
return
}
// start a transaction
tx, err := db.Conn.Begin()
if err != nil {
log.Printf("Error starting transaction: %v\n", err)
http.Error(w, "Error starting transaction: "+err.Error(), http.StatusInternalServerError)
return
}
// Ensure that rollback is attempted in case of failure
defer func() {
if err != nil {
if rbErr := tx.Rollback(); rbErr != nil {
log.Printf("transaction rollback error: %v\n", rbErr)
} else {
log.Println("transaction rolled back")
}
}
}()
err = db.CreateInvite(&db.Invite{
OrgId: auth.OrgId,
OrgRoleId: req.OrgRoleId,
Email: req.Email,
Name: req.Name,
InviterId: currentUserId,
})
}, tx)
if err != nil {
log.Printf("Error creating invite: %v\n", err)
@@ -124,6 +144,22 @@ func InviteUserHandler(w http.ResponseWriter, r *http.Request) {
return
}
err = email.SendInviteEmail(req.Email, req.Name, auth.User.Name, org.Name)
if err != nil {
log.Printf("Error sending invite email: %v\n", err)
http.Error(w, "Error sending invite email: "+err.Error(), http.StatusInternalServerError)
return
}
// commit transaction
err = tx.Commit()
if err != nil {
log.Printf("Error committing transaction: %v\n", err)
http.Error(w, "Error committing transaction: "+err.Error(), http.StatusInternalServerError)
return
}
log.Println("Successfully created invite")
}
@@ -277,9 +313,9 @@ func DeleteInviteHandler(w http.ResponseWriter, r *http.Request) {
return
}
if invite.OrgId != auth.OrgId {
log.Printf("Invite does not belong to org: %v\n", inviteId)
http.Error(w, "Invite does not belong to org: "+inviteId, http.StatusBadRequest)
if invite == nil || invite.OrgId != auth.OrgId {
log.Printf("Invite not found: %v\n", inviteId)
http.Error(w, "Invite not found: "+inviteId, http.StatusNotFound)
return
}

View File

@@ -8,6 +8,7 @@ import (
"os"
"plandex-server/db"
"plandex-server/host"
"time"
"github.com/plandex/plandex/shared"
)
@@ -54,7 +55,9 @@ func proxyActivePlanMethod(w http.ResponseWriter, r *http.Request, planId, branc
}
func proxyRequest(w http.ResponseWriter, originalRequest *http.Request, url string) {
client := &http.Client{}
client := &http.Client{
Timeout: time.Second * 10,
}
// Create a new request based on the original request
req, err := http.NewRequest(originalRequest.Method, url, originalRequest.Body)

View File

@@ -98,6 +98,30 @@ func DeleteOrgUserHandler(w http.ResponseWriter, r *http.Request) {
return
}
// verify user isn't the only org owner
ownerRoleId, err := db.GetOrgOwnerRoleId()
if err != nil {
log.Printf("Error getting org owner role id: %v\n", err)
http.Error(w, "Error getting org owner role id: "+err.Error(), http.StatusInternalServerError)
return
}
if user.OrgRoleId == ownerRoleId {
numOwners, err := db.NumUsersWithRole(auth.OrgId, ownerRoleId)
if err != nil {
log.Printf("Error getting number of org owners: %v\n", err)
http.Error(w, "Error getting number of org owners: "+err.Error(), http.StatusInternalServerError)
return
}
if numOwners == 1 {
log.Println("Cannot delete the only org owner")
http.Error(w, "Cannot delete the only org owner", http.StatusForbidden)
return
}
}
// start a transaction
tx, err := db.Conn.Begin()
if err != nil {

View File

@@ -28,19 +28,19 @@ func getPlanResult(params planResultParams) (*db.PlanFileResult, bool) {
filePath := params.filePath
currentState := params.currentState
streamedChanges := params.streamedChanges
fileContent := params.fileContent
// fileContent := params.fileContent
currentStateLines := strings.Split(currentState, "\n")
// fileContentLines := strings.Split(fileContent, "\n")
log.Printf("\n\ngetPlanResult - path: %s\n", filePath)
log.Println("getPlanResult - currentState:")
log.Println(currentState)
log.Println("getPlanResult - currentStateLines:")
log.Println(currentStateLines)
log.Println("getPlanResult - fileContent:")
log.Println(fileContent)
log.Print("\n\n")
// log.Printf("\n\ngetPlanResult - path: %s\n", filePath)
// log.Println("getPlanResult - currentState:")
// log.Println(currentState)
// log.Println("getPlanResult - currentStateLines:")
// log.Println(currentStateLines)
// log.Println("getPlanResult - fileContent:")
// log.Println(fileContent)
// log.Print("\n\n")
var replacements []*shared.Replacement
for _, streamedChange := range streamedChanges {

View File

@@ -78,17 +78,18 @@ const (
)
type Context struct {
Id string `json:"id"`
OwnerId string `json:"ownerId"`
ContextType ContextType `json:"contextType"`
Name string `json:"name"`
Url string `json:"url"`
FilePath string `json:"file_path"`
Sha string `json:"sha"`
NumTokens int `json:"numTokens"`
Body string `json:"body,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
Id string `json:"id"`
OwnerId string `json:"ownerId"`
ContextType ContextType `json:"contextType"`
Name string `json:"name"`
Url string `json:"url"`
FilePath string `json:"file_path"`
Sha string `json:"sha"`
NumTokens int `json:"numTokens"`
Body string `json:"body,omitempty"`
ForceSkipIgnore bool `json:"forceSkipIgnore"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type ConvoMessage struct {

View File

@@ -139,11 +139,12 @@ type RespondMissingFileRequest struct {
}
type LoadContextParams struct {
ContextType ContextType `json:"contextType"`
Name string `json:"name"`
Url string `json:"url"`
FilePath string `json:"file_path"`
Body string `json:"body"`
ContextType ContextType `json:"contextType"`
Name string `json:"name"`
Url string `json:"url"`
FilePath string `json:"file_path"`
Body string `json:"body"`
ForceSkipIgnore bool `json:"forceSkipIgnore"`
}
type LoadContextRequest []*LoadContextParams

View File

@@ -171,7 +171,37 @@ Model changes are versioned and can be rewound or applied to a branch just like
### .plandex and teams
When you run `plandex new` for the first time in any directory, Plandex will create a `.plandex` directory there for light project-level config. While the `.plandex` directory can be safely added to version control, **if multiple developers are using Plandex with the same project, then for now you should add `.plandex/` to .gitignore (or the ignore file for whatever VCS you use).** When orgs and teams are fully implemented (currently a WIP), it can be re-added to version control so that all team members can work from the same org and project.
When you run `plandex new` for the first time in any directory, Plandex will create a `.plandex` directory there for light project-level config. The `.plandex` directory can be safely added to version control.
### Orgs
If multiple people are using Plandex with the same project, they should all join the same **org** so that they aren't overwriting each other's `.plandex` directories.
When creating a new org, you have the option of automatically granting access to anyone with an email address on your domain. If you choose not to do this, or you want to invite someone from outside your email domain, you can use `plandex invite`.
```bash
plandex invite # follow prompts to invite a new user
```
To join an org you've been invited to, use `plandex sign-in`.
```bash
plandex sign-in # follow the prompts to sign in to your org
```
To list users and pending invites:
```bash
plandex users
```
To revoke an invite or remove a user:
```bash
plandex revoke # follow prompts to revoke an invite or remove a user
```
Orgs will be the basis for plan sharing and collaboriation in future releases.
### Directories
@@ -179,7 +209,7 @@ So far, we've assumed you're running `plandex new` to create plans in your proje
When you run `plandex plans`, in addition to showing you plans in the current directory, Plandex will also show you plans in nearby parent directories or subdirectories. This helps you keep track of what plans you're working on and where they are in your project hierarchy. If you want to switch to a plan in a different directory, first `cd` into that directory, then run `plandex cd` to select the plan.
```bash
<!-- ```bash
cd your-project
plandex new -n root-project-plan # cwd is 'your-project'
plandex current # 'your-project' current plan is root-project-plan
@@ -193,16 +223,13 @@ cd ../ # cwd is now 'your-project', current plan is root-project-plan
plandex plans # shows root-project-plan in current directory + subdir-plan1 and subdir-plan2 in child directory 'some-subdirectory'
cd some-subdirectory # cwd is now 'some-subdirectory', current plan is subdir-plan2
plandex cd subdir-plan1 # cwd is still 'some-subdirectory', current plan is now subdir-plan1
```
``` -->
One more thing to note on directories: you can load context from parent or sibling directories if needed by using `..` in your load paths.
```bash
cd your-project
cd some-subdirectory
plandex cd subdir-plan1
plandex load ../file.go # loads your-project/file.go into subdir-plan1 context
plandex load ../other-subdirectory/test.go # loads your-project/other-subdirectory/test.go into subdir-plan1 context
plandex load ../file.go # loads file.go from parent directory
plandex load ../sibling-dir/test.go # loads test.go from sibling directory
```
### Ignoring files

Binary file not shown.

After

Width:  |  Height:  |  Size: 599 KiB

View File

@@ -0,0 +1,14 @@
Let's add the following commands to the 'app/cli/cmd' directory.
For all commands, look at the cli/types/api.go and shared/req_res.go files for the API request and response types.
Commands:
invite.go - invite a new user to the org. look at the 'checkout' command on accepting optional parameters or prompting if parameters aren't provided.
users.go - list all users in the org, as well as all pending invites in the org, then list them in a table like the one in the 'plans' command.
revoke.go - revoke an invite or remove a user from the org. optionally accept an email parameter. if email is supplied, revoke the user or invite with that email. if email is not supplied, prompt the user to select a user or invite to revoke, similar to how branches are selected in the 'checkout' command.

View File

@@ -0,0 +1,2 @@
- Fix for context update of directory tree when some paths are ignored
- Fix for `plandex branches` command showing no branches immediately after plan creation rather than showing the default 'main' branch

View File

@@ -0,0 +1,4 @@
- Improvements to copy for email verification emails
- Fix for org creation when creating a new account
- Send an email to invited user when they are invited to an org
- Add timeout when forwarding requests from one instance to another within a cluster