Skip to content

Commit e4d8b09

Browse files
committed
feat: initial forgebit-cli implementation
0 parents  commit e4d8b09

27 files changed

Lines changed: 2824 additions & 0 deletions

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
/forgebit-cli
2+
/forgebit
3+
/dist/

CHANGELOG.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# Changelog
2+
3+
All notable changes to this project are documented here.
4+
5+
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6+
7+
## [Unreleased]
8+
9+
Pre-release — nothing has shipped yet.
10+
11+
### Added
12+
13+
- `forgebit login` / `forgebit logout` — browser-based device-authorization login against a vendor-scoped Forgebit API key
14+
- Multi-vendor profiles — the CLI can hold a stored credential per vendor and switch between them without a fresh login
15+
- `forgebit vendor list` / `forgebit vendor switch <id|name>`
16+
- `--vendor <id|name>` on any command to target one vendor for a single call without changing the active default
17+
- `forgebit licenses issue|list|show|verify|revoke|renew` against the Forgebit license API
18+
- `forgebit licenses public-key` — fetch a vendor's Ed25519 public key
19+
- Fully offline license verification (`forgebit licenses verify --offline`) for `jwt` and `forgebit`-type keys, checked locally with no network call
20+
- `forgebit status` — reports whether the CLI is running against the API or offline data
21+
- `--json` output on `licenses` commands for scripting

