diff --git a/README.md b/README.md index b8c5651..23f919a 100644 --- a/README.md +++ b/README.md @@ -180,6 +180,11 @@ anytype space join anytype space leave ``` +#### Self-hosted networks +- Set a default network for joins: `anytype config set networkId ` +- Join with a self-hosted invite link: `anytype space join https:///#` +- Override per-call if needed: `anytype space join --network https:///#` + ## Development ### Project Structure diff --git a/cmd/config/get/get.go b/cmd/config/get/get.go index af47749..8ab9875 100644 --- a/cmd/config/get/get.go +++ b/cmd/config/get/get.go @@ -15,6 +15,7 @@ func NewGetCmd() *cobra.Command { if len(args) == 0 { accountId, _ := config.GetAccountIdFromConfig() techSpaceId, _ := config.GetTechSpaceIdFromConfig() + networkId, _ := config.GetNetworkIdFromConfig() if accountId != "" { output.Info("accountId: %s", accountId) @@ -22,6 +23,9 @@ func NewGetCmd() *cobra.Command { if techSpaceId != "" { output.Info("techSpaceId: %s", techSpaceId) } + if networkId != "" { + output.Info("networkId: %s", networkId) + } return nil } @@ -37,6 +41,11 @@ func NewGetCmd() *cobra.Command { if techSpaceId != "" { output.Info(techSpaceId) } + case "networkId": + networkId, _ := config.GetNetworkIdFromConfig() + if networkId != "" { + output.Info(networkId) + } default: return output.Error("unknown config key: %s", key) } diff --git a/cmd/config/set/set.go b/cmd/config/set/set.go index a5fb12b..fee8296 100644 --- a/cmd/config/set/set.go +++ b/cmd/config/set/set.go @@ -27,6 +27,10 @@ func NewSetCmd() *cobra.Command { if err := config.SetTechSpaceIdToConfig(value); err != nil { return output.Error("Failed to set tech space Id: %w", err) } + case "networkId": + if err := config.SetNetworkIdToConfig(value); err != nil { + return output.Error("Failed to set network Id: %w", err) + } default: return output.Error("unknown config key: %s", key) } diff --git a/cmd/space/join/join.go b/cmd/space/join/join.go index 0e76ede..6be807c 100644 --- a/cmd/space/join/join.go +++ b/cmd/space/join/join.go @@ -1,6 +1,7 @@ package join import ( + "fmt" "net/url" "strings" @@ -22,44 +23,48 @@ func NewJoinCmd() *cobra.Command { cmd := &cobra.Command{ Use: "join ", Short: "Join a space", - Long: "Join a space using an invite link (https://invite.any.coop/...)", + Long: "Join a space using an invite link (https:///#)", Args: cmdutil.ExactArgs(1, "cannot join space: invite-link argument required"), RunE: func(cmd *cobra.Command, args []string) error { input := args[0] var spaceId string if networkId == "" { - networkId = config.AnytypeNetworkAddress + if storedNetworkId, err := config.GetNetworkIdFromConfig(); err == nil && storedNetworkId != "" { + networkId = storedNetworkId + } else { + networkId = config.AnytypeNetworkAddress + } } - if strings.HasPrefix(input, "https://invite.any.coop/") { - u, err := url.Parse(input) + if inviteCid == "" || inviteFileKey == "" { + parsedCid, parsedKey, err := parseInviteLinkParts(input) if err != nil { return output.Error("invalid invite link: %w", err) } - path := strings.TrimPrefix(u.Path, "/") - if path == "" { - return output.Error("invite link missing Cid") + if inviteCid == "" { + if parsedCid == "" { + return output.Error("invalid invite link: missing Cid in path") + } + inviteCid = parsedCid } - inviteCid = path - - inviteFileKey = u.Fragment if inviteFileKey == "" { - return output.Error("invite link missing key (should be after #)") - } - - info, err := core.ViewSpaceInvite(inviteCid, inviteFileKey) - if err != nil { - return output.Error("Failed to view invite: %w", err) + if parsedKey == "" { + return output.Error("invalid invite link: missing key (should be after #)") + } + inviteFileKey = parsedKey } + } - output.Info("Joining space '%s' created by %s...", info.SpaceName, info.CreatorName) - spaceId = info.SpaceId - } else { - return output.Error("invalid invite link format, expected: https://invite.any.coop/{cid}#{key}") + info, err := core.ViewSpaceInvite(inviteCid, inviteFileKey) + if err != nil { + return output.Error("Failed to view invite: %w", err) } + output.Info("Joining space '%s' created by %s...", info.SpaceName, info.CreatorName) + spaceId = info.SpaceId + if err := core.JoinSpace(networkId, spaceId, inviteCid, inviteFileKey); err != nil { return output.Error("Failed to join space: %w", err) } @@ -75,3 +80,44 @@ func NewJoinCmd() *cobra.Command { return cmd } + +func parseInviteLinkParts(input string) (string, string, error) { + u, err := url.Parse(input) + if err != nil { + return "", "", fmt.Errorf("failed to parse: %w", err) + } + + if u.Scheme != "https" && u.Scheme != "http" { + return "", "", fmt.Errorf("unsupported scheme %q (expected http or https)", u.Scheme) + } + + if u.Host == "" { + return "", "", fmt.Errorf("invite link missing host") + } + + var cid string + if path := strings.Trim(u.Path, "/"); path != "" { + parts := strings.Split(path, "/") + cid = parts[len(parts)-1] + } + + key := u.Fragment + + return cid, key, nil +} + +func parseInviteLink(input string) (string, string, error) { + // Convenience wrapper that enforces both cid and key presence. + // The command path uses parseInviteLinkParts to allow partial override via flags. + cid, key, err := parseInviteLinkParts(input) + if err != nil { + return "", "", err + } + if cid == "" { + return "", "", fmt.Errorf("invite link missing Cid in path") + } + if key == "" { + return "", "", fmt.Errorf("invite link missing key (should be after #)") + } + return cid, key, nil +} diff --git a/cmd/space/join/join_test.go b/cmd/space/join/join_test.go new file mode 100644 index 0000000..e683ed0 --- /dev/null +++ b/cmd/space/join/join_test.go @@ -0,0 +1,74 @@ +package join + +import "testing" + +func TestParseInviteLink(t *testing.T) { + tests := []struct { + name string + input string + wantCid string + wantKey string + expectErr bool + }{ + { + name: "default host", + input: "https://invite.any.coop/abc123#filekey", + wantCid: "abc123", + wantKey: "filekey", + }, + { + name: "custom host and nested path", + input: "https://selfhost.local/invites/space-1#k1", + wantCid: "space-1", + wantKey: "k1", + }, + { + name: "missing fragment", + input: "https://selfhost.local/invites/space-1", + expectErr: true, + }, + { + name: "missing cid", + input: "https://selfhost.local/#k1", + expectErr: true, + }, + { + name: "unsupported scheme", + input: "ftp://selfhost.local/space#k1", + expectErr: true, + }, + { + name: "missing host", + input: "https:///space#k1", + expectErr: true, + }, + { + name: "http scheme allowed", + input: "http://selfhost.local/space#k1", + wantCid: "space", + wantKey: "k1", + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + cid, key, err := parseInviteLink(tt.input) + if tt.expectErr { + if err == nil { + t.Fatalf("expected error, got none") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cid != tt.wantCid { + t.Fatalf("cid = %s, want %s", cid, tt.wantCid) + } + if key != tt.wantKey { + t.Fatalf("key = %s, want %s", key, tt.wantKey) + } + }) + } +} diff --git a/core/config/config.go b/core/config/config.go index 07ad290..12dfe9e 100644 --- a/core/config/config.go +++ b/core/config/config.go @@ -15,6 +15,7 @@ type Config struct { // WARNING: This is insecure and should only be used on headless servers AccountKey string `json:"accountKey,omitempty"` SessionToken string `json:"sessionToken,omitempty"` + NetworkId string `json:"networkId,omitempty"` } var ( @@ -118,6 +119,14 @@ func (cm *ConfigManager) SetTechSpaceId(techSpaceId string) error { return cm.Save() } +func (cm *ConfigManager) SetNetworkId(networkId string) error { + cm.mu.Lock() + cm.config.NetworkId = networkId + cm.mu.Unlock() + + return cm.Save() +} + func (cm *ConfigManager) SetSessionToken(token string) error { cm.mu.Lock() cm.config.SessionToken = token diff --git a/core/config/config_helper.go b/core/config/config_helper.go index 787f3ff..1cd32bb 100644 --- a/core/config/config_helper.go +++ b/core/config/config_helper.go @@ -48,6 +48,28 @@ func SetTechSpaceIdToConfig(techSpaceId string) error { return configMgr.SetTechSpaceId(techSpaceId) } +func GetNetworkIdFromConfig() (string, error) { + configMgr := GetConfigManager() + if err := configMgr.Load(); err != nil { + return "", fmt.Errorf("failed to load config: %w", err) + } + + cfg := configMgr.Get() + if cfg.NetworkId == "" { + return "", fmt.Errorf("no network Id found in config") + } + + return cfg.NetworkId, nil +} + +func SetNetworkIdToConfig(networkId string) error { + configMgr := GetConfigManager() + if err := configMgr.Load(); err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + return configMgr.SetNetworkId(networkId) +} + func LoadStoredConfig() (*Config, error) { configMgr := GetConfigManager() if err := configMgr.Load(); err != nil { diff --git a/core/config/config_helper_test.go b/core/config/config_helper_test.go index ea5774d..90d51f3 100644 --- a/core/config/config_helper_test.go +++ b/core/config/config_helper_test.go @@ -3,6 +3,7 @@ package config import ( "os" "path/filepath" + "sync" "testing" ) @@ -15,6 +16,11 @@ func setupTestHome(t *testing.T) string { } t.Cleanup(func() { os.RemoveAll(tempDir) }) t.Setenv("HOME", tempDir) + + // Reset the singleton so GetConfigManager uses the test HOME-based path + instance = nil + once = sync.Once{} + return tempDir } @@ -31,8 +37,11 @@ func TestGetStoredAccountId(t *testing.T) { } accountId, err := GetAccountIdFromConfig() - if err == nil && accountId != "" { - t.Logf("GetAccountIdFromConfig() = %v", accountId) + if err != nil { + t.Fatalf("GetAccountIdFromConfig() returned error: %v", err) + } + if accountId != "test-account-123" { + t.Fatalf("GetAccountIdFromConfig() = %v, want test-account-123", accountId) } } @@ -49,8 +58,11 @@ func TestGetStoredTechSpaceId(t *testing.T) { } techSpaceId, err := GetTechSpaceIdFromConfig() - if err == nil && techSpaceId != "" { - t.Logf("GetTechSpaceIdFromConfig() = %v", techSpaceId) + if err != nil { + t.Fatalf("GetTechSpaceIdFromConfig() returned error: %v", err) + } + if techSpaceId != "tech-space-789" { + t.Fatalf("GetTechSpaceIdFromConfig() = %v, want tech-space-789", techSpaceId) } } @@ -70,10 +82,50 @@ func TestLoadStoredConfig(t *testing.T) { } cfg, err := LoadStoredConfig() - if err == nil && cfg != nil { - if cfg.AccountId != "" || cfg.TechSpaceId != "" { - t.Logf("LoadStoredConfig() loaded config with AccountId=%v, TechSpaceId=%v", - cfg.AccountId, cfg.TechSpaceId) - } + if err != nil { + t.Fatalf("LoadStoredConfig() returned error: %v", err) + } + if cfg == nil { + t.Fatalf("LoadStoredConfig() returned nil config") + return + } + if cfg.AccountId != "test-account-123" { + t.Fatalf("LoadStoredConfig() AccountId = %v, want test-account-123", cfg.AccountId) + } + if cfg.TechSpaceId != "tech-space-789" { + t.Fatalf("LoadStoredConfig() TechSpaceId = %v, want tech-space-789", cfg.TechSpaceId) + } +} + +func TestNetworkIdHelpers(t *testing.T) { + tempDir := setupTestHome(t) + + configPath := filepath.Join(tempDir, ".anytype", "config.json") + if err := os.MkdirAll(filepath.Dir(configPath), 0755); err != nil { + t.Fatalf("Failed to create config directory: %v", err) + } + + if err := os.WriteFile(configPath, []byte(`{"networkId":"net-123"}`), 0644); err != nil { + t.Fatalf("Failed to write test config: %v", err) + } + + networkId, err := GetNetworkIdFromConfig() + if err != nil { + t.Fatalf("GetNetworkIdFromConfig() returned error: %v", err) + } + if networkId != "net-123" { + t.Fatalf("GetNetworkIdFromConfig() = %v, want net-123", networkId) + } + + if err := SetNetworkIdToConfig("net-456"); err != nil { + t.Fatalf("SetNetworkIdToConfig() returned error: %v", err) + } + + networkId, err = GetNetworkIdFromConfig() + if err != nil { + t.Fatalf("GetNetworkIdFromConfig() after set returned error: %v", err) + } + if networkId != "net-456" { + t.Fatalf("GetNetworkIdFromConfig() = %v, want net-456", networkId) } } diff --git a/core/config/config_test.go b/core/config/config_test.go index 5fbaea6..5598610 100644 --- a/core/config/config_test.go +++ b/core/config/config_test.go @@ -18,6 +18,7 @@ func TestConfigManager(t *testing.T) { testConfig := &Config{ AccountId: "test-account-123", TechSpaceId: "test-tech-space-789", + NetworkId: "test-network-456", } cm := &ConfigManager{ @@ -51,6 +52,9 @@ func TestConfigManager(t *testing.T) { if cfg.TechSpaceId != testConfig.TechSpaceId { t.Errorf("TechSpaceId = %v, want %v", cfg.TechSpaceId, testConfig.TechSpaceId) } + if cfg.NetworkId != testConfig.NetworkId { + t.Errorf("NetworkId = %v, want %v", cfg.NetworkId, testConfig.NetworkId) + } }) t.Run("Delete", func(t *testing.T) {