Files
Ilker ManapandClaude Opus 5.5 3104f4b706 Manage people and the tenancy tree, from the web and the CLI
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>
2026-09-28 16:26:39 +02:00

132 lines
3.6 KiB
Go

package cli
import (
"context"
"errors"
"fmt"
"strings"
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
)
// The tree can be built from the web, which mints ids like proj-3kq7..., or
// from `keys init`, which makes slugs. A command that wanted the id made the
// operator copy one off a page, and every --project defaulted to "default",
// which a tree built from the web does not have. These take what a person
// would type instead: an id, a path, or a name that is unique.
// normPath lowercases a path and spaces its separators the way the store joins
// them, so "acme/platform/gateway" matches "Acme / platform / gateway".
func normPath(p string) string {
parts := strings.Split(p, "/")
for i := range parts {
parts[i] = strings.ToLower(strings.TrimSpace(parts[i]))
}
return strings.Join(parts, " / ")
}
type candidate struct{ id, path string }
// pick finds the one candidate an argument names.
func pick(kind, arg string, all []candidate) (candidate, error) {
if arg == "" {
if len(all) == 1 {
return all[0], nil
}
if len(all) == 0 {
return candidate{}, fmt.Errorf("there is no %s yet; create one with `llmgw org add %s`", kind, kind)
}
return candidate{}, fmt.Errorf("which %s? give an id, a path or a name:\n%s", kind, listCandidates(all))
}
for _, c := range all {
if c.id == arg {
return c, nil
}
}
want := normPath(arg)
var matches []candidate
for _, c := range all {
full := normPath(c.path)
segs := strings.Split(full, " / ")
if full == want || segs[len(segs)-1] == want {
matches = append(matches, c)
}
}
switch len(matches) {
case 1:
return matches[0], nil
case 0:
return candidate{}, fmt.Errorf("no %s %q; there is:\n%s", kind, arg, listCandidates(all))
default:
return candidate{}, fmt.Errorf("%q names more than one %s; give its path:\n%s",
arg, kind, listCandidates(matches))
}
}
func listCandidates(cs []candidate) string {
var b strings.Builder
for _, c := range cs {
fmt.Fprintf(&b, " %-40s %s\n", c.path, c.id)
}
return strings.TrimRight(b.String(), "\n")
}
func resolveProject(ctx context.Context, s *store.DB, arg string) (store.ProjectPath, error) {
all, err := s.AllProjects(ctx)
if err != nil {
return store.ProjectPath{}, err
}
cs := make([]candidate, len(all))
for i, p := range all {
cs[i] = candidate{p.ID, p.Path}
}
c, err := pick("project", arg, cs)
return store.ProjectPath{ID: c.id, Path: c.path}, err
}
func resolveTeam(ctx context.Context, s *store.DB, arg string) (store.TeamPath, error) {
all, err := s.AllTeams(ctx)
if err != nil {
return store.TeamPath{}, err
}
cs := make([]candidate, len(all))
for i, t := range all {
cs[i] = candidate{t.ID, t.OrgName + " / " + t.Name}
}
c, err := pick("team", arg, cs)
if err != nil {
return store.TeamPath{}, err
}
for _, t := range all {
if t.ID == c.id {
return t, nil
}
}
return store.TeamPath{}, store.ErrNotFound
}
func resolveOrg(ctx context.Context, s *store.DB, arg string) (store.Org, error) {
all, err := s.AllOrgs(ctx)
if err != nil {
return store.Org{}, err
}
cs := make([]candidate, len(all))
for i, o := range all {
cs[i] = candidate{o.ID, o.Name}
}
c, err := pick("org", arg, cs)
return store.Org{ID: c.id, Name: c.path}, err
}
// resolveUser takes an account's id or its email.
func resolveUser(ctx context.Context, s *store.DB, arg string) (store.User, error) {
if u, err := s.UserByID(ctx, arg); err == nil {
return u, nil
}
u, err := s.UserByEmail(ctx, arg)
if errors.Is(err, store.ErrNotFound) {
return u, fmt.Errorf("no account with id or email %q; `llmgw users list` shows them", arg)
}
return u, err
}