add a user-facing cli

This commit is contained in:
Keiran 2025-08-07 19:10:13 +01:00
parent 46e3901470
commit efd008f12a
9 changed files with 1216 additions and 0 deletions

27
cmd/tcman/main.go Normal file
View File

@ -0,0 +1,27 @@
package main
import (
"fmt"
"os"
"git.keircn.com/keiran/termcloud/internal/cli"
"github.com/spf13/cobra"
)
func main() {
rootCmd := &cobra.Command{
Use: "tcman",
Short: "TermCloud Management CLI",
Long: "Command line interface for managing TermCloud accounts, buckets, and files",
}
rootCmd.AddCommand(cli.NewAccountCmd())
rootCmd.AddCommand(cli.NewBucketCmd())
rootCmd.AddCommand(cli.NewFileCmd())
rootCmd.AddCommand(cli.NewConfigCmd())
if err := rootCmd.Execute(); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
}

3
go.mod
View File

@ -9,12 +9,15 @@ require (
) )
require ( require (
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/labstack/gommon v0.4.2 // indirect github.com/labstack/gommon v0.4.2 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/spf13/cobra v1.9.1 // indirect
github.com/spf13/pflag v1.0.6 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect
golang.org/x/crypto v0.38.0 // indirect golang.org/x/crypto v0.38.0 // indirect

8
go.sum
View File

@ -1,6 +1,9 @@
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
@ -19,6 +22,11 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=

266
internal/cli/account.go Normal file
View File

@ -0,0 +1,266 @@
package cli
import (
"context"
"fmt"
"os"
"strconv"
"strings"
"text/tabwriter"
"github.com/spf13/cobra"
)
func NewAccountCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "account",
Short: "Manage your TermCloud account",
Long: "Create, authenticate, and manage your TermCloud account",
}
cmd.AddCommand(newAccountCreateCmd())
cmd.AddCommand(newAccountLoginCmd())
cmd.AddCommand(newAccountInfoCmd())
cmd.AddCommand(newAccountTopUpCmd())
cmd.AddCommand(newAccountPaymentsCmd())
cmd.AddCommand(newAccountUsageCmd())
return cmd
}
func newAccountCreateCmd() *cobra.Command {
return &cobra.Command{
Use: "create",
Short: "Create a new TermCloud account",
RunE: func(cmd *cobra.Command, args []string) error {
config, err := LoadConfig()
if err != nil {
return fmt.Errorf("failed to load config: %w", err)
}
client := NewClient(config.ServerURL, "")
ctx := context.Background()
account, err := client.CreateAccount(ctx)
if err != nil {
return fmt.Errorf("failed to create account: %w", err)
}
config.AccountNumber = account.AccountNumber
if err := config.Save(); err != nil {
return fmt.Errorf("failed to save config: %w", err)
}
fmt.Printf("Account created successfully!\n")
fmt.Printf("Account Number: %s\n", account.AccountNumber)
fmt.Printf("Status: %s\n", getAccountStatus(account))
fmt.Printf("\nTo activate your account, add funds using: tcman account top-up\n")
return nil
},
}
}
func newAccountLoginCmd() *cobra.Command {
return &cobra.Command{
Use: "login <account-number> <access-token>",
Short: "Login to an existing account",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
accountNumber := args[0]
accessToken := args[1]
config, err := LoadConfig()
if err != nil {
return fmt.Errorf("failed to load config: %w", err)
}
client := NewClient(config.ServerURL, accessToken)
ctx := context.Background()
account, err := client.GetAccount(ctx)
if err != nil {
return fmt.Errorf("failed to authenticate: %w", err)
}
if account.AccountNumber != accountNumber {
return fmt.Errorf("account number mismatch")
}
config.AccountNumber = accountNumber
config.AccessToken = accessToken
if err := config.Save(); err != nil {
return fmt.Errorf("failed to save config: %w", err)
}
fmt.Printf("Successfully logged in!\n")
fmt.Printf("Account: %s\n", account.AccountNumber)
fmt.Printf("Status: %s\n", getAccountStatus(account))
fmt.Printf("Balance: $%.2f USD\n", account.BalanceUSD)
return nil
},
}
}
func newAccountInfoCmd() *cobra.Command {
return &cobra.Command{
Use: "info",
Short: "Show account information",
RunE: func(cmd *cobra.Command, args []string) error {
config, err := LoadConfig()
if err != nil {
return fmt.Errorf("failed to load config: %w", err)
}
if !config.IsAuthenticated() {
return fmt.Errorf("not logged in. Use 'tcman account login' or 'tcman account create'")
}
client := NewClient(config.ServerURL, config.AccessToken)
ctx := context.Background()
account, err := client.GetAccount(ctx)
if err != nil {
return fmt.Errorf("failed to get account info: %w", err)
}
fmt.Printf("Account Information:\n")
fmt.Printf(" Number: %s\n", account.AccountNumber)
fmt.Printf(" Status: %s\n", getAccountStatus(account))
fmt.Printf(" Balance: $%.2f USD\n", account.BalanceUSD)
fmt.Printf(" Created: %s\n", account.CreatedAt.Format("2006-01-02 15:04:05"))
if account.ActivatedAt != nil {
fmt.Printf(" Activated: %s\n", account.ActivatedAt.Format("2006-01-02 15:04:05"))
}
fmt.Printf(" Last Billing: %s\n", account.LastBillingDate.Format("2006-01-02"))
return nil
},
}
}
func newAccountTopUpCmd() *cobra.Command {
return &cobra.Command{
Use: "top-up <amount>",
Short: "Add funds to your account via Bitcoin",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
amount, err := strconv.ParseFloat(args[0], 64)
if err != nil {
return fmt.Errorf("invalid amount: %w", err)
}
if amount < 5.0 {
return fmt.Errorf("minimum top-up amount is $5.00 USD")
}
config, err := LoadConfig()
if err != nil {
return fmt.Errorf("failed to load config: %w", err)
}
if !config.IsAuthenticated() {
return fmt.Errorf("not logged in. Use 'tcman account login' or 'tcman account create'")
}
client := NewClient(config.ServerURL, config.AccessToken)
ctx := context.Background()
payment, err := client.CreatePayment(ctx, amount)
if err != nil {
return fmt.Errorf("failed to create payment: %w", err)
}
fmt.Printf("Bitcoin Payment Created:\n")
fmt.Printf(" Payment ID: %d\n", payment.ID)
fmt.Printf(" Amount: $%.2f USD (%.8f BTC)\n", payment.USDAmount, payment.BTCAmount)
fmt.Printf(" Address: %s\n", payment.BTCAddress)
fmt.Printf(" Status: %s\n", strings.ToUpper(payment.Status))
if payment.ExpiresAt != nil {
fmt.Printf(" Expires: %s\n", payment.ExpiresAt.Format("2006-01-02 15:04:05"))
}
fmt.Printf("\nSend exactly %.8f BTC to the address above.\n", payment.BTCAmount)
fmt.Printf("Check payment status with: tcman account payments\n")
return nil
},
}
}
func newAccountPaymentsCmd() *cobra.Command {
return &cobra.Command{
Use: "payments",
Short: "List payment history",
RunE: func(cmd *cobra.Command, args []string) error {
config, err := LoadConfig()
if err != nil {
return fmt.Errorf("failed to load config: %w", err)
}
if !config.IsAuthenticated() {
return fmt.Errorf("not logged in. Use 'tcman account login' or 'tcman account create'")
}
client := NewClient(config.ServerURL, config.AccessToken)
ctx := context.Background()
payments, err := client.GetPayments(ctx)
if err != nil {
return fmt.Errorf("failed to get payments: %w", err)
}
if len(payments) == 0 {
fmt.Printf("No payments found.\n")
return nil
}
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(w, "ID\tAmount\tBTC Amount\tStatus\tCreated\tConfirmed")
fmt.Fprintln(w, "---\t------\t----------\t------\t-------\t---------")
for _, payment := range payments {
confirmedStr := "N/A"
if payment.ConfirmedAt != nil {
confirmedStr = payment.ConfirmedAt.Format("2006-01-02 15:04")
}
fmt.Fprintf(w, "%d\t$%.2f\t%.8f\t%s\t%s\t%s\n",
payment.ID,
payment.USDAmount,
payment.BTCAmount,
strings.ToUpper(payment.Status),
payment.CreatedAt.Format("2006-01-02 15:04"),
confirmedStr,
)
}
w.Flush()
return nil
},
}
}
func newAccountUsageCmd() *cobra.Command {
return &cobra.Command{
Use: "usage",
Short: "Show billing and usage history",
RunE: func(cmd *cobra.Command, args []string) error {
fmt.Printf("Usage tracking is handled automatically.\n")
fmt.Printf("You are charged monthly based on peak storage usage.\n")
fmt.Printf("Current rate: $0.50 per GB per month.\n")
fmt.Printf("\nUse 'tcman account info' to see your current balance.\n")
return nil
},
}
}
func getAccountStatus(account *Account) string {
if !account.IsActive {
return "INACTIVE (needs funds)"
}
if account.BalanceUSD <= 0 {
return "LOW BALANCE"
}
return "ACTIVE"
}

