From db5f24ad4da6a25163973c931c760e4a1563a5cb Mon Sep 17 00:00:00 2001 From: Pranav Aggarwal Date: Wed, 18 Feb 2026 18:46:35 +0530 Subject: [PATCH 1/3] feat: implement plan get and plan set commands --- cmd/plan/get.go | 91 ++++++++++++++++++ cmd/plan/root.go | 20 ++++ cmd/plan/set.go | 78 +++++++++++++++ cmd/root.go | 2 + pkg/client/nodevalue/nodevalue.go | 28 ++++++ pkg/client/plan.go | 154 ++++++++++++++++++++++++++++++ 6 files changed, 373 insertions(+) create mode 100644 cmd/plan/get.go create mode 100644 cmd/plan/root.go create mode 100644 cmd/plan/set.go create mode 100644 pkg/client/plan.go diff --git a/cmd/plan/get.go b/cmd/plan/get.go new file mode 100644 index 00000000..b971e37d --- /dev/null +++ b/cmd/plan/get.go @@ -0,0 +1,91 @@ +package plan + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/mitchellh/go-wordwrap" + "github.com/spf13/cobra" + "github.com/storacha/go-ucanto/did" + + "github.com/storacha/guppy/internal/cmdutil" + "github.com/storacha/guppy/pkg/config" + "github.com/storacha/guppy/pkg/didmailto" +) + +var getFlags struct { + jsonOutput bool +} + +func init() { + getCmd.Flags().BoolVar(&getFlags.jsonOutput, "json", false, "Output in JSON format") +} + +var getCmd = &cobra.Command{ + Use: "get [account-did]", + Short: "Get the billing plan for an account", + Long: wordwrap.WrapString( + "Displays the billing plan the current account is subscribed to. "+ + "If no account DID is provided, it defaults to the currently logged-in account.", + 80), + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + + cfg, err := config.Load[config.Config]() + if err != nil { + return err + } + c := cmdutil.MustGetClient(cfg.Repo.Dir) + + var accountDID did.DID + + if len(args) > 0 { + accountDID, err = didmailto.FromInput(args[0]) + if err != nil { + return fmt.Errorf("invalid account DID: %w", err) + } + } else { + accounts, err := c.Accounts() + if err != nil { + return fmt.Errorf("listing accounts: %w", err) + } + if len(accounts) == 0 { + return fmt.Errorf("no accounts found. Please login with 'guppy login'") + } + accountDID = accounts[0] + } + + plan, err := c.PlanGet(ctx, accountDID) + if err != nil { + if strings.Contains(err.Error(), "billing profile not found") { + if getFlags.jsonOutput { + fmt.Println(`{"error": "No billing profile found"}`) + return nil + } + fmt.Printf(" No billing profile found for %s\n", accountDID) + fmt.Println(" Please visit the console to set up a plan: https://console.storacha.network") + return nil + } + return fmt.Errorf("getting plan: %w", err) + } + + if getFlags.jsonOutput { + jsonBytes, err := json.MarshalIndent(plan, "", " ") + if err != nil { + return fmt.Errorf("marshaling output: %w", err) + } + fmt.Println(string(jsonBytes)) + } else { + fmt.Printf("Plan Details for %s:\n", accountDID) + fmt.Printf(" Product: %s\n", plan.Product) + fmt.Printf(" Limit: %s\n", plan.Limit) + if plan.UpdatedAt != "" { + fmt.Printf(" Updated: %s\n", plan.UpdatedAt) + } + } + + return nil + }, +} \ No newline at end of file diff --git a/cmd/plan/root.go b/cmd/plan/root.go new file mode 100644 index 00000000..cc91da9e --- /dev/null +++ b/cmd/plan/root.go @@ -0,0 +1,20 @@ +package plan + +import ( + "github.com/mitchellh/go-wordwrap" + "github.com/spf13/cobra" +) + +var Cmd = &cobra.Command{ + Use: "plan", + Short: "Manage billing plans", + Long: wordwrap.WrapString( + "Inspect and manage the billing plan associated with your Storacha account.", + 80), +} + +func init() { + Cmd.AddCommand( + getCmd, + ) +} diff --git a/cmd/plan/set.go b/cmd/plan/set.go new file mode 100644 index 00000000..1f32d96c --- /dev/null +++ b/cmd/plan/set.go @@ -0,0 +1,78 @@ +package plan + +import ( + "fmt" + "strings" + + "github.com/mitchellh/go-wordwrap" + "github.com/spf13/cobra" + "github.com/storacha/go-ucanto/did" + + "github.com/storacha/guppy/internal/cmdutil" + "github.com/storacha/guppy/pkg/config" + "github.com/storacha/guppy/pkg/didmailto" +) + +var setCmd = &cobra.Command{ + Use: "set ", + Short: "Set the billing plan for an account", + Long: wordwrap.WrapString( + "Sets the billing plan for the account. You must provide the Product DID (e.g., did:web:starter.storacha.network). "+ + "Optionally provide the account DID via --account, otherwise it defaults to the current account.", + 80), + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + productDIDStr := args[0] + + productDID, err := did.Parse(productDIDStr) + if err != nil { + return fmt.Errorf("invalid product DID: %w", err) + } + + cfg, err := config.Load[config.Config]() + if err != nil { + return err + } + c := cmdutil.MustGetClient(cfg.Repo.Dir) + + var accountDID did.DID + if cmd.Flag("account").Changed { + val, _ := cmd.Flags().GetString("account") + accountDID, err = didmailto.FromInput(val) + if err != nil { + return fmt.Errorf("invalid account DID: %w", err) + } + } else { + accounts, err := c.Accounts() + if err != nil { + return err + } + if len(accounts) == 0 { + return fmt.Errorf("no accounts found") + } + accountDID = accounts[0] + } + + fmt.Printf("Setting plan for %s to %s...\n", accountDID, productDID) + _, err = c.PlanSet(ctx, accountDID, productDID) + + if err != nil { + if strings.Contains(err.Error(), "billing profile not found") { + fmt.Println("\n Update Failed: No Billing Profile Found") + fmt.Println(" To change plans, you must first set up a payment method.") + fmt.Println(" Please visit the console: https://console.storacha.network") + return nil + } + return err + } + + fmt.Println("Success! Plan updated.") + return nil + }, +} + +func init() { + setCmd.Flags().String("account", "", "The account DID to update") + Cmd.AddCommand(setCmd) +} diff --git a/cmd/root.go b/cmd/root.go index 6a97bdf4..db00548f 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -19,6 +19,7 @@ import ( "github.com/storacha/guppy/cmd/account" "github.com/storacha/guppy/cmd/delegation" "github.com/storacha/guppy/cmd/gateway" + "github.com/storacha/guppy/cmd/plan" "github.com/storacha/guppy/cmd/proof" "github.com/storacha/guppy/cmd/space" "github.com/storacha/guppy/cmd/upload" @@ -103,6 +104,7 @@ func init() { delegation.Cmd, account.Cmd, blob.Cmd, + plan.Cmd, ) } diff --git a/pkg/client/nodevalue/nodevalue.go b/pkg/client/nodevalue/nodevalue.go index 1168768f..6efd5690 100644 --- a/pkg/client/nodevalue/nodevalue.go +++ b/pkg/client/nodevalue/nodevalue.go @@ -1,9 +1,14 @@ package nodevalue import ( + "bytes" + "encoding/json" "fmt" + "strings" "github.com/ipld/go-ipld-prime" + "github.com/ipld/go-ipld-prime/codec/dagjson" + "github.com/ipld/go-ipld-prime/node/basicnode" ) // NodeValue converts an arbitrary IPLD node to a Go value. This is useful as a @@ -66,3 +71,26 @@ func NodeValue(node ipld.Node) (res any, err error) { return nil, fmt.Errorf("unsupported node kind: %s", node.Kind()) } } + +// FromAny converts an arbitrary Go value (struct, map, etc) to an IPLD Node. +func FromAny(val any) (ipld.Node, error) { + if val == nil { + nb := basicnode.Prototype.Any.NewBuilder() + if err := dagjson.Decode(nb, strings.NewReader("null")); err != nil { + return nil, fmt.Errorf("failed to create null node: %w", err) + } + return nb.Build(), nil + } + + b, err := json.Marshal(val) + if err != nil { + return nil, fmt.Errorf("marshaling to json: %w", err) + } + + nb := basicnode.Prototype.Any.NewBuilder() + if err := dagjson.Decode(nb, bytes.NewReader(b)); err != nil { + return nil, fmt.Errorf("decoding json to ipld: %w", err) + } + + return nb.Build(), nil +} diff --git a/pkg/client/plan.go b/pkg/client/plan.go new file mode 100644 index 00000000..75e643cb --- /dev/null +++ b/pkg/client/plan.go @@ -0,0 +1,154 @@ +package client + +import ( + "context" + "fmt" + "sort" + + "github.com/ipld/go-ipld-prime" + ucancap "github.com/storacha/go-libstoracha/capabilities/ucan" + uclient "github.com/storacha/go-ucanto/client" + "github.com/storacha/go-ucanto/core/delegation" + "github.com/storacha/go-ucanto/core/invocation" + "github.com/storacha/go-ucanto/core/receipt" + "github.com/storacha/go-ucanto/core/result" + "github.com/storacha/go-ucanto/did" + "github.com/storacha/go-ucanto/ucan" + "github.com/storacha/go-ucanto/validator" + + "github.com/storacha/guppy/pkg/agentstore" + "github.com/storacha/guppy/pkg/client/nodevalue" +) + +const ( + PlanGetCan = "plan/get" + PlanSetCan = "plan/set" +) + +type PlanResult struct { + Limit string `json:"limit"` + Product string `json:"product"` + UpdatedAt string `json:"updatedAt"` +} + +type MapCaveat map[string]any + +func (m MapCaveat) ToIPLD() (ipld.Node, error) { + return nodevalue.FromAny(m) +} + +func (c *Client) PlanGet(ctx context.Context, account did.DID) (*PlanResult, error) { + return executePlanRequest(ctx, c, PlanGetCan, account, MapCaveat{}) +} + +func (c *Client) PlanSet(ctx context.Context, account did.DID, product did.DID) (*PlanResult, error) { + return executePlanRequest(ctx, c, PlanSetCan, account, MapCaveat{"product": product.String()}) +} + +func executePlanRequest( + ctx context.Context, + c *Client, + can string, + account did.DID, + caveat MapCaveat, +) (*PlanResult, error) { + cap := ucan.NewCapability(can, account.String(), caveat) + + delegations, err := c.Proofs( + agentstore.CapabilityQuery{Can: can, With: account.String()}, + agentstore.CapabilityQuery{Can: "*", With: account.String()}, + ) + if err != nil { + return nil, fmt.Errorf("finding proofs for %s: %w", can, err) + } + + if len(delegations) == 0 { + return nil, fmt.Errorf("no authorizations found for account %s", account) + } + + sort.SliceStable(delegations, func(i, j int) bool { + return isAttestation(delegations[i]) && !isAttestation(delegations[j]) + }) + + var proofs []delegation.Proof + for _, d := range delegations { + proofs = append(proofs, delegation.FromDelegation(d)) + } + + inv, err := invocation.Invoke( + c.Issuer(), + c.Connection().ID(), + cap, + delegation.WithProof(proofs...), + ) + if err != nil { + return nil, fmt.Errorf("creating invocation: %w", err) + } + + resp, err := uclient.Execute(ctx, []invocation.Invocation{inv}, c.Connection()) + if err != nil { + return nil, fmt.Errorf("executing invocation: %w", err) + } + + rcptLink, ok := resp.Get(inv.Link()) + if !ok { + return nil, fmt.Errorf("receipt not found") + } + + anyRcpt, err := receipt.NewAnyReceiptReader().Read(rcptLink, resp.Blocks()) + if err != nil { + return nil, fmt.Errorf("reading receipt: %w", err) + } + + okNode, errNode := result.Unwrap(anyRcpt.Out()) + + if errNode != nil { + val, _ := nodevalue.NodeValue(errNode) + if errMap, ok := val.(map[string]any); ok { + msg, _ := errMap["message"].(string) + name, _ := errMap["name"].(string) + + if msg == "billing profile not found" || msg == "record not found" || name == "PlanNotFound" { + return nil, fmt.Errorf("billing profile not found") + } + + if msg != "" { + return nil, fmt.Errorf("server error: %s", msg) + } + } + return nil, fmt.Errorf("server error: %v", val) + } + + val, err := nodevalue.NodeValue(okNode) + if err != nil { + return nil, fmt.Errorf("decoding result: %w", err) + } + + res := &PlanResult{} + if m, ok := val.(map[string]any); ok { + if innerOk, hasOk := m["ok"].(map[string]any); hasOk { + m = innerOk + } + if p, ok := m["product"].(string); ok { + res.Product = p + } + if l, ok := m["limit"]; ok { + res.Limit = fmt.Sprintf("%v", l) + } + if u, ok := m["updatedAt"]; ok { + res.UpdatedAt = fmt.Sprintf("%v", u) + } + } + + return res, nil +} + +func isAttestation(d delegation.Delegation) bool { + for _, rawCap := range d.Capabilities() { + source := validator.NewSource(rawCap, d) + if _, err := ucancap.Attest.Match(source); err == nil { + return true + } + } + return false +} From b98fb6bf9b5162bc7a7648cda4490b7a3d78e7f1 Mon Sep 17 00:00:00 2001 From: Pranav Aggarwal Date: Fri, 20 Feb 2026 17:40:19 +0530 Subject: [PATCH 2/3] feat: add usage report command --- cmd/plan/get.go | 2 +- cmd/root.go | 2 + cmd/usage/report.go | 73 ++++++++++++++++++++ cmd/usage/root.go | 15 +++++ pkg/client/plan.go | 4 +- pkg/client/usage.go | 159 ++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 252 insertions(+), 3 deletions(-) create mode 100644 cmd/usage/report.go create mode 100644 cmd/usage/root.go create mode 100644 pkg/client/usage.go diff --git a/cmd/plan/get.go b/cmd/plan/get.go index b971e37d..2ebe219f 100644 --- a/cmd/plan/get.go +++ b/cmd/plan/get.go @@ -88,4 +88,4 @@ var getCmd = &cobra.Command{ return nil }, -} \ No newline at end of file +} diff --git a/cmd/root.go b/cmd/root.go index db00548f..8a9212a0 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -23,6 +23,7 @@ import ( "github.com/storacha/guppy/cmd/proof" "github.com/storacha/guppy/cmd/space" "github.com/storacha/guppy/cmd/upload" + "github.com/storacha/guppy/cmd/usage" ) var ( @@ -105,6 +106,7 @@ func init() { account.Cmd, blob.Cmd, plan.Cmd, + usage.Cmd, ) } diff --git a/cmd/usage/report.go b/cmd/usage/report.go new file mode 100644 index 00000000..e3c86626 --- /dev/null +++ b/cmd/usage/report.go @@ -0,0 +1,73 @@ +package usage + +import ( + "fmt" + "time" + + "github.com/dustin/go-humanize" + "github.com/spf13/cobra" + "github.com/storacha/go-ucanto/did" + + "github.com/storacha/guppy/internal/cmdutil" + "github.com/storacha/guppy/pkg/config" +) + +func newReportCmd() *cobra.Command { + return &cobra.Command{ + Use: "report [space-did]", + Short: "Show storage usage", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + + cfg, err := config.Load[config.Config]() + if err != nil { + return err + } + + c := cmdutil.MustGetClient(cfg.Repo.Dir) + + var spaceDID did.DID + + if len(args) > 0 { + parsedDID, err := did.Parse(args[0]) + if err != nil { + return fmt.Errorf("invalid space DID: %w", err) + } + spaceDID = parsedDID + } else { + spaces, err := c.Spaces() + if err != nil { + return fmt.Errorf("listing spaces: %w", err) + } + if len(spaces) == 0 { + return fmt.Errorf("no spaces found") + } + spaceDID = spaces[0].DID() + } + + to := time.Now().UTC() + from := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC) + + fmt.Printf("Fetching usage for space %s...\n", spaceDID) + + reports, err := c.UsageReport(ctx, spaceDID, from, to) + if err != nil { + return err + } + + fmt.Println("---------------------------------------------------") + if len(reports) == 0 { + fmt.Println("No usage data found for this period.") + } else { + for providerDID, report := range reports { + fmt.Printf("Provider: %s\n", providerDID) + fmt.Printf("Total Usage: %s\n", humanize.IBytes(report.Size)) + fmt.Println("---------------------------------------------------") + } + } + + return nil + }, + } +} diff --git a/cmd/usage/root.go b/cmd/usage/root.go new file mode 100644 index 00000000..660995a9 --- /dev/null +++ b/cmd/usage/root.go @@ -0,0 +1,15 @@ +package usage + +import ( + "github.com/spf13/cobra" +) + +var Cmd = &cobra.Command{ + Use: "usage", + Short: "Manage usage reports", + Long: "View storage usage reports for your spaces.", +} + +func init() { + Cmd.AddCommand(newReportCmd()) +} diff --git a/pkg/client/plan.go b/pkg/client/plan.go index 75e643cb..301eb167 100644 --- a/pkg/client/plan.go +++ b/pkg/client/plan.go @@ -109,9 +109,9 @@ func executePlanRequest( name, _ := errMap["name"].(string) if msg == "billing profile not found" || msg == "record not found" || name == "PlanNotFound" { - return nil, fmt.Errorf("billing profile not found") + return nil, fmt.Errorf("billing profile not found") } - + if msg != "" { return nil, fmt.Errorf("server error: %s", msg) } diff --git a/pkg/client/usage.go b/pkg/client/usage.go new file mode 100644 index 00000000..44e96298 --- /dev/null +++ b/pkg/client/usage.go @@ -0,0 +1,159 @@ +package client + +import ( + "context" + "fmt" + "strconv" + "time" + + "github.com/ipld/go-ipld-prime" + uclient "github.com/storacha/go-ucanto/client" + "github.com/storacha/go-ucanto/core/delegation" + "github.com/storacha/go-ucanto/core/invocation" + "github.com/storacha/go-ucanto/core/receipt" + "github.com/storacha/go-ucanto/core/result" + "github.com/storacha/go-ucanto/did" + "github.com/storacha/go-ucanto/ucan" + + "github.com/storacha/guppy/pkg/agentstore" + "github.com/storacha/guppy/pkg/client/nodevalue" +) + +const UsageReportCan = "usage/report" + +type UsagePeriod struct { + From int64 `json:"from"` + To int64 `json:"to"` +} + +type UsageCaveat struct { + Period UsagePeriod `json:"period"` +} + +func (u UsageCaveat) ToIPLD() (ipld.Node, error) { + return nodevalue.FromAny(u) +} + +type UsageReport struct { + Provider string + Size uint64 +} + +func (c *Client) UsageReport(ctx context.Context, space did.DID, from, to time.Time) (map[string]UsageReport, error) { + caveat := UsageCaveat{ + Period: UsagePeriod{ + From: from.Unix(), + To: to.Unix(), + }, + } + + cap := ucan.NewCapability(UsageReportCan, space.String(), caveat) + + delegations, err := c.Proofs( + agentstore.CapabilityQuery{Can: UsageReportCan, With: space.String()}, + agentstore.CapabilityQuery{Can: "*", With: space.String()}, + ) + if err != nil { + return nil, fmt.Errorf("finding proofs: %w", err) + } + + if len(delegations) == 0 { + return nil, fmt.Errorf("no authorizations found for space %s", space) + } + + var proofs []delegation.Proof + for _, d := range delegations { + proofs = append(proofs, delegation.FromDelegation(d)) + } + + inv, err := invocation.Invoke( + c.Issuer(), + c.Connection().ID(), + cap, + delegation.WithProof(proofs...), + ) + if err != nil { + return nil, fmt.Errorf("creating invocation: %w", err) + } + + resp, err := uclient.Execute(ctx, []invocation.Invocation{inv}, c.Connection()) + if err != nil { + return nil, fmt.Errorf("executing invocation: %w", err) + } + + rcptLink, ok := resp.Get(inv.Link()) + if !ok { + return nil, fmt.Errorf("receipt not found") + } + + anyRcpt, err := receipt.NewAnyReceiptReader().Read(rcptLink, resp.Blocks()) + if err != nil { + return nil, fmt.Errorf("reading receipt: %w", err) + } + + okNode, errNode := result.Unwrap(anyRcpt.Out()) + + if errNode != nil { + val, _ := nodevalue.NodeValue(errNode) + if errMap, ok := val.(map[string]any); ok { + if msg, ok := errMap["message"].(string); ok && msg != "" { + return nil, fmt.Errorf("server error: %s", msg) + } + } + return nil, fmt.Errorf("server error: %v", val) + } + + val, err := nodevalue.NodeValue(okNode) + if err != nil { + return nil, fmt.Errorf("decoding result: %w", err) + } + + reports := make(map[string]UsageReport) + if m, ok := val.(map[string]any); ok { + if innerOk, hasOk := m["ok"].(map[string]any); hasOk { + m = innerOk + } + for providerDID, data := range m { + if pdMap, ok := data.(map[string]any); ok { + report := UsageReport{Provider: providerDID} + + if sizeMap, ok := pdMap["size"].(map[string]any); ok { + report.Size = parseUint64(sizeMap["final"]) + } else if sizeVal, ok := pdMap["size"]; ok { + report.Size = parseUint64(sizeVal) + } + + reports[providerDID] = report + } + } + } + + return reports, nil +} + +func parseUint64(v any) uint64 { + switch n := v.(type) { + case int: + return uint64(n) + case int32: + return uint64(n) + case int64: + return uint64(n) + case uint: + return uint64(n) + case uint32: + return uint64(n) + case uint64: + return n + case float32: + return uint64(n) + case float64: + return uint64(n) + default: + s := fmt.Sprintf("%v", v) + if val, err := strconv.ParseUint(s, 10, 64); err == nil { + return val + } + } + return 0 +} From b2c44b24ed694eb5295ef5ef9643925c53ee79ad Mon Sep 17 00:00:00 2001 From: Pranav Aggarwal Date: Sat, 21 Feb 2026 10:54:36 +0530 Subject: [PATCH 3/3] feat: add proof ls command --- cmd/proof/ls.go | 48 +++++++++++++++++++++++++++++++++++++++++++++++ cmd/proof/root.go | 1 + 2 files changed, 49 insertions(+) create mode 100644 cmd/proof/ls.go diff --git a/cmd/proof/ls.go b/cmd/proof/ls.go new file mode 100644 index 00000000..95b5fc24 --- /dev/null +++ b/cmd/proof/ls.go @@ -0,0 +1,48 @@ +package proof + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/storacha/guppy/internal/cmdutil" + "github.com/storacha/guppy/pkg/config" +) + +var lsCmd = &cobra.Command{ + Use: "ls", + Short: "List proofs of capabilities delegated to this agent.", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := config.Load[config.Config]() + if err != nil { + return err + } + + c := cmdutil.MustGetClient(cfg.Repo.Dir) + + proofs, err := c.Proofs() + if err != nil { + return fmt.Errorf("getting proofs: %w", err) + } + + if len(proofs) == 0 { + fmt.Println("No proofs found in the local agent store.") + return nil + } + + fmt.Printf("Found %d proof(s):\n", len(proofs)) + + for _, p := range proofs { + fmt.Printf("CID: %s\n", p.Link().String()) + fmt.Printf("Issuer: %s\n", p.Issuer().DID().String()) + fmt.Println("Capabilities:") + + for _, cap := range p.Capabilities() { + fmt.Printf(" - %v (with: %v)\n", cap.Can(), cap.With()) + } + } + + return nil + }, +} diff --git a/cmd/proof/root.go b/cmd/proof/root.go index 9b52f13c..f241fbd6 100644 --- a/cmd/proof/root.go +++ b/cmd/proof/root.go @@ -11,4 +11,5 @@ var Cmd = &cobra.Command{ func init() { Cmd.AddCommand(addCmd) + Cmd.AddCommand(lsCmd) }