gogcli/internal/cmd/gmail_track_setup.go
2026-04-28 10:22:55 +01:00

240 lines
7.4 KiB
Go

package cmd
import (
"context"
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"github.com/steipete/gogcli/internal/input"
"github.com/steipete/gogcli/internal/tracking"
"github.com/steipete/gogcli/internal/ui"
)
type GmailTrackSetupCmd struct {
WorkerName string `name:"worker-name" help:"Cloudflare Worker name (defaults to gog-email-tracker-<account>)"`
DatabaseName string `name:"db-name" help:"D1 database name (defaults to worker name)"`
WorkerURL string `name:"worker-url" aliases:"domain" help:"Tracking worker base URL (e.g. https://gog-email-tracker.<acct>.workers.dev)"`
TrackingKey string `name:"tracking-key" help:"Tracking key (base64; generates one if omitted)"`
AdminKey string `name:"admin-key" help:"Admin key for /opens (generates one if omitted)"`
Deploy bool `name:"deploy" help:"Provision D1 + deploy the worker (requires wrangler)"`
WorkerDir string `name:"worker-dir" help:"Worker directory (default: internal/tracking/worker)"`
}
func (c *GmailTrackSetupCmd) Run(ctx context.Context, flags *RootFlags) error {
u := ui.FromContext(ctx)
// Avoid hitting the keyring for implicit account selection in dry-run mode.
if flags != nil && flags.DryRun &&
strings.TrimSpace(flags.Account) == "" &&
strings.TrimSpace(os.Getenv("GOG_ACCOUNT")) == "" {
return usage("missing --account (dry-run requires an explicit account and does not auto-select)")
}
account, cfg, err := loadTrackingConfigForAccount(flags)
if err != nil {
return err
}
workerName := strings.TrimSpace(c.WorkerName)
if workerName == "" {
workerName = strings.TrimSpace(cfg.WorkerName)
}
if workerName == "" {
workerName = tracking.DefaultWorkerName(account)
}
workerName = tracking.SanitizeWorkerName(workerName)
if workerName == "" {
return fmt.Errorf("invalid worker name")
}
c.WorkerName = workerName
dbName := strings.TrimSpace(c.DatabaseName)
if dbName == "" {
dbName = strings.TrimSpace(cfg.DatabaseName)
}
if dbName == "" {
dbName = workerName
}
c.DatabaseName = dbName
if c.WorkerURL == "" {
c.WorkerURL = strings.TrimSpace(cfg.WorkerURL)
}
if c.WorkerURL == "" && !flags.NoInput && !(flags != nil && flags.DryRun) {
line, readErr := input.PromptLine(ctx, "Tracking worker base URL (e.g. https://...workers.dev): ")
if readErr != nil {
if errors.Is(readErr, io.EOF) || errors.Is(readErr, os.ErrClosed) {
return &ExitError{Code: 1, Err: errors.New("cancelled")}
}
return fmt.Errorf("read worker url: %w", readErr)
}
c.WorkerURL = strings.TrimSpace(line)
}
c.WorkerURL = strings.TrimSpace(c.WorkerURL)
if c.WorkerURL == "" {
return usage("required: --worker-url")
}
explicitTrackingKey := strings.TrimSpace(c.TrackingKey) != ""
key := strings.TrimSpace(c.TrackingKey)
currentVersion := cfg.TrackingCurrentKeyVersion
if currentVersion <= 0 {
currentVersion = 1
}
trackingKeys := map[int]string{}
if !explicitTrackingKey {
versions := tracking.NormalizeTrackingKeyVersions(cfg.TrackingKeyVersions, currentVersion)
if len(versions) > 0 {
loadedKeys, loadedCurrentVersion, loadErr := tracking.LoadTrackingKeys(account, versions, currentVersion)
if loadErr != nil {
return fmt.Errorf("load tracking keys: %w", loadErr)
}
if len(loadedKeys) > 0 {
trackingKeys = loadedKeys
currentVersion = loadedCurrentVersion
}
}
}
if key == "" {
key = strings.TrimSpace(cfg.TrackingKey)
}
if key == "" {
key, err = tracking.GenerateKey()
if err != nil {
return fmt.Errorf("generate tracking key: %w", err)
}
}
if len(trackingKeys) == 0 || explicitTrackingKey {
currentVersion = 1
trackingKeys = map[int]string{currentVersion: key}
}
if strings.TrimSpace(trackingKeys[currentVersion]) == "" {
trackingKeys[currentVersion] = key
}
key = trackingKeys[currentVersion]
versions := tracking.NormalizeTrackingKeyVersions(mapKeys(trackingKeys), currentVersion)
adminKey := strings.TrimSpace(c.AdminKey)
if adminKey == "" {
adminKey = strings.TrimSpace(cfg.AdminKey)
}
if adminKey == "" {
adminKey, err = generateAdminKey()
if err != nil {
return fmt.Errorf("generate admin key: %w", err)
}
}
if c.WorkerDir == "" {
c.WorkerDir = filepath.Join("internal", "tracking", "worker")
}
// Avoid touching keyring and avoid provisioning/deploying in dry-run mode.
if err := dryRunExit(ctx, flags, "gmail.track.setup", map[string]any{
"account": account,
"worker_url": c.WorkerURL,
"worker_name": workerName,
"database_name": c.DatabaseName,
"deploy": c.Deploy,
"worker_dir": c.WorkerDir,
"tracking_key_set": strings.TrimSpace(key) != "",
"tracking_key_version": currentVersion,
"tracking_key_versions": versions,
"admin_key_set": strings.TrimSpace(adminKey) != "",
}); err != nil {
return err
}
if err := tracking.SaveTrackingKeys(account, trackingKeys, currentVersion, adminKey); err != nil {
return fmt.Errorf("save tracking secrets: %w", err)
}
cfg.Enabled = true
cfg.WorkerURL = c.WorkerURL
cfg.WorkerName = workerName
cfg.DatabaseName = c.DatabaseName
cfg.SecretsInKeyring = true
cfg.TrackingKey = ""
cfg.TrackingKeyVersions = versions
cfg.TrackingCurrentKeyVersion = currentVersion
cfg.AdminKey = ""
if c.Deploy {
dbID, deployErr := tracking.DeployWorker(ctx, u.Err(), tracking.DeployOptions{
WorkerDir: c.WorkerDir,
WorkerName: workerName,
DatabaseName: c.DatabaseName,
TrackingKey: key,
TrackingKeys: trackingKeys,
TrackingCurrentVersion: currentVersion,
AdminKey: adminKey,
})
if deployErr != nil {
return deployErr
}
cfg.DatabaseID = dbID
}
if err := tracking.SaveConfig(account, cfg); err != nil {
return fmt.Errorf("save tracking config: %w", err)
}
path, _ := tracking.ConfigPath()
u.Out().Printf("configured\ttrue")
u.Out().Printf("account\t%s", account)
if path != "" {
u.Out().Printf("config_path\t%s", path)
}
u.Out().Printf("worker_url\t%s", cfg.WorkerURL)
u.Out().Printf("worker_name\t%s", cfg.WorkerName)
u.Out().Printf("database_name\t%s", cfg.DatabaseName)
u.Out().Printf("tracking_key_version\t%d", cfg.TrackingCurrentKeyVersion)
if cfg.DatabaseID != "" {
u.Out().Printf("database_id\t%s", cfg.DatabaseID)
}
if !c.Deploy {
u.Err().Println("")
u.Err().Println("Next steps (manual worker deploy):")
u.Err().Printf(" - cd %s", c.WorkerDir)
u.Err().Println(" - use these values when prompted:")
u.Err().Printf(" TRACKING_KEY=%s", key)
for _, version := range versions {
u.Err().Printf(" TRACKING_KEY_V%d=%s", version, trackingKeys[version])
}
u.Err().Printf(" TRACKING_CURRENT_KEY_VERSION=%d", currentVersion)
u.Err().Printf(" ADMIN_KEY=%s", adminKey)
u.Err().Printf(" - wrangler d1 create %s", c.DatabaseName)
u.Err().Printf(" - set wrangler.toml name=%s + database_id", cfg.WorkerName)
u.Err().Println(" - wrangler d1 execute <db> --file schema.sql --remote")
u.Err().Println(" - wrangler secret put TRACKING_KEY")
for _, version := range versions {
u.Err().Printf(" - wrangler secret put TRACKING_KEY_V%d", version)
}
u.Err().Println(" - wrangler secret put TRACKING_CURRENT_KEY_VERSION")
u.Err().Println(" - wrangler secret put ADMIN_KEY")
u.Err().Println(" - wrangler deploy")
}
return nil
}
func generateAdminKey() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(b), nil
}