221
internal/cli/bucket.go Normal file
View File

@ -0,0 +1,221 @@
package cli
import (
"context"
"fmt"
"os"
"text/tabwriter"
"github.com/spf13/cobra"
)
func NewBucketCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "bucket",
Short: "Manage storage buckets",
Long: "Create, list, and manage your storage buckets",
}
cmd.AddCommand(newBucketCreateCmd())
cmd.AddCommand(newBucketListCmd())
cmd.AddCommand(newBucketDeleteCmd())
cmd.AddCommand(newBucketInfoCmd())
return cmd
}
func newBucketCreateCmd() *cobra.Command {
return &cobra.Command{
Use: "create <bucket-name>",
Short: "Create a new bucket",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
config, err := LoadConfig()
if err != nil {
return fmt.Errorf("failed to load config: %w", err)
}
if !config.IsAuthenticated() {
return fmt.Errorf("not logged in. Use 'tcman account login' or 'tcman account create'")
}
bucketName := args[0]
client := NewClient(config.ServerURL, config.AccessToken)
ctx := context.Background()
bucket, err := client.CreateBucket(ctx, bucketName)
if err != nil {
return fmt.Errorf("failed to create bucket: %w", err)
}
fmt.Printf("Bucket '%s' created successfully!\n", bucket.Name)
fmt.Printf("ID: %d\n", bucket.ID)
fmt.Printf("Storage Used: %d bytes\n", bucket.StorageUsedBytes)
fmt.Printf("Created: %s\n", bucket.CreatedAt.Format("2006-01-02 15:04:05"))
return nil
},
}
}
func newBucketListCmd() *cobra.Command {
return &cobra.Command{
Use: "list",
Short: "List all buckets",
RunE: func(cmd *cobra.Command, args []string) error {
config, err := LoadConfig()
if err != nil {
return fmt.Errorf("failed to load config: %w", err)
}
if !config.IsAuthenticated() {
return fmt.Errorf("not logged in. Use 'tcman account login' or 'tcman account create'")
}
client := NewClient(config.ServerURL, config.AccessToken)
ctx := context.Background()
buckets, err := client.ListBuckets(ctx)
if err != nil {
return fmt.Errorf("failed to list buckets: %w", err)
}
if len(buckets) == 0 {
fmt.Printf("No buckets found.\n")
return nil
}
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(w, "Name\tStorage Used\tCreated")
fmt.Fprintln(w, "----\t------------\t-------")
for _, bucket := range buckets {
storageStr := formatBytes(bucket.StorageUsedBytes)
fmt.Fprintf(w, "%s\t%s\t%s\n",
bucket.Name,
storageStr,
bucket.CreatedAt.Format("2006-01-02 15:04"),
)
}
w.Flush()
return nil
},
}
}
func newBucketInfoCmd() *cobra.Command {
return &cobra.Command{
Use: "info <bucket-name>",
Short: "Show bucket information",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
config, err := LoadConfig()
if err != nil {
return fmt.Errorf("failed to load config: %w", err)
}
if !config.IsAuthenticated() {
return fmt.Errorf("not logged in. Use 'tcman account login' or 'tcman account create'")
}
bucketName := args[0]
client := NewClient(config.ServerURL, config.AccessToken)
ctx := context.Background()
bucket, err := client.GetBucket(ctx, bucketName)
if err != nil {
return fmt.Errorf("failed to get bucket info: %w", err)
}
fmt.Printf("Bucket Information:\n")
fmt.Printf(" Name: %s\n", bucket.Name)
fmt.Printf(" ID: %d\n", bucket.ID)
fmt.Printf(" Storage Used: %s\n", formatBytes(bucket.StorageUsedBytes))
fmt.Printf(" Created: %s\n", bucket.CreatedAt.Format("2006-01-02 15:04:05"))
objects, err := client.ListObjects(ctx, bucketName)
if err != nil {
fmt.Printf(" Objects: Error loading (%v)\n", err)
} else {
fmt.Printf(" Objects: %d\n", len(objects))
}
return nil
},
}
}
func newBucketDeleteCmd() *cobra.Command {
var force bool
cmd := &cobra.Command{
Use: "delete <bucket-name>",
Short: "Delete a bucket",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
config, err := LoadConfig()
if err != nil {
return fmt.Errorf("failed to load config: %w", err)
}
if !config.IsAuthenticated() {
return fmt.Errorf("not logged in. Use 'tcman account login' or 'tcman account create'")
}
bucketName := args[0]
client := NewClient(config.ServerURL, config.AccessToken)
ctx := context.Background()
if !force {
objects, err := client.ListObjects(ctx, bucketName)
if err != nil {
return fmt.Errorf("failed to check bucket contents: %w", err)
}
if len(objects) > 0 {
return fmt.Errorf("bucket '%s' contains %d objects. Use --force to delete anyway", bucketName, len(objects))
}
fmt.Printf("Are you sure you want to delete bucket '%s'? (y/N): ", bucketName)
var response string
fmt.Scanln(&response)
if response != "y" && response != "yes" && response != "Y" && response != "YES" {
fmt.Printf("Cancelled.\n")
return nil
}
}
err = client.DeleteBucket(ctx, bucketName)
if err != nil {
return fmt.Errorf("failed to delete bucket: %w", err)
}
fmt.Printf("Bucket '%s' deleted successfully.\n", bucketName)
return nil
},
}
cmd.Flags().BoolVar(&force, "force", false, "Force deletion without confirmation")
return cmd
}
func formatBytes(bytes int64) string {
if bytes == 0 {
return "0 B"
}
const unit = 1024
if bytes < unit {
return fmt.Sprintf("%d B", bytes)
}
div, exp := int64(unit), 0
for n := bytes / unit; n >= unit; n /= unit {
div *= unit
exp++
}
units := []string{"KB", "MB", "GB", "TB"}
return fmt.Sprintf("%.1f %s", float64(bytes)/float64(div), units[exp])
}

