diff --git a/internal/app/git.go b/internal/app/git.go index 8afe825..0400fb7 100644 --- a/internal/app/git.go +++ b/internal/app/git.go @@ -9,6 +9,8 @@ import ( // CloneRepoInput configures a repository clone. type CloneRepoInput struct { + KnotHost string + SSHPort int Handle string Repo string Destination string @@ -16,10 +18,18 @@ type CloneRepoInput struct { // CloneRepo clones a Tangled repository into Destination. func (s *Service) CloneRepo(ctx context.Context, in CloneRepoInput) (*RepoCloneResult, error) { + if in.SSHPort == 0 { + in.SSHPort = 22 + } + if in.SSHPort < 1 || in.SSHPort > 65535 { + return nil, fmt.Errorf("SSH port must be between 1 and 65535") + } if err := s.git.CloneRepo(ctx, gitutil.CloneRepoParams{ - Handle: in.Handle, - Repo: in.Repo, - RepoDir: in.Destination, + KnotHost: in.KnotHost, + SSHPort: in.SSHPort, + Handle: in.Handle, + Repo: in.Repo, + RepoDir: in.Destination, }); err != nil { return nil, err } diff --git a/internal/app/repos.go b/internal/app/repos.go index b8c414b..8d70d26 100644 --- a/internal/app/repos.go +++ b/internal/app/repos.go @@ -13,6 +13,7 @@ import ( "github.com/alyraffauf/tg/internal/tangledlex" "github.com/alyraffauf/tg/knot" "github.com/alyraffauf/tg/tangled" + "github.com/bluesky-social/indigo/atproto/syntax" ) // ViewRepo fetches a single repository record. @@ -113,6 +114,7 @@ type ProvisionRepoInput struct { // CreateRepoInput configures provisioning and optional local setup. type CreateRepoInput struct { KnotHost string + SSHPort int Name string Description string Clone bool @@ -122,6 +124,14 @@ type CreateRepoInput struct { // CreateRepo provisions a repository and performs requested local Git setup. func (s *Service) CreateRepo(ctx context.Context, in CreateRepoInput) (*RepoCreateResult, error) { + if (in.Clone || in.PushPath != "") && (in.SSHPort < 1 || in.SSHPort > 65535) { + return nil, fmt.Errorf("SSH port must be between 1 and 65535") + } + knotHost, err := parseKnotHostname(in.KnotHost) + if err != nil { + return nil, err + } + in.KnotHost = knotHost uri, handle, err := s.provisionRepo(ctx, ProvisionRepoInput{ KnotHost: in.KnotHost, Name: in.Name, Description: in.Description, }) @@ -130,7 +140,10 @@ func (s *Service) CreateRepo(ctx context.Context, in CreateRepoInput) (*RepoCrea } result := &RepoCreateResult{Handle: handle, Name: in.Name, URI: uri, Knot: in.KnotHost} if in.Clone { - if _, err := s.CloneRepo(ctx, CloneRepoInput{Handle: handle, Repo: in.Name, Destination: in.Name}); err != nil { + if _, err := s.CloneRepo(ctx, CloneRepoInput{ + KnotHost: in.KnotHost, SSHPort: in.SSHPort, + Handle: handle, Repo: in.Name, Destination: in.Name, + }); err != nil { return nil, fmt.Errorf("clone new repository: %w", err) } result.Cloned = true @@ -139,7 +152,7 @@ func (s *Service) CreateRepo(ctx context.Context, in CreateRepoInput) (*RepoCrea return result, nil } pushResult, err := s.pushNewRepo(ctx, PushNewRepoInput{ - KnotHost: in.KnotHost, RepoURI: uri, Dir: in.PushPath, + KnotHost: in.KnotHost, SSHPort: in.SSHPort, RepoURI: uri, Dir: in.PushPath, Handle: handle, Repo: in.Name, RemoteName: in.RemoteName, }) if pushResult.defaultBranchWarning != nil { @@ -190,6 +203,14 @@ func (s *Service) provisionRepo(ctx context.Context, in ProvisionRepoInput) (uri return uri, s.ownerHandle(ctx, did), nil } +func parseKnotHostname(raw string) (string, error) { + hostname, err := syntax.ParseHandle(raw) + if err != nil { + return "", fmt.Errorf("invalid Knot hostname %q: %w", raw, err) + } + return hostname.Normalize().String(), nil +} + func (s *Service) ownerHandle(ctx context.Context, did string) string { if ident, err := s.resolver.ResolveDID(ctx, did); err == nil { return ident.Handle.String() @@ -240,7 +261,8 @@ func (s *Service) pushNewRepo(ctx context.Context, in PushNewRepoInput) (pushNew branch, defaultBranchErr := s.setDefaultBranchFromDir(ctx, in.KnotHost, in.RepoURI, in.Dir) result := pushNewRepoResult{defaultBranch: branch, defaultBranchWarning: defaultBranchErr} if err := s.git.PushNewRepo(ctx, gitutil.PushNewRepoParams{ - Dir: in.Dir, Handle: in.Handle, Repo: in.Repo, RemoteName: in.RemoteName, + Dir: in.Dir, KnotHost: in.KnotHost, SSHPort: in.SSHPort, + Handle: in.Handle, Repo: in.Repo, RemoteName: in.RemoteName, }); err != nil { return result, fmt.Errorf("push to new repository: %w", err) } @@ -250,6 +272,7 @@ func (s *Service) pushNewRepo(ctx context.Context, in PushNewRepoInput) (pushNew // PushNewRepoInput configures pushing a newly created repository. type PushNewRepoInput struct { KnotHost string + SSHPort int RepoURI string Dir string Handle string diff --git a/internal/app/repos_create_test.go b/internal/app/repos_create_test.go new file mode 100644 index 0000000..cdd553b --- /dev/null +++ b/internal/app/repos_create_test.go @@ -0,0 +1,80 @@ +package app + +import ( + "context" + "strings" + "testing" +) + +func TestCreateRepoValidatesConnectionSettingsBeforeProvisioning(t *testing.T) { + tests := []struct { + name string + knotHost string + sshPort int + pushPath string + clone bool + wantHost string + wantErr string + wantClones int + wantCreates int + wantPuts int + }{ + {name: "malformed hostname", knotHost: "https://knot.example/path", wantErr: "invalid Knot hostname"}, + {name: "zero push port", knotHost: "knot.example", pushPath: ".", wantErr: "SSH port"}, + {name: "negative push port", knotHost: "knot.example", sshPort: -1, pushPath: ".", wantErr: "SSH port"}, + {name: "push port above maximum", knotHost: "knot.example", sshPort: 65536, pushPath: ".", wantErr: "SSH port"}, + {name: "zero clone port", knotHost: "knot.example", clone: true, wantErr: "SSH port"}, + {name: "valid custom port", knotHost: "knot.example", sshPort: 2222, pushPath: ".", wantHost: "knot.example", wantCreates: 1, wantPuts: 1}, + {name: "clone from custom knot and port", knotHost: "knot.example", sshPort: 2222, clone: true, wantHost: "knot.example", wantClones: 1, wantCreates: 1, wantPuts: 1}, + {name: "hostname is canonicalized", knotHost: "KNOT.EXAMPLE", wantHost: "knot.example", wantCreates: 1, wantPuts: 1}, + {name: "unused invalid port", knotHost: "knot.example", wantHost: "knot.example", wantCreates: 1, wantPuts: 1}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + pds := &testPDS{} + knotClient := &testKnot{} + git := &testGit{} + service := testService(pds, git, knotClient) + + result, err := service.CreateRepo(context.Background(), CreateRepoInput{ + KnotHost: tt.knotHost, SSHPort: tt.sshPort, Name: "example", Clone: tt.clone, PushPath: tt.pushPath, + }) + if tt.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("CreateRepo() error = %v, want containing %q", err, tt.wantErr) + } + } else { + if err != nil { + t.Fatalf("CreateRepo() error = %v", err) + } + if result.Knot != tt.wantHost { + t.Fatalf("CreateRepo() Knot = %q, want %q", result.Knot, tt.wantHost) + } + } + if knotClient.createCalls != tt.wantCreates || len(pds.puts) != tt.wantPuts { + t.Fatalf("create/put calls = %d/%d, want %d/%d", knotClient.createCalls, len(pds.puts), tt.wantCreates, tt.wantPuts) + } + if len(git.clones) != tt.wantClones { + t.Fatalf("clone calls = %+v, want %d", git.clones, tt.wantClones) + } + if tt.wantClones == 1 && (git.clones[0].KnotHost != tt.wantHost || git.clones[0].SSHPort != tt.sshPort) { + t.Fatalf("clone destination = %+v, want %s:%d", git.clones[0], tt.wantHost, tt.sshPort) + } + if tt.wantErr != "" && (pds.serviceAuthCalls != 0 || len(git.pushes) != 0) { + t.Fatalf("service auth/push calls = %d/%d, want no side effects", pds.serviceAuthCalls, len(git.pushes)) + } + if tt.wantErr == "" { + wantAudience := "did:web:" + tt.wantHost + if len(pds.serviceAuthAudiences) == 0 { + t.Fatal("service auth was not requested") + } + for _, audience := range pds.serviceAuthAudiences { + if audience != wantAudience { + t.Fatalf("service auth audience = %q, want %q", audience, wantAudience) + } + } + } + }) + } +} diff --git a/internal/app/service_test.go b/internal/app/service_test.go index 060a01a..7fbc4ce 100644 --- a/internal/app/service_test.go +++ b/internal/app/service_test.go @@ -42,7 +42,7 @@ func TestCreateRepoRecordsDefaultBranchOutcome(t *testing.T) { service := testService(pds, git, knotClient) result, err := service.CreateRepo(context.Background(), CreateRepoInput{ - KnotHost: "knot.example", Name: "example", PushPath: ".", RemoteName: "origin", + KnotHost: "knot.example", SSHPort: 2222, Name: "example", PushPath: ".", RemoteName: "origin", }) if err != nil { t.Fatalf("CreateRepo() error = %v", err) @@ -59,6 +59,9 @@ func TestCreateRepoRecordsDefaultBranchOutcome(t *testing.T) { if len(git.pushes) != 1 { t.Fatalf("git pushes = %+v", git.pushes) } + if git.pushes[0].KnotHost != "knot.example" || git.pushes[0].SSHPort != 2222 { + t.Fatalf("git push destination = %+v", git.pushes[0]) + } }) } } @@ -214,7 +217,7 @@ func testService(pds *testPDS, git *testGit, knotClient *testKnot) *Service { resolver: resolver, sessions: testSessions{pds: pds}, git: git, - knot: testKnotFactory{client: knotClient}, + knot: &testKnotFactory{client: knotClient}, } } @@ -259,10 +262,12 @@ func (s testSessions) APIClient(context.Context) (*atclient.APIClient, error) { } type testPDS struct { - puts []atproto.PutRecordInput - deletes []atproto.DeleteRecordInput - record *atproto.GetRecordOutput - putErr error + puts []atproto.PutRecordInput + deletes []atproto.DeleteRecordInput + record *atproto.GetRecordOutput + putErr error + serviceAuthCalls int + serviceAuthAudiences []string } func (p *testPDS) PutRecord(_ context.Context, input atproto.PutRecordInput) (string, string, error) { @@ -295,16 +300,23 @@ func (p *testPDS) ListAllRecords(context.Context, string, string, atproto.ListRe return nil, errors.New("not implemented") } -func (p *testPDS) GetServiceAuth(context.Context, string, string) (string, error) { +func (p *testPDS) GetServiceAuth(_ context.Context, audience, _ string) (string, error) { + p.serviceAuthCalls++ + p.serviceAuthAudiences = append(p.serviceAuthAudiences, audience) return "token", nil } type testGit struct { branch string + clones []gitutil.CloneRepoParams pushes []gitutil.PushNewRepoParams } -func (g *testGit) CloneRepo(context.Context, gitutil.CloneRepoParams) error { return nil } +func (g *testGit) CloneRepo(_ context.Context, input gitutil.CloneRepoParams) error { + g.clones = append(g.clones, input) + return nil +} + func (g *testGit) PushNewRepo(_ context.Context, input gitutil.PushNewRepoParams) error { g.pushes = append(g.pushes, input) return nil @@ -323,18 +335,24 @@ func (g *testGit) DetectRepoFromCWD(context.Context) (*gitutil.RepoContext, erro type testKnotFactory struct { client knotClient + hosts []string } -func (f testKnotFactory) New(string, string) knotClient { return f.client } +func (f *testKnotFactory) New(host string, _ string) knotClient { + f.hosts = append(f.hosts, host) + return f.client +} type testKnot struct { setDefaultBranchErr error deleteErr error deleteCalls int mergeCalls int + createCalls int } func (k *testKnot) CreateRepo(context.Context, knot.CreateRepoInput) (string, error) { + k.createCalls++ return "did:plc:repo", nil } func (k *testKnot) DeleteRepo(context.Context, knot.DeleteRepoInput) error { diff --git a/internal/cli/repo_create.go b/internal/cli/repo_create.go index 0dc9876..e759740 100644 --- a/internal/cli/repo_create.go +++ b/internal/cli/repo_create.go @@ -9,6 +9,7 @@ import ( func newRepoCreateCommand(service *app.Service) *cobra.Command { var description, knotHost, pushPath, remote string + var sshPort int var clone bool command := &cobra.Command{ @@ -35,7 +36,7 @@ Requires authentication (run "tg auth login" first).`, } result, err := service.CreateRepo(ctx, app.CreateRepoInput{ - KnotHost: selectedKnot, Name: args[0], Description: description, + KnotHost: selectedKnot, SSHPort: sshPort, Name: args[0], Description: description, Clone: clone, PushPath: pushPath, RemoteName: remote, }) if err != nil { @@ -46,6 +47,7 @@ Requires authentication (run "tg auth login" first).`, } command.Flags().StringVar(&description, "description", "", "Repository description") command.Flags().StringVar(&knotHost, "knot", "", "knot host to create on (default "+app.DefaultKnot+")") + command.Flags().IntVar(&sshPort, "ssh-port", 22, "SSH port for cloning from or pushing to the selected Knot") command.Flags().BoolVar(&clone, "clone", false, "Clone the new repository into the current directory") command.Flags().StringVar(&pushPath, "push", "", "Push an existing local repository at this path to the new remote (e.g. .)") command.Flags().StringVar(&remote, "remote", "origin", "Remote name to use with --push") diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index 1f4a2f1..1e42588 100644 --- a/internal/cli/root_test.go +++ b/internal/cli/root_test.go @@ -30,6 +30,26 @@ func TestNewRootCreatesIndependentCommandState(t *testing.T) { } } +func TestRepoCreateSSHPortHelp(t *testing.T) { + create, _, err := NewRoot(&app.Service{}).Find([]string{"repo", "create"}) + if err != nil { + t.Fatalf("find repo create command: %v", err) + } + flag := create.Flags().Lookup("ssh-port") + if flag == nil || flag.Usage != "SSH port for cloning from or pushing to the selected Knot" { + t.Fatalf("ssh-port flag = %+v", flag) + } + if flag.DefValue != "22" { + t.Fatalf("ssh-port default = %q, want 22", flag.DefValue) + } + if err := create.Flags().Set("ssh-port", "2200"); err != nil { + t.Fatalf("set ssh-port: %v", err) + } + if got := flag.Value.String(); got != "2200" { + t.Fatalf("ssh-port = %q, want 2200", got) + } +} + func TestNewRootCreatesIndependentStateCommands(t *testing.T) { firstRoot := NewRoot(&app.Service{}) secondRoot := NewRoot(&app.Service{}) diff --git a/internal/gitutil/clone_repo.go b/internal/gitutil/clone_repo.go index 63540c5..94d512d 100644 --- a/internal/gitutil/clone_repo.go +++ b/internal/gitutil/clone_repo.go @@ -6,14 +6,16 @@ import ( // CloneRepoParams groups the inputs to CloneRepo. type CloneRepoParams struct { - Handle string // Tangled owner handle - Repo string // repository name - RepoDir string // local directory to clone into + KnotHost string // Knot hosting the repository + SSHPort int // Knot SSH port + Handle string // Tangled owner handle + Repo string // repository name + RepoDir string // local directory to clone into } // CloneRepo clones handle/repo from Tangled into params.RepoDir. func (c *Client) CloneRepo(ctx context.Context, params CloneRepoParams) error { - url := tangledRemoteURL(params.Handle, params.Repo) + url := knotRemoteURL(params.KnotHost, params.SSHPort, params.Handle, params.Repo) return c.run(ctx, "git", "clone", url, params.RepoDir) } diff --git a/internal/gitutil/push_repo.go b/internal/gitutil/push_repo.go index 0e07fff..e28f7b5 100644 --- a/internal/gitutil/push_repo.go +++ b/internal/gitutil/push_repo.go @@ -7,6 +7,8 @@ import ( type PushNewRepoParams struct { Dir string // local repository to push from + KnotHost string // Knot hosting the repository + SSHPort int // Knot SSH port Handle string // Tangled owner handle Repo string // repository name RemoteName string // git remote to add and push to @@ -15,7 +17,7 @@ type PushNewRepoParams struct { // PushNewRepo adds a remote at Dir and pushes the current branch. // Fails if RemoteName already exists. func (c *Client) PushNewRepo(ctx context.Context, params PushNewRepoParams) error { - remoteURL := tangledRemoteURL(params.Handle, params.Repo) + remoteURL := knotRemoteURL(params.KnotHost, params.SSHPort, params.Handle, params.Repo) if err := c.runIn(params.Dir, ctx, "git", "remote", "add", params.RemoteName, remoteURL); err != nil { return fmt.Errorf("add remote %q (already exists? use --remote to pick another name): %w", params.RemoteName, err) } diff --git a/internal/gitutil/repo_context.go b/internal/gitutil/repo_context.go index 89a7173..69e4874 100644 --- a/internal/gitutil/repo_context.go +++ b/internal/gitutil/repo_context.go @@ -8,6 +8,8 @@ import ( "os/exec" "slices" "strings" + + "github.com/alyraffauf/tg/knot" ) // tangledHost is the hostname for Tangled repositories. @@ -16,11 +18,27 @@ const tangledHost = "tangled.org" // defaultRemote is the conventional name of the primary git remote. const defaultRemote = "origin" -// tangledRemoteURL builds the SSH clone/push URL for a Tangled repo. +// tangledRemoteURL builds the hosted Tangled SSH URL for a repository. func tangledRemoteURL(handle, repo string) string { return "git@" + tangledHost + ":" + handle + "/" + repo } +// knotRemoteURL builds an SSH URL for a repository on knotHost. Tangled's +// default Knot and callers without a selected Knot use the hosted proxy. +func knotRemoteURL(knotHost string, sshPort int, handle, repo string) string { + gitHost := knotHost + if gitHost == "" || gitHost == knot.DefaultKnot { + gitHost = tangledHost + } + if sshPort != 22 { + return fmt.Sprintf("ssh://git@%s:%d/%s/%s", gitHost, sshPort, handle, repo) + } + if gitHost == tangledHost { + return tangledRemoteURL(handle, repo) + } + return "git@" + gitHost + ":" + handle + "/" + repo +} + // RepoContext holds the handle and repo name parsed from a git remote URL. type RepoContext struct { Handle string diff --git a/internal/gitutil/repo_context_test.go b/internal/gitutil/repo_context_test.go index 4b68e64..b347e72 100644 --- a/internal/gitutil/repo_context_test.go +++ b/internal/gitutil/repo_context_test.go @@ -3,6 +3,8 @@ package gitutil import ( "slices" "testing" + + "github.com/alyraffauf/tg/knot" ) func TestParseTangledURL(t *testing.T) { @@ -55,6 +57,29 @@ func TestParseTangledURL(t *testing.T) { } } +func TestKnotRemoteURL(t *testing.T) { + tests := []struct { + name string + knotHost string + sshPort int + want string + }{ + {name: "hosted proxy", sshPort: 22, want: "git@tangled.org:aly.codes/tg"}, + {name: "hosted proxy and custom port", sshPort: 2222, want: "ssh://git@tangled.org:2222/aly.codes/tg"}, + {name: "default knot through hosted proxy", knotHost: knot.DefaultKnot, sshPort: 22, want: "git@tangled.org:aly.codes/tg"}, + {name: "default knot through hosted proxy and custom port", knotHost: knot.DefaultKnot, sshPort: 2222, want: "ssh://git@tangled.org:2222/aly.codes/tg"}, + {name: "custom knot and port", knotHost: "knot.secluded.site", sshPort: 2222, want: "ssh://git@knot.secluded.site:2222/aly.codes/tg"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := knotRemoteURL(tt.knotHost, tt.sshPort, "aly.codes", "tg"); got != tt.want { + t.Fatalf("knotRemoteURL() = %q, want %q", got, tt.want) + } + }) + } +} + func TestOriginFirst(t *testing.T) { tests := []struct { name string