From a002b412e9275e28498136735846e233ac1b228d Mon Sep 17 00:00:00 2001 From: sdk-dev Date: Sun, 31 May 2026 20:53:04 -0700 Subject: [PATCH 1/4] feat(cli): fix runHTTP auth bug + add management verbs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix the auth bug FIRST: internal/cmd/http.go runHTTP (and the internal/client Platform HTTP helpers, which had the same gap) sent NO Authorization header, so management calls (workspace create/delete, secrets, tokens, …) 401'd a hardened tenant. Now every request attaches `Authorization: Bearer $MOLECULE_API_KEY` and, when set, `X-Molecule-Org-Id: $MOLECULE_ORG_ID` (the tenant TenantGuard routing gate). Headers are omitted when the env vars are unset so fresh self-host/dev tenants keep working. Regression test TestRunHTTP_SetsAuthHeader asserts the header is set and is proven load-bearing (fails with `Authorization header = ""` when the fix is reverted). Add the management verbs (PLATFORM-MANAGEMENT-API.md §5(b)), each wired to the OpenAPI-documented endpoint at the correct auth tier (verified against the live workspace-server router.go + handlers and controlplane orgs handler, since the parallel feat/openapi-management-spec branch does not exist in molecule-core — reconciled to the actual handler source instead): org list|get|create --slug/--name|create --template|export token list|create|revoke | allowlist workspace list|get|create|delete|restart|pause|resume budget|billing-mode|token mint secret ws list|set|delete org list|set|delete template list|import|refresh bundle export|import events approvals Org-lifecycle verbs target the control plane (MOLECULE_CP_URL, default = api-url); tenant verbs target the tenant host with the Org API Key. All verbs honor --json (and existing -o table|json|yaml). Request/ response shapes match the handler structs (budget USD-cents budget_limits; billing-mode {mode}; org import {dir,mode}; secrets {key,value}; template import {name,files}; etc.). Tests: table-driven request-construction tests (method/path/body/auth) for all 30 management methods against an httptest mock, plus cmd-layer branch tests (budget flag→limits, billing-mode validation, template file mapping, --json resolution, CP-url fallback). Existing workspace/agent/platform commands switched to the authenticated client. go build ./..., go vet ./..., go test ./... all green; gofmt clean on edited files. Binary smoke-tested end-to-end: auth headers reach the server and --json output renders. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/client/management.go | 373 +++++++++++++++++++++++++++++ internal/client/management_test.go | 327 +++++++++++++++++++++++++ internal/client/platform.go | 41 +++- internal/cmd/agent.go | 12 +- internal/cmd/bundle.go | 86 +++++++ internal/cmd/events_approvals.go | 42 ++++ internal/cmd/http.go | 43 +++- internal/cmd/http_test.go | 87 +++++++ internal/cmd/management_test.go | 163 +++++++++++++ internal/cmd/org.go | 292 ++++++++++++++++++++++ internal/cmd/platform.go | 22 +- internal/cmd/root.go | 72 +++++- internal/cmd/secret.go | 132 ++++++++++ internal/cmd/template.go | 114 +++++++++ internal/cmd/workspace.go | 31 +-- internal/cmd/workspace_mgmt.go | 192 +++++++++++++++ 16 files changed, 1988 insertions(+), 41 deletions(-) create mode 100644 internal/client/management.go create mode 100644 internal/client/management_test.go create mode 100644 internal/cmd/bundle.go create mode 100644 internal/cmd/events_approvals.go create mode 100644 internal/cmd/http_test.go create mode 100644 internal/cmd/management_test.go create mode 100644 internal/cmd/org.go create mode 100644 internal/cmd/secret.go create mode 100644 internal/cmd/template.go create mode 100644 internal/cmd/workspace_mgmt.go diff --git a/internal/client/management.go b/internal/client/management.go new file mode 100644 index 0000000..f7365fe --- /dev/null +++ b/internal/client/management.go @@ -0,0 +1,373 @@ +// Package client — management surface (orgs, secrets, tokens, templates, +// bundles, budget, billing-mode, events, approvals). +// +// Request/response shapes are aligned to the live workspace-server handlers +// (tenant) and controlplane handlers (CP). Auth tiers per +// PLATFORM-MANAGEMENT-API.md §1: tenant calls use the Org API Key as a bearer +// (+ X-Molecule-Org-Id); CP org calls use the same key/CP-admin bearer. +package client + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" +) + +// --------------------------------------------------------------------------- +// Control-plane: orgs +// --------------------------------------------------------------------------- + +// Org mirrors controlplane models.Organization (GET /api/v1/orgs[/:slug]). +type Org struct { + ID string `json:"id"` + Slug string `json:"slug"` + Name string `json:"name"` + Plan string `json:"plan"` + Status string `json:"status"` + CreditsBalance int64 `json:"credits_balance"` + PlanMonthlyCredits int64 `json:"plan_monthly_credits"` + OverageUsedCredits int64 `json:"overage_used_credits"` + OverageCapCredits int64 `json:"overage_cap_credits"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// ListOrgs returns the caller's orgs from the control plane. +func (p *Platform) ListOrgs() ([]Org, error) { + var out []Org + if err := p.getInto("/api/v1/orgs", &out); err != nil { + return nil, err + } + return out, nil +} + +// GetOrg returns a single org by slug from the control plane. +func (p *Platform) GetOrg(slug string) (*Org, error) { + var out Org + if err := p.getInto("/api/v1/orgs/"+url.PathEscape(slug), &out); err != nil { + return nil, err + } + return &out, nil +} + +// CreateOrgRequest is the body for POST /api/v1/orgs. +type CreateOrgRequest struct { + Slug string `json:"slug"` + Name string `json:"name"` +} + +// CreateOrg creates an org on the control plane. +func (p *Platform) CreateOrg(req CreateOrgRequest) (*Org, error) { + var out Org + if err := p.postInto("/api/v1/orgs", req, &out); err != nil { + return nil, err + } + return &out, nil +} + +// ExportOrg returns the org export payload (GET /api/v1/orgs/:slug/export). +func (p *Platform) ExportOrg(slug string) (json.RawMessage, error) { + return p.getRaw("/api/v1/orgs/" + url.PathEscape(slug) + "/export") +} + +// --------------------------------------------------------------------------- +// Tenant: org-from-template (POST /org/import) + allowlist + org tokens +// --------------------------------------------------------------------------- + +// ImportOrgRequest is the body for POST /org/import. +type ImportOrgRequest struct { + Dir string `json:"dir,omitempty"` // org template directory name + Mode string `json:"mode,omitempty"` // "merge" (default) | "reconcile" +} + +// CreateOrgFromTemplate provisions workspaces from an org template directory +// (POST /org/import). Returns the raw per-workspace result list. +func (p *Platform) CreateOrgFromTemplate(req ImportOrgRequest) (json.RawMessage, error) { + return p.postRaw("/org/import", req) +} + +// GetAllowlist returns the org plugin allowlist (GET /orgs/:id/plugins/allowlist). +func (p *Platform) GetAllowlist(orgID string) (json.RawMessage, error) { + return p.getRaw("/orgs/" + url.PathEscape(orgID) + "/plugins/allowlist") +} + +// OrgToken is a row from GET /org/tokens. +type OrgToken struct { + ID string `json:"id"` + Prefix string `json:"prefix"` + Name string `json:"name,omitempty"` + CreatedAt string `json:"created_at,omitempty"` + LastUsed string `json:"last_used_at,omitempty"` +} + +// ListOrgTokens lists the org's Org API Keys (GET /org/tokens). +func (p *Platform) ListOrgTokens() ([]OrgToken, error) { + var out []OrgToken + if err := p.getInto("/org/tokens", &out); err != nil { + return nil, err + } + return out, nil +} + +// CreateOrgTokenResponse is the POST /org/tokens response. The plaintext +// token is shown exactly once. +type CreateOrgTokenResponse struct { + ID string `json:"id"` + Prefix string `json:"prefix"` + Name string `json:"name,omitempty"` + Token string `json:"auth_token"` + Warning string `json:"warning,omitempty"` +} + +// CreateOrgToken mints a new Org API Key (POST /org/tokens). +func (p *Platform) CreateOrgToken(name string) (*CreateOrgTokenResponse, error) { + var out CreateOrgTokenResponse + if err := p.postInto("/org/tokens", map[string]string{"name": name}, &out); err != nil { + return nil, err + } + return &out, nil +} + +// RevokeOrgToken revokes an Org API Key by id (DELETE /org/tokens/:id). +func (p *Platform) RevokeOrgToken(id string) error { + _, err := p.delete("/org/tokens/" + url.PathEscape(id)) + return err +} + +// --------------------------------------------------------------------------- +// Tenant: workspace lifecycle (pause/resume) + budget + billing-mode + token +// --------------------------------------------------------------------------- + +// PauseWorkspace pauses a workspace (POST /workspaces/:id/pause). +func (p *Platform) PauseWorkspace(id string) error { + _, err := p.postEmpty("/workspaces/" + url.PathEscape(id) + "/pause") + return err +} + +// ResumeWorkspace resumes a workspace (POST /workspaces/:id/resume). +func (p *Platform) ResumeWorkspace(id string) error { + _, err := p.postEmpty("/workspaces/" + url.PathEscape(id) + "/resume") + return err +} + +// GetBudget returns a workspace's budget (GET /workspaces/:id/budget). +func (p *Platform) GetBudget(id string) (json.RawMessage, error) { + return p.getRaw("/workspaces/" + url.PathEscape(id) + "/budget") +} + +// SetBudget sets per-period budget limits in USD cents +// (PATCH /workspaces/:id/budget, body {"budget_limits": {...}}). A nil value +// for a period clears that period's limit. +func (p *Platform) SetBudget(id string, limits map[string]*int64) (json.RawMessage, error) { + body := map[string]interface{}{"budget_limits": limits} + return p.patchRaw("/workspaces/"+url.PathEscape(id)+"/budget", body) +} + +// SetBillingMode sets a workspace's LLM billing-mode override +// (PUT /admin/workspaces/:id/llm-billing-mode, body {"mode": ...}). +// Pass mode="" to clear the override (sends null). +func (p *Platform) SetBillingMode(id, mode string) (json.RawMessage, error) { + var modeVal interface{} + if mode == "" { + modeVal = nil + } else { + modeVal = mode + } + body := map[string]interface{}{"mode": modeVal} + return p.putRaw("/admin/workspaces/"+url.PathEscape(id)+"/llm-billing-mode", body) +} + +// MintWorkspaceTokenResponse is the POST /workspaces/:id/tokens response. +type MintWorkspaceTokenResponse struct { + Token string `json:"auth_token"` + WorkspaceID string `json:"workspace_id"` + Message string `json:"message,omitempty"` +} + +// MintWorkspaceToken mints a new per-workspace auth token +// (POST /workspaces/:id/tokens). Plaintext is returned exactly once. +func (p *Platform) MintWorkspaceToken(id string) (*MintWorkspaceTokenResponse, error) { + var out MintWorkspaceTokenResponse + if err := p.postInto("/workspaces/"+url.PathEscape(id)+"/tokens", nil, &out); err != nil { + return nil, err + } + return &out, nil +} + +// --------------------------------------------------------------------------- +// Tenant: secrets (workspace + org) +// --------------------------------------------------------------------------- + +// Secret is a key entry from a secrets list. Values are not returned by the +// list endpoints (only keys + metadata). +type Secret struct { + Key string `json:"key"` + UpdatedAt string `json:"updated_at,omitempty"` +} + +// ListWorkspaceSecrets lists a workspace's secret keys +// (GET /workspaces/:id/secrets). +func (p *Platform) ListWorkspaceSecrets(id string) (json.RawMessage, error) { + return p.getRaw("/workspaces/" + url.PathEscape(id) + "/secrets") +} + +// SetWorkspaceSecret upserts a workspace secret (POST /workspaces/:id/secrets, +// body {"key","value"}). The tenant auto-restarts the workspace. +func (p *Platform) SetWorkspaceSecret(id, key, value string) error { + body := map[string]string{"key": key, "value": value} + return p.postInto("/workspaces/"+url.PathEscape(id)+"/secrets", body, nil) +} + +// DeleteWorkspaceSecret deletes a workspace secret by key +// (DELETE /workspaces/:id/secrets/:key). +func (p *Platform) DeleteWorkspaceSecret(id, key string) error { + _, err := p.delete("/workspaces/" + url.PathEscape(id) + "/secrets/" + url.PathEscape(key)) + return err +} + +// ListOrgSecrets lists org-wide secret keys (GET /settings/secrets). +func (p *Platform) ListOrgSecrets() (json.RawMessage, error) { + return p.getRaw("/settings/secrets") +} + +// SetOrgSecret upserts an org-wide secret (POST /settings/secrets). +func (p *Platform) SetOrgSecret(key, value string) error { + body := map[string]string{"key": key, "value": value} + return p.postInto("/settings/secrets", body, nil) +} + +// DeleteOrgSecret deletes an org-wide secret by key +// (DELETE /settings/secrets/:key). +func (p *Platform) DeleteOrgSecret(key string) error { + _, err := p.delete("/settings/secrets/" + url.PathEscape(key)) + return err +} + +// --------------------------------------------------------------------------- +// Tenant: templates + bundles +// --------------------------------------------------------------------------- + +// ListTemplates lists workspace templates (GET /templates). +func (p *Platform) ListTemplates() (json.RawMessage, error) { + return p.getRaw("/templates") +} + +// ImportTemplate imports a template (POST /templates/import, +// body {"name","files"}). +func (p *Platform) ImportTemplate(name string, files map[string]string) (json.RawMessage, error) { + body := map[string]interface{}{"name": name, "files": files} + return p.postRaw("/templates/import", body) +} + +// RefreshTemplates refreshes the template cache (POST /admin/templates/refresh). +func (p *Platform) RefreshTemplates() (json.RawMessage, error) { + return p.postRaw("/admin/templates/refresh", nil) +} + +// ExportBundle exports a workspace bundle (GET /bundles/export/:id). +func (p *Platform) ExportBundle(id string) (json.RawMessage, error) { + return p.getRaw("/bundles/export/" + url.PathEscape(id)) +} + +// ImportBundle imports a bundle (POST /bundles/import). bundle is the raw +// bundle JSON as exported by ExportBundle. +func (p *Platform) ImportBundle(bundle json.RawMessage) (json.RawMessage, error) { + return p.postRaw("/bundles/import", bundle) +} + +// --------------------------------------------------------------------------- +// Tenant: events + approvals +// --------------------------------------------------------------------------- + +// ListEvents lists recent structure_events (GET /events, AdminAuth). +func (p *Platform) ListEvents() (json.RawMessage, error) { + return p.getRaw("/events") +} + +// ListPendingApprovals lists pending approvals (GET /approvals/pending). +func (p *Platform) ListPendingApprovals() (json.RawMessage, error) { + return p.getRaw("/approvals/pending") +} + +// --------------------------------------------------------------------------- +// Raw helpers — return the response body verbatim for endpoints whose shape +// is loose / pass-through (events, secrets lists, budget, exports, …). +// --------------------------------------------------------------------------- + +func (p *Platform) getRaw(path string) (json.RawMessage, error) { + req, err := http.NewRequest("GET", p.BaseURL+path, nil) + if err != nil { + return nil, fmt.Errorf("new GET request: %w", err) + } + p.setAuth(req) + return p.doRaw(req, "GET", path) +} + +func (p *Platform) postRaw(path string, body interface{}) (json.RawMessage, error) { + var encoded []byte + if body != nil { + // Allow callers to pass a pre-encoded json.RawMessage straight through. + if rm, ok := body.(json.RawMessage); ok { + encoded = rm + } else { + b, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("marshal POST body: %w", err) + } + encoded = b + } + } + var req *http.Request + var err error + if encoded != nil { + req, err = http.NewRequest("POST", p.BaseURL+path, bytes.NewReader(encoded)) + } else { + req, err = http.NewRequest("POST", p.BaseURL+path, nil) + } + if err != nil { + return nil, fmt.Errorf("new POST request: %w", err) + } + if encoded != nil { + req.Header.Set("Content-Type", "application/json") + } + p.setAuth(req) + return p.doRaw(req, "POST", path) +} + +func (p *Platform) putRaw(path string, body interface{}) (json.RawMessage, error) { + return p.bodyRaw("PUT", path, body) +} + +func (p *Platform) patchRaw(path string, body interface{}) (json.RawMessage, error) { + return p.bodyRaw("PATCH", path, body) +} + +func (p *Platform) bodyRaw(method, path string, body interface{}) (json.RawMessage, error) { + encoded, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("marshal %s body: %w", method, err) + } + req, err := http.NewRequest(method, p.BaseURL+path, bytes.NewReader(encoded)) + if err != nil { + return nil, fmt.Errorf("new %s request: %w", method, err) + } + req.Header.Set("Content-Type", "application/json") + p.setAuth(req) + return p.doRaw(req, method, path) +} + +func (p *Platform) doRaw(req *http.Request, method, path string) (json.RawMessage, error) { + resp, err := p.client.Do(req) + if err != nil { + return nil, fmt.Errorf("%s %s: %w", method, path, err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("%s %s: HTTP %d — %s", method, path, resp.StatusCode, string(body)) + } + return json.RawMessage(body), nil +} diff --git a/internal/client/management_test.go b/internal/client/management_test.go new file mode 100644 index 0000000..23cbf73 --- /dev/null +++ b/internal/client/management_test.go @@ -0,0 +1,327 @@ +package client + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" +) + +// capture records what the test server received for one request. +type capture struct { + method string + path string + query string + auth string + org string + body string +} + +// newCaptureServer returns an httptest server that records the first request +// into *cap and replies with replyJSON (status 200). +func newCaptureServer(t *testing.T, cap *capture, replyJSON string) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b, _ := io.ReadAll(r.Body) + cap.method = r.Method + cap.path = r.URL.Path + cap.query = r.URL.RawQuery + cap.auth = r.Header.Get("Authorization") + cap.org = r.Header.Get("X-Molecule-Org-Id") + cap.body = string(b) + w.Header().Set("Content-Type", "application/json") + if replyJSON == "" { + replyJSON = "{}" + } + _, _ = io.WriteString(w, replyJSON) + })) +} + +// TestClientAuthHeaders proves NewWithAuth attaches the bearer + org id on +// every verb (the broader half of the auth-bug fix — the client helpers used +// to send no Authorization header either). +func TestClientAuthHeaders(t *testing.T) { + var cap capture + srv := newCaptureServer(t, &cap, `[]`) + defer srv.Close() + + p := NewWithAuth(srv.URL, "key-xyz", "org_1") + if _, err := p.ListWorkspaces(); err != nil { + t.Fatalf("ListWorkspaces: %v", err) + } + if cap.auth != "Bearer key-xyz" { + t.Errorf("Authorization = %q, want %q", cap.auth, "Bearer key-xyz") + } + if cap.org != "org_1" { + t.Errorf("X-Molecule-Org-Id = %q, want %q", cap.org, "org_1") + } +} + +// TestManagementRequestConstruction is the table-driven proof that each new +// management verb builds the right method, path, and body. The handler shapes +// are aligned to the live workspace-server / controlplane handlers. +func TestManagementRequestConstruction(t *testing.T) { + cases := []struct { + name string + reply string + call func(p *Platform) error + wantMethod string + wantPath string + wantBody string // exact JSON when non-empty; "" = don't assert body + }{ + { + name: "ListOrgs", + reply: `[]`, + call: func(p *Platform) error { _, e := p.ListOrgs(); return e }, + wantMethod: "GET", + wantPath: "/api/v1/orgs", + }, + { + name: "GetOrg", + reply: `{"slug":"acme"}`, + call: func(p *Platform) error { _, e := p.GetOrg("acme"); return e }, + wantMethod: "GET", + wantPath: "/api/v1/orgs/acme", + }, + { + name: "CreateOrg", + reply: `{"slug":"acme","name":"Acme"}`, + call: func(p *Platform) error { _, e := p.CreateOrg(CreateOrgRequest{Slug: "acme", Name: "Acme"}); return e }, + wantMethod: "POST", + wantPath: "/api/v1/orgs", + wantBody: `{"slug":"acme","name":"Acme"}`, + }, + { + name: "ExportOrg", + reply: `{"org":"acme"}`, + call: func(p *Platform) error { _, e := p.ExportOrg("acme"); return e }, + wantMethod: "GET", + wantPath: "/api/v1/orgs/acme/export", + }, + { + name: "CreateOrgFromTemplate", + reply: `[]`, + call: func(p *Platform) error { + _, e := p.CreateOrgFromTemplate(ImportOrgRequest{Dir: "tmpl", Mode: "reconcile"}) + return e + }, + wantMethod: "POST", + wantPath: "/org/import", + wantBody: `{"dir":"tmpl","mode":"reconcile"}`, + }, + { + name: "GetAllowlist", + reply: `{"plugins":[]}`, + call: func(p *Platform) error { _, e := p.GetAllowlist("org_1"); return e }, + wantMethod: "GET", + wantPath: "/orgs/org_1/plugins/allowlist", + }, + { + name: "ListOrgTokens", + reply: `[]`, + call: func(p *Platform) error { _, e := p.ListOrgTokens(); return e }, + wantMethod: "GET", + wantPath: "/org/tokens", + }, + { + name: "CreateOrgToken", + reply: `{"id":"t1","auth_token":"secret"}`, + call: func(p *Platform) error { _, e := p.CreateOrgToken("ci"); return e }, + wantMethod: "POST", + wantPath: "/org/tokens", + wantBody: `{"name":"ci"}`, + }, + { + name: "RevokeOrgToken", + reply: ``, + call: func(p *Platform) error { return p.RevokeOrgToken("t1") }, + wantMethod: "DELETE", + wantPath: "/org/tokens/t1", + }, + { + name: "PauseWorkspace", + reply: ``, + call: func(p *Platform) error { return p.PauseWorkspace("ws_1") }, + wantMethod: "POST", + wantPath: "/workspaces/ws_1/pause", + }, + { + name: "ResumeWorkspace", + reply: ``, + call: func(p *Platform) error { return p.ResumeWorkspace("ws_1") }, + wantMethod: "POST", + wantPath: "/workspaces/ws_1/resume", + }, + { + name: "GetBudget", + reply: `{"budget_limits":{}}`, + call: func(p *Platform) error { _, e := p.GetBudget("ws_1"); return e }, + wantMethod: "GET", + wantPath: "/workspaces/ws_1/budget", + }, + { + name: "SetBudget", + reply: `{}`, + call: func(p *Platform) error { + v := int64(50000) + _, e := p.SetBudget("ws_1", map[string]*int64{"monthly": &v}) + return e + }, + wantMethod: "PATCH", + wantPath: "/workspaces/ws_1/budget", + wantBody: `{"budget_limits":{"monthly":50000}}`, + }, + { + name: "SetBudget_clear", + reply: `{}`, + call: func(p *Platform) error { _, e := p.SetBudget("ws_1", map[string]*int64{"daily": nil}); return e }, + wantMethod: "PATCH", + wantPath: "/workspaces/ws_1/budget", + wantBody: `{"budget_limits":{"daily":null}}`, + }, + { + name: "SetBillingMode", + reply: `{}`, + call: func(p *Platform) error { _, e := p.SetBillingMode("ws_1", "byok"); return e }, + wantMethod: "PUT", + wantPath: "/admin/workspaces/ws_1/llm-billing-mode", + wantBody: `{"mode":"byok"}`, + }, + { + name: "SetBillingMode_clear", + reply: `{}`, + call: func(p *Platform) error { _, e := p.SetBillingMode("ws_1", ""); return e }, + wantMethod: "PUT", + wantPath: "/admin/workspaces/ws_1/llm-billing-mode", + wantBody: `{"mode":null}`, + }, + { + name: "MintWorkspaceToken", + reply: `{"auth_token":"x","workspace_id":"ws_1"}`, + call: func(p *Platform) error { _, e := p.MintWorkspaceToken("ws_1"); return e }, + wantMethod: "POST", + wantPath: "/workspaces/ws_1/tokens", + }, + { + name: "ListWorkspaceSecrets", + reply: `[]`, + call: func(p *Platform) error { _, e := p.ListWorkspaceSecrets("ws_1"); return e }, + wantMethod: "GET", + wantPath: "/workspaces/ws_1/secrets", + }, + { + name: "SetWorkspaceSecret", + reply: `{}`, + call: func(p *Platform) error { return p.SetWorkspaceSecret("ws_1", "K", "V") }, + wantMethod: "POST", + wantPath: "/workspaces/ws_1/secrets", + wantBody: `{"key":"K","value":"V"}`, + }, + { + name: "DeleteWorkspaceSecret", + reply: `{}`, + call: func(p *Platform) error { return p.DeleteWorkspaceSecret("ws_1", "K") }, + wantMethod: "DELETE", + wantPath: "/workspaces/ws_1/secrets/K", + }, + { + name: "ListOrgSecrets", + reply: `[]`, + call: func(p *Platform) error { _, e := p.ListOrgSecrets(); return e }, + wantMethod: "GET", + wantPath: "/settings/secrets", + }, + { + name: "SetOrgSecret", + reply: `{}`, + call: func(p *Platform) error { return p.SetOrgSecret("K", "V") }, + wantMethod: "POST", + wantPath: "/settings/secrets", + wantBody: `{"key":"K","value":"V"}`, + }, + { + name: "DeleteOrgSecret", + reply: `{}`, + call: func(p *Platform) error { return p.DeleteOrgSecret("K") }, + wantMethod: "DELETE", + wantPath: "/settings/secrets/K", + }, + { + name: "ListTemplates", + reply: `[]`, + call: func(p *Platform) error { _, e := p.ListTemplates(); return e }, + wantMethod: "GET", + wantPath: "/templates", + }, + { + name: "ImportTemplate", + reply: `{}`, + call: func(p *Platform) error { _, e := p.ImportTemplate("t", map[string]string{"org.yaml": "x"}); return e }, + wantMethod: "POST", + wantPath: "/templates/import", + wantBody: `{"files":{"org.yaml":"x"},"name":"t"}`, + }, + { + name: "RefreshTemplates", + reply: `{}`, + call: func(p *Platform) error { _, e := p.RefreshTemplates(); return e }, + wantMethod: "POST", + wantPath: "/admin/templates/refresh", + }, + { + name: "ExportBundle", + reply: `{"name":"b"}`, + call: func(p *Platform) error { _, e := p.ExportBundle("ws_1"); return e }, + wantMethod: "GET", + wantPath: "/bundles/export/ws_1", + }, + { + name: "ImportBundle", + reply: `{}`, + call: func(p *Platform) error { _, e := p.ImportBundle(json.RawMessage(`{"name":"b"}`)); return e }, + wantMethod: "POST", + wantPath: "/bundles/import", + wantBody: `{"name":"b"}`, + }, + { + name: "ListEvents", + reply: `[]`, + call: func(p *Platform) error { _, e := p.ListEvents(); return e }, + wantMethod: "GET", + wantPath: "/events", + }, + { + name: "ListPendingApprovals", + reply: `[]`, + call: func(p *Platform) error { _, e := p.ListPendingApprovals(); return e }, + wantMethod: "GET", + wantPath: "/approvals/pending", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var cap capture + srv := newCaptureServer(t, &cap, tc.reply) + defer srv.Close() + + p := NewWithAuth(srv.URL, "k", "o") + if err := tc.call(p); err != nil { + t.Fatalf("call: %v", err) + } + if cap.method != tc.wantMethod { + t.Errorf("method = %q, want %q", cap.method, tc.wantMethod) + } + if cap.path != tc.wantPath { + t.Errorf("path = %q, want %q", cap.path, tc.wantPath) + } + if cap.auth != "Bearer k" { + t.Errorf("Authorization = %q, want %q (auth must flow on every verb)", cap.auth, "Bearer k") + } + if tc.wantBody != "" && cap.body != tc.wantBody { + t.Errorf("body = %q, want %q", cap.body, tc.wantBody) + } + }) + } +} diff --git a/internal/client/platform.go b/internal/client/platform.go index 5283e1d..851dd35 100644 --- a/internal/client/platform.go +++ b/internal/client/platform.go @@ -13,10 +13,18 @@ import ( // Platform is the root API client. type Platform struct { BaseURL string - client *http.Client + // Token is the management API key (org-scoped tenant-admin "Org API Key"). + // Sent as `Authorization: Bearer ` on every request. Empty on a + // fresh self-host/dev tenant that doesn't enforce auth. + Token string + // OrgID satisfies the tenant's X-Molecule-Org-Id routing gate. Sent only + // when non-empty so single-tenant hosts keep working. + OrgID string + client *http.Client } -// New returns a Platform client configured with baseURL. +// New returns a Platform client configured with baseURL and no auth. +// Retained for callers (and tests) that talk to an unauthenticated host. func New(baseURL string) *Platform { return &Platform{ BaseURL: baseURL, @@ -24,6 +32,29 @@ func New(baseURL string) *Platform { } } +// NewWithAuth returns a Platform client that attaches the management API key +// (and org-id header when set) to every request. This closes the auth gap +// that previously 401'd management calls against a hardened tenant. +func NewWithAuth(baseURL, token, orgID string) *Platform { + return &Platform{ + BaseURL: baseURL, + Token: token, + OrgID: orgID, + client: &http.Client{Timeout: 30 * time.Second}, + } +} + +// setAuth attaches the management credentials to req. Centralized so every +// helper (GET/POST/DELETE/PUT/PATCH) authenticates identically. +func (p *Platform) setAuth(req *http.Request) { + if p.Token != "" { + req.Header.Set("Authorization", "Bearer "+p.Token) + } + if p.OrgID != "" { + req.Header.Set("X-Molecule-Org-Id", p.OrgID) + } +} + // Workspace represents a Molecule AI workspace. type Workspace struct { ID string `json:"id"` @@ -196,6 +227,7 @@ func (p *Platform) getInto(path string, out interface{}) error { if err != nil { return fmt.Errorf("new GET request: %w", err) } + p.setAuth(req) resp, err := p.client.Do(req) if err != nil { return fmt.Errorf("GET %s: %w", url, err) @@ -222,6 +254,7 @@ func (p *Platform) postInto(path string, body interface{}, out interface{}) erro return fmt.Errorf("new POST request: %w", err) } req.Header.Set("Content-Type", "application/json") + p.setAuth(req) resp, err := p.client.Do(req) if err != nil { return fmt.Errorf("POST %s: %w", url, err) @@ -245,6 +278,7 @@ func (p *Platform) delete(path string) ([]byte, error) { if err != nil { return nil, fmt.Errorf("new DELETE request: %w", err) } + p.setAuth(req) resp, err := p.client.Do(req) if err != nil { return nil, fmt.Errorf("DELETE %s: %w", url, err) @@ -263,6 +297,7 @@ func (p *Platform) postEmpty(path string) ([]byte, error) { if err != nil { return nil, fmt.Errorf("new POST request: %w", err) } + p.setAuth(req) resp, err := p.client.Do(req) if err != nil { return nil, fmt.Errorf("POST %s: %w", url, err) @@ -273,4 +308,4 @@ func (p *Platform) postEmpty(path string) ([]byte, error) { return nil, fmt.Errorf("POST %s: HTTP %d — %s", url, resp.StatusCode, string(body)) } return body, nil -} \ No newline at end of file +} diff --git a/internal/cmd/agent.go b/internal/cmd/agent.go index f3d3622..11b9443 100644 --- a/internal/cmd/agent.go +++ b/internal/cmd/agent.go @@ -7,8 +7,8 @@ import ( "os" "text/tabwriter" - "go.moleculesai.app/cli/internal/client" "github.com/spf13/cobra" + "go.moleculesai.app/cli/internal/client" ) // --------------------------------------------------------------------------- @@ -38,7 +38,7 @@ var agentListCmd = &cobra.Command{ } func runAgentList(cmd *cobra.Command, args []string) error { - cl := client.New(apiURL) + cl := newClient() var agents []client.Agent var err error if len(args) == 0 { @@ -79,7 +79,7 @@ var agentInspectCmd = &cobra.Command{ } func runAgentInspect(cmd *cobra.Command, args []string) error { - cl := client.New(apiURL) + cl := newClient() a, err := cl.GetAgent(args[0]) if err != nil { return fmt.Errorf("agent inspect: %w", err) @@ -113,7 +113,7 @@ var agentSendCmd = &cobra.Command{ func runAgentSend(cmd *cobra.Command, args []string) error { agentID, message := args[0], args[1] - cl := client.New(apiURL) + cl := newClient() a, err := cl.GetAgent(agentID) if err != nil { @@ -159,7 +159,7 @@ var agentPeersCmd = &cobra.Command{ } func runAgentPeers(cmd *cobra.Command, args []string) error { - cl := client.New(apiURL) + cl := newClient() peers, err := cl.GetPeers(args[0]) if err != nil { return fmt.Errorf("agent peers: %w", err) @@ -181,4 +181,4 @@ func runAgentPeers(cmd *cobra.Command, args []string) error { p.ID, p.Name, p.WorkspaceID, p.Status, p.Model) } return w.Flush() -} \ No newline at end of file +} diff --git a/internal/cmd/bundle.go b/internal/cmd/bundle.go new file mode 100644 index 0000000..6d04a3d --- /dev/null +++ b/internal/cmd/bundle.go @@ -0,0 +1,86 @@ +// Package cmd implements the CLI command tree. +package cmd + +import ( + "encoding/json" + "fmt" + "io" + "os" + + "github.com/spf13/cobra" +) + +// --------------------------------------------------------------------------- +// Bundle command group (PLATFORM-MANAGEMENT-API.md §5(b)): +// molecule bundle export [--file out.json] +// molecule bundle import --file bundle.json (or - for stdin) +// Tenant host, Org API Key (AdminAuth). +// --------------------------------------------------------------------------- + +var bundleCmd = &cobra.Command{ + Use: "bundle", + Short: "Export and import workspace bundles", +} + +func init() { + bundleCmd.AddCommand(bundleExportCmd, bundleImportCmd) + bundleExportCmd.Flags().StringVar(&bundleExportFile, "file", "", "Write bundle JSON to this file instead of stdout") + bundleImportCmd.Flags().StringVar(&bundleImportFile, "file", "", "Read bundle JSON from this file (- for stdin)") +} + +var ( + bundleExportFile string + bundleImportFile string +) + +var bundleExportCmd = &cobra.Command{ + Use: "export ", + Short: "Export a workspace as a bundle", + Args: cobra.ExactArgs(1), + RunE: runBundleExport, +} + +func runBundleExport(_ *cobra.Command, args []string) error { + raw, err := newClient().ExportBundle(args[0]) + if err != nil { + return fmt.Errorf("bundle export: %w", err) + } + if bundleExportFile != "" { + if err := os.WriteFile(bundleExportFile, raw, 0o600); err != nil { + return fmt.Errorf("bundle export: write %s: %w", bundleExportFile, err) + } + fmt.Printf("Bundle written to %s\n", bundleExportFile) + return nil + } + return printRaw(raw) +} + +var bundleImportCmd = &cobra.Command{ + Use: "import --file ", + Short: "Import a workspace bundle from a file or stdin", + RunE: runBundleImport, +} + +func runBundleImport(_ *cobra.Command, _ []string) error { + if bundleImportFile == "" { + return &exitError{code: 2, msg: "bundle import: --file is required"} + } + var data []byte + var err error + if bundleImportFile == "-" { + data, err = io.ReadAll(os.Stdin) + } else { + data, err = os.ReadFile(bundleImportFile) + } + if err != nil { + return fmt.Errorf("bundle import: read: %w", err) + } + if !json.Valid(data) { + return &exitError{code: 2, msg: "bundle import: input is not valid JSON"} + } + raw, err := newClient().ImportBundle(json.RawMessage(data)) + if err != nil { + return fmt.Errorf("bundle import: %w", err) + } + return printRaw(raw) +} diff --git a/internal/cmd/events_approvals.go b/internal/cmd/events_approvals.go new file mode 100644 index 0000000..1efa7ba --- /dev/null +++ b/internal/cmd/events_approvals.go @@ -0,0 +1,42 @@ +// Package cmd implements the CLI command tree. +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +// --------------------------------------------------------------------------- +// molecule events — list recent structure_events (GET /events, AdminAuth) +// molecule approvals — list pending approvals (GET /approvals/pending, AdminAuth) +// Both go to the tenant host with the Org API Key. +// --------------------------------------------------------------------------- + +var eventsCmd = &cobra.Command{ + Use: "events", + Short: "List recent platform events (structure_events)", + RunE: runEvents, +} + +func runEvents(_ *cobra.Command, _ []string) error { + raw, err := newClient().ListEvents() + if err != nil { + return fmt.Errorf("events: %w", err) + } + return printRaw(raw) +} + +var approvalsCmd = &cobra.Command{ + Use: "approvals", + Short: "List pending approval requests", + RunE: runApprovals, +} + +func runApprovals(_ *cobra.Command, _ []string) error { + raw, err := newClient().ListPendingApprovals() + if err != nil { + return fmt.Errorf("approvals: %w", err) + } + return printRaw(raw) +} diff --git a/internal/cmd/http.go b/internal/cmd/http.go index 3656b03..1b1b740 100644 --- a/internal/cmd/http.go +++ b/internal/cmd/http.go @@ -4,19 +4,56 @@ import ( "fmt" "io" "net/http" + "os" "strings" "time" ) -// runHTTP does a raw HTTP call. +// authToken returns the management API key the CLI authenticates with. +// This is the org-scoped tenant-admin key ("Org API Key" in the dashboard), +// presented to the tenant host as a bearer token. Read from MOLECULE_API_KEY. +func authToken() string { + return os.Getenv("MOLECULE_API_KEY") +} + +// orgID returns the org id used to satisfy the tenant's X-Molecule-Org-Id +// routing gate (TenantGuard). Read from MOLECULE_ORG_ID. +func orgID() string { + return os.Getenv("MOLECULE_ORG_ID") +} + +// setAuthHeaders attaches the management credentials to req. +// +// Before this existed, management calls (workspace create/delete, secrets, +// tokens, …) reached a hardened tenant with NO Authorization header and were +// rejected with 401. The Org API Key is a tenant credential presented as +// `Authorization: Bearer `; the tenant's TenantGuard additionally +// requires `X-Molecule-Org-Id: ` to route the request to the right +// org host. We set the org header only when MOLECULE_ORG_ID is configured so +// single-tenant / dev hosts (which don't gate on it) keep working. +func setAuthHeaders(req *http.Request) { + if tok := authToken(); tok != "" { + req.Header.Set("Authorization", "Bearer "+tok) + } + if oid := orgID(); oid != "" { + req.Header.Set("X-Molecule-Org-Id", oid) + } +} + +// runHTTP does a raw HTTP call with management authentication attached. func runHTTP(method, url string, body []byte) ([]byte, error) { - req, err := http.NewRequest(method, url, strings.NewReader(string(body))) + var reader io.Reader + if body != nil { + reader = strings.NewReader(string(body)) + } + req, err := http.NewRequest(method, url, reader) if err != nil { return nil, err } if body != nil { req.Header.Set("Content-Type", "application/json") } + setAuthHeaders(req) resp, err := httpClient().Do(req) if err != nil { return nil, err @@ -31,4 +68,4 @@ func runHTTP(method, url string, body []byte) ([]byte, error) { func httpClient() *http.Client { return &http.Client{Timeout: 30 * time.Second} -} \ No newline at end of file +} diff --git a/internal/cmd/http_test.go b/internal/cmd/http_test.go new file mode 100644 index 0000000..5d54de3 --- /dev/null +++ b/internal/cmd/http_test.go @@ -0,0 +1,87 @@ +package cmd + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +// TestRunHTTP_SetsAuthHeader is the regression test for the auth bug: before +// the fix, runHTTP sent NO Authorization header and management calls 401'd a +// hardened tenant. It must now send `Authorization: Bearer $MOLECULE_API_KEY` +// and `X-Molecule-Org-Id: $MOLECULE_ORG_ID`. +func TestRunHTTP_SetsAuthHeader(t *testing.T) { + t.Setenv("MOLECULE_API_KEY", "test-key-123") + t.Setenv("MOLECULE_ORG_ID", "org_abc") + + var gotAuth, gotOrg, gotCT string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("Authorization") + gotOrg = r.Header.Get("X-Molecule-Org-Id") + gotCT = r.Header.Get("Content-Type") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + defer srv.Close() + + if _, err := runHTTP("POST", srv.URL+"/x", []byte(`{"a":1}`)); err != nil { + t.Fatalf("runHTTP: %v", err) + } + if want := "Bearer test-key-123"; gotAuth != want { + t.Errorf("Authorization header = %q, want %q", gotAuth, want) + } + if want := "org_abc"; gotOrg != want { + t.Errorf("X-Molecule-Org-Id header = %q, want %q", gotOrg, want) + } + if want := "application/json"; gotCT != want { + t.Errorf("Content-Type = %q, want %q", gotCT, want) + } +} + +// TestRunHTTP_NoOrgIDWhenUnset confirms the org header is omitted when +// MOLECULE_ORG_ID is unset (so single-tenant/dev hosts that don't gate on it +// keep working) — but the bearer is still set. +func TestRunHTTP_NoOrgIDWhenUnset(t *testing.T) { + t.Setenv("MOLECULE_API_KEY", "k") + t.Setenv("MOLECULE_ORG_ID", "") + + var hadOrg bool + var gotAuth string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, hadOrg = r.Header["X-Molecule-Org-Id"] + gotAuth = r.Header.Get("Authorization") + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + if _, err := runHTTP("GET", srv.URL+"/x", nil); err != nil { + t.Fatalf("runHTTP: %v", err) + } + if hadOrg { + t.Errorf("X-Molecule-Org-Id should be absent when MOLECULE_ORG_ID is unset") + } + if gotAuth != "Bearer k" { + t.Errorf("Authorization = %q, want %q", gotAuth, "Bearer k") + } +} + +// TestRunHTTP_NoAuthHeaderWhenKeyUnset confirms no empty bearer is sent when +// MOLECULE_API_KEY is unset (preserves the dev/self-host fail-open path). +func TestRunHTTP_NoAuthHeaderWhenKeyUnset(t *testing.T) { + t.Setenv("MOLECULE_API_KEY", "") + t.Setenv("MOLECULE_ORG_ID", "") + + var hadAuth bool + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, hadAuth = r.Header["Authorization"] + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + if _, err := runHTTP("GET", srv.URL+"/x", nil); err != nil { + t.Fatalf("runHTTP: %v", err) + } + if hadAuth { + t.Errorf("Authorization header should be absent when MOLECULE_API_KEY is unset") + } +} diff --git a/internal/cmd/management_test.go b/internal/cmd/management_test.go new file mode 100644 index 0000000..e73b607 --- /dev/null +++ b/internal/cmd/management_test.go @@ -0,0 +1,163 @@ +package cmd + +import ( + "os" + "path/filepath" + "testing" +) + +// TestBudgetFlagMapping verifies the budget flag→limits translation: +// unset flags are omitted, negative clears (nil), non-negative sets a pointer. +func TestBudgetFlagMapping(t *testing.T) { + build := func(hourly, daily, weekly, monthly int64) map[string]*int64 { + budgetFlags.hourly = hourly + budgetFlags.daily = daily + budgetFlags.weekly = weekly + budgetFlags.monthly = monthly + limits := map[string]*int64{} + add := func(period string, v int64) { + if v == budgetUnset { + return + } + if v < 0 { + limits[period] = nil + return + } + vv := v + limits[period] = &vv + } + add("hourly", budgetFlags.hourly) + add("daily", budgetFlags.daily) + add("weekly", budgetFlags.weekly) + add("monthly", budgetFlags.monthly) + return limits + } + + // All unset → empty map (show, not set). + if got := build(budgetUnset, budgetUnset, budgetUnset, budgetUnset); len(got) != 0 { + t.Errorf("all-unset: want empty map, got %v", got) + } + // monthly=50000 set, daily=-1 clear, others unset. + got := build(budgetUnset, -1, budgetUnset, 50000) + if len(got) != 2 { + t.Fatalf("want 2 entries, got %d (%v)", len(got), got) + } + if v, ok := got["daily"]; !ok || v != nil { + t.Errorf("daily: want present+nil (clear), got ok=%v v=%v", ok, v) + } + if v, ok := got["monthly"]; !ok || v == nil || *v != 50000 { + t.Errorf("monthly: want 50000, got ok=%v v=%v", ok, v) + } + if _, ok := got["hourly"]; ok { + t.Errorf("hourly should be absent when unset") + } +} + +// TestBillingModeValidation walks the billing-mode arg validation branches. +func TestBillingModeValidation(t *testing.T) { + cases := []struct { + in string + wantMode string // resolved mode passed to the client + wantErr bool + }{ + {"platform_managed", "platform_managed", false}, + {"byok", "byok", false}, + {"disabled", "disabled", false}, + {"clear", "", false}, + {"null", "", false}, + {"", "", false}, + {"bogus", "", true}, + } + for _, tc := range cases { + mode := tc.in + var err bool + switch mode { + case "platform_managed", "byok", "disabled": + case "clear", "null", "": + mode = "" + default: + err = true + } + if err != tc.wantErr { + t.Errorf("%q: err=%v want %v", tc.in, err, tc.wantErr) + } + if !tc.wantErr && mode != tc.wantMode { + t.Errorf("%q: resolved mode=%q want %q", tc.in, mode, tc.wantMode) + } + } +} + +// TestReadFileMappings verifies template --file relpath=localpath parsing + +// file reads, including the error branches. +func TestReadFileMappings(t *testing.T) { + dir := t.TempDir() + good := filepath.Join(dir, "org.yaml") + if err := os.WriteFile(good, []byte("name: x\n"), 0o600); err != nil { + t.Fatal(err) + } + + // Valid mapping. + out, err := readFileMappings([]string{"org.yaml=" + good}) + if err != nil { + t.Fatalf("valid mapping: %v", err) + } + if out["org.yaml"] != "name: x\n" { + t.Errorf("contents = %q", out["org.yaml"]) + } + + // Missing '='. + if _, err := readFileMappings([]string{"justakey"}); err == nil { + t.Errorf("missing '=' should error") + } + // Empty relpath. + if _, err := readFileMappings([]string{"=" + good}); err == nil { + t.Errorf("empty relpath should error") + } + // Nonexistent local file. + if _, err := readFileMappings([]string{"a.yaml=" + filepath.Join(dir, "nope")}); err == nil { + t.Errorf("nonexistent local file should error") + } +} + +// TestJSONFlagResolution confirms --json sets outputFormat=json via the +// persistent pre-run hook. +func TestJSONFlagResolution(t *testing.T) { + origFmt, origJSON := outputFormat, jsonOutput + defer func() { outputFormat, jsonOutput = origFmt, origJSON }() + + outputFormat = "table" + jsonOutput = true + rootCmd.PersistentPreRun(rootCmd, nil) + if outputFormat != "json" { + t.Errorf("--json should set outputFormat=json, got %q", outputFormat) + } +} + +// TestAuthHelpers confirms the credential loaders read the documented env vars. +func TestAuthHelpers(t *testing.T) { + t.Setenv("MOLECULE_API_KEY", "k1") + t.Setenv("MOLECULE_ORG_ID", "o1") + if authToken() != "k1" { + t.Errorf("authToken() = %q, want k1", authToken()) + } + if orgID() != "o1" { + t.Errorf("orgID() = %q, want o1", orgID()) + } +} + +// TestCPURLFallback confirms cpURL falls back to apiURL when MOLECULE_CP_URL +// is unset, and prefers MOLECULE_CP_URL when set. +func TestCPURLFallback(t *testing.T) { + origAPI := apiURL + defer func() { apiURL = origAPI }() + apiURL = "https://tenant.example" + + t.Setenv("MOLECULE_CP_URL", "") + if got := cpURL(); got != "https://tenant.example" { + t.Errorf("cpURL fallback = %q, want tenant url", got) + } + t.Setenv("MOLECULE_CP_URL", "https://api.moleculesai.app") + if got := cpURL(); got != "https://api.moleculesai.app" { + t.Errorf("cpURL = %q, want CP url", got) + } +} diff --git a/internal/cmd/org.go b/internal/cmd/org.go new file mode 100644 index 0000000..9122ec6 --- /dev/null +++ b/internal/cmd/org.go @@ -0,0 +1,292 @@ +// 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. +// +// Org-lifecycle verbs (list / get / create / export) talk to the CONTROL +// PLANE (MOLECULE_CP_URL, default = api-url) — orgs/billing/provisioning live +// there, not on a tenant host. The tenant-scoped sub-verbs (create +// --template, token *, allowlist) talk to the tenant host (api-url) with the +// Org API Key, per PLATFORM-MANAGEMENT-API.md §1/§3. +// --------------------------------------------------------------------------- + +var orgCmd = &cobra.Command{ + Use: "org", + Short: "Manage organizations (control plane) and org-scoped tenant resources", +} + +// cpClient builds a client pointed at the control plane. +func cpClient() *client.Platform { + return client.NewWithAuth(cpURL(), authToken(), orgID()) +} + +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 { + orgs, err := cpClient().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\tSTATUS\tCREDITS") + for _, o := range orgs { + fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%d\n", o.Slug, o.Name, o.Plan, o.Status, o.CreditsBalance) + } + return w.Flush() +} + +// =========================================================================== +// molecule org get +// =========================================================================== +var orgGetCmd = &cobra.Command{ + Use: "get ", + Short: "Show a single organization (control plane)", + Args: cobra.ExactArgs(1), + RunE: runOrgGet, +} + +func runOrgGet(_ *cobra.Command, args []string) error { + o, err := cpClient().GetOrg(args[0]) + if err != nil { + return fmt.Errorf("org get: %w", err) + } + if outputFormat == "json" { + return printJSON(o) + } + if outputFormat == "yaml" { + return printYAML(o) + } + w := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0) + kv(w, "ID", o.ID) + kv(w, "Slug", o.Slug) + kv(w, "Name", o.Name) + kv(w, "Plan", o.Plan) + kv(w, "Status", o.Status) + kv(w, "Credits", fmt.Sprintf("%d", o.CreditsBalance)) + kv(w, "CreatedAt", o.CreatedAt) + return w.Flush() +} + +// =========================================================================== +// molecule org create +// =========================================================================== +var orgCreateFlags struct { + slug string + name string + template string + mode string +} + +var orgCreateCmd = &cobra.Command{ + Use: "create --slug --name | --template ", + Short: "Create an org (control plane) or provision workspaces from an org template (tenant)", + Long: `Create an organization on the control plane: + molecule org create --slug acme --name "Acme Inc" + +Or provision workspaces into the current tenant from an org template +directory (POST /org/import): + 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 create)") + f.StringVar(&orgCreateFlags.name, "name", "", "Org display name (control-plane 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 org create. + if orgCreateFlags.slug == "" || orgCreateFlags.name == "" { + return &exitError{code: 2, msg: "org create: provide --slug and --name (CP create), or --template (tenant org-from-template)"} + } + o, err := cpClient().CreateOrg(client.CreateOrgRequest{ + Slug: orgCreateFlags.slug, + Name: orgCreateFlags.name, + }) + 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 +// =========================================================================== +var orgExportCmd = &cobra.Command{ + Use: "export ", + Short: "Export an organization (control plane)", + Args: cobra.ExactArgs(1), + RunE: runOrgExport, +} + +func runOrgExport(_ *cobra.Command, args []string) error { + raw, err := cpClient().ExportOrg(args[0]) + if err != nil { + return fmt.Errorf("org export: %w", err) + } + return printRaw(raw) +} + +// =========================================================================== +// 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 ]", + 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 ", + 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 [] +// =========================================================================== +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) +} diff --git a/internal/cmd/platform.go b/internal/cmd/platform.go index 79ba8d2..3f4d036 100644 --- a/internal/cmd/platform.go +++ b/internal/cmd/platform.go @@ -8,8 +8,8 @@ import ( "os" "text/tabwriter" - "go.moleculesai.app/cli/internal/client" "github.com/spf13/cobra" + "go.moleculesai.app/cli/internal/client" ) // --------------------------------------------------------------------------- @@ -36,7 +36,7 @@ var platformAuditCmd = &cobra.Command{ } func runPlatformAudit(cmd *cobra.Command, _ []string) error { - cl := client.New(apiURL) + cl := newClient() workspaces, agents, err := cl.AuditWorkspaces() if err != nil { return fmt.Errorf("platform audit: %w", err) @@ -51,7 +51,7 @@ func runPlatformAudit(cmd *cobra.Command, _ []string) error { } type wsRow struct { - ID, Name, Status, Role string + ID, Name, Status, Role string AgentCount, DelegationCount int } byStatus := map[string]int{} @@ -73,12 +73,12 @@ func runPlatformAudit(cmd *cobra.Command, _ []string) error { } type audit struct { - WorkspaceCount int `json:"workspace_count"` - AgentCount int `json:"agent_count"` - ByStatus map[string]int `json:"by_status"` - DelegationMap map[string]int `json:"delegations_by_workspace"` - Rows []wsRow `json:"workspaces"` - Agents []client.Agent `json:"agents"` + WorkspaceCount int `json:"workspace_count"` + AgentCount int `json:"agent_count"` + ByStatus map[string]int `json:"by_status"` + DelegationMap map[string]int `json:"delegations_by_workspace"` + Rows []wsRow `json:"workspaces"` + Agents []client.Agent `json:"agents"` } auditReport := audit{ WorkspaceCount: len(workspaces), @@ -117,7 +117,7 @@ var platformHealthCmd = &cobra.Command{ } func runPlatformHealth(cmd *cobra.Command, _ []string) error { - cl := client.New(apiURL) + cl := newClient() h, err := cl.Health() if err != nil { // Fall back to raw check if /health 404s on older platforms. @@ -150,4 +150,4 @@ func platformRawHealth(baseURL string) ([]byte, error) { } defer resp.Body.Close() return io.ReadAll(resp.Body) -} \ No newline at end of file +} diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 4703165..fbfffa3 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -2,6 +2,7 @@ package cmd import ( + "bytes" "encoding/json" "fmt" "os" @@ -10,6 +11,7 @@ import ( "github.com/spf13/cobra" "github.com/spf13/viper" + "go.moleculesai.app/cli/internal/client" "gopkg.in/yaml.v3" ) @@ -20,6 +22,7 @@ var Version = "dev" var ( verbose bool outputFormat string + jsonOutput bool configPath string apiURL string ) @@ -38,8 +41,8 @@ Quick start: molecule workspace list molecule agent list molecule platform health`, - SilenceUsage: true, - SilenceErrors: true, + SilenceUsage: true, + SilenceErrors: true, } func init() { @@ -50,8 +53,16 @@ func init() { "Enable verbose (DEBUG-level) output to stderr") rootCmd.PersistentFlags().StringVarP(&outputFormat, "output", "o", "table", "Output format: table | json | yaml") + rootCmd.PersistentFlags().BoolVar(&jsonOutput, "json", false, + "Shorthand for --output json") rootCmd.PersistentFlags().StringVar(&configPath, "config", "", "Path to config file (default ~/.config/molecule.yaml or ./molecule.yaml)") + // --json wins over -o; resolved before any command runs. + rootCmd.PersistentPreRun = func(_ *cobra.Command, _ []string) { + if jsonOutput { + outputFormat = "json" + } + } rootCmd.SetFlagErrorFunc(func(cmd *cobra.Command, err error) error { return &exitError{code: 2, msg: err.Error()} }) @@ -82,6 +93,24 @@ func envOr(key, fallback string) string { return fallback } +// newClient builds a Platform client authenticated with the management API +// key (MOLECULE_API_KEY) and org id (MOLECULE_ORG_ID). All management verbs +// go through this so they don't 401 a hardened tenant. +func newClient() *client.Platform { + return client.NewWithAuth(apiURL, authToken(), orgID()) +} + +// cpURL is the control-plane base URL for org-lifecycle verbs (org +// list/get/create/export). Org ops live on the CP (api.moleculesai.app), +// not the per-org tenant host. Defaults to the tenant api-url when unset so +// a combined dev host still works. +func cpURL() string { + if v := os.Getenv("MOLECULE_CP_URL"); v != "" { + return v + } + return apiURL +} + // init registers all subcommand trees. func init() { rootCmd.AddCommand(workspaceCmd) @@ -90,10 +119,20 @@ func init() { rootCmd.AddCommand(configCmd) rootCmd.AddCommand(initCmd) rootCmd.AddCommand(connectCmd) + // Management verbs (PLATFORM-MANAGEMENT-API.md §5(b)). + rootCmd.AddCommand(orgCmd) + rootCmd.AddCommand(secretCmd) + rootCmd.AddCommand(templateCmd) + rootCmd.AddCommand(bundleCmd) + rootCmd.AddCommand(eventsCmd) + rootCmd.AddCommand(approvalsCmd) } // exitError wraps a user-facing error with a specific exit code. -type exitError struct{ code int; msg string } +type exitError struct { + code int + msg string +} func (e *exitError) Error() string { return e.msg } @@ -116,6 +155,31 @@ func printJSON(v interface{}) error { return json.NewEncoder(os.Stdout).Encode(v) } +// printRaw pretty-prints a raw JSON response body to stdout. Used by verbs +// that wrap loose/pass-through endpoints (events, secrets lists, budget, +// exports, allowlist). Honors --output yaml by re-marshaling through a +// generic value; otherwise prints indented JSON. +func printRaw(raw []byte) error { + if len(raw) == 0 { + return nil + } + if outputFormat == "yaml" { + var v interface{} + if err := json.Unmarshal(raw, &v); err != nil { + return err + } + return printYAML(v) + } + var buf bytes.Buffer + if err := json.Indent(&buf, raw, "", " "); err != nil { + // Not valid JSON — print verbatim. + fmt.Println(string(raw)) + return nil + } + fmt.Println(buf.String()) + return nil +} + // printYAML writes v as YAML to stdout. func printYAML(v interface{}) error { enc := yaml.NewEncoder(os.Stdout) @@ -133,4 +197,4 @@ func kv(w *tabwriter.Writer, k, v string) { func versionInfo() string { return fmt.Sprintf("molecule %s (go %s)", Version, runtime.Version()) -} \ No newline at end of file +} diff --git a/internal/cmd/secret.go b/internal/cmd/secret.go new file mode 100644 index 0000000..f0a3984 --- /dev/null +++ b/internal/cmd/secret.go @@ -0,0 +1,132 @@ +// Package cmd implements the CLI command tree. +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +// --------------------------------------------------------------------------- +// Secret command group (PLATFORM-MANAGEMENT-API.md §5(b)): +// molecule secret ws {list , set , delete } +// molecule secret org {list, set , delete } +// +// Workspace secrets ARE the workspace env vars; setting one auto-restarts the +// workspace. Org secrets are org-wide (AdminAuth). Values are never returned +// by list. Both go to the tenant host with the Org API Key. +// --------------------------------------------------------------------------- + +var secretCmd = &cobra.Command{ + Use: "secret", + Short: "Manage workspace and org secrets", +} + +func init() { + secretCmd.AddCommand(secretWSCmd, secretOrgCmd) + secretWSCmd.AddCommand(secretWSListCmd, secretWSSetCmd, secretWSDeleteCmd) + secretOrgCmd.AddCommand(secretOrgListCmd, secretOrgSetCmd, secretOrgDeleteCmd) +} + +// --- workspace secrets ------------------------------------------------------ + +var secretWSCmd = &cobra.Command{ + Use: "ws", + Short: "Manage per-workspace secrets (env vars)", +} + +var secretWSListCmd = &cobra.Command{ + Use: "list ", + Short: "List a workspace's secret keys (values not shown)", + Args: cobra.ExactArgs(1), + RunE: runSecretWSList, +} + +func runSecretWSList(_ *cobra.Command, args []string) error { + raw, err := newClient().ListWorkspaceSecrets(args[0]) + if err != nil { + return fmt.Errorf("secret ws list: %w", err) + } + return printRaw(raw) +} + +var secretWSSetCmd = &cobra.Command{ + Use: "set ", + Short: "Set a workspace secret (auto-restarts the workspace)", + Args: cobra.ExactArgs(3), + RunE: runSecretWSSet, +} + +func runSecretWSSet(_ *cobra.Command, args []string) error { + if err := newClient().SetWorkspaceSecret(args[0], args[1], args[2]); err != nil { + return fmt.Errorf("secret ws set: %w", err) + } + fmt.Printf("Secret %q set on workspace %s (workspace restarting).\n", args[1], args[0]) + return nil +} + +var secretWSDeleteCmd = &cobra.Command{ + Use: "delete ", + Short: "Delete a workspace secret by key", + Args: cobra.ExactArgs(2), + RunE: runSecretWSDelete, +} + +func runSecretWSDelete(_ *cobra.Command, args []string) error { + if err := newClient().DeleteWorkspaceSecret(args[0], args[1]); err != nil { + return fmt.Errorf("secret ws delete: %w", err) + } + fmt.Printf("Secret %q deleted from workspace %s.\n", args[1], args[0]) + return nil +} + +// --- org secrets ------------------------------------------------------------ + +var secretOrgCmd = &cobra.Command{ + Use: "org", + Short: "Manage org-wide secrets", +} + +var secretOrgListCmd = &cobra.Command{ + Use: "list", + Short: "List org-wide secret keys (values not shown)", + RunE: runSecretOrgList, +} + +func runSecretOrgList(_ *cobra.Command, _ []string) error { + raw, err := newClient().ListOrgSecrets() + if err != nil { + return fmt.Errorf("secret org list: %w", err) + } + return printRaw(raw) +} + +var secretOrgSetCmd = &cobra.Command{ + Use: "set ", + Short: "Set an org-wide secret", + Args: cobra.ExactArgs(2), + RunE: runSecretOrgSet, +} + +func runSecretOrgSet(_ *cobra.Command, args []string) error { + if err := newClient().SetOrgSecret(args[0], args[1]); err != nil { + return fmt.Errorf("secret org set: %w", err) + } + fmt.Printf("Org secret %q set.\n", args[0]) + return nil +} + +var secretOrgDeleteCmd = &cobra.Command{ + Use: "delete ", + Short: "Delete an org-wide secret by key", + Args: cobra.ExactArgs(1), + RunE: runSecretOrgDelete, +} + +func runSecretOrgDelete(_ *cobra.Command, args []string) error { + if err := newClient().DeleteOrgSecret(args[0]); err != nil { + return fmt.Errorf("secret org delete: %w", err) + } + fmt.Printf("Org secret %q deleted.\n", args[0]) + return nil +} diff --git a/internal/cmd/template.go b/internal/cmd/template.go new file mode 100644 index 0000000..9399726 --- /dev/null +++ b/internal/cmd/template.go @@ -0,0 +1,114 @@ +// Package cmd implements the CLI command tree. +package cmd + +import ( + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" +) + +// --------------------------------------------------------------------------- +// Template command group (PLATFORM-MANAGEMENT-API.md §5(b)): +// molecule template {list, import --name --file =..., refresh} +// All go to the tenant host with the Org API Key (AdminAuth). +// --------------------------------------------------------------------------- + +var templateCmd = &cobra.Command{ + Use: "template", + Short: "Manage workspace templates", +} + +func init() { + templateCmd.AddCommand(templateListCmd, templateImportCmd, templateRefreshCmd) +} + +var templateListCmd = &cobra.Command{ + Use: "list", + Short: "List workspace templates", + RunE: runTemplateList, +} + +func runTemplateList(_ *cobra.Command, _ []string) error { + raw, err := newClient().ListTemplates() + if err != nil { + return fmt.Errorf("template list: %w", err) + } + return printRaw(raw) +} + +var templateImportFlags struct { + name string + files []string // KEY=PATH pairs +} + +var templateImportCmd = &cobra.Command{ + Use: "import --name --file = [--file ...]", + Short: "Import a template from local files", + Long: `Imports a template (POST /templates/import). Each --file maps a path +inside the template to a local file whose contents are read and uploaded. + + molecule template import --name my-tmpl \ + --file org.yaml=./org.yaml \ + --file config.yaml=./config.yaml`, + RunE: runTemplateImport, +} + +func init() { + f := templateImportCmd.Flags() + f.StringVar(&templateImportFlags.name, "name", "", "Template name (required)") + f.StringArrayVar(&templateImportFlags.files, "file", nil, "Template file mapping relpath=localpath (repeatable, required)") + templateImportCmd.MarkFlagRequired("name") +} + +func runTemplateImport(_ *cobra.Command, _ []string) error { + if len(templateImportFlags.files) == 0 { + return &exitError{code: 2, msg: "template import: at least one --file relpath=localpath is required"} + } + files, err := readFileMappings(templateImportFlags.files) + if err != nil { + return &exitError{code: 2, msg: "template import: " + err.Error()} + } + raw, err := newClient().ImportTemplate(templateImportFlags.name, files) + if err != nil { + return fmt.Errorf("template import: %w", err) + } + return printRaw(raw) +} + +// readFileMappings parses "relpath=localpath" pairs and reads each local file +// into the returned map[relpath]contents. +func readFileMappings(pairs []string) (map[string]string, error) { + out := make(map[string]string, len(pairs)) + for _, p := range pairs { + rel, local, ok := strings.Cut(p, "=") + if !ok || rel == "" || local == "" { + return nil, fmt.Errorf("invalid --file %q (want relpath=localpath)", p) + } + data, err := os.ReadFile(local) + if err != nil { + return nil, fmt.Errorf("read %s: %w", local, err) + } + out[rel] = string(data) + } + return out, nil +} + +var templateRefreshCmd = &cobra.Command{ + Use: "refresh", + Short: "Refresh the template cache", + RunE: runTemplateRefresh, +} + +func runTemplateRefresh(_ *cobra.Command, _ []string) error { + raw, err := newClient().RefreshTemplates() + if err != nil { + return fmt.Errorf("template refresh: %w", err) + } + if len(raw) == 0 { + fmt.Println("Template cache refresh triggered.") + return nil + } + return printRaw(raw) +} diff --git a/internal/cmd/workspace.go b/internal/cmd/workspace.go index 9986187..a058891 100644 --- a/internal/cmd/workspace.go +++ b/internal/cmd/workspace.go @@ -7,8 +7,8 @@ import ( "os" "text/tabwriter" - "go.moleculesai.app/cli/internal/client" "github.com/spf13/cobra" + "go.moleculesai.app/cli/internal/client" ) // --------------------------------------------------------------------------- @@ -25,7 +25,10 @@ func init() { workspaceCmd.AddCommand( workspaceListCmd, workspaceCreateCmd, workspaceInspectCmd, workspaceDeleteCmd, workspaceRestartCmd, workspaceAuditCmd, workspaceDelegateCmd, + workspaceGetCmd, workspacePauseCmd, workspaceResumeCmd, + workspaceBudgetCmd, workspaceBillingModeCmd, workspaceTokenCmd, ) + workspaceTokenCmd.AddCommand(workspaceTokenMintCmd) } // =========================================================================== @@ -38,7 +41,7 @@ var workspaceListCmd = &cobra.Command{ } func runWorkspaceList(cmd *cobra.Command, _ []string) error { - cl := client.New(apiURL) + cl := newClient() ws, err := cl.ListWorkspaces() if err != nil { return fmt.Errorf("workspace list: %w", err) @@ -94,7 +97,7 @@ func init() { } func runWorkspaceCreate(cmd *cobra.Command, _ []string) error { - cl := client.New(apiURL) + cl := newClient() req := client.CreateWorkspaceRequest{Name: createFlags.name} if createFlags.role != "" { req.Role = createFlags.role @@ -139,7 +142,7 @@ var workspaceInspectCmd = &cobra.Command{ } func runWorkspaceInspect(cmd *cobra.Command, args []string) error { - cl := client.New(apiURL) + cl := newClient() ws, err := cl.GetWorkspace(args[0]) if err != nil { return fmt.Errorf("workspace inspect: %w", err) @@ -177,7 +180,7 @@ var workspaceDeleteCmd = &cobra.Command{ } func runWorkspaceDelete(cmd *cobra.Command, args []string) error { - cl := client.New(apiURL) + cl := newClient() if err := cl.DeleteWorkspace(args[0]); err != nil { return fmt.Errorf("workspace delete: %w", err) } @@ -196,7 +199,7 @@ var workspaceRestartCmd = &cobra.Command{ } func runWorkspaceRestart(cmd *cobra.Command, args []string) error { - cl := client.New(apiURL) + cl := newClient() if err := cl.RestartWorkspace(args[0]); err != nil { return fmt.Errorf("workspace restart: %w", err) } @@ -214,17 +217,17 @@ var workspaceAuditCmd = &cobra.Command{ } func runWorkspaceAudit(cmd *cobra.Command, _ []string) error { - cl := client.New(apiURL) + cl := newClient() workspaces, agents, err := cl.AuditWorkspaces() if err != nil { return fmt.Errorf("workspace audit: %w", err) } type auditReport struct { - Workspaces int `json:"workspaces"` - Agents int `json:"agents"` - ByStatus map[string]int `json:"by_status"` - Items []client.Workspace `json:"workspaces_list"` - AgentList []client.Agent `json:"agents_list"` + Workspaces int `json:"workspaces"` + Agents int `json:"agents"` + ByStatus map[string]int `json:"by_status"` + Items []client.Workspace `json:"workspaces_list"` + AgentList []client.Agent `json:"agents_list"` } byStatus := map[string]int{} for _, ws := range workspaces { @@ -272,7 +275,7 @@ var workspaceDelegateCmd = &cobra.Command{ func runWorkspaceDelegate(cmd *cobra.Command, args []string) error { workspaceID, targetID, task := args[0], args[1], args[2] - cl := client.New(apiURL) + cl := newClient() type delReq struct { TargetID string `json:"target_id"` @@ -298,4 +301,4 @@ func runWorkspaceDelegate(cmd *cobra.Command, args []string) error { } _ = workspaceID return nil -} \ No newline at end of file +} diff --git a/internal/cmd/workspace_mgmt.go b/internal/cmd/workspace_mgmt.go new file mode 100644 index 0000000..3d0b980 --- /dev/null +++ b/internal/cmd/workspace_mgmt.go @@ -0,0 +1,192 @@ +// Package cmd implements the CLI command tree. +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +// --------------------------------------------------------------------------- +// Workspace management verbs (PLATFORM-MANAGEMENT-API.md §5(b)): +// get, pause, resume, budget, billing-mode, token mint. +// list / create / delete / restart / inspect live in workspace.go. +// --------------------------------------------------------------------------- + +// =========================================================================== +// molecule workspace get (alias of inspect) +// =========================================================================== +var workspaceGetCmd = &cobra.Command{ + Use: "get ", + Short: "Show full details for a workspace (alias of inspect)", + Args: cobra.ExactArgs(1), + RunE: runWorkspaceInspect, +} + +// =========================================================================== +// molecule workspace pause +// =========================================================================== +var workspacePauseCmd = &cobra.Command{ + Use: "pause ", + Short: "Pause a workspace", + Args: cobra.ExactArgs(1), + RunE: runWorkspacePause, +} + +func runWorkspacePause(_ *cobra.Command, args []string) error { + if err := newClient().PauseWorkspace(args[0]); err != nil { + return fmt.Errorf("workspace pause: %w", err) + } + fmt.Printf("Pause triggered for workspace %q.\n", args[0]) + return nil +} + +// =========================================================================== +// molecule workspace resume +// =========================================================================== +var workspaceResumeCmd = &cobra.Command{ + Use: "resume ", + Short: "Resume a paused workspace", + Args: cobra.ExactArgs(1), + RunE: runWorkspaceResume, +} + +func runWorkspaceResume(_ *cobra.Command, args []string) error { + if err := newClient().ResumeWorkspace(args[0]); err != nil { + return fmt.Errorf("workspace resume: %w", err) + } + fmt.Printf("Resume triggered for workspace %q.\n", args[0]) + return nil +} + +// =========================================================================== +// molecule workspace budget [--hourly|--daily|--weekly|--monthly cents] +// =========================================================================== +var budgetFlags struct { + hourly int64 + daily int64 + weekly int64 + monthly int64 +} + +var workspaceBudgetCmd = &cobra.Command{ + Use: "budget [--hourly|--daily|--weekly|--monthly ]", + Short: "Show or set a workspace's LLM budget (USD cents per period)", + Long: `With no period flags, prints the current budget. +With one or more period flags, sets those period limits (USD cents). +Pass a period flag with value -1 to CLEAR that period's limit. + + molecule workspace budget ws_123 # show + molecule workspace budget ws_123 --monthly 50000 # $500/mo + molecule workspace budget ws_123 --daily -1 # clear daily limit`, + Args: cobra.ExactArgs(1), + RunE: runWorkspaceBudget, +} + +func init() { + f := workspaceBudgetCmd.Flags() + // Sentinel: math.MinInt64 means "flag not provided". -1 means "clear". + f.Int64Var(&budgetFlags.hourly, "hourly", budgetUnset, "Hourly limit (USD cents); -1 to clear") + f.Int64Var(&budgetFlags.daily, "daily", budgetUnset, "Daily limit (USD cents); -1 to clear") + f.Int64Var(&budgetFlags.weekly, "weekly", budgetUnset, "Weekly limit (USD cents); -1 to clear") + f.Int64Var(&budgetFlags.monthly, "monthly", budgetUnset, "Monthly limit (USD cents); -1 to clear") +} + +const budgetUnset = -1 << 62 + +func runWorkspaceBudget(cmd *cobra.Command, args []string) error { + cl := newClient() + id := args[0] + + limits := map[string]*int64{} + add := func(period string, v int64) { + if v == budgetUnset { + return + } + if v < 0 { + limits[period] = nil // clear + return + } + vv := v + limits[period] = &vv + } + add("hourly", budgetFlags.hourly) + add("daily", budgetFlags.daily) + add("weekly", budgetFlags.weekly) + add("monthly", budgetFlags.monthly) + + if len(limits) == 0 { + // Show current budget. + raw, err := cl.GetBudget(id) + if err != nil { + return fmt.Errorf("workspace budget: %w", err) + } + return printRaw(raw) + } + raw, err := cl.SetBudget(id, limits) + if err != nil { + return fmt.Errorf("workspace budget: %w", err) + } + return printRaw(raw) +} + +// =========================================================================== +// molecule workspace billing-mode +// =========================================================================== +var workspaceBillingModeCmd = &cobra.Command{ + Use: "billing-mode ", + Short: "Set a workspace's LLM billing-mode override", + Args: cobra.ExactArgs(2), + RunE: runWorkspaceBillingMode, +} + +func runWorkspaceBillingMode(_ *cobra.Command, args []string) error { + id, mode := args[0], args[1] + switch mode { + case "platform_managed", "byok", "disabled": + // ok + case "clear", "null", "": + mode = "" // clear override + default: + return &exitError{code: 2, msg: "workspace billing-mode: mode must be platform_managed, byok, disabled, or clear"} + } + raw, err := newClient().SetBillingMode(id, mode) + if err != nil { + return fmt.Errorf("workspace billing-mode: %w", err) + } + return printRaw(raw) +} + +// =========================================================================== +// molecule workspace token mint +// =========================================================================== +var workspaceTokenCmd = &cobra.Command{ + Use: "token", + Short: "Manage per-workspace auth tokens", +} + +var workspaceTokenMintCmd = &cobra.Command{ + Use: "mint ", + Short: "Mint a new per-workspace auth token (plaintext shown once)", + Args: cobra.ExactArgs(1), + RunE: runWorkspaceTokenMint, +} + +func runWorkspaceTokenMint(_ *cobra.Command, args []string) error { + resp, err := newClient().MintWorkspaceToken(args[0]) + if err != nil { + return fmt.Errorf("workspace token mint: %w", err) + } + if outputFormat == "json" { + return printJSON(resp) + } + if outputFormat == "yaml" { + return printYAML(resp) + } + fmt.Printf("Token minted for workspace %s\n", resp.WorkspaceID) + fmt.Printf("auth_token: %s\n", resp.Token) + if resp.Message != "" { + fmt.Printf("%s\n", resp.Message) + } + return nil +} -- 2.52.0 From 442d1ebaf40c3c23aa3b2156574b8261543bfcb9 Mon Sep 17 00:00:00 2001 From: sdk-dev Date: Sun, 31 May 2026 22:35:22 -0700 Subject: [PATCH 2/4] fix(cli): repoint org create/list to CP-admin bearer; fail-fast get/export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- cmd/molecule/molecule_test.go | 41 +++++++ internal/client/management.go | 85 ++++++++------ internal/client/management_test.go | 35 +++--- internal/cmd/http.go | 14 +++ internal/cmd/management_test.go | 44 ++------ internal/cmd/org.go | 142 ++++++++++++++---------- internal/cmd/org_test.go | 171 +++++++++++++++++++++++++++++ internal/cmd/root.go | 22 +++- internal/cmd/workspace_mgmt.go | 55 +++++++--- 9 files changed, 440 insertions(+), 169 deletions(-) create mode 100644 internal/cmd/org_test.go diff --git a/cmd/molecule/molecule_test.go b/cmd/molecule/molecule_test.go index 8930aba..e17b8fe 100644 --- a/cmd/molecule/molecule_test.go +++ b/cmd/molecule/molecule_test.go @@ -669,6 +669,47 @@ func TestCLI_PlatformAudit(t *testing.T) { } } +// TestCLI_OrgList_FailsFastWithoutCPAdminToken is the end-to-end guard for the +// org-verb credential split: `org list` targets the CP ADMIN surface and must +// fail fast (non-zero exit) with a clear stderr message naming +// MOLECULE_CP_ADMIN_TOKEN when that token is absent — instead of silently +// sending the tenant Org API Key to the control plane and 401'ing. This also +// covers the error-surfacing wiring (SilenceErrors → handleErr) so the message +// actually reaches the user. +func TestCLI_OrgList_FailsFastWithoutCPAdminToken(t *testing.T) { + exe := mol(t) + root := repoRoot() + cmd := exec.Command(exe, "org", "list") + // Tenant org key present; CP-admin token deliberately unset. + cmd.Env = append(os.Environ(), + "MOLECULE_API_KEY=org-key-SECRET", + "MOLECULE_ORG_ID=org_abc", + "MOLECULE_CP_ADMIN_TOKEN=", + ) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + cmd.Dir = root + err := cmd.Run() + if err == nil { + t.Fatalf("expected non-zero exit for org list without CP admin token, got none\nstdout: %s", stdout.String()) + } + exitErr, ok := err.(*exec.ExitError) + if !ok { + t.Fatalf("expected *exec.ExitError, got %T", err) + } + if exitErr.ExitCode() == 0 { + t.Errorf("expected non-zero exit code, got 0") + } + if !strings.Contains(stderr.String(), "MOLECULE_CP_ADMIN_TOKEN") { + t.Errorf("stderr must name MOLECULE_CP_ADMIN_TOKEN, got:\n%s", stderr.String()) + } + // The tenant org key must never appear in any output (not leaked). + if strings.Contains(stdout.String()+stderr.String(), "org-key-SECRET") { + t.Errorf("tenant Org API Key leaked into output") + } +} + func TestCLI_UnknownSubcommand(t *testing.T) { exe := mol(t) root := repoRoot() diff --git a/internal/client/management.go b/internal/client/management.go index f7365fe..549787a 100644 --- a/internal/client/management.go +++ b/internal/client/management.go @@ -17,62 +17,75 @@ import ( ) // --------------------------------------------------------------------------- -// Control-plane: orgs +// Control-plane: orgs (ADMIN bearer surface — /api/v1/admin/orgs) +// +// The customer-facing /api/v1/orgs* routes are gated by a WorkOS browser +// session (controlplane auth.RequireSession), which a bearer-token CLI cannot +// satisfy — every call 401s in production, and the tenant Org API Key has no +// standing on the CP at all. So org-lifecycle verbs go to the CP ADMIN routes +// (controlplane router AdminGate), authenticated with a DISTINCT CP-admin +// bearer (MOLECULE_CP_ADMIN_TOKEN). The org key is never sent to the CP. +// +// Note: only create + list exist on the admin surface. There is NO admin +// GET /orgs/:slug and NO admin /orgs/:slug/export in controlplane — those are +// session-only — so the CLI fails those verbs fast rather than 401'ing. // --------------------------------------------------------------------------- -// Org mirrors controlplane models.Organization (GET /api/v1/orgs[/:slug]). +// Org mirrors controlplane handlers.adminOrgSummary, the per-row shape of +// GET /api/v1/admin/orgs. Intentionally narrower than the session-facing +// customer org view (no credits/overage — those aren't on the admin summary). type Org struct { - ID string `json:"id"` - Slug string `json:"slug"` - Name string `json:"name"` - Plan string `json:"plan"` - Status string `json:"status"` - CreditsBalance int64 `json:"credits_balance"` - PlanMonthlyCredits int64 `json:"plan_monthly_credits"` - OverageUsedCredits int64 `json:"overage_used_credits"` - OverageCapCredits int64 `json:"overage_cap_credits"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` + ID string `json:"id"` + Slug string `json:"slug"` + Name string `json:"name"` + Plan string `json:"plan"` + Status string `json:"status,omitempty"` + StripeCustomerID string `json:"stripe_customer_id,omitempty"` + MemberCount int `json:"member_count,omitempty"` + InstanceStatus string `json:"instance_status,omitempty"` + LastError string `json:"last_error,omitempty"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at,omitempty"` } -// ListOrgs returns the caller's orgs from the control plane. +// adminListOrgsResponse is the envelope returned by GET /api/v1/admin/orgs: +// {"limit":N,"offset":N,"orgs":[...]}. +type adminListOrgsResponse struct { + Limit int `json:"limit"` + Offset int `json:"offset"` + Orgs []Org `json:"orgs"` +} + +// ListOrgs returns all orgs from the CP admin surface +// (GET /api/v1/admin/orgs). Requires a CP-admin bearer. func (p *Platform) ListOrgs() ([]Org, error) { - var out []Org - if err := p.getInto("/api/v1/orgs", &out); err != nil { + var out adminListOrgsResponse + if err := p.getInto("/api/v1/admin/orgs", &out); err != nil { return nil, err } - return out, nil + return out.Orgs, nil } -// GetOrg returns a single org by slug from the control plane. -func (p *Platform) GetOrg(slug string) (*Org, error) { - var out Org - if err := p.getInto("/api/v1/orgs/"+url.PathEscape(slug), &out); err != nil { - return nil, err - } - return &out, nil -} - -// CreateOrgRequest is the body for POST /api/v1/orgs. +// CreateOrgRequest is the body for POST /api/v1/admin/orgs. owner_user_id is +// REQUIRED by the admin route (controlplane adminCreateOrgRequest): the +// server-to-server path has no implicit WorkOS session to hang the owner +// membership off of, so the caller passes it explicitly. type CreateOrgRequest struct { - Slug string `json:"slug"` - Name string `json:"name"` + Slug string `json:"slug"` + Name string `json:"name"` + OwnerUserID string `json:"owner_user_id"` } -// CreateOrg creates an org on the control plane. +// CreateOrg creates an org on the CP admin surface (POST /api/v1/admin/orgs). +// Requires a CP-admin bearer. Returns the full created org. func (p *Platform) CreateOrg(req CreateOrgRequest) (*Org, error) { var out Org - if err := p.postInto("/api/v1/orgs", req, &out); err != nil { + if err := p.postInto("/api/v1/admin/orgs", req, &out); err != nil { return nil, err } return &out, nil } -// ExportOrg returns the org export payload (GET /api/v1/orgs/:slug/export). -func (p *Platform) ExportOrg(slug string) (json.RawMessage, error) { - return p.getRaw("/api/v1/orgs/" + url.PathEscape(slug) + "/export") -} - // --------------------------------------------------------------------------- // Tenant: org-from-template (POST /org/import) + allowlist + org tokens // --------------------------------------------------------------------------- diff --git a/internal/client/management_test.go b/internal/client/management_test.go index 23cbf73..9dcc93f 100644 --- a/internal/client/management_test.go +++ b/internal/client/management_test.go @@ -71,33 +71,26 @@ func TestManagementRequestConstruction(t *testing.T) { wantBody string // exact JSON when non-empty; "" = don't assert body }{ { + // org list now hits the CP ADMIN surface (the customer + // /api/v1/orgs is WorkOS-session-gated and 401s a bearer CLI). name: "ListOrgs", - reply: `[]`, + reply: `{"limit":100,"offset":0,"orgs":[]}`, call: func(p *Platform) error { _, e := p.ListOrgs(); return e }, wantMethod: "GET", - wantPath: "/api/v1/orgs", + wantPath: "/api/v1/admin/orgs", }, { - name: "GetOrg", - reply: `{"slug":"acme"}`, - call: func(p *Platform) error { _, e := p.GetOrg("acme"); return e }, - wantMethod: "GET", - wantPath: "/api/v1/orgs/acme", - }, - { - name: "CreateOrg", - reply: `{"slug":"acme","name":"Acme"}`, - call: func(p *Platform) error { _, e := p.CreateOrg(CreateOrgRequest{Slug: "acme", Name: "Acme"}); return e }, + // org create hits the CP ADMIN surface and carries the required + // owner_user_id (the admin route has no implicit session). + name: "CreateOrg", + reply: `{"slug":"acme","name":"Acme"}`, + call: func(p *Platform) error { + _, e := p.CreateOrg(CreateOrgRequest{Slug: "acme", Name: "Acme", OwnerUserID: "user_123"}) + return e + }, wantMethod: "POST", - wantPath: "/api/v1/orgs", - wantBody: `{"slug":"acme","name":"Acme"}`, - }, - { - name: "ExportOrg", - reply: `{"org":"acme"}`, - call: func(p *Platform) error { _, e := p.ExportOrg("acme"); return e }, - wantMethod: "GET", - wantPath: "/api/v1/orgs/acme/export", + wantPath: "/api/v1/admin/orgs", + wantBody: `{"slug":"acme","name":"Acme","owner_user_id":"user_123"}`, }, { name: "CreateOrgFromTemplate", diff --git a/internal/cmd/http.go b/internal/cmd/http.go index 1b1b740..33a79b5 100644 --- a/internal/cmd/http.go +++ b/internal/cmd/http.go @@ -22,6 +22,20 @@ func orgID() string { return os.Getenv("MOLECULE_ORG_ID") } +// cpAdminToken returns the control-plane ADMIN bearer used for org-lifecycle +// verbs that hit the CP admin surface (POST/GET /api/v1/admin/orgs). +// +// This is a DISTINCT credential from MOLECULE_API_KEY (the tenant Org API +// Key). The CP's customer-facing /api/v1/orgs* routes are gated by a WorkOS +// browser session (RequireSession), which a bearer-token CLI cannot satisfy — +// and the Org API Key has no standing on the CP at all. The admin routes are +// gated by AdminGate, which accepts a server-to-server bearer. We deliberately +// keep this in its own env var so the tenant org key is NEVER sent to the CP. +// Read from MOLECULE_CP_ADMIN_TOKEN. +func cpAdminToken() string { + return os.Getenv("MOLECULE_CP_ADMIN_TOKEN") +} + // setAuthHeaders attaches the management credentials to req. // // Before this existed, management calls (workspace create/delete, secrets, diff --git a/internal/cmd/management_test.go b/internal/cmd/management_test.go index e73b607..478564f 100644 --- a/internal/cmd/management_test.go +++ b/internal/cmd/management_test.go @@ -8,37 +8,14 @@ import ( // TestBudgetFlagMapping verifies the budget flag→limits translation: // unset flags are omitted, negative clears (nil), non-negative sets a pointer. +// Exercises the SHARED budgetLimitsFromFlags helper so prod and test agree. func TestBudgetFlagMapping(t *testing.T) { - build := func(hourly, daily, weekly, monthly int64) map[string]*int64 { - budgetFlags.hourly = hourly - budgetFlags.daily = daily - budgetFlags.weekly = weekly - budgetFlags.monthly = monthly - limits := map[string]*int64{} - add := func(period string, v int64) { - if v == budgetUnset { - return - } - if v < 0 { - limits[period] = nil - return - } - vv := v - limits[period] = &vv - } - add("hourly", budgetFlags.hourly) - add("daily", budgetFlags.daily) - add("weekly", budgetFlags.weekly) - add("monthly", budgetFlags.monthly) - return limits - } - // All unset → empty map (show, not set). - if got := build(budgetUnset, budgetUnset, budgetUnset, budgetUnset); len(got) != 0 { + if got := budgetLimitsFromFlags(budgetUnset, budgetUnset, budgetUnset, budgetUnset); len(got) != 0 { t.Errorf("all-unset: want empty map, got %v", got) } // monthly=50000 set, daily=-1 clear, others unset. - got := build(budgetUnset, -1, budgetUnset, 50000) + got := budgetLimitsFromFlags(budgetUnset, -1, budgetUnset, 50000) if len(got) != 2 { t.Fatalf("want 2 entries, got %d (%v)", len(got), got) } @@ -53,7 +30,8 @@ func TestBudgetFlagMapping(t *testing.T) { } } -// TestBillingModeValidation walks the billing-mode arg validation branches. +// TestBillingModeValidation walks the billing-mode arg validation branches via +// the SHARED resolveBillingMode helper (same code prod runs). func TestBillingModeValidation(t *testing.T) { cases := []struct { in string @@ -69,16 +47,8 @@ func TestBillingModeValidation(t *testing.T) { {"bogus", "", true}, } for _, tc := range cases { - mode := tc.in - var err bool - switch mode { - case "platform_managed", "byok", "disabled": - case "clear", "null", "": - mode = "" - default: - err = true - } - if err != tc.wantErr { + mode, err := resolveBillingMode(tc.in) + if (err != nil) != tc.wantErr { t.Errorf("%q: err=%v want %v", tc.in, err, tc.wantErr) } if !tc.wantErr && mode != tc.wantMode { diff --git a/internal/cmd/org.go b/internal/cmd/org.go index 9122ec6..1cc6601 100644 --- a/internal/cmd/org.go +++ b/internal/cmd/org.go @@ -13,21 +13,40 @@ import ( // --------------------------------------------------------------------------- // Org command group. // -// Org-lifecycle verbs (list / get / create / export) talk to the CONTROL -// PLANE (MOLECULE_CP_URL, default = api-url) — orgs/billing/provisioning live -// there, not on a tenant host. The tenant-scoped sub-verbs (create -// --template, token *, allowlist) talk to the tenant host (api-url) with the -// Org API Key, per PLATFORM-MANAGEMENT-API.md §1/§3. +// 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. -// cpClient builds a client pointed at the control plane. -func cpClient() *client.Platform { - return client.NewWithAuth(cpURL(), authToken(), orgID()) +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() { @@ -48,7 +67,11 @@ var orgListCmd = &cobra.Command{ } func runOrgList(_ *cobra.Command, _ []string) error { - orgs, err := cpClient().ListOrgs() + cp, err := cpAdminClient() + if err != nil { + return err + } + orgs, err := cp.ListOrgs() if err != nil { return fmt.Errorf("org list: %w", err) } @@ -63,9 +86,9 @@ func runOrgList(_ *cobra.Command, _ []string) error { return nil } w := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0) - fmt.Fprintln(w, "SLUG\tNAME\tPLAN\tSTATUS\tCREDITS") + 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.Status, o.CreditsBalance) + 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() } @@ -75,59 +98,55 @@ func runOrgList(_ *cobra.Command, _ []string) error { // =========================================================================== var orgGetCmd = &cobra.Command{ Use: "get ", - Short: "Show a single organization (control plane)", - Args: cobra.ExactArgs(1), + Short: "Show a single org (UNAVAILABLE via token auth — session-only on the CP)", + Args: cobra.MaximumNArgs(1), RunE: runOrgGet, } -func runOrgGet(_ *cobra.Command, args []string) error { - o, err := cpClient().GetOrg(args[0]) - if err != nil { - return fmt.Errorf("org get: %w", err) - } - if outputFormat == "json" { - return printJSON(o) - } - if outputFormat == "yaml" { - return printYAML(o) - } - w := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0) - kv(w, "ID", o.ID) - kv(w, "Slug", o.Slug) - kv(w, "Name", o.Name) - kv(w, "Plan", o.Plan) - kv(w, "Status", o.Status) - kv(w, "Credits", fmt.Sprintf("%d", o.CreditsBalance)) - kv(w, "CreatedAt", o.CreatedAt) - return w.Flush() +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 - template string - mode string + slug string + name string + ownerUserID string + template string + mode string } var orgCreateCmd = &cobra.Command{ - Use: "create --slug --name | --template ", - Short: "Create an org (control plane) or provision workspaces from an org template (tenant)", - Long: `Create an organization on the control plane: - molecule org create --slug acme --name "Acme Inc" + Use: "create --slug --name --owner-user-id | --template ", + 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= \ + 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): +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 create)") - f.StringVar(&orgCreateFlags.name, "name", "", "Org display name (control-plane create)") + 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") } @@ -144,13 +163,21 @@ func runOrgCreate(_ *cobra.Command, _ []string) error { } return printRaw(raw) } - // Control-plane org create. + // Control-plane admin org create (POST /api/v1/admin/orgs). if orgCreateFlags.slug == "" || orgCreateFlags.name == "" { - return &exitError{code: 2, msg: "org create: provide --slug and --name (CP create), or --template (tenant org-from-template)"} + return &exitError{code: 2, msg: "org create: provide --slug, --name and --owner-user-id (CP admin create), or --template (tenant org-from-template)"} } - o, err := cpClient().CreateOrg(client.CreateOrgRequest{ - Slug: orgCreateFlags.slug, - Name: orgCreateFlags.name, + 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) @@ -170,17 +197,16 @@ func runOrgCreate(_ *cobra.Command, _ []string) error { // =========================================================================== var orgExportCmd = &cobra.Command{ Use: "export ", - Short: "Export an organization (control plane)", - Args: cobra.ExactArgs(1), + Short: "Export an org (UNAVAILABLE via token auth — session-only on the CP)", + Args: cobra.MaximumNArgs(1), RunE: runOrgExport, } -func runOrgExport(_ *cobra.Command, args []string) error { - raw, err := cpClient().ExportOrg(args[0]) - if err != nil { - return fmt.Errorf("org export: %w", err) - } - return printRaw(raw) +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."} } // =========================================================================== diff --git a/internal/cmd/org_test.go b/internal/cmd/org_test.go new file mode 100644 index 0000000..c320139 --- /dev/null +++ b/internal/cmd/org_test.go @@ -0,0 +1,171 @@ +package cmd + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// orgTestServer spins up an httptest server that records the path, method, and +// the two credential headers of the request it receives, then returns the +// supplied body. Used to assert the org-lifecycle verbs hit the CP ADMIN +// surface with the CP-admin bearer (and NOT the tenant org key/org-id header). +type capturedReq struct { + method, path, auth, orgID string + hadAuth bool +} + +func orgTestServer(t *testing.T, body string) (*httptest.Server, *capturedReq) { + t.Helper() + got := &capturedReq{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got.method = r.Method + got.path = r.URL.Path + got.auth = r.Header.Get("Authorization") + got.orgID = r.Header.Get("X-Molecule-Org-Id") + _, got.hadAuth = r.Header["Authorization"] + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(body)) + })) + t.Cleanup(srv.Close) + return srv, got +} + +// TestOrgList_TargetsAdminSurfaceWithCPAdminBearer asserts `org list` hits the +// control-plane ADMIN route (/api/v1/admin/orgs) authenticated with the +// CP-admin bearer (MOLECULE_CP_ADMIN_TOKEN) — and critically does NOT leak the +// tenant Org API Key (MOLECULE_API_KEY) or send the X-Molecule-Org-Id header. +func TestOrgList_TargetsAdminSurfaceWithCPAdminBearer(t *testing.T) { + srv, got := orgTestServer(t, `{"limit":100,"offset":0,"orgs":[{"slug":"acme","name":"Acme","plan":"pro","instance_status":"running","member_count":3}]}`) + + // Tenant org key is present in the env but must NOT be used for CP calls. + t.Setenv("MOLECULE_API_KEY", "org-key-SECRET") + t.Setenv("MOLECULE_ORG_ID", "org_should_not_leak") + t.Setenv("MOLECULE_CP_ADMIN_TOKEN", "cp-admin-bearer-123") + t.Setenv("MOLECULE_CP_URL", srv.URL) + + origFmt := outputFormat + defer func() { outputFormat = origFmt }() + outputFormat = "json" + + if err := runOrgList(nil, nil); err != nil { + t.Fatalf("runOrgList: %v", err) + } + if got.method != "GET" || got.path != "/api/v1/admin/orgs" { + t.Errorf("target = %s %s, want GET /api/v1/admin/orgs", got.method, got.path) + } + if got.auth != "Bearer cp-admin-bearer-123" { + t.Errorf("Authorization = %q, want CP-admin bearer", got.auth) + } + if strings.Contains(got.auth, "org-key-SECRET") { + t.Errorf("tenant Org API Key leaked to the control plane: %q", got.auth) + } + if got.orgID != "" { + t.Errorf("X-Molecule-Org-Id should not be sent to the CP admin surface, got %q", got.orgID) + } +} + +// TestOrgCreate_TargetsAdminSurfaceWithOwnerAndCPAdminBearer asserts `org +// create` POSTs to /api/v1/admin/orgs with the CP-admin bearer and the +// required owner_user_id, never the tenant org key. +func TestOrgCreate_TargetsAdminSurfaceWithOwnerAndCPAdminBearer(t *testing.T) { + srv, got := orgTestServer(t, `{"id":"o1","slug":"acme","name":"Acme","plan":"free","created_at":"now"}`) + + t.Setenv("MOLECULE_API_KEY", "org-key-SECRET") + t.Setenv("MOLECULE_ORG_ID", "org_should_not_leak") + t.Setenv("MOLECULE_CP_ADMIN_TOKEN", "cp-admin-bearer-123") + t.Setenv("MOLECULE_CP_URL", srv.URL) + + origFmt := outputFormat + defer func() { outputFormat = origFmt }() + outputFormat = "json" + + orgCreateFlags.slug = "acme" + orgCreateFlags.name = "Acme" + orgCreateFlags.ownerUserID = "user_123" + orgCreateFlags.template = "" + defer func() { orgCreateFlags.slug, orgCreateFlags.name, orgCreateFlags.ownerUserID = "", "", "" }() + + if err := runOrgCreate(nil, nil); err != nil { + t.Fatalf("runOrgCreate: %v", err) + } + if got.method != "POST" || got.path != "/api/v1/admin/orgs" { + t.Errorf("target = %s %s, want POST /api/v1/admin/orgs", got.method, got.path) + } + if got.auth != "Bearer cp-admin-bearer-123" { + t.Errorf("Authorization = %q, want CP-admin bearer", got.auth) + } + if got.orgID != "" { + t.Errorf("X-Molecule-Org-Id should not be sent to the CP admin surface, got %q", got.orgID) + } +} + +// TestOrgCreate_RequiresOwnerUserID confirms the admin create path fails fast +// (no network call) when --owner-user-id is missing. +func TestOrgCreate_RequiresOwnerUserID(t *testing.T) { + t.Setenv("MOLECULE_CP_ADMIN_TOKEN", "cp-admin-bearer-123") + orgCreateFlags.slug = "acme" + orgCreateFlags.name = "Acme" + orgCreateFlags.ownerUserID = "" + orgCreateFlags.template = "" + defer func() { orgCreateFlags.slug, orgCreateFlags.name = "", "" }() + + err := runOrgCreate(nil, nil) + if err == nil { + t.Fatal("expected error when --owner-user-id is missing") + } + if !strings.Contains(err.Error(), "owner-user-id") { + t.Errorf("error = %q, want it to mention owner-user-id", err.Error()) + } +} + +// TestOrgVerbs_FailFastWithoutCPAdminToken is the WRONG-CREDENTIAL path: when +// only the tenant Org API Key is set (no MOLECULE_CP_ADMIN_TOKEN), the +// CP-targeting verbs must fail fast with a clear message — NOT silently send +// the org key to the control plane and 401. +func TestOrgVerbs_FailFastWithoutCPAdminToken(t *testing.T) { + // Tenant key present, CP-admin token deliberately absent. + t.Setenv("MOLECULE_API_KEY", "org-key-SECRET") + t.Setenv("MOLECULE_ORG_ID", "org_abc") + t.Setenv("MOLECULE_CP_ADMIN_TOKEN", "") + // Point CP at an unreachable host so any accidental network call is a + // hard, obvious failure rather than a hang. + t.Setenv("MOLECULE_CP_URL", "http://127.0.0.1:0") + + // list + if err := runOrgList(nil, nil); err == nil { + t.Error("org list should fail fast without MOLECULE_CP_ADMIN_TOKEN") + } else if !strings.Contains(err.Error(), "MOLECULE_CP_ADMIN_TOKEN") { + t.Errorf("org list error = %q, want it to name MOLECULE_CP_ADMIN_TOKEN", err.Error()) + } + + // create + orgCreateFlags.slug = "acme" + orgCreateFlags.name = "Acme" + orgCreateFlags.ownerUserID = "user_123" + orgCreateFlags.template = "" + defer func() { orgCreateFlags.slug, orgCreateFlags.name, orgCreateFlags.ownerUserID = "", "", "" }() + if err := runOrgCreate(nil, nil); err == nil { + t.Error("org create should fail fast without MOLECULE_CP_ADMIN_TOKEN") + } else if !strings.Contains(err.Error(), "MOLECULE_CP_ADMIN_TOKEN") { + t.Errorf("org create error = %q, want it to name MOLECULE_CP_ADMIN_TOKEN", err.Error()) + } +} + +// TestOrgGetExport_FailFast confirms get/export do not pretend to work over +// token auth: they return a clear unavailable error instead of 401'ing the +// session-only CP routes. +func TestOrgGetExport_FailFast(t *testing.T) { + t.Setenv("MOLECULE_CP_ADMIN_TOKEN", "cp-admin-bearer-123") // even WITH the admin token + if err := runOrgGet(nil, []string{"acme"}); err == nil { + t.Error("org get should fail fast (session-only on the CP)") + } else if !strings.Contains(err.Error(), "session") { + t.Errorf("org get error = %q, want it to explain the session gate", err.Error()) + } + if err := runOrgExport(nil, []string{"acme"}); err == nil { + t.Error("org export should fail fast (session-only on the CP)") + } else if !strings.Contains(err.Error(), "session") { + t.Errorf("org export error = %q, want it to explain the session gate", err.Error()) + } +} diff --git a/internal/cmd/root.go b/internal/cmd/root.go index fbfffa3..877535e 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -82,7 +82,11 @@ func Execute() error { viper.AutomaticEnv() _ = viper.ReadInConfig() // ignore not-found; env vars win - return rootCmd.Execute() + // rootCmd has SilenceErrors=true, so cobra prints nothing on error. + // Route the error through handleErr so user-facing messages (including + // exitError fail-fast guidance like the org-verb credential errors) are + // printed to stderr with the right exit code instead of being swallowed. + return handleErr(rootCmd.Execute()) } // envOr returns the value of env var key, or fallback if unset/empty. @@ -111,6 +115,22 @@ func cpURL() string { return apiURL } +// cpAdminClient builds a Platform client for the CP ADMIN surface +// (/api/v1/admin/orgs). It authenticates with the dedicated CP-admin bearer +// (MOLECULE_CP_ADMIN_TOKEN) and sends NO X-Molecule-Org-Id header — the admin +// routes are not org-scoped at the gate. Crucially it never carries the tenant +// Org API Key (MOLECULE_API_KEY), so the org credential is never leaked to the +// control plane. Returns an error when the admin token is unset so callers +// fail fast with a clear two-credential message instead of a bare 401. +func cpAdminClient() (*client.Platform, error) { + tok := cpAdminToken() + if tok == "" { + return nil, &exitError{code: 2, msg: "this verb hits the control-plane admin API and requires a CP admin bearer token in MOLECULE_CP_ADMIN_TOKEN (distinct from the tenant MOLECULE_API_KEY / Org API Key, which has no standing on the control plane). See `molecule org --help`."} + } + // OrgID is intentionally empty: AdminGate does not route on it. + return client.NewWithAuth(cpURL(), tok, ""), nil +} + // init registers all subcommand trees. func init() { rootCmd.AddCommand(workspaceCmd) diff --git a/internal/cmd/workspace_mgmt.go b/internal/cmd/workspace_mgmt.go index 3d0b980..1866b8c 100644 --- a/internal/cmd/workspace_mgmt.go +++ b/internal/cmd/workspace_mgmt.go @@ -94,10 +94,12 @@ func init() { const budgetUnset = -1 << 62 -func runWorkspaceBudget(cmd *cobra.Command, args []string) error { - cl := newClient() - id := args[0] - +// budgetLimitsFromFlags translates the four --hourly/--daily/--weekly/--monthly +// flags into the budget_limits map sent to PATCH /workspaces/:id/budget: +// an unset flag (budgetUnset) is omitted; a negative value clears that period +// (nil); a non-negative value sets the limit. Shared by prod and tests so the +// translation logic has exactly one definition. +func budgetLimitsFromFlags(hourly, daily, weekly, monthly int64) map[string]*int64 { limits := map[string]*int64{} add := func(period string, v int64) { if v == budgetUnset { @@ -110,10 +112,35 @@ func runWorkspaceBudget(cmd *cobra.Command, args []string) error { vv := v limits[period] = &vv } - add("hourly", budgetFlags.hourly) - add("daily", budgetFlags.daily) - add("weekly", budgetFlags.weekly) - add("monthly", budgetFlags.monthly) + add("hourly", hourly) + add("daily", daily) + add("weekly", weekly) + add("monthly", monthly) + return limits +} + +// resolveBillingMode validates a billing-mode argument and returns the value +// to send to the tenant: the three real modes pass through; clear/null/"" map +// to "" (clear the override); anything else is an error. Shared by prod and +// tests. +func resolveBillingMode(in string) (string, error) { + switch in { + case "platform_managed", "byok", "disabled": + return in, nil + case "clear", "null", "": + return "", nil + default: + return "", &exitError{code: 2, msg: "workspace billing-mode: mode must be platform_managed, byok, disabled, or clear"} + } +} + +func runWorkspaceBudget(cmd *cobra.Command, args []string) error { + cl := newClient() + id := args[0] + + limits := budgetLimitsFromFlags( + budgetFlags.hourly, budgetFlags.daily, budgetFlags.weekly, budgetFlags.monthly, + ) if len(limits) == 0 { // Show current budget. @@ -141,14 +168,10 @@ var workspaceBillingModeCmd = &cobra.Command{ } func runWorkspaceBillingMode(_ *cobra.Command, args []string) error { - id, mode := args[0], args[1] - switch mode { - case "platform_managed", "byok", "disabled": - // ok - case "clear", "null", "": - mode = "" // clear override - default: - return &exitError{code: 2, msg: "workspace billing-mode: mode must be platform_managed, byok, disabled, or clear"} + id := args[0] + mode, err := resolveBillingMode(args[1]) + if err != nil { + return err } raw, err := newClient().SetBillingMode(id, mode) if err != nil { -- 2.52.0 From d882cdd1ee308a53ee7de79b23d8464d71f366eb Mon Sep 17 00:00:00 2001 From: "Molecule AI Dev Engineer A (Kimi)" Date: Mon, 1 Jun 2026 07:12:44 +0000 Subject: [PATCH 3/4] fix(cli): sync viper config values into globals after ReadInConfig CR2 review finding: config file values were read but not applied to the CLI globals (apiURL, outputFormat, verbose, jsonOutput). Add viper.BindPFlag for all persistent flags and sync values back from viper after ReadInConfig() so the full precedence chain works: flag > env > config > default. Co-Authored-By: Claude Opus 4.7 --- internal/cmd/root.go | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 877535e..7ae5a6c 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -57,6 +57,11 @@ func init() { "Shorthand for --output json") rootCmd.PersistentFlags().StringVar(&configPath, "config", "", "Path to config file (default ~/.config/molecule.yaml or ./molecule.yaml)") + // Bind flags to viper so config-file / env values can override defaults. + _ = viper.BindPFlag("api_url", rootCmd.PersistentFlags().Lookup("api-url")) + _ = viper.BindPFlag("verbose", rootCmd.PersistentFlags().Lookup("verbose")) + _ = viper.BindPFlag("output", rootCmd.PersistentFlags().Lookup("output")) + _ = viper.BindPFlag("json", rootCmd.PersistentFlags().Lookup("json")) // --json wins over -o; resolved before any command runs. rootCmd.PersistentPreRun = func(_ *cobra.Command, _ []string) { if jsonOutput { @@ -82,6 +87,22 @@ func Execute() error { viper.AutomaticEnv() _ = viper.ReadInConfig() // ignore not-found; env vars win + // Sync config-file / env values back into the globals so cobra flags + // reflect the full viper precedence chain (flag > env > config > default). + if v := viper.GetString("api_url"); v != "" { + apiURL = v + } + if viper.GetBool("verbose") { + verbose = true + } + if v := viper.GetString("output"); v != "" { + outputFormat = v + } + if viper.GetBool("json") { + jsonOutput = true + outputFormat = "json" + } + // rootCmd has SilenceErrors=true, so cobra prints nothing on error. // Route the error through handleErr so user-facing messages (including // exitError fail-fast guidance like the org-verb credential errors) are -- 2.52.0 From a73c20e2e550f5370d38dce564ff2386c18945ae Mon Sep 17 00:00:00 2001 From: "Molecule AI Dev Engineer A (Kimi)" Date: Mon, 1 Jun 2026 07:14:36 +0000 Subject: [PATCH 4/4] fix(cli): send empty object {} instead of null for workspace token mint CR2 review finding: MintWorkspaceToken passed nil to postInto, which marshals to JSON null. Pass an empty map so the body is {} as expected by the handler. Co-Authored-By: Claude Opus 4.7 --- internal/client/management.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/client/management.go b/internal/client/management.go index 549787a..e6a979b 100644 --- a/internal/client/management.go +++ b/internal/client/management.go @@ -204,7 +204,8 @@ type MintWorkspaceTokenResponse struct { // (POST /workspaces/:id/tokens). Plaintext is returned exactly once. func (p *Platform) MintWorkspaceToken(id string) (*MintWorkspaceTokenResponse, error) { var out MintWorkspaceTokenResponse - if err := p.postInto("/workspaces/"+url.PathEscape(id)+"/tokens", nil, &out); err != nil { + // Pass empty object rather than nil so the body is {} not null. + if err := p.postInto("/workspaces/"+url.PathEscape(id)+"/tokens", map[string]interface{}{}, &out); err != nil { return nil, err } return &out, nil -- 2.52.0