241
internal/cli/client.go Normal file
View File

@ -0,0 +1,241 @@
package cli
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
type Client struct {
baseURL string
httpClient *http.Client
token string
}
type Account struct {
ID int64 `json:"id"`
AccountNumber string `json:"accountNumber"`
BalanceUSD float64 `json:"balanceUsd"`
IsActive bool `json:"isActive"`
CreatedAt time.Time `json:"createdAt"`
ActivatedAt *time.Time `json:"activatedAt,omitempty"`
LastBillingDate time.Time `json:"lastBillingDate"`
}
type Payment struct {
ID int64 `json:"id"`
AccountID int64 `json:"accountId"`
PaymentType string `json:"paymentType"`
BTCAddress string `json:"btcAddress,omitempty"`
BTCAmount float64 `json:"btcAmount,omitempty"`
USDAmount float64 `json:"usdAmount"`
Confirmations int `json:"confirmations"`
TxHash string `json:"txHash,omitempty"`
Status string `json:"status"`
ExpiresAt *time.Time `json:"expiresAt,omitempty"`
CreatedAt time.Time `json:"createdAt"`
ConfirmedAt *time.Time `json:"confirmedAt,omitempty"`
}
type Bucket struct {
ID int64 `json:"id"`
Name string `json:"name"`
OwnerID int64 `json:"ownerId"`
StorageUsedBytes int64 `json:"storageUsedBytes"`
CreatedAt time.Time `json:"createdAt"`
}
type Object struct {
ID int64 `json:"id"`
BucketID int64 `json:"bucketId"`
Key string `json:"key"`
SizeBytes int64 `json:"sizeBytes"`
ContentType string `json:"contentType"`
LastModified time.Time `json:"lastModified"`
VersionID string `json:"versionId"`
MD5Checksum string `json:"md5Checksum"`
CustomMetadata map[string]interface{} `json:"customMetadata"`
}
func NewClient(baseURL, token string) *Client {
return &Client{
baseURL: baseURL,
httpClient: &http.Client{
Timeout: 30 * time.Second,
},
token: token,
}
}
func (c *Client) makeRequest(ctx context.Context, method, path string, body interface{}, result interface{}) error {
var reqBody io.Reader
if body != nil {
jsonData, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("failed to marshal request body: %w", err)
}
reqBody = bytes.NewBuffer(jsonData)
}
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, reqBody)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if c.token != "" {
req.Header.Set("X-Access-Token", c.token)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("server error %d: %s", resp.StatusCode, string(body))
}
if result != nil && resp.StatusCode != http.StatusNoContent {
if err := json.NewDecoder(resp.Body).Decode(result); err != nil {
return fmt.Errorf("failed to decode response: %w", err)
}
}
return nil
}
func (c *Client) CreateAccount(ctx context.Context) (*Account, error) {
var account Account
err := c.makeRequest(ctx, "POST", "/api/accounts", nil, &account)
return &account, err
}
func (c *Client) GetAccount(ctx context.Context) (*Account, error) {
var account Account
err := c.makeRequest(ctx, "GET", "/api/account", nil, &account)
return &account, err
}
func (c *Client) CreatePayment(ctx context.Context, usdAmount float64) (*Payment, error) {
req := map[string]interface{}{
"usd_amount": usdAmount,
}
var payment Payment
err := c.makeRequest(ctx, "POST", "/api/payments", req, &payment)
return &payment, err
}
func (c *Client) GetPayment(ctx context.Context, paymentID int64) (*Payment, error) {
var payment Payment
path := fmt.Sprintf("/api/payments/%d", paymentID)
err := c.makeRequest(ctx, "GET", path, nil, &payment)
return &payment, err
}
func (c *Client) GetPayments(ctx context.Context) ([]Payment, error) {
var payments []Payment
err := c.makeRequest(ctx, "GET", "/api/payments", nil, &payments)
return payments, err
}
func (c *Client) CreateBucket(ctx context.Context, name string) (*Bucket, error) {
req := map[string]interface{}{
"name": name,
}
var bucket Bucket
err := c.makeRequest(ctx, "PUT", "/api/buckets/"+name, req, &bucket)
return &bucket, err
}
func (c *Client) ListBuckets(ctx context.Context) ([]Bucket, error) {
var buckets []Bucket
err := c.makeRequest(ctx, "GET", "/api/buckets", nil, &buckets)
return buckets, err
}
func (c *Client) GetBucket(ctx context.Context, name string) (*Bucket, error) {
var bucket Bucket
path := fmt.Sprintf("/api/buckets/%s", name)
err := c.makeRequest(ctx, "GET", path, nil, &bucket)
return &bucket, err
}
func (c *Client) DeleteBucket(ctx context.Context, name string) error {
path := fmt.Sprintf("/api/buckets/%s", name)
return c.makeRequest(ctx, "DELETE", path, nil, nil)
}
func (c *Client) ListObjects(ctx context.Context, bucketName string) ([]Object, error) {
var objects []Object
path := fmt.Sprintf("/api/buckets/%s/objects", bucketName)
err := c.makeRequest(ctx, "GET", path, nil, &objects)
return objects, err
}
func (c *Client) PutObject(ctx context.Context, bucketName, key string, data []byte, contentType string) error {
req, err := http.NewRequestWithContext(ctx, "PUT",
fmt.Sprintf("%s/api/buckets/%s/objects/%s", c.baseURL, bucketName, key),
bytes.NewReader(data))
if err != nil {
return err
}
if contentType != "" {
req.Header.Set("Content-Type", contentType)
}
if c.token != "" {
req.Header.Set("X-Access-Token", c.token)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("upload failed %d: %s", resp.StatusCode, string(body))
}
return nil
}
func (c *Client) GetObject(ctx context.Context, bucketName, key string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, "GET",
fmt.Sprintf("%s/api/buckets/%s/objects/%s", c.baseURL, bucketName, key), nil)
if err != nil {
return nil, err
}
if c.token != "" {
req.Header.Set("X-Access-Token", c.token)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("download failed %d: %s", resp.StatusCode, string(body))
}
return io.ReadAll(resp.Body)
}
func (c *Client) DeleteObject(ctx context.Context, bucketName, key string) error {
path := fmt.Sprintf("/api/buckets/%s/objects/%s", bucketName, key)
return c.makeRequest(ctx, "DELETE", path, nil, nil)
}

