442d1ebaf4
Review fix for #13. The CP org verbs targeted /api/v1/orgs*, which is gated by RequireSession() (WorkOS cookie-only) — a bearer-token CLI can't authenticate and these 401 in prod; the tenant Org API Key has no standing on the CP at all. - org create/list now target the CP ADMIN routes (POST/GET /api/v1/admin/orgs, AdminGate bearer), authenticated with a DISTINCT credential MOLECULE_CP_ADMIN_TOKEN (never the tenant MOLECULE_API_KEY). create now requires --owner-user-id, per controlplane adminCreateOrgRequest{slug,name,owner_user_id}. ListOrgs decodes the {limit,offset,orgs[]} admin-summary envelope. Two-credential split is documented in `org`/`org create` help text; the org key is never sent to the CP. - org get/export have NO AdminGate-reachable route on the CP (session-only), so they fail fast with a clear "session-only, use the dashboard" error instead of shipping verbs that 401. - cpAdminClient() fails fast with guidance when MOLECULE_CP_ADMIN_TOKEN is unset (wrong-credential path), rather than silently sending the org key to the CP. - Wire Execute() through handleErr so SilenceErrors'd exitError messages actually print (they were previously swallowed by main's bare os.Exit(1)) — required for the fail-fast guidance to reach the user. - Optional cleanup: extract resolveBillingMode()/budgetLimitsFromFlags() so prod and tests share one definition. - Tests: client + cmd assert org verbs hit /api/v1/admin/orgs with the CP-admin bearer (no org-id header, no org-key leak), the missing-owner and missing-admin-token fail-fast paths, get/export fail-fast, and an e2e CLI test that `org list` without the admin token exits non-zero naming MOLECULE_CP_ADMIN_TOKEN. Budget shape (budget_limits) left unchanged — confirmed correct; the OpenAPI spec is the stale one (fixed separately on #2056). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
319 lines
11 KiB
Go
319 lines
11 KiB
Go
// Package cmd implements the CLI command tree.
|
|
package cmd
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"text/tabwriter"
|
|
|
|
"github.com/spf13/cobra"
|
|
"go.moleculesai.app/cli/internal/client"
|
|
)
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Org command group.
|
|
//
|
|
// TWO credentials, two surfaces — do not conflate them:
|
|
//
|
|
// - Org-LIFECYCLE verbs (create / list) talk to the CONTROL-PLANE ADMIN API
|
|
// (MOLECULE_CP_URL, default = api-url) and need a CP-ADMIN bearer in
|
|
// MOLECULE_CP_ADMIN_TOKEN. The customer-facing /api/v1/orgs* routes are
|
|
// WorkOS-session-gated (RequireSession) and cannot be reached by any
|
|
// bearer-token CLI; the admin routes (/api/v1/admin/orgs, AdminGate) can.
|
|
// The tenant Org API Key is NEVER sent to the control plane.
|
|
//
|
|
// - org get / org export have NO bearer-reachable CP route (session-only),
|
|
// so they fail fast with guidance rather than 401'ing.
|
|
//
|
|
// - Tenant-scoped sub-verbs (create --template, token *, allowlist) talk to
|
|
// the TENANT host (api-url) with the Org API Key (MOLECULE_API_KEY +
|
|
// MOLECULE_ORG_ID), per PLATFORM-MANAGEMENT-API.md §1/§3.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
var orgCmd = &cobra.Command{
|
|
Use: "org",
|
|
Short: "Manage organizations (control plane) and org-scoped tenant resources",
|
|
Long: `Manage organizations.
|
|
|
|
Org-lifecycle verbs (create, list) use the CONTROL-PLANE ADMIN API and require
|
|
a CP-admin bearer token — a credential DISTINCT from the tenant Org API Key:
|
|
|
|
MOLECULE_CP_ADMIN_TOKEN CP admin bearer (org create/list)
|
|
MOLECULE_CP_URL CP base URL (default: --api-url)
|
|
|
|
The tenant Org API Key (MOLECULE_API_KEY / MOLECULE_ORG_ID) is used ONLY for
|
|
the tenant-scoped sub-verbs (create --template, token *, allowlist) and is
|
|
never sent to the control plane.
|
|
|
|
org get / org export are not available to token-authenticated callers (the
|
|
control plane gates them behind a WorkOS browser session); use the dashboard.`,
|
|
}
|
|
|
|
func init() {
|
|
orgCmd.AddCommand(
|
|
orgListCmd, orgGetCmd, orgCreateCmd, orgExportCmd,
|
|
orgTokenCmd, orgAllowlistCmd,
|
|
)
|
|
orgTokenCmd.AddCommand(orgTokenListCmd, orgTokenCreateCmd, orgTokenRevokeCmd)
|
|
}
|
|
|
|
// ===========================================================================
|
|
// molecule org list
|
|
// ===========================================================================
|
|
var orgListCmd = &cobra.Command{
|
|
Use: "list",
|
|
Short: "List organizations (control plane)",
|
|
RunE: runOrgList,
|
|
}
|
|
|
|
func runOrgList(_ *cobra.Command, _ []string) error {
|
|
cp, err := cpAdminClient()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
orgs, err := cp.ListOrgs()
|
|
if err != nil {
|
|
return fmt.Errorf("org list: %w", err)
|
|
}
|
|
if outputFormat == "json" {
|
|
return printJSON(orgs)
|
|
}
|
|
if outputFormat == "yaml" {
|
|
return printYAML(orgs)
|
|
}
|
|
if len(orgs) == 0 {
|
|
fmt.Println("No organizations found.")
|
|
return nil
|
|
}
|
|
w := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
|
|
fmt.Fprintln(w, "SLUG\tNAME\tPLAN\tINSTANCE\tMEMBERS")
|
|
for _, o := range orgs {
|
|
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%d\n", o.Slug, o.Name, o.Plan, o.InstanceStatus, o.MemberCount)
|
|
}
|
|
return w.Flush()
|
|
}
|
|
|
|
// ===========================================================================
|
|
// molecule org get <slug>
|
|
// ===========================================================================
|
|
var orgGetCmd = &cobra.Command{
|
|
Use: "get <slug>",
|
|
Short: "Show a single org (UNAVAILABLE via token auth — session-only on the CP)",
|
|
Args: cobra.MaximumNArgs(1),
|
|
RunE: runOrgGet,
|
|
}
|
|
|
|
func runOrgGet(_ *cobra.Command, _ []string) error {
|
|
// The control plane exposes GET /api/v1/orgs/:slug only behind a WorkOS
|
|
// browser session (RequireSession); there is no AdminGate-reachable
|
|
// admin equivalent. A token-authenticated CLI therefore cannot serve
|
|
// this verb — fail fast with guidance instead of shipping a guaranteed
|
|
// 401. `org list` (admin-bearer) covers the common "what orgs exist".
|
|
return &exitError{code: 2, msg: "org get is not available to token-authenticated callers: the control plane gates GET /api/v1/orgs/:slug behind a WorkOS browser session, and no CP-admin bearer route exists for it. Use the dashboard, or `molecule org list` (needs MOLECULE_CP_ADMIN_TOKEN) for a fleet overview."}
|
|
}
|
|
|
|
// ===========================================================================
|
|
// molecule org create
|
|
// ===========================================================================
|
|
var orgCreateFlags struct {
|
|
slug string
|
|
name string
|
|
ownerUserID string
|
|
template string
|
|
mode string
|
|
}
|
|
|
|
var orgCreateCmd = &cobra.Command{
|
|
Use: "create --slug <slug> --name <name> --owner-user-id <id> | --template <dir>",
|
|
Short: "Create an org (control-plane admin) or provision workspaces from an org template (tenant)",
|
|
Long: `Create an organization on the control plane (admin surface):
|
|
MOLECULE_CP_ADMIN_TOKEN=<cp-admin-bearer> \
|
|
molecule org create --slug acme --name "Acme Inc" --owner-user-id user_123
|
|
|
|
This targets POST /api/v1/admin/orgs (AdminGate) — NOT the WorkOS-session-gated
|
|
POST /api/v1/orgs, which a bearer-token CLI cannot reach. It requires a CP-admin
|
|
bearer in MOLECULE_CP_ADMIN_TOKEN (distinct from the tenant Org API Key
|
|
MOLECULE_API_KEY) and an explicit --owner-user-id (the server-to-server path has
|
|
no implicit session to own the new org).
|
|
|
|
Or provision workspaces into the current tenant from an org template
|
|
directory (POST /org/import) — this uses the tenant Org API Key:
|
|
molecule org create --template my-org-template [--mode merge|reconcile]`,
|
|
RunE: runOrgCreate,
|
|
}
|
|
|
|
func init() {
|
|
f := orgCreateCmd.Flags()
|
|
f.StringVar(&orgCreateFlags.slug, "slug", "", "Org slug (control-plane admin create)")
|
|
f.StringVar(&orgCreateFlags.name, "name", "", "Org display name (control-plane admin create)")
|
|
f.StringVar(&orgCreateFlags.ownerUserID, "owner-user-id", "", "Owner user id for the new org (required for control-plane admin create)")
|
|
f.StringVar(&orgCreateFlags.template, "template", "", "Org template directory (tenant org-from-template)")
|
|
f.StringVar(&orgCreateFlags.mode, "mode", "", "Template import mode: merge (default) | reconcile")
|
|
}
|
|
|
|
func runOrgCreate(_ *cobra.Command, _ []string) error {
|
|
// Template path → tenant POST /org/import.
|
|
if orgCreateFlags.template != "" {
|
|
raw, err := newClient().CreateOrgFromTemplate(client.ImportOrgRequest{
|
|
Dir: orgCreateFlags.template,
|
|
Mode: orgCreateFlags.mode,
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("org create --template: %w", err)
|
|
}
|
|
return printRaw(raw)
|
|
}
|
|
// Control-plane admin org create (POST /api/v1/admin/orgs).
|
|
if orgCreateFlags.slug == "" || orgCreateFlags.name == "" {
|
|
return &exitError{code: 2, msg: "org create: provide --slug, --name and --owner-user-id (CP admin create), or --template (tenant org-from-template)"}
|
|
}
|
|
if orgCreateFlags.ownerUserID == "" {
|
|
return &exitError{code: 2, msg: "org create: --owner-user-id is required for control-plane create (the admin route POST /api/v1/admin/orgs has no implicit session to own the org)"}
|
|
}
|
|
cp, err := cpAdminClient()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
o, err := cp.CreateOrg(client.CreateOrgRequest{
|
|
Slug: orgCreateFlags.slug,
|
|
Name: orgCreateFlags.name,
|
|
OwnerUserID: orgCreateFlags.ownerUserID,
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("org create: %w", err)
|
|
}
|
|
if outputFormat == "json" {
|
|
return printJSON(o)
|
|
}
|
|
if outputFormat == "yaml" {
|
|
return printYAML(o)
|
|
}
|
|
fmt.Printf("Organization created: %s (%s)\n", o.Name, o.Slug)
|
|
return nil
|
|
}
|
|
|
|
// ===========================================================================
|
|
// molecule org export <slug>
|
|
// ===========================================================================
|
|
var orgExportCmd = &cobra.Command{
|
|
Use: "export <slug>",
|
|
Short: "Export an org (UNAVAILABLE via token auth — session-only on the CP)",
|
|
Args: cobra.MaximumNArgs(1),
|
|
RunE: runOrgExport,
|
|
}
|
|
|
|
func runOrgExport(_ *cobra.Command, _ []string) error {
|
|
// Same story as org get: GET /api/v1/orgs/:slug/export is session-only on
|
|
// the control plane with no AdminGate-reachable equivalent, so a
|
|
// token-authenticated CLI cannot serve it. Fail fast.
|
|
return &exitError{code: 2, msg: "org export is not available to token-authenticated callers: the control plane gates GET /api/v1/orgs/:slug/export behind a WorkOS browser session, and no CP-admin bearer route exists for it. Use the dashboard."}
|
|
}
|
|
|
|
// ===========================================================================
|
|
// molecule org token {list,create,revoke}
|
|
// ===========================================================================
|
|
var orgTokenCmd = &cobra.Command{
|
|
Use: "token",
|
|
Short: "Manage Org API Keys (tenant org-admin tokens)",
|
|
}
|
|
|
|
var orgTokenListCmd = &cobra.Command{
|
|
Use: "list",
|
|
Short: "List Org API Keys",
|
|
RunE: runOrgTokenList,
|
|
}
|
|
|
|
func runOrgTokenList(_ *cobra.Command, _ []string) error {
|
|
toks, err := newClient().ListOrgTokens()
|
|
if err != nil {
|
|
return fmt.Errorf("org token list: %w", err)
|
|
}
|
|
if outputFormat == "json" {
|
|
return printJSON(toks)
|
|
}
|
|
if outputFormat == "yaml" {
|
|
return printYAML(toks)
|
|
}
|
|
if len(toks) == 0 {
|
|
fmt.Println("No org tokens found.")
|
|
return nil
|
|
}
|
|
w := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
|
|
fmt.Fprintln(w, "ID\tPREFIX\tNAME\tCREATED AT")
|
|
for _, t := range toks {
|
|
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", t.ID, t.Prefix, t.Name, t.CreatedAt)
|
|
}
|
|
return w.Flush()
|
|
}
|
|
|
|
var orgTokenCreateFlags struct{ name string }
|
|
|
|
var orgTokenCreateCmd = &cobra.Command{
|
|
Use: "create [--name <name>]",
|
|
Short: "Mint a new Org API Key (plaintext shown once)",
|
|
RunE: runOrgTokenCreate,
|
|
}
|
|
|
|
func init() {
|
|
orgTokenCreateCmd.Flags().StringVar(&orgTokenCreateFlags.name, "name", "", "Human-readable token name")
|
|
}
|
|
|
|
func runOrgTokenCreate(_ *cobra.Command, _ []string) error {
|
|
resp, err := newClient().CreateOrgToken(orgTokenCreateFlags.name)
|
|
if err != nil {
|
|
return fmt.Errorf("org token create: %w", err)
|
|
}
|
|
if outputFormat == "json" {
|
|
return printJSON(resp)
|
|
}
|
|
if outputFormat == "yaml" {
|
|
return printYAML(resp)
|
|
}
|
|
fmt.Printf("Org API Key created: %s (prefix %s)\n", resp.ID, resp.Prefix)
|
|
fmt.Printf("auth_token: %s\n", resp.Token)
|
|
if resp.Warning != "" {
|
|
fmt.Printf("WARNING: %s\n", resp.Warning)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
var orgTokenRevokeCmd = &cobra.Command{
|
|
Use: "revoke <token-id>",
|
|
Short: "Revoke an Org API Key by id",
|
|
Args: cobra.ExactArgs(1),
|
|
RunE: runOrgTokenRevoke,
|
|
}
|
|
|
|
func runOrgTokenRevoke(_ *cobra.Command, args []string) error {
|
|
if err := newClient().RevokeOrgToken(args[0]); err != nil {
|
|
return fmt.Errorf("org token revoke: %w", err)
|
|
}
|
|
fmt.Printf("Org token %q revoked.\n", args[0])
|
|
return nil
|
|
}
|
|
|
|
// ===========================================================================
|
|
// molecule org allowlist [<org-id>]
|
|
// ===========================================================================
|
|
var orgAllowlistCmd = &cobra.Command{
|
|
Use: "allowlist [org-id]",
|
|
Short: "Show the org plugin allowlist",
|
|
Args: cobra.MaximumNArgs(1),
|
|
RunE: runOrgAllowlist,
|
|
}
|
|
|
|
func runOrgAllowlist(_ *cobra.Command, args []string) error {
|
|
id := orgID()
|
|
if len(args) == 1 {
|
|
id = args[0]
|
|
}
|
|
if id == "" {
|
|
return &exitError{code: 2, msg: "org allowlist: provide an org id argument or set MOLECULE_ORG_ID"}
|
|
}
|
|
raw, err := newClient().GetAllowlist(id)
|
|
if err != nil {
|
|
return fmt.Errorf("org allowlist: %w", err)
|
|
}
|
|
return printRaw(raw)
|
|
}
|