From efd008f12aaf7415a2fbc60bd4621a0003176077 Mon Sep 17 00:00:00 2001 From: Keiran Date: Thu, 7 Aug 2025 19:10:13 +0100 Subject: [PATCH] add a user-facing cli --- cmd/tcman/main.go | 27 ++++ go.mod | 3 + go.sum | 8 ++ internal/cli/account.go | 266 +++++++++++++++++++++++++++++++++++++ internal/cli/bucket.go | 221 ++++++++++++++++++++++++++++++ internal/cli/client.go | 241 +++++++++++++++++++++++++++++++++ internal/cli/cmd_config.go | 132 ++++++++++++++++++ internal/cli/config.go | 83 ++++++++++++ internal/cli/file.go | 235 ++++++++++++++++++++++++++++++++ 9 files changed, 1216 insertions(+) create mode 100644 cmd/tcman/main.go create mode 100644 internal/cli/account.go create mode 100644 internal/cli/bucket.go create mode 100644 internal/cli/client.go create mode 100644 internal/cli/cmd_config.go create mode 100644 internal/cli/config.go create mode 100644 internal/cli/file.go diff --git a/cmd/tcman/main.go b/cmd/tcman/main.go new file mode 100644 index 0000000..457e4f4 --- /dev/null +++ b/cmd/tcman/main.go @@ -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) + } +} diff --git a/go.mod b/go.mod index db41d32..22f1f06 100644 --- a/go.mod +++ b/go.mod @@ -9,12 +9,15 @@ require ( ) require ( + github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/labstack/gommon v0.4.2 // indirect github.com/mattn/go-colorable v0.1.14 // 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/fasttemplate v1.2.2 // indirect golang.org/x/crypto v0.38.0 // indirect diff --git a/go.sum b/go.sum index 42a188c..48e28cd 100644 --- a/go.sum +++ b/go.sum @@ -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.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 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/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= 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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 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/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= diff --git a/internal/cli/account.go b/internal/cli/account.go new file mode 100644 index 0000000..fb46574 --- /dev/null +++ b/internal/cli/account.go @@ -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 ", + 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 ", + 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" +} diff --git a/internal/cli/bucket.go b/internal/cli/bucket.go new file mode 100644 index 0000000..cd8bc50 --- /dev/null +++ b/internal/cli/bucket.go @@ -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 ", + 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 ", + 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 ", + 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]) +} diff --git a/internal/cli/client.go b/internal/cli/client.go new file mode 100644 index 0000000..64eda73 --- /dev/null +++ b/internal/cli/client.go @@ -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) +} diff --git a/internal/cli/cmd_config.go b/internal/cli/cmd_config.go new file mode 100644 index 0000000..075927f --- /dev/null +++ b/internal/cli/cmd_config.go @@ -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 ", + 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 +} diff --git a/internal/cli/config.go b/internal/cli/config.go new file mode 100644 index 0000000..f136951 --- /dev/null +++ b/internal/cli/config.go @@ -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 != "" +} diff --git a/internal/cli/file.go b/internal/cli/file.go new file mode 100644 index 0000000..cccce73 --- /dev/null +++ b/internal/cli/file.go @@ -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 [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 [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 ", + 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 ", + 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 +}