cmd/licenses.go

Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
package cmd
2+
3+
import (
4+
"encoding/json"
5+
"errors"
6+
"fmt"
7+
"os"
8+
9+
"github.com/boone-studios/forgebit-cli/internal/forgebit"
10+
"github.com/spf13/cobra"
11+
)
12+
13+
func printJSON(v any) error {
14+
encoder := json.NewEncoder(os.Stdout)
15+
encoder.SetIndent("", " ")
16+
return encoder.Encode(v)
17+
}
18+
19+
var licensesCmd = &cobra.Command{
20+
Use: "licenses",
21+
Short: "Issue, inspect, and manage Forgebit licenses",
22+
}
23+
24+
func init() {
25+
rootCmd.AddCommand(licensesCmd)
26+
}
27+
28+
func requireAuth() (*forgebit.APIClient, error) {
29+
return resolveAPIClient()
30+
}
31+
32+
func toAnyMap(m map[string]string) map[string]any {
33+
if len(m) == 0 {
34+
return nil
35+
}
36+
out := make(map[string]any, len(m))
37+
for k, v := range m {
38+
out[k] = v
39+
}
40+
return out
41+
}
42+
43+
func formatExpiry(expiresAt *string) string {
44+
if expiresAt == nil || *expiresAt == "" {
45+
return "never"
46+
}
47+
return *expiresAt
48+
}
49+
50+
var issueCmd = &cobra.Command{
51+
Use: "issue",
52+
Short: "Issue a new license",
53+
RunE: func(c *cobra.Command, args []string) error {
54+
client, err := requireAuth()
55+
if err != nil {
56+
return err
57+
}
58+
59+
flags := c.Flags()
60+
productID, _ := flags.GetString("product-id")
61+
customerEmail, _ := flags.GetString("customer-email")
62+
customerName, _ := flags.GetString("customer-name")
63+
tier, _ := flags.GetString("tier")
64+
licenseType, _ := flags.GetString("license-type")
65+
licenseTypeID, _ := flags.GetString("license-type-id")
66+
durationType, _ := flags.GetString("duration-type")
67+
expiresAt, _ := flags.GetString("expires-at")
68+
isOffline, _ := flags.GetBool("offline")
69+
isFloating, _ := flags.GetBool("floating")
70+
seats, _ := flags.GetInt("seats")
71+
metadata, _ := flags.GetStringToString("metadata")
72+
features, _ := flags.GetStringToString("features")
73+
orderReference, _ := flags.GetString("order-reference")
74+
asJSON, _ := flags.GetBool("json")
75+
76+
if licenseType == "" && licenseTypeID == "" {
77+
return errors.New("one of --license-type or --license-type-id is required")
78+
}
79+
if isFloating && seats <= 0 {
80+
return errors.New("--seats is required when --floating is set")
81+
}
82+
83+
params := forgebit.IssueLicenseParams{
84+
CustomerEmail: customerEmail,
85+
CustomerName: customerName,
86+
ProductID: productID,
87+
Tier: tier,
88+
LicenseType: licenseType,
89+
LicenseTypeID: licenseTypeID,
90+
LicenseDuration: durationType,
91+
ExpiresAt: expiresAt,
92+
IsOffline: isOffline,
93+
IsFloating: isFloating,
94+
MaxConcurrentUsers: seats,
95+
Metadata: toAnyMap(metadata),
96+
Features: toAnyMap(features),
97+
OrderReference: orderReference,
98+
}
99+
100+
result, err := client.IssueLicense(c.Context(), params)
101+
if err != nil {
102+
return err
103+
}
104+
105+
if asJSON {
106+
return printJSON(result)
107+
}
108+
109+
fmt.Printf("→ %s\n", result.Key)
110+
fmt.Printf(" license: %s type: %s tier: %s vendor: %s expires: %s\n",
111+
result.License.ID, result.License.LicenseType, result.License.Tier, result.License.VendorID, formatExpiry(result.License.ExpiresAt))
112+
return nil
113+
},
114+
}
115+
116+
var listCmd = &cobra.Command{
117+
Use: "list",
118+
Short: "List licenses",
119+
RunE: func(c *cobra.Command, args []string) error {
120+
client, err := requireAuth()
121+
if err != nil {
122+
return err
123+
}
124+
125+
flags := c.Flags()
126+
productID, _ := flags.GetString("product-id")
127+
email, _ := flags.GetString("email")
128+
tier, _ := flags.GetString("tier")
129+
licenseType, _ := flags.GetString("license-type")
130+
active, _ := flags.GetBool("active")
131+
inactive, _ := flags.GetBool("inactive")
132+
perPage, _ := flags.GetInt("per-page")
133+
asJSON, _ := flags.GetBool("json")
134+
135+
if active && inactive {
136+
return errors.New("--active and --inactive are mutually exclusive")
137+
}
138+
139+
var isActive *bool
140+
if active {
141+
t := true
142+
isActive = &t
143+
} else if inactive {
144+
f := false
145+
isActive = &f
146+
}
147+
148+
result, err := client.ListLicenses(c.Context(), forgebit.ListLicensesParams{
149+
ProductID: productID,
150+
Email: email,
151+
Tier: tier,
152+
LicenseType: licenseType,
153+
IsActive: isActive,
154+
PerPage: perPage,
155+
})
156+
if err != nil {
157+
return err
158+
}
159+
160+
if asJSON {
161+
return printJSON(result)
162+
}
163+
164+
if len(result.Data) == 0 {
165+
fmt.Println("No licenses found.")
166+
return nil
167+
}
168+
for _, license := range result.Data {
169+
fmt.Printf("%s tier:%s type:%s env:%s expires:%s\n",
170+
license.ID, license.Tier, license.LicenseType, license.Environment, formatExpiry(license.ExpiresAt))
171+
}
172+
if result.Meta.Pagination.NextCursor != nil {
173+
fmt.Printf("(more results — next cursor: %s)\n", *result.Meta.Pagination.NextCursor)
174+
}
175+
return nil
176+
},
177+
}
178+
179+
var showCmd = &cobra.Command{
180+
Use: "show <license-id>",
181+
Short: "Show a single license",
182+
Args: cobra.ExactArgs(1),
183+
RunE: func(c *cobra.Command, args []string) error {
184+
client, err := requireAuth()
185+
if err != nil {
186+
return err
187+
}
188+
189+
asJSON, _ := c.Flags().GetBool("json")
190+
191+
result, err := client.ShowLicense(c.Context(), args[0])
192+
if err != nil {
193+
return err
194+
}
195+
196+
if asJSON {
197+
return printJSON(result)
198+
}
199+
200+
license := result.License
201+
fmt.Printf("%s tier:%s type:%s env:%s vendor:%s expires:%s\n",
202+
license.ID, license.Tier, license.LicenseType, license.Environment, license.VendorID, formatExpiry(license.ExpiresAt))
203+
return nil
204+
},
205+
}
206+
207+
func init() {
208+
issueCmd.Flags().String("product-id", "", "product ID the license belongs to (required)")
209+
issueCmd.Flags().String("customer-email", "", "customer email (required)")
210+
issueCmd.Flags().String("customer-name", "", "customer name")
211+
issueCmd.Flags().String("tier", "", "license tier (required)")
212+
issueCmd.Flags().String("license-type", "", "license type slug (jwt, forgebit, serial, hmac, hwid, encfile)")
213+
issueCmd.Flags().String("license-type-id", "", "license type ID, alternative to --license-type")
214+
issueCmd.Flags().String("duration-type", "", "trial, subscription, or perpetual (required)")
215+
issueCmd.Flags().String("expires-at", "", "explicit expiry (RFC3339)")
216+
issueCmd.Flags().Bool("offline", false, "mark the license as offline-capable")
217+
issueCmd.Flags().Bool("floating", false, "issue a floating (seat-based) license")
218+
issueCmd.Flags().Int("seats", 0, "max concurrent users; required with --floating")
219+
issueCmd.Flags().StringToString("metadata", nil, "metadata key=value pairs, repeatable")
220+
issueCmd.Flags().StringToString("features", nil, "feature flag key=value pairs, repeatable")
221+
issueCmd.Flags().String("order-reference", "", "external order/purchase reference")
222+
issueCmd.Flags().Bool("json", false, "print the raw API response as JSON")
223+
_ = issueCmd.MarkFlagRequired("product-id")
224+
_ = issueCmd.MarkFlagRequired("customer-email")
225+
_ = issueCmd.MarkFlagRequired("tier")
226+
_ = issueCmd.MarkFlagRequired("duration-type")
227+
228+
listCmd.Flags().String("product-id", "", "filter by product ID")
229+
listCmd.Flags().String("email", "", "filter by customer email (substring match)")
230+
listCmd.Flags().String("tier", "", "filter by tier")
231+
listCmd.Flags().String("license-type", "", "filter by license type slug")
232+
listCmd.Flags().Bool("active", false, "only active licenses")
233+
listCmd.Flags().Bool("inactive", false, "only inactive/expired/revoked licenses")
234+
listCmd.Flags().Int("per-page", 15, "results per page (max 100)")
235+
listCmd.Flags().Bool("json", false, "print the raw API response as JSON")
236+
237+
showCmd.Flags().Bool("json", false, "print the raw API response as JSON")
238+
239+
licensesCmd.AddCommand(issueCmd, listCmd, showCmd)
240+
}