132
internal/cli/cmd_config.go Normal file
View File

@ -0,0 +1,132 @@
package cli
import (
"fmt"
"os"
"github.com/spf13/cobra"
)
func NewConfigCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "config",
Short: "Manage CLI configuration",
Long: "View and modify CLI configuration settings",
}
cmd.AddCommand(newConfigShowCmd())
cmd.AddCommand(newConfigSetCmd())
cmd.AddCommand(newConfigResetCmd())
return cmd
}
func newConfigShowCmd() *cobra.Command {
return &cobra.Command{
Use: "show",
Short: "Show current configuration",
RunE: func(cmd *cobra.Command, args []string) error {
config, err := LoadConfig()
if err != nil {
return fmt.Errorf("failed to load config: %w", err)
}
configPath, _ := GetConfigPath()
fmt.Printf("Configuration:\n")
fmt.Printf(" Config file: %s\n", configPath)
fmt.Printf(" Server URL: %s\n", config.ServerURL)
fmt.Printf(" Account Number: %s\n", getConfigValue(config.AccountNumber, "Not set"))
fmt.Printf(" Access Token: %s\n", getConfigValue(config.AccessToken, "Not set"))
if config.IsAuthenticated() {
fmt.Printf(" Status: Authenticated\n")
} else {
fmt.Printf(" Status: Not authenticated\n")
}
return nil
},
}
}
func newConfigSetCmd() *cobra.Command {
return &cobra.Command{
Use: "set <key> <value>",
Short: "Set a configuration value",
Long: `Set a configuration value. Available keys:
server-url - TermCloud server URL (e.g., https://api.termcloud.com)`,
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
key := args[0]
value := args[1]
config, err := LoadConfig()
if err != nil {
return fmt.Errorf("failed to load config: %w", err)
}
switch key {
case "server-url":
config.ServerURL = value
fmt.Printf("Server URL set to: %s\n", value)
default:
return fmt.Errorf("unknown config key: %s", key)
}
if err := config.Save(); err != nil {
return fmt.Errorf("failed to save config: %w", err)
}
return nil
},
}
}
func newConfigResetCmd() *cobra.Command {
var force bool
cmd := &cobra.Command{
Use: "reset",
Short: "Reset configuration to defaults",
RunE: func(cmd *cobra.Command, args []string) error {
if !force {
fmt.Printf("This will delete all configuration including login credentials.\n")
fmt.Printf("Are you sure? (y/N): ")
var response string
fmt.Scanln(&response)
if response != "y" && response != "yes" && response != "Y" && response != "YES" {
fmt.Printf("Cancelled.\n")
return nil
}
}
configPath, err := GetConfigPath()
if err != nil {
return fmt.Errorf("failed to get config path: %w", err)
}
if err := os.Remove(configPath); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("failed to remove config file: %w", err)
}
fmt.Printf("Configuration reset successfully.\n")
fmt.Printf("You'll need to log in again with 'tcman account login' or create a new account.\n")
return nil
},
}
cmd.Flags().BoolVar(&force, "force", false, "Force reset without confirmation")
return cmd
}
func getConfigValue(value, defaultValue string) string {
if value == "" {
return defaultValue
}
if len(value) > 20 {
return value[:17] + "..."
}
return value
}

