Nothing in the tree could be renamed, moved or removed, and an approved person stayed in the project they were first put in — from the web or the CLI. Now: - /admin/users is the tree, each project opening onto its people and keys, with approve, move, suspend, reinstate and remove for people, revoke for keys, and add, rename, move and remove at every level, plus a search over every account. Forms post and get the page back, so it works with no script; htmx 2.0.11 (0BSD, embedded, checked against npm's integrity hash) swaps only the changed part, keeping open projects open. - llmgw org tree/add/rename/move/remove, users move/delete, and keys list --all. - Projects, teams and organizations are named by id, path (Acme/platform/gateway) or unique name; users by id or email. With a single project --project can be left out, instead of defaulting to "default", which a tree built from the web does not have. Moving a person moves the keys they made; operator keys stay with the project. Only empty things are removed, and never a project that had a key, since usage history refers to it. Also: keys init no longer reparents an existing team or project; reinstating an account with no project is refused (a rejected signup used to come back active and fail at its first key); rejecting a signup removes it; keys limits --budget takes whole units like the quotas page. Checked in headless Chrome: a move from an open project updates the page in place, keeps the project open, and the search still filters. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
335 lines
9.2 KiB
Go
335 lines
9.2 KiB
Go
package cli
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
|
|
"github.com/spf13/cobra"
|
|
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
|
|
)
|
|
|
|
func newOrgCmd() *cobra.Command {
|
|
var sf storeFlags
|
|
cmd := &cobra.Command{
|
|
Use: "org",
|
|
Short: "Organizations, teams and projects: see, add, rename, move, remove",
|
|
Long: "The tenancy tree an account lives in. A person is in a project, a project\n" +
|
|
"in a team, a team in an organization, and limits can be set on any of them.\n\n" +
|
|
"Anything that takes one of them accepts its id, its path\n" +
|
|
"(\"Acme/platform/gateway\") or its name when that name is unique.",
|
|
}
|
|
sf.bind(cmd)
|
|
open := func(cmd *cobra.Command) (*store.DB, error) { return sf.open(cmd) }
|
|
cmd.AddCommand(newOrgTreeCmd(open), newOrgAddCmd(open), newOrgRenameCmd(open),
|
|
newOrgMoveCmd(open), newOrgRemoveCmd(open))
|
|
return cmd
|
|
}
|
|
|
|
type dbOpener func(*cobra.Command) (*store.DB, error)
|
|
|
|
func newOrgTreeCmd(open dbOpener) *cobra.Command {
|
|
var people bool
|
|
cmd := &cobra.Command{
|
|
Use: "tree",
|
|
Short: "Show the whole tree, with how many accounts and keys each project has",
|
|
Args: cobra.NoArgs,
|
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
|
s, err := open(cmd)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer s.Close()
|
|
tree, err := s.Tree(cmd.Context())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var byProject map[string][]store.User
|
|
if people {
|
|
users, err := s.Users(cmd.Context(), "")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
byProject = map[string][]store.User{}
|
|
for _, u := range users {
|
|
byProject[u.ProjectID] = append(byProject[u.ProjectID], u)
|
|
}
|
|
}
|
|
printTree(cmd.OutOrStdout(), tree, byProject)
|
|
if people && len(byProject[""]) > 0 {
|
|
fmt.Fprintf(cmd.OutOrStdout(), "\nwaiting for approval\n")
|
|
for _, u := range byProject[""] {
|
|
fmt.Fprintf(cmd.OutOrStdout(), " %s %s %s\n", u.Email, u.State, u.ID)
|
|
}
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
cmd.Flags().BoolVar(&people, "people", false, "list the accounts in each project")
|
|
return cmd
|
|
}
|
|
|
|
func printTree(w io.Writer, tree []store.TreeOrg, people map[string][]store.User) {
|
|
if len(tree) == 0 {
|
|
fmt.Fprintln(w, "no organizations yet; `llmgw org add org <name>` starts one")
|
|
return
|
|
}
|
|
for _, o := range tree {
|
|
fmt.Fprintf(w, "%s (%s)\n", o.Name, o.ID)
|
|
if len(o.Teams) == 0 {
|
|
fmt.Fprintln(w, " no teams")
|
|
}
|
|
for _, t := range o.Teams {
|
|
fmt.Fprintf(w, " %s (%s)\n", t.Name, t.ID)
|
|
if len(t.Projects) == 0 {
|
|
fmt.Fprintln(w, " no projects")
|
|
}
|
|
for _, p := range t.Projects {
|
|
fmt.Fprintf(w, " %s (%s) %s, %s\n", p.Name, p.ID,
|
|
countOf(p.Users, "account"), keysOf(p))
|
|
for _, u := range people[p.ID] {
|
|
admin := ""
|
|
if u.IsAdmin {
|
|
admin = " admin"
|
|
}
|
|
fmt.Fprintf(w, " %s %s%s\n", u.Email, u.State, admin)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func keysOf(p store.TreeProject) string {
|
|
s := countOf(p.ActiveKeys, "key")
|
|
if revoked := p.Keys - p.ActiveKeys; revoked > 0 {
|
|
s += fmt.Sprintf(" (+%d revoked)", revoked)
|
|
}
|
|
return s
|
|
}
|
|
|
|
func countOf(n int, noun string) string {
|
|
if n == 1 {
|
|
return "1 " + noun
|
|
}
|
|
return fmt.Sprintf("%d %ss", n, noun)
|
|
}
|
|
|
|
func levelArg(arg string) (string, error) {
|
|
switch arg {
|
|
case "org", "organization":
|
|
return "org", nil
|
|
case "team", "project":
|
|
return arg, nil
|
|
}
|
|
return "", fmt.Errorf("%q is not org, team or project", arg)
|
|
}
|
|
|
|
func newOrgAddCmd(open dbOpener) *cobra.Command {
|
|
var in string
|
|
cmd := &cobra.Command{
|
|
Use: "add <org|team|project> <name>",
|
|
Short: "Add an organization, a team to one, or a project to a team",
|
|
Example: " llmgw org add org Acme\n" +
|
|
" llmgw org add team platform --in Acme\n" +
|
|
" llmgw org add project gateway --in Acme/platform",
|
|
Args: cobra.ExactArgs(2),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
level, err := levelArg(args[0])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
s, err := open(cmd)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer s.Close()
|
|
ctx := cmd.Context()
|
|
var id, where string
|
|
switch level {
|
|
case "org":
|
|
id, err = s.CreateOrg(ctx, args[1])
|
|
case "team":
|
|
var o store.Org
|
|
if o, err = resolveOrg(ctx, s, in); err == nil {
|
|
id, err = s.CreateTeam(ctx, o.ID, args[1])
|
|
where = " in " + o.Name
|
|
}
|
|
case "project":
|
|
var t store.TeamPath
|
|
if t, err = resolveTeam(ctx, s, in); err == nil {
|
|
id, err = s.CreateProject(ctx, t.ID, args[1])
|
|
where = " in " + t.OrgName + " / " + t.Name
|
|
}
|
|
}
|
|
if errors.Is(err, store.ErrDuplicateName) {
|
|
return fmt.Errorf("there is already a %s named %q there", level, args[1])
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
fmt.Fprintf(cmd.OutOrStdout(), "%s %s%s (%s)\n", level, args[1], where, id)
|
|
return nil
|
|
},
|
|
}
|
|
cmd.Flags().StringVar(&in, "in", "", "the organization a team goes in, or the team a project goes in")
|
|
return cmd
|
|
}
|
|
|
|
func newOrgRenameCmd(open dbOpener) *cobra.Command {
|
|
return &cobra.Command{
|
|
Use: "rename <org|team|project> <which> <new name>",
|
|
Short: "Rename an organization, team or project; ids and everything under it stay",
|
|
Example: " llmgw org rename project Acme/platform/gateway api-gateway",
|
|
Args: cobra.ExactArgs(3),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
level, err := levelArg(args[0])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
s, err := open(cmd)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer s.Close()
|
|
id, err := resolveLevel(cmd.Context(), s, level, args[1])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
switch level {
|
|
case "org":
|
|
err = s.RenameOrg(cmd.Context(), id, args[2])
|
|
case "team":
|
|
err = s.RenameTeam(cmd.Context(), id, args[2])
|
|
case "project":
|
|
err = s.RenameProject(cmd.Context(), id, args[2])
|
|
}
|
|
if errors.Is(err, store.ErrDuplicateName) {
|
|
return fmt.Errorf("there is already a %s named %q beside it", level, args[2])
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
fmt.Fprintf(cmd.OutOrStdout(), "%s %s is now %q\n", level, id, args[2])
|
|
return nil
|
|
},
|
|
}
|
|
}
|
|
|
|
func newOrgMoveCmd(open dbOpener) *cobra.Command {
|
|
var to string
|
|
cmd := &cobra.Command{
|
|
Use: "move <team|project> <which> --to <parent>",
|
|
Short: "Put a team in another organization, or a project in another team",
|
|
Long: "Everything under it moves too: projects, accounts and keys. From then on it\n" +
|
|
"inherits the new parent's limits. Usage already recorded is not rewritten.",
|
|
Example: " llmgw org move project Acme/platform/gateway --to Scania/ml\n" +
|
|
" llmgw org move team platform --to Scania",
|
|
Args: cobra.ExactArgs(2),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
s, err := open(cmd)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer s.Close()
|
|
ctx := cmd.Context()
|
|
switch args[0] {
|
|
case "team":
|
|
t, err := resolveTeam(ctx, s, args[1])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
o, err := resolveOrg(ctx, s, to)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := s.MoveTeam(ctx, t.ID, o.ID); err != nil {
|
|
return dupAs(err, "team", t.Name)
|
|
}
|
|
fmt.Fprintf(cmd.OutOrStdout(), "team %s is now in %s\n", t.Name, o.Name)
|
|
case "project":
|
|
p, err := resolveProject(ctx, s, args[1])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
t, err := resolveTeam(ctx, s, to)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := s.MoveProject(ctx, p.ID, t.ID); err != nil {
|
|
return dupAs(err, "project", p.Path)
|
|
}
|
|
fmt.Fprintf(cmd.OutOrStdout(), "%s is now in %s / %s\n", p.Path, t.OrgName, t.Name)
|
|
default:
|
|
return fmt.Errorf("only a team or a project moves; to move an account, `llmgw users move`")
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
cmd.Flags().StringVar(&to, "to", "", "the organization or team it goes to")
|
|
_ = cmd.MarkFlagRequired("to")
|
|
return cmd
|
|
}
|
|
|
|
func dupAs(err error, level, name string) error {
|
|
if errors.Is(err, store.ErrDuplicateName) {
|
|
return fmt.Errorf("the destination already has a %s named like %s", level, name)
|
|
}
|
|
return err
|
|
}
|
|
|
|
func newOrgRemoveCmd(open dbOpener) *cobra.Command {
|
|
return &cobra.Command{
|
|
Use: "remove <org|team|project> <which>",
|
|
Short: "Remove an organization, team or project that nothing is in",
|
|
Long: "Only something empty is removed: an organization with no teams, a team with\n" +
|
|
"no projects, a project with no accounts and no keys. A project that ever had\n" +
|
|
"a key keeps it, revoked, because usage history refers to it; rename such a\n" +
|
|
"project instead. Its limits go with it.",
|
|
Args: cobra.ExactArgs(2),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
level, err := levelArg(args[0])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
s, err := open(cmd)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer s.Close()
|
|
id, err := resolveLevel(cmd.Context(), s, level, args[1])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
switch level {
|
|
case "org":
|
|
err = s.DeleteOrg(cmd.Context(), id)
|
|
case "team":
|
|
err = s.DeleteTeam(cmd.Context(), id)
|
|
case "project":
|
|
err = s.DeleteProject(cmd.Context(), id)
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("%s %s was not removed: %w", level, args[1], err)
|
|
}
|
|
fmt.Fprintf(cmd.OutOrStdout(), "%s %s removed\n", level, id)
|
|
return nil
|
|
},
|
|
}
|
|
}
|
|
|
|
func resolveLevel(ctx context.Context, s *store.DB, level, arg string) (string, error) {
|
|
switch level {
|
|
case "org":
|
|
o, err := resolveOrg(ctx, s, arg)
|
|
return o.ID, err
|
|
case "team":
|
|
t, err := resolveTeam(ctx, s, arg)
|
|
return t.ID, err
|
|
default:
|
|
p, err := resolveProject(ctx, s, arg)
|
|
return p.ID, err
|
|
}
|
|
}
|