diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b2713c..53704f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ ## 0.7.1 - Unreleased +### Added + +- Accounts: add first-class named WhatsApp accounts with isolated stores, `--account NAME`, and `wacli accounts list/add/use/show/remove`. + +### Fixed + +- Store: fix migration of legacy databases whose `groups` table existed before group hierarchy columns were introduced. + +### Docs + +- Docs: add a dedicated accounts page covering YAML config, store selection precedence, and multi-account usage. + ## 0.7.0 - 2026-05-06 ### Added diff --git a/README.md b/README.md index d6f0889..4ce3c73 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ More recipes — replies, mentions, stickers, voice, reactions, channels, histor | Area | Pages | | --- | --- | -| **Setup** | [overview](docs/overview.md) · [auth](docs/auth.md) · [sync](docs/sync.md) · [doctor](docs/doctor.md) | +| **Setup** | [overview](docs/overview.md) · [auth](docs/auth.md) · [accounts](docs/accounts.md) · [sync](docs/sync.md) · [doctor](docs/doctor.md) | | **Messaging** | [messages](docs/messages.md) · [send](docs/send.md) · [media](docs/media.md) · [presence](docs/presence.md) | | **Address book** | [contacts](docs/contacts.md) · [chats](docs/chats.md) · [groups](docs/groups.md) · [channels](docs/channels.md) | | **History** | [history coverage / fill / backfill](docs/history.md) | @@ -75,9 +75,9 @@ More recipes — replies, mentions, stickers, voice, reactions, channels, histor ## Configuration -Default store: `~/.local/state/wacli` on Linux, `~/.wacli` elsewhere. Existing `~/.wacli` directories on Linux keep working. +Default store: `~/.local/state/wacli` on Linux, `~/.wacli` elsewhere. Existing `~/.wacli` directories on Linux keep working. Use `wacli accounts add NAME` and `--account NAME` for first-class multi-account stores. -**Global flags:** `--store DIR`, `--json`, `--events`, `--full`, `--timeout DUR`, `--lock-wait DUR`, `--read-only`. +**Global flags:** `--store DIR`, `--account NAME`, `--json`, `--events`, `--full`, `--timeout DUR`, `--lock-wait DUR`, `--read-only`. **Environment overrides:** diff --git a/cmd/wacli/accounts.go b/cmd/wacli/accounts.go new file mode 100644 index 0000000..7521308 --- /dev/null +++ b/cmd/wacli/accounts.go @@ -0,0 +1,289 @@ +package main + +import ( + "fmt" + "os" + "sort" + "time" + + "github.com/spf13/cobra" + "github.com/steipete/wacli/internal/config" + "github.com/steipete/wacli/internal/fsutil" + "github.com/steipete/wacli/internal/out" +) + +type accountPayload struct { + Name string `json:"name"` + Label string `json:"label,omitempty"` + ConfiguredStore string `json:"configured_store"` + StoreDir string `json:"store_dir"` + Default bool `json:"default"` +} + +func newAccountsCmd(flags *rootFlags) *cobra.Command { + cmd := &cobra.Command{ + Use: "accounts", + Short: "Manage named WhatsApp accounts", + } + cmd.AddCommand(newAccountsListCmd(flags)) + cmd.AddCommand(newAccountsAddCmd(flags)) + cmd.AddCommand(newAccountsUseCmd(flags)) + cmd.AddCommand(newAccountsShowCmd(flags)) + cmd.AddCommand(newAccountsRemoveCmd(flags)) + return cmd +} + +func newAccountsListCmd(flags *rootFlags) *cobra.Command { + return &cobra.Command{ + Use: "list", + Short: "List configured accounts", + RunE: func(cmd *cobra.Command, args []string) error { + path := config.DefaultConfigPath() + cfg, _, err := config.LoadAccountsConfigIfExists(path) + if err != nil { + return err + } + accounts := sortedAccounts(path, cfg) + payloads := accountPayloads(accounts) + if flags.asJSON { + return out.WriteJSON(os.Stdout, map[string]any{ + "config_path": path, + "default_account": cfg.DefaultAccount, + "accounts": payloads, + }) + } + if len(accounts) == 0 { + fmt.Fprintln(os.Stdout, "No accounts configured. Run `wacli accounts add personal`.") + return nil + } + w := newTableWriter(os.Stdout) + fmt.Fprintln(w, "DEFAULT\tNAME\tSTORE") + for _, account := range accounts { + mark := "" + if account.Default { + mark = "*" + } + fmt.Fprintf(w, "%s\t%s\t%s\n", mark, account.Name, account.StoreDir) + } + _ = w.Flush() + return nil + }, + } +} + +func newAccountsAddCmd(flags *rootFlags) *cobra.Command { + opts := authOptions{idleExit: 30 * time.Second, qrFormat: "terminal"} + var noAuth bool + cmd := &cobra.Command{ + Use: "add NAME", + Short: "Add an account and authenticate it", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if err := flags.requireWritable(); err != nil { + return err + } + name := args[0] + if err := config.ValidateAccountName(name); err != nil { + return err + } + if !noAuth { + if _, err := validateAuthOptions(flags, opts); err != nil { + return err + } + } + path := config.DefaultConfigPath() + cfg, _, err := config.LoadAccountsConfigIfExists(path) + if err != nil { + return err + } + if _, ok := cfg.Accounts[name]; ok { + return fmt.Errorf("account %q already exists", name) + } + cfg.Accounts[name] = config.AccountEntry{Store: config.DefaultAccountStore(name)} + if cfg.DefaultAccount == "" { + cfg.DefaultAccount = name + } + storeDir := config.ListAccounts(path, cfg) + var added config.Account + for _, account := range storeDir { + if account.Name == name { + added = account + break + } + } + if err := fsutil.EnsurePrivateDir(added.StoreDir); err != nil { + return fmt.Errorf("create account store: %w", err) + } + if err := config.SaveAccountsConfig(path, cfg); err != nil { + return err + } + if noAuth { + if flags.asJSON { + return out.WriteJSON(os.Stdout, map[string]any{ + "config_path": path, + "account": accountPayloadFromAccount(added), + }) + } + fmt.Fprintf(os.Stdout, "Account %s added at %s. Run `wacli --account %s auth` to authenticate.\n", name, added.StoreDir, name) + return nil + } + + oldAccount := flags.account + oldStore := flags.storeDir + flags.account = name + flags.storeDir = "" + defer func() { + flags.account = oldAccount + flags.storeDir = oldStore + }() + + if !flags.asJSON { + fmt.Fprintf(os.Stdout, "Account %s added at %s\n", name, added.StoreDir) + } + res, err := runAuth(flags, opts) + if err != nil { + return err + } + if flags.asJSON { + return out.WriteJSON(os.Stdout, map[string]any{ + "account": accountPayloadFromAccount(added), + "authenticated": true, + "messages_stored": res.MessagesStored, + }) + } + fmt.Fprintf(os.Stdout, "Account %s authenticated. Messages stored: %d\n", name, res.MessagesStored) + return nil + }, + } + addAuthFlags(cmd, &opts) + cmd.Flags().BoolVar(&noAuth, "no-auth", false, "create the account without running auth") + return cmd +} + +func newAccountsUseCmd(flags *rootFlags) *cobra.Command { + return &cobra.Command{ + Use: "use NAME", + Short: "Set the default account", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if err := flags.requireWritable(); err != nil { + return err + } + name := args[0] + if err := config.ValidateAccountName(name); err != nil { + return err + } + path := config.DefaultConfigPath() + cfg, err := config.LoadAccountsConfig(path) + if err != nil { + return err + } + if _, ok := cfg.Accounts[name]; !ok { + return fmt.Errorf("account %q is not configured", name) + } + cfg.DefaultAccount = name + if err := config.SaveAccountsConfig(path, cfg); err != nil { + return err + } + if flags.asJSON { + return out.WriteJSON(os.Stdout, map[string]any{"default_account": name}) + } + fmt.Fprintf(os.Stdout, "Default account: %s\n", name) + return nil + }, + } +} + +func newAccountsShowCmd(flags *rootFlags) *cobra.Command { + return &cobra.Command{ + Use: "show NAME", + Short: "Show one configured account", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + _, account, err := config.ResolveAccountStore(config.DefaultConfigPath(), args[0]) + if err != nil { + return err + } + payload := accountPayloadFromAccount(account) + if flags.asJSON { + return out.WriteJSON(os.Stdout, payload) + } + fmt.Fprintf(os.Stdout, "Name: %s\nStore: %s\nDefault: %t\n", payload.Name, payload.StoreDir, payload.Default) + if payload.Label != "" { + fmt.Fprintf(os.Stdout, "Label: %s\n", payload.Label) + } + return nil + }, + } +} + +func newAccountsRemoveCmd(flags *rootFlags) *cobra.Command { + return &cobra.Command{ + Use: "remove NAME", + Short: "Remove an account from config without deleting its store", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if err := flags.requireWritable(); err != nil { + return err + } + name := args[0] + if err := config.ValidateAccountName(name); err != nil { + return err + } + path := config.DefaultConfigPath() + cfg, err := config.LoadAccountsConfig(path) + if err != nil { + return err + } + entry, ok := cfg.Accounts[name] + if !ok { + return fmt.Errorf("account %q is not configured", name) + } + storeDir := config.ListAccounts(path, &config.AccountsConfig{ + DefaultAccount: cfg.DefaultAccount, + Accounts: map[string]config.AccountEntry{name: entry}, + })[0].StoreDir + delete(cfg.Accounts, name) + if cfg.DefaultAccount == name { + cfg.DefaultAccount = "" + } + if err := config.SaveAccountsConfig(path, cfg); err != nil { + return err + } + if flags.asJSON { + return out.WriteJSON(os.Stdout, map[string]any{ + "removed": name, + "store_dir_kept": storeDir, + }) + } + fmt.Fprintf(os.Stdout, "Removed account %s. Store kept at %s\n", name, storeDir) + return nil + }, + } +} + +func sortedAccounts(path string, cfg *config.AccountsConfig) []config.Account { + accounts := config.ListAccounts(path, cfg) + sort.Slice(accounts, func(i, j int) bool { + return accounts[i].Name < accounts[j].Name + }) + return accounts +} + +func accountPayloads(accounts []config.Account) []accountPayload { + payloads := make([]accountPayload, 0, len(accounts)) + for _, account := range accounts { + payloads = append(payloads, accountPayloadFromAccount(account)) + } + return payloads +} + +func accountPayloadFromAccount(account config.Account) accountPayload { + return accountPayload{ + Name: account.Name, + Label: account.Label, + ConfiguredStore: account.ConfiguredStore, + StoreDir: account.StoreDir, + Default: account.Default, + } +} diff --git a/cmd/wacli/accounts_test.go b/cmd/wacli/accounts_test.go new file mode 100644 index 0000000..6d77c3c --- /dev/null +++ b/cmd/wacli/accounts_test.go @@ -0,0 +1,117 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/steipete/wacli/internal/config" +) + +func TestAccountsAddNoAuthCreatesConfig(t *testing.T) { + isolateAccountConfigHome(t) + + var stdout string + stderr := captureRootStderr(t, func() { + stdout = captureRootStdout(t, func() { + if err := execute([]string{"accounts", "add", "personal", "--no-auth"}); err != nil { + t.Fatalf("execute accounts add: %v", err) + } + }) + }) + if stderr != "" { + t.Fatalf("stderr = %q, want empty", stderr) + } + if !strings.Contains(stdout, "Account personal added") { + t.Fatalf("stdout = %q, want account added", stdout) + } + + cfg, err := config.LoadAccountsConfig(config.DefaultConfigPath()) + if err != nil { + t.Fatalf("LoadAccountsConfig: %v", err) + } + if cfg.DefaultAccount != "personal" { + t.Fatalf("DefaultAccount = %q, want personal", cfg.DefaultAccount) + } + account, ok := cfg.Accounts["personal"] + if !ok { + t.Fatal("personal account missing") + } + if account.Store != "accounts/personal" { + t.Fatalf("Store = %q, want accounts/personal", account.Store) + } + if _, err := os.Stat(filepath.Join(filepath.Dir(config.DefaultConfigPath()), "accounts", "personal")); err != nil { + t.Fatalf("account store not created: %v", err) + } +} + +func TestAccountsAddValidatesAuthFlagsBeforeSaving(t *testing.T) { + isolateAccountConfigHome(t) + + err := execute([]string{"--json", "accounts", "add", "personal", "--qr-format", "text"}) + if err == nil || !strings.Contains(err.Error(), "--qr-format=text cannot be combined with --json") { + t.Fatalf("execute error = %v, want QR/json validation error", err) + } + if _, statErr := os.Stat(config.DefaultConfigPath()); !os.IsNotExist(statErr) { + t.Fatalf("config stat error = %v, want not exist", statErr) + } + storeDir := filepath.Join(filepath.Dir(config.DefaultConfigPath()), "accounts", "personal") + if _, statErr := os.Stat(storeDir); !os.IsNotExist(statErr) { + t.Fatalf("store stat error = %v, want not exist", statErr) + } +} + +func TestAccountsAddRejectsWhitespaceName(t *testing.T) { + isolateAccountConfigHome(t) + + err := execute([]string{"accounts", "add", " work ", "--no-auth"}) + if err == nil || !strings.Contains(err.Error(), "whitespace") { + t.Fatalf("execute error = %v, want whitespace validation error", err) + } + if _, statErr := os.Stat(config.DefaultConfigPath()); !os.IsNotExist(statErr) { + t.Fatalf("config stat error = %v, want not exist", statErr) + } +} + +func TestAccountsListJSON(t *testing.T) { + isolateAccountConfigHome(t) + cfgPath := config.DefaultConfigPath() + cfg := &config.AccountsConfig{ + DefaultAccount: "work", + Accounts: map[string]config.AccountEntry{ + "personal": {Store: "accounts/personal"}, + "work": {Store: "accounts/work"}, + }, + } + if err := config.SaveAccountsConfig(cfgPath, cfg); err != nil { + t.Fatal(err) + } + + var stdout string + stdout = captureRootStdout(t, func() { + if err := execute([]string{"--json", "accounts", "list"}); err != nil { + t.Fatalf("execute accounts list: %v", err) + } + }) + + var payload struct { + Data struct { + DefaultAccount string `json:"default_account"` + Accounts []struct { + Name string `json:"name"` + Default bool `json:"default"` + } `json:"accounts"` + } `json:"data"` + } + if err := json.Unmarshal([]byte(stdout), &payload); err != nil { + t.Fatalf("json.Unmarshal(%q): %v", stdout, err) + } + if payload.Data.DefaultAccount != "work" || len(payload.Data.Accounts) != 2 { + t.Fatalf("payload = %+v, want work and 2 accounts", payload) + } + if payload.Data.Accounts[0].Name != "personal" || payload.Data.Accounts[1].Name != "work" || !payload.Data.Accounts[1].Default { + t.Fatalf("accounts = %+v, want sorted personal/work with work default", payload.Data.Accounts) + } +} diff --git a/cmd/wacli/auth.go b/cmd/wacli/auth.go index b6c4b70..7e2cca6 100644 --- a/cmd/wacli/auth.go +++ b/cmd/wacli/auth.go @@ -16,69 +16,27 @@ import ( "go.mau.fi/whatsmeow/types" ) +type authOptions struct { + follow bool + idleExit time.Duration + downloadMedia bool + qrFormat string + phone string +} + +type validatedAuthOptions struct { + qrFormat string + pairPhone string +} + func newAuthCmd(flags *rootFlags) *cobra.Command { - var follow bool - var idleExit time.Duration - var downloadMedia bool - var qrFormat string - var phone string + opts := authOptions{idleExit: 30 * time.Second, qrFormat: "terminal"} cmd := &cobra.Command{ Use: "auth", Short: "Authenticate with WhatsApp (QR) and bootstrap sync", RunE: func(cmd *cobra.Command, args []string) error { - if err := flags.requireWritable(); err != nil { - return err - } - qrFormat, err := normalizeAuthQRFormat(qrFormat) - if err != nil { - return err - } - if flags.asJSON && qrFormat == "text" { - return fmt.Errorf("--qr-format=text cannot be combined with --json because both write to stdout") - } - pairPhone, err := normalizePairPhone(phone) - if err != nil { - return err - } - maxMessages, maxDBSize, err := resolveSyncStorageLimits(syncStorageLimitFlags{}) - if err != nil { - return err - } - ctx, stop := signalContextWithEvents(out.NewEventWriter(os.Stderr, flags.events)) - defer stop() - - a, lk, err := newApp(ctx, flags, true, true) - if err != nil { - return err - } - defer closeApp(a, lk) - - mode := appPkg.SyncModeBootstrap - if follow { - mode = appPkg.SyncModeFollow - } - - if a.Events().Enabled() { - _ = a.Events().Emit("auth_starting", nil) - } else { - fmt.Fprintln(os.Stderr, "Starting authentication…") - } - res, err := a.Sync(ctx, appPkg.SyncOptions{ - Mode: mode, - AllowQR: true, - DownloadMedia: downloadMedia, - RefreshContacts: true, - RefreshGroups: true, - RefreshChannels: true, - IdleExit: idleExit, - OnQRCode: authQRWriter(qrFormat, os.Stdout, os.Stderr, a.Events()), - PairPhoneNumber: pairPhone, - OnPairCode: authPairCodeWriter(pairPhone, os.Stderr, a.Events()), - MaxMessages: maxMessages, - MaxDBSizeBytes: maxDBSize, - WarnNoLimits: true, - }) + res, err := runAuth(flags, opts) if err != nil { return err } @@ -95,11 +53,7 @@ func newAuthCmd(flags *rootFlags) *cobra.Command { }, } - cmd.Flags().BoolVar(&follow, "follow", false, "keep syncing after auth") - cmd.Flags().DurationVar(&idleExit, "idle-exit", 30*time.Second, "exit after being idle (bootstrap/once modes)") - cmd.Flags().BoolVar(&downloadMedia, "download-media", false, "download media in the background during sync") - cmd.Flags().StringVar(&qrFormat, "qr-format", "terminal", "QR output format: terminal or text") - cmd.Flags().StringVar(&phone, "phone", "", "pair by phone number instead of QR code") + addAuthFlags(cmd, &opts) cmd.AddCommand(newAuthStatusCmd(flags)) cmd.AddCommand(newAuthLogoutCmd(flags)) @@ -107,6 +61,77 @@ func newAuthCmd(flags *rootFlags) *cobra.Command { return cmd } +func addAuthFlags(cmd *cobra.Command, opts *authOptions) { + cmd.Flags().BoolVar(&opts.follow, "follow", false, "keep syncing after auth") + cmd.Flags().DurationVar(&opts.idleExit, "idle-exit", 30*time.Second, "exit after being idle (bootstrap/once modes)") + cmd.Flags().BoolVar(&opts.downloadMedia, "download-media", false, "download media in the background during sync") + cmd.Flags().StringVar(&opts.qrFormat, "qr-format", "terminal", "QR output format: terminal or text") + cmd.Flags().StringVar(&opts.phone, "phone", "", "pair by phone number instead of QR code") +} + +func runAuth(flags *rootFlags, opts authOptions) (appPkg.SyncResult, error) { + if err := flags.requireWritable(); err != nil { + return appPkg.SyncResult{}, err + } + validated, err := validateAuthOptions(flags, opts) + if err != nil { + return appPkg.SyncResult{}, err + } + maxMessages, maxDBSize, err := resolveSyncStorageLimits(syncStorageLimitFlags{}) + if err != nil { + return appPkg.SyncResult{}, err + } + ctx, stop := signalContextWithEvents(out.NewEventWriter(os.Stderr, flags.events)) + defer stop() + + a, lk, err := newApp(ctx, flags, true, true) + if err != nil { + return appPkg.SyncResult{}, err + } + defer closeApp(a, lk) + + mode := appPkg.SyncModeBootstrap + if opts.follow { + mode = appPkg.SyncModeFollow + } + + if a.Events().Enabled() { + _ = a.Events().Emit("auth_starting", nil) + } else { + fmt.Fprintln(os.Stderr, "Starting authentication…") + } + return a.Sync(ctx, appPkg.SyncOptions{ + Mode: mode, + AllowQR: true, + DownloadMedia: opts.downloadMedia, + RefreshContacts: true, + RefreshGroups: true, + RefreshChannels: true, + IdleExit: opts.idleExit, + OnQRCode: authQRWriter(validated.qrFormat, os.Stdout, os.Stderr, a.Events()), + PairPhoneNumber: validated.pairPhone, + OnPairCode: authPairCodeWriter(validated.pairPhone, os.Stderr, a.Events()), + MaxMessages: maxMessages, + MaxDBSizeBytes: maxDBSize, + WarnNoLimits: true, + }) +} + +func validateAuthOptions(flags *rootFlags, opts authOptions) (validatedAuthOptions, error) { + qrFormat, err := normalizeAuthQRFormat(opts.qrFormat) + if err != nil { + return validatedAuthOptions{}, err + } + if flags.asJSON && qrFormat == "text" { + return validatedAuthOptions{}, fmt.Errorf("--qr-format=text cannot be combined with --json because both write to stdout") + } + pairPhone, err := normalizePairPhone(opts.phone) + if err != nil { + return validatedAuthOptions{}, err + } + return validatedAuthOptions{qrFormat: qrFormat, pairPhone: pairPhone}, nil +} + func normalizePairPhone(phone string) (string, error) { phone = strings.TrimSpace(phone) if phone == "" { diff --git a/cmd/wacli/doctor.go b/cmd/wacli/doctor.go index 103ff75..7266987 100644 --- a/cmd/wacli/doctor.go +++ b/cmd/wacli/doctor.go @@ -11,7 +11,6 @@ import ( "time" "github.com/spf13/cobra" - "github.com/steipete/wacli/internal/config" "github.com/steipete/wacli/internal/lock" "github.com/steipete/wacli/internal/out" "github.com/steipete/wacli/internal/store" @@ -116,11 +115,10 @@ func newDoctorCmd(flags *rootFlags) *cobra.Command { ctx, cancel := withTimeout(context.Background(), flags) defer cancel() - storeDir := flags.storeDir - if storeDir == "" { - storeDir = config.DefaultStoreDir() + storeDir, err := resolveStoreDir(flags) + if err != nil { + return err } - storeDir, _ = filepath.Abs(storeDir) var lockHeld bool var lockInfo string diff --git a/cmd/wacli/root.go b/cmd/wacli/root.go index b6d0276..a067dd6 100644 --- a/cmd/wacli/root.go +++ b/cmd/wacli/root.go @@ -22,6 +22,7 @@ const docsURL = "https://wacli.sh" type rootFlags struct { storeDir string + account string asJSON bool fullOutput bool events bool @@ -44,6 +45,7 @@ func execute(args []string) error { rootCmd.SetVersionTemplate("wacli {{.Version}}\n") rootCmd.PersistentFlags().StringVar(&flags.storeDir, "store", "", "store directory (default: $WACLI_STORE_DIR, XDG state dir on Linux, or ~/.wacli)") + rootCmd.PersistentFlags().StringVar(&flags.account, "account", "", "named account from config.yaml") rootCmd.PersistentFlags().BoolVar(&flags.asJSON, "json", false, "output JSON instead of human-readable text") rootCmd.PersistentFlags().BoolVar(&flags.fullOutput, "full", false, "disable truncation in table output") rootCmd.PersistentFlags().BoolVar(&flags.events, "events", false, "emit machine-readable NDJSON lifecycle events on stderr") @@ -52,6 +54,7 @@ func execute(args []string) error { rootCmd.PersistentFlags().BoolVar(&flags.readOnly, "read-only", false, "reject commands that intentionally write WhatsApp or the local store (or set WACLI_READONLY=1)") rootCmd.AddCommand(newVersionCmd()) + rootCmd.AddCommand(newAccountsCmd(&flags)) rootCmd.AddCommand(newDoctorCmd(&flags)) rootCmd.AddCommand(newAuthCmd(&flags)) rootCmd.AddCommand(newSyncCmd(&flags)) @@ -88,11 +91,13 @@ func writeRootError(flags rootFlags, err error) { } func newApp(ctx context.Context, flags *rootFlags, needLock bool, allowUnauthed bool) (*app.App, *lock.Lock, error) { - storeDir := resolveStoreDir(flags) + storeDir, err := resolveStoreDir(flags) + if err != nil { + return nil, nil, err + } var lk *lock.Lock if needLock { - var err error lk, err = lock.AcquireWithTimeout(ctx, storeDir, flags.lockWait) if err != nil { return nil, nil, err @@ -116,16 +121,43 @@ func newApp(ctx context.Context, flags *rootFlags, needLock bool, allowUnauthed return a, lk, nil } -func resolveStoreDir(flags *rootFlags) string { +func resolveStoreDir(flags *rootFlags) (string, error) { storeDir := "" + account := "" if flags != nil { storeDir = flags.storeDir + account = strings.TrimSpace(flags.account) } - if storeDir == "" { + if storeDir != "" && account != "" { + return "", fmt.Errorf("--store and --account cannot be combined") + } + switch { + case storeDir != "": + case account != "": + resolved, _, err := config.ResolveAccountStore(config.DefaultConfigPath(), account) + if err != nil { + return "", err + } + storeDir = resolved + case os.Getenv(config.EnvStoreDir) != "": storeDir = config.DefaultStoreDir() + default: + cfg, found, err := config.LoadAccountsConfigIfExists(config.DefaultConfigPath()) + if err != nil { + return "", err + } + if found && strings.TrimSpace(cfg.DefaultAccount) != "" { + resolved, _, err := config.ResolveAccountStore(config.DefaultConfigPath(), cfg.DefaultAccount) + if err != nil { + return "", err + } + storeDir = resolved + } else { + storeDir = config.DefaultStoreDir() + } } storeDir, _ = filepath.Abs(storeDir) - return storeDir + return storeDir, nil } func (f *rootFlags) isReadOnly() bool { diff --git a/cmd/wacli/root_test.go b/cmd/wacli/root_test.go index 509ffde..abc10ec 100644 --- a/cmd/wacli/root_test.go +++ b/cmd/wacli/root_test.go @@ -6,8 +6,11 @@ import ( "errors" "io" "os" + "path/filepath" "strings" "testing" + + "github.com/steipete/wacli/internal/config" ) func captureRootStderr(t *testing.T, fn func()) string { @@ -93,3 +96,64 @@ func TestRootFlagsReadOnlyEnv(t *testing.T) { t.Fatal("isReadOnly = false, want true") } } + +func TestResolveStoreDirAccount(t *testing.T) { + isolateAccountConfigHome(t) + cfgPath := config.DefaultConfigPath() + cfg := &config.AccountsConfig{ + Accounts: map[string]config.AccountEntry{ + "work": {Store: "accounts/work"}, + }, + } + if err := config.SaveAccountsConfig(cfgPath, cfg); err != nil { + t.Fatal(err) + } + + got, err := resolveStoreDir(&rootFlags{account: "work"}) + if err != nil { + t.Fatalf("resolveStoreDir: %v", err) + } + want := filepath.Join(filepath.Dir(cfgPath), "accounts", "work") + if got != want { + t.Fatalf("storeDir = %q, want %q", got, want) + } +} + +func TestResolveStoreDirStoreAndAccountConflict(t *testing.T) { + _, err := resolveStoreDir(&rootFlags{storeDir: "/tmp/wacli", account: "work"}) + if err == nil || !strings.Contains(err.Error(), "cannot be combined") { + t.Fatalf("resolveStoreDir error = %v, want conflict", err) + } +} + +func TestResolveStoreDirEnvBeatsDefaultAccount(t *testing.T) { + isolateAccountConfigHome(t) + cfgPath := config.DefaultConfigPath() + cfg := &config.AccountsConfig{ + DefaultAccount: "work", + Accounts: map[string]config.AccountEntry{ + "work": {Store: "accounts/work"}, + }, + } + if err := config.SaveAccountsConfig(cfgPath, cfg); err != nil { + t.Fatal(err) + } + envStore := filepath.Join(t.TempDir(), "env-store") + t.Setenv(config.EnvStoreDir, envStore) + + got, err := resolveStoreDir(&rootFlags{}) + if err != nil { + t.Fatalf("resolveStoreDir: %v", err) + } + if got != envStore { + t.Fatalf("storeDir = %q, want %q", got, envStore) + } +} + +func isolateAccountConfigHome(t *testing.T) { + t.Helper() + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state")) + t.Setenv(config.EnvStoreDir, "") +} diff --git a/cmd/wacli/send_ipc.go b/cmd/wacli/send_ipc.go index a96d9cb..ad65330 100644 --- a/cmd/wacli/send_ipc.go +++ b/cmd/wacli/send_ipc.go @@ -65,7 +65,11 @@ func sendDelegateSocketPath(storeDir string) string { func delegateSend(ctx context.Context, flags *rootFlags, req sendDelegateRequest) (sendDelegateResponse, error) { req.Version = sendDelegateVersion req.TimeoutMS = durationMillis(flags.timeout) - path := sendDelegateSocketPath(resolveStoreDir(flags)) + storeDir, err := resolveStoreDir(flags) + if err != nil { + return sendDelegateResponse{}, err + } + path := sendDelegateSocketPath(storeDir) var d net.Dialer conn, err := d.DialContext(ctx, "unix", path) diff --git a/docs/accounts.md b/docs/accounts.md new file mode 100644 index 0000000..affc235 --- /dev/null +++ b/docs/accounts.md @@ -0,0 +1,57 @@ +# accounts + +Read when: using more than one WhatsApp account, choosing the active account, or migrating from manual `--store` directories. + +`wacli accounts` manages named accounts. Each account is an isolated store directory with its own WhatsApp linked-device session, local mirror database, media files, and lock. + +## Commands + +```bash +wacli accounts list +wacli accounts add NAME [--no-auth] +wacli accounts use NAME +wacli accounts show NAME +wacli accounts remove NAME +``` + +Use a named account with any command: + +```bash +wacli --account work chats list +wacli --account personal send text --to 1234567890 --message "hi" +``` + +## Config + +The default config path is `/config.yaml`, where `` is the default store root (`~/.wacli` on macOS and existing legacy Linux installs, otherwise `~/.local/state/wacli` on Linux). + +```yaml +default_account: personal + +accounts: + personal: + store: accounts/personal + work: + store: accounts/work +``` + +Relative `store` paths resolve from the config directory. Absolute paths are allowed for custom layouts. + +## Selection Rules + +Store selection is intentionally explicit: + +1. `--store DIR` uses that exact store and cannot be combined with `--account`. +2. `--account NAME` resolves `NAME` from `config.yaml`. +3. `WACLI_STORE_DIR` keeps its existing override behavior for scripts and one-off stores. +4. If `default_account` is set, commands use that account. +5. Otherwise existing single-store behavior remains: XDG state dir on Linux, or `~/.wacli` elsewhere. + +Account names may contain letters, digits, `.`, `_`, and `-`, and must start with a letter or digit. + +## Notes + +- `accounts add NAME` creates the isolated store and then runs the normal auth/bootstrap flow for that account. Use `--no-auth` to only write config and create the store. +- Locks are per account store, so `wacli --account personal sync --follow` and `wacli --account work chats list` do not block each other unless they share the same store path. +- Cross-account search or status should be explicit aggregate commands, not accidental shared database queries. +- Use `--store DIR` for one-off migration/debugging against an old manual store. diff --git a/docs/auth.md b/docs/auth.md index 24358e0..e653459 100644 --- a/docs/auth.md +++ b/docs/auth.md @@ -2,7 +2,7 @@ Read when: pairing a store, checking auth state, logging out, or choosing QR vs phone pairing. -`wacli auth` connects interactively and bootstraps sync after successful pairing. `wacli sync` never shows a QR code, so use `auth` first for a new store. +`wacli auth` connects interactively and bootstraps sync after successful pairing. `wacli sync` never shows a QR code, so use `auth` first for a new store or named account. ## Commands @@ -10,6 +10,7 @@ Read when: pairing a store, checking auth state, logging out, or choosing QR vs wacli auth [--follow] [--idle-exit 30s] [--download-media] [--qr-format terminal|text] [--phone PHONE] [--events] wacli auth status wacli auth logout +wacli --account work auth status ``` ## Notes @@ -23,6 +24,7 @@ wacli auth logout - `--events` emits NDJSON lifecycle events on stderr, including raw QR and phone-pairing codes for external renderers. - `auth status` reports whether the local store is authenticated. - `auth logout` invalidates the linked-device session and requires writable mode. +- For multiple accounts, prefer `wacli accounts add NAME`; it creates an isolated account store and runs the same auth/bootstrap flow. ## Examples diff --git a/docs/index.md b/docs/index.md index c53154e..948f867 100644 --- a/docs/index.md +++ b/docs/index.md @@ -21,6 +21,7 @@ A script-friendly WhatsApp CLI built on [`whatsmeow`](https://github.com/tulir/w ## Pick your path - **Trying it.** Read [Install](install.md), then [Quickstart](quickstart.md). Pair, sync, and send your first message in under five minutes. +- **Using multiple WhatsApp accounts.** Read [Accounts](accounts.md) for named account stores and `--account`. - **Searching old chats.** Read [Sync](sync.md) for the sync model and [History](history.md) for coverage planning and on-demand backfill. - **Managing chat state.** Read [Chats](chats.md) for archive, pin, mute, and read/unread commands. - **Managing local storage.** Read [Store](store.md) for stats, dry-run cleanup, and local-only pruning. diff --git a/docs/integrations.md b/docs/integrations.md index f3013fe..5bc3cc5 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -20,7 +20,7 @@ The default store is: - Linux: `~/.local/state/wacli`, with legacy `~/.wacli` reused when present. - macOS and other platforms: `~/.wacli`. -Override with `--store DIR` or `WACLI_STORE_DIR`. +Override with `--store DIR` or `WACLI_STORE_DIR`. Named accounts live in `config.yaml` and resolve with `--account NAME`; each account points at a normal isolated store directory. The store contains two SQLite databases: @@ -29,6 +29,8 @@ The store contains two SQLite databases: Companion tools should not read or write `session.db` unless they are explicitly working on WhatsApp session internals. Never write to `wacli.db` from a companion tool. +For multi-account tools, iterate configured accounts explicitly and annotate derived rows with the account name in the companion tool's own database. Do not merge account data into `wacli.db`. + ## Read-only SQLite Open the database in SQLite read-only mode: diff --git a/docs/overview.md b/docs/overview.md index 5297a2c..6401289 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -2,7 +2,7 @@ Read when: you need the user-facing command map, global flags, store model, or links to command-specific docs. -`wacli` is a WhatsApp CLI built on `whatsmeow`. It pairs as a linked WhatsApp Web device, stores message metadata locally, supports offline search, and exposes send/media/group/contact workflows for scripts and humans. +`wacli` is a WhatsApp CLI built on `whatsmeow`. It pairs as a linked WhatsApp Web device, stores message metadata locally, supports offline search, and exposes send/media/group/contact workflows for scripts and humans. Named accounts let multiple WhatsApp identities use isolated stores via `--account`. ## Store and output @@ -22,6 +22,7 @@ Read when: you need the user-facing command map, global flags, store model, or l ## Command pages - [auth](auth.md) - pair, inspect auth status, logout. +- [accounts](accounts.md) - create and select named account stores. - [sync](sync.md) - sync messages, contacts, groups, channels, and optional media. - [messages](messages.md) - list, search, show, and contextualize stored messages. - [send](send.md) - send text, files, stickers, replies, and reactions. diff --git a/docs/spec.md b/docs/spec.md index 50f3255..b589bd4 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -137,6 +137,7 @@ Fallback: Global flags: - `--store DIR` (default: XDG state dir on Linux, `~/.wacli` elsewhere) +- `--account NAME` (named account from `config.yaml`; mutually exclusive with `--store`) - `--json` (default: human text) - `--full` (disable table truncation; non-TTY output keeps full IDs) - `--timeout DURATION` (non-sync commands; e.g. `5m`) @@ -154,6 +155,19 @@ Global flags: - `wacli auth status` - `wacli auth logout` +### Accounts + +- `wacli accounts list` +- `wacli accounts add NAME [--no-auth]` +- `wacli accounts use NAME` +- `wacli accounts show NAME` +- `wacli accounts remove NAME` + +Named accounts resolve to isolated store directories. Account config lives in +`/config.yaml`; relative account store paths resolve from that config +directory. `--store` remains the direct manual-store escape hatch and cannot be +combined with `--account`. + ### Sync - `wacli sync [--once] [--follow] [--download-media] [--webhook URL] [--webhook-secret SECRET]` diff --git a/docs/store.md b/docs/store.md index de5ec4c..d271a8f 100644 --- a/docs/store.md +++ b/docs/store.md @@ -2,7 +2,7 @@ Read when: inspecting local SQLite size/counts or pruning old local chat/group rows. -`wacli store` manages the local `wacli.db` mirror. Cleanup commands only delete local wacli cache/history rows; they do not delete WhatsApp chats, leave groups, or remove messages from WhatsApp servers. +`wacli store` manages the selected account's local `wacli.db` mirror. Cleanup commands only delete local wacli cache/history rows; they do not delete WhatsApp chats, leave groups, or remove messages from WhatsApp servers. ## Commands @@ -29,6 +29,7 @@ wacli groups prune [--days N] [--left-only=false|--include-active] [--dry-run] [ - Destructive cleanup commands require confirmation unless `--confirm` is passed. - Use `--dry-run` first; it lists what would be deleted without changing the local store. - Use `--read-only` or `WACLI_READONLY=1` to make cleanup commands fail before opening the store for writes. +- Use `--account NAME` to target a named account store. Use `--store DIR` for manual stores or migration debugging; it cannot be combined with `--account`. ## Examples diff --git a/go.mod b/go.mod index 0b7c67b..08aeb1f 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( golang.org/x/sys v0.43.0 golang.org/x/term v0.42.0 google.golang.org/protobuf v1.36.11 + gopkg.in/yaml.v3 v3.0.1 ) require ( @@ -20,6 +21,7 @@ require ( github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/kr/pretty v0.1.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.22 // indirect github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 // indirect @@ -33,5 +35,6 @@ require ( golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect golang.org/x/sync v0.20.0 // indirect golang.org/x/text v0.36.0 // indirect + gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect rsc.io/qr v0.2.0 // indirect ) diff --git a/go.sum b/go.sum index 17d5b93..70ee694 100644 --- a/go.sum +++ b/go.sum @@ -22,8 +22,10 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= @@ -77,6 +79,7 @@ golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= diff --git a/internal/config/config.go b/internal/config/config.go index 0240a5e..aea76ac 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,9 +1,17 @@ package config import ( + "bytes" + "errors" + "fmt" + "io" "os" "path/filepath" + "regexp" "runtime" + "strings" + + "gopkg.in/yaml.v3" ) // EnvStoreDir is the environment variable that overrides the default store @@ -12,6 +20,28 @@ import ( // every invocation. const EnvStoreDir = "WACLI_STORE_DIR" +const ConfigFileName = "config.yaml" + +var accountNameRE = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`) + +type AccountsConfig struct { + DefaultAccount string `yaml:"default_account,omitempty"` + Accounts map[string]AccountEntry `yaml:"accounts,omitempty"` +} + +type AccountEntry struct { + Store string `yaml:"store"` + Label string `yaml:"label,omitempty"` +} + +type Account struct { + Name string + Label string + ConfiguredStore string + StoreDir string + Default bool +} + // DefaultStoreDir returns the store directory to use when --store is not // supplied. It checks WACLI_STORE_DIR first, then falls back to the XDG state // directory on Linux or ~/.wacli on other platforms. @@ -19,6 +49,13 @@ func DefaultStoreDir() string { if dir := os.Getenv(EnvStoreDir); dir != "" { return dir } + return DefaultBaseDir() +} + +// DefaultBaseDir returns wacli's platform default state root without honoring +// WACLI_STORE_DIR. Account config lives here so a temporary store override +// does not hide the account registry. +func DefaultBaseDir() string { xdgStateHome := os.Getenv("XDG_STATE_HOME") home, err := os.UserHomeDir() if err != nil || home == "" { @@ -30,6 +67,153 @@ func DefaultStoreDir() string { return defaultStoreDirFor(runtime.GOOS, home, xdgStateHome, pathExists) } +func DefaultConfigPath() string { + return filepath.Join(DefaultBaseDir(), ConfigFileName) +} + +func ValidateAccountName(name string) error { + if name == "" { + return fmt.Errorf("account name is required") + } + if strings.TrimSpace(name) != name { + return fmt.Errorf("invalid account name %q: leading or trailing whitespace is not allowed", name) + } + if !accountNameRE.MatchString(name) { + return fmt.Errorf("invalid account name %q: use letters, digits, '.', '_', or '-', starting with a letter or digit", name) + } + return nil +} + +func LoadAccountsConfig(path string) (*AccountsConfig, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var cfg AccountsConfig + dec := yaml.NewDecoder(bytes.NewReader(data)) + dec.KnownFields(true) + if err := dec.Decode(&cfg); err != nil { + if errors.Is(err, io.EOF) { + cfg = AccountsConfig{} + } else { + return nil, fmt.Errorf("parse account config %s: %w", path, err) + } + } + if cfg.Accounts == nil { + cfg.Accounts = map[string]AccountEntry{} + } + for name, entry := range cfg.Accounts { + if err := ValidateAccountName(name); err != nil { + return nil, err + } + if strings.TrimSpace(entry.Store) == "" { + return nil, fmt.Errorf("account %q store is required", name) + } + if strings.ContainsAny(entry.Store, "?#") { + return nil, fmt.Errorf("account %q store must not contain '?' or '#'", name) + } + } + if cfg.DefaultAccount != "" { + if err := ValidateAccountName(cfg.DefaultAccount); err != nil { + return nil, fmt.Errorf("default_account: %w", err) + } + if _, ok := cfg.Accounts[cfg.DefaultAccount]; !ok { + return nil, fmt.Errorf("default_account %q is not defined", cfg.DefaultAccount) + } + } + return &cfg, nil +} + +func LoadAccountsConfigIfExists(path string) (*AccountsConfig, bool, error) { + cfg, err := LoadAccountsConfig(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return &AccountsConfig{Accounts: map[string]AccountEntry{}}, false, nil + } + return nil, false, err + } + return cfg, true, nil +} + +func SaveAccountsConfig(path string, cfg *AccountsConfig) error { + if cfg == nil { + cfg = &AccountsConfig{} + } + if cfg.Accounts == nil { + cfg.Accounts = map[string]AccountEntry{} + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return fmt.Errorf("create config dir: %w", err) + } + data, err := yaml.Marshal(cfg) + if err != nil { + return fmt.Errorf("encode account config: %w", err) + } + tmp := path + ".tmp" + if err := os.WriteFile(tmp, data, 0o600); err != nil { + return fmt.Errorf("write account config: %w", err) + } + if err := os.Rename(tmp, path); err != nil { + _ = os.Remove(tmp) + return fmt.Errorf("replace account config: %w", err) + } + _ = os.Chmod(path, 0o600) + return nil +} + +func ResolveAccountStore(path, name string) (string, Account, error) { + if err := ValidateAccountName(name); err != nil { + return "", Account{}, err + } + cfg, err := LoadAccountsConfig(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return "", Account{}, fmt.Errorf("account config not found at %s; run `wacli accounts add %s`", path, name) + } + return "", Account{}, err + } + entry, ok := cfg.Accounts[name] + if !ok { + return "", Account{}, fmt.Errorf("account %q is not configured; run `wacli accounts list`", name) + } + storeDir := resolveConfiguredStore(filepath.Dir(path), entry.Store) + return storeDir, Account{ + Name: name, + Label: entry.Label, + ConfiguredStore: entry.Store, + StoreDir: storeDir, + Default: cfg.DefaultAccount == name, + }, nil +} + +func ListAccounts(path string, cfg *AccountsConfig) []Account { + if cfg == nil || len(cfg.Accounts) == 0 { + return nil + } + out := make([]Account, 0, len(cfg.Accounts)) + for name, entry := range cfg.Accounts { + out = append(out, Account{ + Name: name, + Label: entry.Label, + ConfiguredStore: entry.Store, + StoreDir: resolveConfiguredStore(filepath.Dir(path), entry.Store), + Default: cfg.DefaultAccount == name, + }) + } + return out +} + +func DefaultAccountStore(name string) string { + return filepath.ToSlash(filepath.Join("accounts", name)) +} + +func resolveConfiguredStore(baseDir, store string) string { + if filepath.IsAbs(store) { + return filepath.Clean(store) + } + return filepath.Clean(filepath.Join(baseDir, store)) +} + func defaultStoreDirFor(goos, home, xdgStateHome string, exists func(string) bool) string { legacy := filepath.Join(home, ".wacli") if goos != "linux" { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 5abf81c..b2b30a8 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -4,6 +4,7 @@ import ( "os" "path/filepath" "runtime" + "strings" "testing" ) @@ -77,3 +78,64 @@ func TestDefaultStoreDirFor(t *testing.T) { } }) } + +func TestAccountsConfigRoundTrip(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.yaml") + cfg := &AccountsConfig{ + DefaultAccount: "personal", + Accounts: map[string]AccountEntry{ + "personal": {Store: "accounts/personal"}, + "work": {Store: "/tmp/wacli-work", Label: "Work"}, + }, + } + + if err := SaveAccountsConfig(path, cfg); err != nil { + t.Fatalf("SaveAccountsConfig: %v", err) + } + loaded, err := LoadAccountsConfig(path) + if err != nil { + t.Fatalf("LoadAccountsConfig: %v", err) + } + if loaded.DefaultAccount != "personal" { + t.Fatalf("DefaultAccount = %q, want personal", loaded.DefaultAccount) + } + store, account, err := ResolveAccountStore(path, "personal") + if err != nil { + t.Fatalf("ResolveAccountStore: %v", err) + } + wantStore := filepath.Join(filepath.Dir(path), "accounts", "personal") + if store != wantStore || account.StoreDir != wantStore { + t.Fatalf("store = %q/%q, want %q", store, account.StoreDir, wantStore) + } + if !account.Default { + t.Fatal("account.Default = false, want true") + } +} + +func TestAccountsConfigKnownFields(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.yaml") + if err := os.WriteFile(path, []byte("unknown: true\n"), 0o600); err != nil { + t.Fatal(err) + } + + _, err := LoadAccountsConfig(path) + if err == nil || !strings.Contains(err.Error(), "field unknown not found") { + t.Fatalf("LoadAccountsConfig error = %v, want unknown field error", err) + } +} + +func TestValidateAccountName(t *testing.T) { + valid := []string{"personal", "work-2", "client.foo", "a_b"} + for _, name := range valid { + if err := ValidateAccountName(name); err != nil { + t.Fatalf("ValidateAccountName(%q): %v", name, err) + } + } + + invalid := []string{"", " work", "work ", ".hidden", "-work", "bad/name", "bad?name", "bad name"} + for _, name := range invalid { + if err := ValidateAccountName(name); err == nil { + t.Fatalf("ValidateAccountName(%q) succeeded, want error", name) + } + } +} diff --git a/internal/store/schema.go b/internal/store/schema.go index 3889232..3aad132 100644 --- a/internal/store/schema.go +++ b/internal/store/schema.go @@ -35,9 +35,6 @@ const coreSchemaSQL = ` left_at INTEGER, updated_at INTEGER NOT NULL ); - - CREATE INDEX IF NOT EXISTS idx_groups_linked_parent_jid ON groups(linked_parent_jid); - CREATE TABLE IF NOT EXISTS group_participants ( group_jid TEXT NOT NULL, user_jid TEXT NOT NULL, diff --git a/internal/store/schema_test.go b/internal/store/schema_test.go index c7984e5..2f6f12f 100644 --- a/internal/store/schema_test.go +++ b/internal/store/schema_test.go @@ -133,6 +133,52 @@ func TestOpenMigratesGroupHierarchyColumns(t *testing.T) { } } +func TestOpenMigratesLegacyGroupsWithoutMigrationTable(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "wacli.db") + + raw, err := sql.Open("sqlite3", path) + if err != nil { + t.Fatalf("sql.Open: %v", err) + } + if _, err := raw.Exec(` + CREATE TABLE groups ( + jid TEXT PRIMARY KEY, + name TEXT, + owner_jid TEXT, + created_ts INTEGER, + left_at INTEGER, + updated_at INTEGER NOT NULL + ); + INSERT INTO groups(jid, name, updated_at) VALUES('g@g.us', 'Old', 1); + `); err != nil { + _ = raw.Close() + t.Fatalf("create legacy schema: %v", err) + } + if err := raw.Close(); err != nil { + t.Fatalf("raw close: %v", err) + } + + db, err := Open(path) + if err != nil { + t.Fatalf("Open legacy DB: %v", err) + } + defer db.Close() + + groupCols, err := tableColumns(db.sql, "groups") + if err != nil { + t.Fatalf("groups tableColumns: %v", err) + } + for _, want := range []string{"is_parent", "linked_parent_jid"} { + if !groupCols[want] { + t.Fatalf("expected migrated groups column %q to exist", want) + } + } + if !indexExists(t, db.sql, "idx_groups_linked_parent_jid") { + t.Fatalf("expected migrated linked-parent group index to exist") + } +} + func TestOpenMigratesContactsSystemNameColumn(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "wacli.db")