83
internal/cli/config.go Normal file
View File

@ -0,0 +1,83 @@
package cli
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
)
type Config struct {
ServerURL string `json:"serverUrl"`
AccessToken string `json:"accessToken"`
AccountNumber string `json:"accountNumber"`
}
func GetConfigDir() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
configDir := filepath.Join(home, ".tcman")
if err := os.MkdirAll(configDir, 0755); err != nil {
return "", err
}
return configDir, nil
}
func GetConfigPath() (string, error) {
configDir, err := GetConfigDir()
if err != nil {
return "", err
}
return filepath.Join(configDir, "config.json"), nil
}
func LoadConfig() (*Config, error) {
configPath, err := GetConfigPath()
if err != nil {
return nil, err
}
data, err := os.ReadFile(configPath)
if err != nil {
if os.IsNotExist(err) {
return &Config{
ServerURL: "http://localhost:8080",
}, nil
}
return nil, err
}
var config Config
if err := json.Unmarshal(data, &config); err != nil {
return nil, fmt.Errorf("invalid config file: %w", err)
}
if config.ServerURL == "" {
config.ServerURL = "http://localhost:8080"
}
return &config, nil
}
func (c *Config) Save() error {
configPath, err := GetConfigPath()
if err != nil {
return err
}
data, err := json.MarshalIndent(c, "", " ")
if err != nil {
return err
}
return os.WriteFile(configPath, data, 0644)
}
func (c *Config) IsAuthenticated() bool {
return c.AccessToken != "" && c.AccountNumber != ""
}

