diff --git a/README.md b/README.md index bf38326..f4fa171 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,32 @@ go install github.com/alyraffauf/tg/cmd/tg@latest ## Usage +### Authentication + +Log in interactively with OAuth: + +```bash +tg auth login alice.example.com +``` + +For headless use, pass an atproto app password as the second argument: + +```bash +tg auth login alice.example.com xxxx-xxxx-xxxx-xxxx +``` + +To avoid exposing the app password in shell history, pass it on standard input: + +```bash +printf '%s\n' "$ATPROTO_APP_PASSWORD" | tg auth login alice.example.com --password-stdin +``` + +Authentication is persisted locally. The current account is recorded in +`~/.config/tg/auth.json` (or `$XDG_CONFIG_HOME/tg/auth.json`); OAuth session +credentials are stored under `~/.config/tg/oauth/`, and app-password sessions +are stored in `~/.config/tg/password-session.json`. These files are created +with user-only permissions. Use `tg auth logout` to remove the active login. + `tg` auto-detects the repository from the `origin` remote when run inside a cloned Tangled repo. For now, only ssh origins are supported. You can also pass a fully-qualified `handle/repo` argument. ### Repositories diff --git a/atproto/auth.go b/atproto/auth.go index 39ba857..b20574a 100644 --- a/atproto/auth.go +++ b/atproto/auth.go @@ -11,6 +11,7 @@ import ( "github.com/bluesky-social/indigo/atproto/atclient" "github.com/bluesky-social/indigo/atproto/auth/oauth" + "github.com/bluesky-social/indigo/atproto/identity" "github.com/bluesky-social/indigo/atproto/syntax" ) @@ -41,15 +42,18 @@ var DefaultScopes = []string{ } type AuthManager struct { - App *oauth.ClientApp - Store *FileStore - state authState - statePath string + App *oauth.ClientApp + Store *FileStore + state authState + statePath string + passwordPath string + passwordSession *atclient.PasswordSessionData } type authState struct { CurrentDID string `json:"current_did,omitempty"` CurrentSession string `json:"current_session,omitempty"` + Method string `json:"method,omitempty"` } // ConfigDir returns the configuration directory for tg. @@ -76,16 +80,105 @@ func NewAuthManager(callbackURL string, dir string) (*AuthManager, error) { store := NewFileStore(filepath.Join(dir, "oauth")) manager := &AuthManager{ - App: oauth.NewClientApp(&config, store), - Store: store, - statePath: filepath.Join(dir, "auth.json"), + App: oauth.NewClientApp(&config, store), + Store: store, + statePath: filepath.Join(dir, "auth.json"), + passwordPath: filepath.Join(dir, "password-session.json"), } if err := manager.loadState(); err != nil { return nil, fmt.Errorf("load auth state: %w", err) } + if manager.state.Method == "password" { + data, err := os.ReadFile(manager.passwordPath) + if err != nil { + manager.state = authState{} + if err := manager.saveState(); err != nil { + return nil, fmt.Errorf("clear unusable password auth state: %w", err) + } + return manager, nil + } + var session atclient.PasswordSessionData + if err := json.Unmarshal(data, &session); err != nil { + if removeErr := os.Remove(manager.passwordPath); removeErr != nil && !os.IsNotExist(removeErr) { + return nil, fmt.Errorf("remove unusable password session: %w", removeErr) + } + manager.state = authState{} + if err := manager.saveState(); err != nil { + return nil, fmt.Errorf("clear unusable password auth state: %w", err) + } + return manager, nil + } + if session.AccountDID == "" || session.Host == "" || session.RefreshToken == "" { + if err := os.Remove(manager.passwordPath); err != nil && !os.IsNotExist(err) { + return nil, fmt.Errorf("remove incomplete password session: %w", err) + } + manager.state = authState{} + if err := manager.saveState(); err != nil { + return nil, fmt.Errorf("clear incomplete password auth state: %w", err) + } + return manager, nil + } + manager.passwordSession = &session + } return manager, nil } +// LoginWithPassword authenticates with an atproto app password and persists +// the resulting access/refresh token pair for subsequent invocations. +func (m *AuthManager) LoginWithPassword(ctx context.Context, identifier, password string) error { + atid, err := syntax.ParseAtIdentifier(identifier) + if err != nil { + return err + } + persistSession := func(_ context.Context, data atclient.PasswordSessionData) { + _ = m.savePasswordSession(&data) + } + client, err := atclient.LoginWithPassword( + ctx, + identity.DefaultDirectory(), + atid, + password, + "", + persistSession, + ) + if err != nil { + return err + } + if client.Auth == nil { + return errors.New("password login returned no auth session") + } + passwordAuth, ok := client.Auth.(*atclient.PasswordAuth) + if !ok { + return errors.New("password login returned an unexpected auth type") + } + if m.IsAuthenticated() { + if err := m.Logout(ctx); err != nil { + return fmt.Errorf("replace current login: %w", err) + } + } + if err := m.savePasswordSession(&passwordAuth.Session); err != nil { + return err + } + m.state = authState{ + CurrentDID: passwordAuth.Session.AccountDID.String(), + Method: "password", + } + return m.saveState() +} + +func (m *AuthManager) savePasswordSession(session *atclient.PasswordSessionData) error { + snapshot := *session + m.passwordSession = &snapshot + if err := os.MkdirAll(filepath.Dir(m.passwordPath), 0o700); err != nil { + return err + } + data, err := json.MarshalIndent(&snapshot, "", " ") + if err != nil { + return err + } + return os.WriteFile(m.passwordPath, data, 0o600) +} + func (m *AuthManager) StartLogin(ctx context.Context, identifier string) (string, error) { return m.App.StartAuthFlow(ctx, identifier) } @@ -95,9 +188,25 @@ func (m *AuthManager) FinishLogin(ctx context.Context, query url.Values) error { if err != nil { return err } + if m.IsAuthenticated() { + if err := m.Logout(ctx); err != nil { + return fmt.Errorf("replace current login: %w", err) + } + } - m.state.CurrentDID = session.AccountDID.String() - m.state.CurrentSession = session.SessionID + return m.activateOAuthSession(session.AccountDID.String(), session.SessionID) +} + +func (m *AuthManager) activateOAuthSession(did, sessionID string) error { + if err := os.Remove(m.passwordPath); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("remove previous password session: %w", err) + } + m.passwordSession = nil + m.state = authState{ + CurrentDID: did, + CurrentSession: sessionID, + Method: "oauth", + } return m.saveState() } @@ -113,7 +222,7 @@ func (m *AuthManager) CurrentDID() syntax.DID { } func (m *AuthManager) IsAuthenticated() bool { - return m.state.CurrentDID != "" && m.state.CurrentSession != "" + return m.state.CurrentDID != "" && (m.state.CurrentSession != "" || m.passwordSession != nil) } func (m *AuthManager) CurrentSession(ctx context.Context) (*oauth.ClientSession, error) { @@ -124,6 +233,12 @@ func (m *AuthManager) CurrentSession(ctx context.Context) (*oauth.ClientSession, } func (m *AuthManager) APIClient(ctx context.Context) (*atclient.APIClient, error) { + if m.state.Method == "password" && m.passwordSession != nil { + persistSession := func(_ context.Context, data atclient.PasswordSessionData) { + _ = m.savePasswordSession(&data) + } + return atclient.ResumePasswordSession(*m.passwordSession, persistSession), nil + } session, err := m.CurrentSession(ctx) if err != nil { return nil, err @@ -135,6 +250,22 @@ func (m *AuthManager) Logout(ctx context.Context) error { if !m.IsAuthenticated() { return nil } + if m.state.Method == "password" { + client := atclient.ResumePasswordSession(*m.passwordSession, nil) + passwordAuth, ok := client.Auth.(*atclient.PasswordAuth) + if !ok { + return errors.New("password session has an unexpected auth type") + } + if err := passwordAuth.Logout(ctx, client.Client); err != nil { + return fmt.Errorf("revoke password session: %w", err) + } + if err := os.Remove(m.passwordPath); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("remove password session: %w", err) + } + m.passwordSession = nil + m.state = authState{} + return m.saveState() + } if err := m.App.Logout(ctx, m.CurrentDID(), m.state.CurrentSession); err != nil { return err } diff --git a/atproto/auth_test.go b/atproto/auth_test.go new file mode 100644 index 0000000..e6e2d18 --- /dev/null +++ b/atproto/auth_test.go @@ -0,0 +1,128 @@ +package atproto + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/bluesky-social/indigo/atproto/atclient" + "github.com/bluesky-social/indigo/atproto/syntax" +) + +func TestActivateOAuthSessionClearsPasswordAuth(t *testing.T) { + dir := t.TempDir() + m, err := NewAuthManager("http://127.0.0.1/callback", dir) + if err != nil { + t.Fatal(err) + } + did := syntax.DID("did:plc:password") + if err := m.savePasswordSession(&atclient.PasswordSessionData{AccountDID: did, AccessToken: "access", RefreshToken: "refresh", Host: "https://pds.example"}); err != nil { + t.Fatal(err) + } + m.state = authState{CurrentDID: did.String(), Method: "password"} + + if err := m.activateOAuthSession("did:plc:oauth", "oauth-session"); err != nil { + t.Fatal(err) + } + if m.state.Method != "oauth" || m.state.CurrentDID != "did:plc:oauth" || m.state.CurrentSession != "oauth-session" { + t.Fatalf("unexpected OAuth state: %+v", m.state) + } + if m.passwordSession != nil { + t.Fatal("password session remained loaded") + } + if _, err := os.Stat(m.passwordPath); !os.IsNotExist(err) { + t.Fatalf("password session file was not removed: %v", err) + } +} + +func TestNewAuthManagerRecoversFromMissingPasswordSession(t *testing.T) { + dir := t.TempDir() + data, _ := json.Marshal(authState{CurrentDID: "did:plc:stale", Method: "password"}) + if err := os.WriteFile(filepath.Join(dir, "auth.json"), data, 0o600); err != nil { + t.Fatal(err) + } + m, err := NewAuthManager("http://127.0.0.1/callback", dir) + if err != nil { + t.Fatal(err) + } + if m.IsAuthenticated() { + t.Fatal("stale password state should be cleared") + } +} + +func TestNewAuthManagerRecoversFromCorruptPasswordSession(t *testing.T) { + dir := t.TempDir() + state, _ := json.Marshal(authState{CurrentDID: "did:plc:stale", Method: "password"}) + if err := os.WriteFile(filepath.Join(dir, "auth.json"), state, 0o600); err != nil { + t.Fatal(err) + } + passwordPath := filepath.Join(dir, "password-session.json") + if err := os.WriteFile(passwordPath, []byte("not json"), 0o600); err != nil { + t.Fatal(err) + } + m, err := NewAuthManager("http://127.0.0.1/callback", dir) + if err != nil { + t.Fatal(err) + } + if m.IsAuthenticated() { + t.Fatal("corrupt password state should be cleared") + } + if _, err := os.Stat(passwordPath); !os.IsNotExist(err) { + t.Fatalf("corrupt password session was not removed: %v", err) + } +} + +func TestNewAuthManagerRecoversFromIncompletePasswordSession(t *testing.T) { + dir := t.TempDir() + state, _ := json.Marshal(authState{CurrentDID: "did:plc:stale", Method: "password"}) + if err := os.WriteFile(filepath.Join(dir, "auth.json"), state, 0o600); err != nil { + t.Fatal(err) + } + session, _ := json.Marshal(atclient.PasswordSessionData{AccountDID: syntax.DID("did:plc:stale")}) + passwordPath := filepath.Join(dir, "password-session.json") + if err := os.WriteFile(passwordPath, session, 0o600); err != nil { + t.Fatal(err) + } + m, err := NewAuthManager("http://127.0.0.1/callback", dir) + if err != nil { + t.Fatal(err) + } + if m.IsAuthenticated() { + t.Fatal("incomplete password state should be cleared") + } +} + +func TestPasswordLogoutRevokesAndRemovesSession(t *testing.T) { + var authorization string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/xrpc/com.atproto.server.deleteSession" { + t.Errorf("unexpected path %q", r.URL.Path) + } + authorization = r.Header.Get("Authorization") + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + dir := t.TempDir() + did := syntax.DID("did:plc:test") + m := &AuthManager{statePath: filepath.Join(dir, "auth.json"), passwordPath: filepath.Join(dir, "password-session.json"), state: authState{CurrentDID: did.String(), Method: "password"}} + if err := m.savePasswordSession(&atclient.PasswordSessionData{AccountDID: did, AccessToken: "access", RefreshToken: "refresh", Host: server.URL}); err != nil { + t.Fatal(err) + } + if err := m.Logout(context.Background()); err != nil { + t.Fatal(err) + } + if authorization != "Bearer refresh" { + t.Fatalf("authorization = %q", authorization) + } + if m.IsAuthenticated() { + t.Fatal("manager remained authenticated") + } + if _, err := os.Stat(m.passwordPath); !os.IsNotExist(err) { + t.Fatalf("password session file was not removed: %v", err) + } +} diff --git a/internal/cli/auth_login.go b/internal/cli/auth_login.go index d765e6b..c471134 100644 --- a/internal/cli/auth_login.go +++ b/internal/cli/auth_login.go @@ -3,29 +3,38 @@ package cli import ( "context" "fmt" + "io" "net/http" "os/exec" "runtime" + "strings" "github.com/spf13/cobra" ) +var authLoginPasswordStdin bool + var authLoginCmd = &cobra.Command{ - Use: "login [handle]", - Short: "Log in to atproto via OAuth", - Long: `Log in to atproto via OAuth using a local browser callback.`, - Args: cobra.MaximumNArgs(1), + Use: "login [app-password]", + Short: "Log in to atproto via OAuth or an app password", + Long: `Log in with OAuth, or use an app password as the second argument for headless login.`, + Args: cobra.RangeArgs(1, 2), RunE: func(cmd *cobra.Command, args []string) error { if auth == nil { return fmt.Errorf("auth is not available") } - identifier := "" - if len(args) == 1 { - identifier = args[0] + identifier := args[0] + password, usePassword, err := loginPassword(args, authLoginPasswordStdin, cmd.InOrStdin()) + if err != nil { + return err } - if identifier == "" { - return fmt.Errorf("handle or DID required") + if usePassword { + if err := auth.LoginWithPassword(cmd.Context(), identifier, password); err != nil { + return err + } + fmt.Printf("Logged in as %s\n", auth.CurrentDID()) + return nil } server, resultChannel, err := runCallbackServer() @@ -58,6 +67,31 @@ var authLoginCmd = &cobra.Command{ }, } +func init() { + authLoginCmd.Flags().BoolVar(&authLoginPasswordStdin, "password-stdin", false, "Read the app password from standard input") +} + +func loginPassword(args []string, fromStdin bool, stdin io.Reader) (string, bool, error) { + if !fromStdin { + if len(args) < 2 { + return "", false, nil + } + return args[1], true, nil + } + if len(args) == 2 { + return "", false, fmt.Errorf("app password argument and --password-stdin cannot be used together") + } + data, err := io.ReadAll(stdin) + if err != nil { + return "", false, fmt.Errorf("read app password from stdin: %w", err) + } + password := strings.TrimSpace(string(data)) + if password == "" { + return "", false, fmt.Errorf("app password from stdin is empty") + } + return password, true, nil +} + // runCallbackServer starts the local HTTP server that receives the OAuth // redirect after the user approves the login in their browser. func runCallbackServer() (*http.Server, <-chan error, error) { diff --git a/internal/cli/auth_login_test.go b/internal/cli/auth_login_test.go new file mode 100644 index 0000000..c11b183 --- /dev/null +++ b/internal/cli/auth_login_test.go @@ -0,0 +1,36 @@ +package cli + +import ( + "strings" + "testing" +) + +func TestLoginPassword(t *testing.T) { + tests := []struct { + name string + args []string + stdinFlag bool + stdin string + want string + wantUse bool + wantErr bool + }{ + {"oauth", []string{"alice.example"}, false, "", "", false, false}, + {"argument", []string{"alice.example", "app-pass"}, false, "", "app-pass", true, false}, + {"stdin", []string{"alice.example"}, true, "app-pass\n", "app-pass", true, false}, + {"both", []string{"alice.example", "app-pass"}, true, "other", "", false, true}, + {"empty stdin", []string{"alice.example"}, true, "\n", "", false, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, use, err := loginPassword(tt.args, tt.stdinFlag, strings.NewReader(tt.stdin)) + if (err != nil) != tt.wantErr { + t.Fatalf("error = %v, wantErr %v", err, tt.wantErr) + } + if got != tt.want || use != tt.wantUse { + t.Fatalf("got (%q, %v), want (%q, %v)", got, use, tt.want, tt.wantUse) + } + }) + } +} diff --git a/internal/cli/auth_token.go b/internal/cli/auth_token.go index 0bd7146..831e6e5 100644 --- a/internal/cli/auth_token.go +++ b/internal/cli/auth_token.go @@ -3,18 +3,34 @@ package cli import ( "fmt" + "github.com/bluesky-social/indigo/atproto/atclient" "github.com/spf13/cobra" ) var authTokenCmd = &cobra.Command{ Use: "token", - Short: "Print the current OAuth access token", + Short: "Print the current access token", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { if auth == nil || !auth.IsAuthenticated() { return fmt.Errorf("not logged in; run \"tg auth login\" first") } + if auth.CurrentDID().String() != "" { + client, err := auth.APIClient(cmd.Context()) + if err != nil { + return fmt.Errorf("resume auth session: %w", err) + } + if passwordAuth, ok := client.Auth.(*atclient.PasswordAuth); ok { + token, _ := passwordAuth.GetTokens() + if token == "" { + return fmt.Errorf("current session has no access token") + } + fmt.Fprintln(cmd.OutOrStdout(), token) + return nil + } + } + session, err := auth.CurrentSession(cmd.Context()) if err != nil { return fmt.Errorf("resume OAuth session: %w", err) diff --git a/internal/cli/pr_create.go b/internal/cli/pr_create.go index 7492716..0ef8c4a 100644 --- a/internal/cli/pr_create.go +++ b/internal/cli/pr_create.go @@ -16,20 +16,21 @@ import ( const patchMimeType = "application/gzip" var ( - prCreateTitle string - prCreateBody string - prCreateBodyFile string - prCreateBase string - prCreateHead string - prCreateRepo string + prCreateTitle string + prCreateBody string + prCreateBodyFile string + prCreateBase string + prCreateHead string + prCreateRepo string + prCreateSourceRepo string ) var prCreateCmd = &cobra.Command{ Use: "create", Short: "Create a pull request from the current branch", Long: "Create a pull request by uploading a gzipped git patch and writing a sh.tangled.repo.pull record. " + - "The source and target repository are the same. By default, the current branch is the source and " + - "origin's default branch is the target. Use --repo to target a different Tangled repository.", + "By default, the current repository and branch are both the source and target repository, and origin's " + + "default branch is the target branch. Use --repo and --source-repo for a fork-based pull request.", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() @@ -69,6 +70,20 @@ var prCreateCmd = &cobra.Command{ if !strings.HasPrefix(target.URI, "at://") { return fmt.Errorf("target repository %q has no strong at:// URI", repo) } + source := target + if prCreateSourceRepo != "" { + sourceHandle, sourceName, err := parseHandleRepo(prCreateSourceRepo) + if err != nil { + return err + } + source, err = resolveRepoRecord(ctx, sourceHandle, sourceName) + if err != nil { + return fmt.Errorf("resolve source repository: %w", err) + } + } + if source.Value.RepoDid == "" { + return fmt.Errorf("source repository has no repo DID") + } patch, err := gitutil.GeneratePatch(ctx, repoDir, base, head) if err != nil { @@ -85,12 +100,13 @@ var prCreateCmd = &cobra.Command{ } uri, err := createPullRecord(ctx, atClient, auth.CurrentDID().String(), prCreateRecord{ - Title: prCreateTitle, - Body: body, - RepoDid: target.Value.RepoDid, - Base: base, - Head: head, - Patch: blob, + Title: prCreateTitle, + Body: body, + TargetRepoDid: target.Value.RepoDid, + SourceRepoDid: source.Value.RepoDid, + Base: base, + Head: head, + Patch: blob, }) if err != nil { return err @@ -109,16 +125,18 @@ func init() { prCreateCmd.Flags().StringVarP(&prCreateBase, "base", "B", "", "Target branch (default: origin's default branch)") prCreateCmd.Flags().StringVarP(&prCreateHead, "head", "H", "", "Source branch (default: current branch)") prCreateCmd.Flags().StringVarP(&prCreateRepo, "repo", "R", "", "Target repository as handle/repo") + prCreateCmd.Flags().StringVar(&prCreateSourceRepo, "source-repo", "", "Source repository as handle/repo (for fork-based pull requests)") prCreateCmd.MarkFlagRequired("title") } type prCreateRecord struct { - Title string - Body string - RepoDid string - Base string - Head string - Patch *atproto.Blob + Title string + Body string + TargetRepoDid string + SourceRepoDid string + Base string + Head string + Patch *atproto.Blob } // pullRecord is the sh.tangled.repo.pull lexicon shape used for record writes. @@ -133,15 +151,13 @@ type pullRecord struct { } type pullTarget struct { - Repo string `json:"repo"` - RepoDid string `json:"repoDid"` - Branch string `json:"branch"` + Repo string `json:"repo"` + Branch string `json:"branch"` } type pullSource struct { - Repo string `json:"repo"` - RepoDid string `json:"repoDid"` - Branch string `json:"branch"` + Repo string `json:"repo,omitempty"` + Branch string `json:"branch"` } type pullRound struct { @@ -179,35 +195,37 @@ func prTargetBranch(ctx context.Context, repoDir string) (string, error) { } func createPullRecord(ctx context.Context, atClient *atproto.ATProto, did string, input prCreateRecord) (string, error) { - now := time.Now().UTC().Format(time.RFC3339) - record := pullRecord{ + record := newPullRecord(input, time.Now().UTC()) + uri, _, err := atClient.PutRecord(ctx, atproto.PutRecordInput{ + Repo: did, + Collection: "sh.tangled.repo.pull", + Rkey: string(syntax.NewTIDNow(0)), + Record: record, + }) + if err != nil { + return "", fmt.Errorf("create pull request record: %w", err) + } + return uri, nil +} + +func newPullRecord(input prCreateRecord, createdAt time.Time) pullRecord { + now := createdAt.Format(time.RFC3339) + return pullRecord{ Type: "sh.tangled.repo.pull", Title: input.Title, Body: input.Body, CreatedAt: now, Target: pullTarget{ - Repo: input.RepoDid, - RepoDid: input.RepoDid, - Branch: input.Base, + Repo: input.TargetRepoDid, + Branch: input.Base, }, Source: pullSource{ - Repo: input.RepoDid, - RepoDid: input.RepoDid, - Branch: input.Head, + Repo: input.SourceRepoDid, + Branch: input.Head, }, Rounds: []pullRound{{ CreatedAt: now, PatchBlob: input.Patch, }}, } - uri, _, err := atClient.PutRecord(ctx, atproto.PutRecordInput{ - Repo: did, - Collection: "sh.tangled.repo.pull", - Rkey: string(syntax.NewTIDNow(0)), - Record: record, - }) - if err != nil { - return "", fmt.Errorf("create pull request record: %w", err) - } - return uri, nil } diff --git a/internal/cli/pr_create_test.go b/internal/cli/pr_create_test.go new file mode 100644 index 0000000..85389ec --- /dev/null +++ b/internal/cli/pr_create_test.go @@ -0,0 +1,26 @@ +package cli + +import ( + "testing" + "time" + + "github.com/alyraffauf/tg/atproto" +) + +func TestNewPullRecordUsesDistinctSourceAndTarget(t *testing.T) { + record := newPullRecord(prCreateRecord{ + Title: "Cross-repo change", + TargetRepoDid: "did:plc:upstream", + SourceRepoDid: "did:plc:fork", + Base: "main", + Head: "feature", + Patch: &atproto.Blob{}, + }, time.Date(2026, 7, 17, 0, 0, 0, 0, time.UTC)) + + if record.Target.Repo != "did:plc:upstream" { + t.Fatalf("unexpected target: %+v", record.Target) + } + if record.Source.Repo != "did:plc:fork" { + t.Fatalf("unexpected source: %+v", record.Source) + } +} diff --git a/internal/cli/repo_fork.go b/internal/cli/repo_fork.go index 4205951..e81d7f6 100644 --- a/internal/cli/repo_fork.go +++ b/internal/cli/repo_fork.go @@ -3,6 +3,7 @@ package cli import ( "context" "fmt" + "strings" "time" "github.com/alyraffauf/tg/atproto" @@ -48,7 +49,7 @@ var repoForkCmd = &cobra.Command{ repoDID, err := knot.New(source.Knot, token).CreateRepo(ctx, knot.CreateRepoInput{ Name: name, Rkey: name, - Source: source.URI, + Source: forkSourceURL(source.Knot, source.RepoDID), }) if err != nil { return err @@ -63,6 +64,7 @@ var repoForkCmd = &cobra.Command{ Knot: source.Knot, CreatedAt: time.Now().UTC().Format(time.RFC3339), RepoDid: repoDID, + Source: source.URI, }, }) if err != nil { @@ -91,8 +93,17 @@ func deleteFork(ctx context.Context, atClient *atproto.ATProto, knotHost, did, n } type forkSource struct { - URI string - Knot string + URI string + Knot string + RepoDID string +} + +func forkSourceURL(knotHost, repoDID string) string { + base := strings.TrimRight(knotHost, "/") + if !strings.HasPrefix(base, "http://") && !strings.HasPrefix(base, "https://") { + base = "https://" + base + } + return base + "/" + repoDID } func getForkSource(ctx context.Context, handle, name string) (forkSource, error) { @@ -108,10 +119,13 @@ func getForkSource(ctx context.Context, handle, name string) (forkSource, error) if repo.Value.Knot == "" { return forkSource{}, fmt.Errorf("source repository %s/%s has no knot", handle, name) } + if repo.Value.RepoDid == "" { + return forkSource{}, fmt.Errorf("source repository %s/%s has no repo DID", handle, name) + } if repo.URI != "" { uri = repo.URI } - return forkSource{URI: uri, Knot: repo.Value.Knot}, nil + return forkSource{URI: uri, Knot: repo.Value.Knot, RepoDID: repo.Value.RepoDid}, nil } type repoForkResult struct { diff --git a/internal/cli/repo_fork_test.go b/internal/cli/repo_fork_test.go new file mode 100644 index 0000000..14c6a68 --- /dev/null +++ b/internal/cli/repo_fork_test.go @@ -0,0 +1,24 @@ +package cli + +import "testing" + +func TestForkSourceURL(t *testing.T) { + tests := []struct { + name string + knot string + repoDID string + want string + }{ + {"bare host", "knot.gaze.systems", "did:plc:abc", "https://knot.gaze.systems/did:plc:abc"}, + {"https host", "https://knot.gaze.systems", "did:plc:abc", "https://knot.gaze.systems/did:plc:abc"}, + {"trailing slash", "https://knot.gaze.systems/", "did:plc:abc", "https://knot.gaze.systems/did:plc:abc"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := forkSourceURL(tt.knot, tt.repoDID); got != tt.want { + t.Fatalf("forkSourceURL() = %q, want %q", got, tt.want) + } + }) + } +} diff --git a/tangled/get_repo.go b/tangled/get_repo.go index 32cc51d..9f36848 100644 --- a/tangled/get_repo.go +++ b/tangled/get_repo.go @@ -17,6 +17,7 @@ type RepoRecord struct { Owner string `json:"owner,omitempty"` AddedAt string `json:"addedAt,omitempty"` RepoDid string `json:"repoDid,omitempty"` + Source string `json:"source,omitempty"` Spindle string `json:"spindle,omitempty"` Website string `json:"website,omitempty"` Labels []string `json:"labels,omitempty"`