Files
sdk-dev 442d1ebaf4
CI / Test / test (pull_request) Waiting to run
Release Go binaries / release (pull_request) Blocked by required conditions
Release Go binaries / test (pull_request) Waiting to run
fix(cli): repoint org create/list to CP-admin bearer; fail-fast get/export
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>
2026-05-31 22:35:22 -07:00

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)
}