235
internal/cli/file.go Normal file
View File

@ -0,0 +1,235 @@
package cli
import (
"context"
"fmt"
"mime"
"os"
"path/filepath"
"strings"
"text/tabwriter"
"github.com/spf13/cobra"
)
func NewFileCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "file",
Short: "Manage files in buckets",
Long: "Upload, download, list, and manage files in your buckets",
}
cmd.AddCommand(newFileUploadCmd())
cmd.AddCommand(newFileDownloadCmd())
cmd.AddCommand(newFileListCmd())
cmd.AddCommand(newFileDeleteCmd())
return cmd
}
func newFileUploadCmd() *cobra.Command {
var contentType string
cmd := &cobra.Command{
Use: "upload <bucket-name> <local-file> [remote-key]",
Short: "Upload a file to a bucket",
Args: cobra.RangeArgs(2, 3),
RunE: func(cmd *cobra.Command, args []string) error {
config, err := LoadConfig()
if err != nil {
return fmt.Errorf("failed to load config: %w", err)
}
if !config.IsAuthenticated() {
return fmt.Errorf("not logged in. Use 'tcman account login' or 'tcman account create'")
}
bucketName := args[0]
localFile := args[1]
remoteKey := filepath.Base(localFile)
if len(args) == 3 {
remoteKey = args[2]
}
data, err := os.ReadFile(localFile)
if err != nil {
return fmt.Errorf("failed to read file: %w", err)
}
if contentType == "" {
contentType = mime.TypeByExtension(filepath.Ext(localFile))
}
client := NewClient(config.ServerURL, config.AccessToken)
ctx := context.Background()
fmt.Printf("Uploading %s to %s/%s (%s)...\n", localFile, bucketName, remoteKey, formatBytes(int64(len(data))))
err = client.PutObject(ctx, bucketName, remoteKey, data, contentType)
if err != nil {
return fmt.Errorf("failed to upload file: %w", err)
}
fmt.Printf("File uploaded successfully!\n")
fmt.Printf("Bucket: %s\n", bucketName)
fmt.Printf("Key: %s\n", remoteKey)
fmt.Printf("Size: %s\n", formatBytes(int64(len(data))))
if contentType != "" {
fmt.Printf("Content-Type: %s\n", contentType)
}
return nil
},
}
cmd.Flags().StringVar(&contentType, "content-type", "", "Override content type")
return cmd
}
func newFileDownloadCmd() *cobra.Command {
return &cobra.Command{
Use: "download <bucket-name> <remote-key> [local-file]",
Short: "Download a file from a bucket",
Args: cobra.RangeArgs(2, 3),
RunE: func(cmd *cobra.Command, args []string) error {
config, err := LoadConfig()
if err != nil {
return fmt.Errorf("failed to load config: %w", err)
}
if !config.IsAuthenticated() {
return fmt.Errorf("not logged in. Use 'tcman account login' or 'tcman account create'")
}
bucketName := args[0]
remoteKey := args[1]
localFile := filepath.Base(remoteKey)
if len(args) == 3 {
localFile = args[2]
}
client := NewClient(config.ServerURL, config.AccessToken)
ctx := context.Background()
fmt.Printf("Downloading %s/%s to %s...\n", bucketName, remoteKey, localFile)
data, err := client.GetObject(ctx, bucketName, remoteKey)
if err != nil {
return fmt.Errorf("failed to download file: %w", err)
}
err = os.WriteFile(localFile, data, 0644)
if err != nil {
return fmt.Errorf("failed to write file: %w", err)
}
fmt.Printf("File downloaded successfully!\n")
fmt.Printf("Size: %s\n", formatBytes(int64(len(data))))
fmt.Printf("Saved to: %s\n", localFile)
return nil
},
}
}
func newFileListCmd() *cobra.Command {
return &cobra.Command{
Use: "list <bucket-name>",
Short: "List files in a bucket",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
config, err := LoadConfig()
if err != nil {
return fmt.Errorf("failed to load config: %w", err)
}
if !config.IsAuthenticated() {
return fmt.Errorf("not logged in. Use 'tcman account login' or 'tcman account create'")
}
bucketName := args[0]
client := NewClient(config.ServerURL, config.AccessToken)
ctx := context.Background()
objects, err := client.ListObjects(ctx, bucketName)
if err != nil {
return fmt.Errorf("failed to list objects: %w", err)
}
if len(objects) == 0 {
fmt.Printf("No files found in bucket '%s'.\n", bucketName)
return nil
}
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(w, "Key\tSize\tContent-Type\tLast Modified")
fmt.Fprintln(w, "---\t----\t------------\t-------------")
for _, obj := range objects {
contentType := obj.ContentType
if contentType == "" {
contentType = "application/octet-stream"
}
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n",
obj.Key,
formatBytes(obj.SizeBytes),
contentType,
obj.LastModified.Format("2006-01-02 15:04:05"),
)
}
w.Flush()
return nil
},
}
}
func newFileDeleteCmd() *cobra.Command {
var force bool
cmd := &cobra.Command{
Use: "delete <bucket-name> <remote-key>",
Short: "Delete a file from a bucket",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
config, err := LoadConfig()
if err != nil {
return fmt.Errorf("failed to load config: %w", err)
}
if !config.IsAuthenticated() {
return fmt.Errorf("not logged in. Use 'tcman account login' or 'tcman account create'")
}
bucketName := args[0]
remoteKey := args[1]
client := NewClient(config.ServerURL, config.AccessToken)
ctx := context.Background()
if !force {
fmt.Printf("Are you sure you want to delete '%s' from bucket '%s'? (y/N): ", remoteKey, bucketName)
var response string
fmt.Scanln(&response)
response = strings.ToLower(response)
if response != "y" && response != "yes" {
fmt.Printf("Cancelled.\n")
return nil
}
}
err = client.DeleteObject(ctx, bucketName, remoteKey)
if err != nil {
return fmt.Errorf("failed to delete file: %w", err)
}
fmt.Printf("File '%s' deleted from bucket '%s'.\n", remoteKey, bucketName)
return nil
},
}
cmd.Flags().BoolVar(&force, "force", false, "Force deletion without confirmation")
return cmd
}