Alex Dunmow 7615bd92ca feat(cli): multi-account login, private-plugin SDK, publish dirty handling
- ninja login forces account selection (interactive when >1); creds now
  carry ActiveAccountID/Slug. New `ninja account` group.
- ninja plugin list / delete / delete-version split public vs active-account
  @private sections; `publish --private` is sticky in plugin.mod.
- GetPluginRequest gains active_account_id so @private resolution works
  alongside the public (scope, name) path.
- publish auto-commits a dirty plugin.mod (path-scoped, leaves other staged
  paths alone) so the bump→publish loop never trips the dirty check.
  --allow-dirty is replaced with --strict (default now ships dirty trees
  via stash-create).
- bump auto-commits its plugin.mod write with `bump to X.Y.Z`; --no-commit
  opts out.
- Design doc updated to match the new defaults.
2026-06-04 08:49:23 +08:00

89 lines
2.2 KiB
Go

package creds
import (
"encoding/json"
"errors"
"os"
"path/filepath"
)
type Credentials struct {
DefaultHost string `json:"default_host"`
Hosts map[string]HostCreds `json:"hosts"`
}
type HostCreds struct {
Token string `json:"token"`
User string `json:"user,omitempty"`
DefaultScope string `json:"default_scope,omitempty"`
// ActiveAccountID is the orchestrator-side UUID of the account that
// account-scoped commands (notably `ninja plugins publish --private`)
// operate against. Set during `ninja login` (forced selection when the
// user belongs to more than one account) and changeable via
// `ninja account set`.
ActiveAccountID string `json:"active_account_id,omitempty"`
// ActiveAccountSlug mirrors ActiveAccountID in human-readable form for
// display in CLI output. The orchestrator-side slug is authoritative;
// the CLI refreshes it whenever it talks to the server.
ActiveAccountSlug string `json:"active_account_slug,omitempty"`
}
func filePath() (string, error) {
dir, err := os.UserConfigDir()
if err != nil {
return "", err
}
return filepath.Join(dir, "ninja", "credentials.json"), nil
}
func Load() (*Credentials, error) {
p, err := filePath()
if err != nil {
return nil, err
}
b, err := os.ReadFile(p)
if errors.Is(err, os.ErrNotExist) {
return &Credentials{Hosts: map[string]HostCreds{}}, nil
}
if err != nil {
return nil, err
}
c := &Credentials{Hosts: map[string]HostCreds{}}
if err := json.Unmarshal(b, c); err != nil {
return nil, err
}
return c, nil
}
func (c *Credentials) Save() error {
p, err := filePath()
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil {
return err
}
b, err := json.MarshalIndent(c, "", " ")
if err != nil {
return err
}
return os.WriteFile(p, b, 0o600)
}
func (c *Credentials) Resolve(host string) (string, HostCreds, error) {
if host == "" {
host = c.DefaultHost
}
if host == "" {
host = "https://my.blockninjacms.com"
}
if t := os.Getenv("NINJA_TOKEN"); t != "" {
return host, HostCreds{Token: t}, nil
}
hc, ok := c.Hosts[host]
if !ok || hc.Token == "" {
return host, HostCreds{}, errors.New("not logged in; run `ninja login`")
}
return host, hc, nil
}