cmd/licenses_keys.go

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
package cmd
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"os"
7+
8+
"github.com/spf13/cobra"
9+
)
10+
11+
var publicKeyCmd = &cobra.Command{
12+
Use: "public-key",
13+
Short: "Fetch a vendor's Ed25519 public key for offline verification",
14+
RunE: func(c *cobra.Command, args []string) error {
15+
client, err := requireAuth()
16+
if err != nil {
17+
return err
18+
}
19+
20+
flags := c.Flags()
21+
vendorID, _ := flags.GetString("vendor-id")
22+
kid, _ := flags.GetString("kid")
23+
out, _ := flags.GetString("out")
24+
25+
if vendorID == "" {
26+
return errors.New("--vendor-id is required (find it in the output of `licenses issue`, `show`, `list`, or `verify`)")
27+
}
28+
29+
if kid == "" {
30+
keys, err := client.VendorKeys(c.Context(), vendorID)
31+
if err != nil {
32+
return err
33+
}
34+
35+
var active []string
36+
for _, k := range keys {
37+
if k.IsActive {
38+
active = append(active, k.Kid)
39+
}
40+
}
41+
switch len(active) {
42+
case 0:
43+
return errors.New("no active vendor keys found; pass --kid explicitly")
44+
case 1:
45+
kid = active[0]
46+
default:
47+
return fmt.Errorf("multiple active vendor keys found (%v); pass --kid to pick one", active)
48+
}
49+
}
50+
51+
pem, err := client.VendorPublicKeyPEM(c.Context(), vendorID, kid)
52+
if err != nil {
53+
return err
54+
}
55+
56+
if out == "" {
57+
fmt.Print(pem)
58+
return nil
59+
}
60+
61+
if err := os.WriteFile(out, []byte(pem), 0o644); err != nil {
62+
return err
63+
}
64+
fmt.Printf("→ wrote %s (kid %s)\n", out, kid)
65+
return nil
66+
},
67+
}
68+
69+
func init() {
70+
publicKeyCmd.Flags().String("vendor-id", "", "vendor ID (required)")
71+
publicKeyCmd.Flags().String("kid", "", "specific key ID; auto-selected if there's exactly one active key")
72+
publicKeyCmd.Flags().String("out", "", "write the PEM to a file instead of stdout")
73+
_ = publicKeyCmd.MarkFlagRequired("vendor-id")
74+
75+
licensesCmd.AddCommand(publicKeyCmd)
76+
}

0 commit comments

Comments
 (0)