diff --git a/.gitignore b/.gitignore index 0d55313..cfb181e 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ -tg +/tg +result/ diff --git a/README.md b/README.md index 12d0541..77b0da7 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,15 @@ go install github.com/alyraffauf/tg/cmd/tg@latest # List repositories for a user tg repo list microcosm.blue +# Create a repository (requires `tg auth login`) +tg repo create my-tool --description "A small tool" + +# Create and clone it into the current directory +tg repo create my-tool --clone + +# Create and push an existing local repo (at the given path) to the new remote +tg repo create my-tool --push=. + # Clone a repository tg repo clone microcosm.blue/microcosm-rs diff --git a/atproto/records.go b/atproto/records.go new file mode 100644 index 0000000..a9425a0 --- /dev/null +++ b/atproto/records.go @@ -0,0 +1,33 @@ +package atproto + +import ( + "context" + "fmt" + + "github.com/bluesky-social/indigo/atproto/atclient" + "github.com/bluesky-social/indigo/atproto/syntax" +) + +// PutRecordInput is the argument to a com.atproto.repo.putRecord call. +type PutRecordInput struct { + Repo string `json:"repo"` + Collection string `json:"collection"` + Rkey string `json:"rkey"` + Record any `json:"record"` +} + +// PutRecord writes a record to the PDS, returning its at:// URI and CID. The +// record must include its $type field. +func PutRecord(ctx context.Context, pds *atclient.APIClient, in PutRecordInput) (uri, cid string, err error) { + if pds == nil { + return "", "", fmt.Errorf("PDS client is required") + } + var out struct { + URI string `json:"uri"` + CID string `json:"cid,omitempty"` + } + if err := pds.Post(ctx, syntax.NSID("com.atproto.repo.putRecord"), in, &out); err != nil { + return "", "", fmt.Errorf("put %s/%s record: %w", in.Collection, in.Rkey, err) + } + return out.URI, out.CID, nil +} diff --git a/atproto/serviceauth.go b/atproto/serviceauth.go new file mode 100644 index 0000000..3a5d0cf --- /dev/null +++ b/atproto/serviceauth.go @@ -0,0 +1,36 @@ +package atproto + +import ( + "context" + "fmt" + "time" + + "github.com/bluesky-social/indigo/atproto/atclient" + "github.com/bluesky-social/indigo/atproto/syntax" +) + +const serviceAuthTTL = 60 * time.Second + +// GetServiceAuth mints a short-lived service-auth JWT scoped to one lexicon +// method on one audience (e.g. a knot's did:web). Present it to that audience +// as a Bearer token. +func GetServiceAuth(ctx context.Context, pds *atclient.APIClient, audience, lexiconMethod string) (string, error) { + if pds == nil { + return "", fmt.Errorf("PDS client is required") + } + var out struct { + Token string `json:"token"` + } + params := map[string]any{ + "aud": audience, + "exp": time.Now().Add(serviceAuthTTL).Unix(), + "lxm": lexiconMethod, + } + if err := pds.Get(ctx, syntax.NSID("com.atproto.server.getServiceAuth"), params, &out); err != nil { + return "", fmt.Errorf("get service auth for %q: %w", audience, err) + } + if out.Token == "" { + return "", fmt.Errorf("PDS returned an empty service auth token for %q", audience) + } + return out.Token, nil +} diff --git a/cmd/tg/main.go b/cmd/tg/main.go index e6233f9..584f132 100644 --- a/cmd/tg/main.go +++ b/cmd/tg/main.go @@ -1,12 +1,17 @@ package main import ( + "log/slog" "os" "github.com/alyraffauf/tg/internal/cli" ) func main() { + // Indigo logs retried DPoP-nonce challenges at WARN; suppress to keep CLI + // output clean. + slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))) + if err := cli.Execute(); err != nil { os.Exit(1) } diff --git a/internal/cli/repo_create.go b/internal/cli/repo_create.go new file mode 100644 index 0000000..76745b3 --- /dev/null +++ b/internal/cli/repo_create.go @@ -0,0 +1,211 @@ +package cli + +import ( + "context" + "fmt" + "os" + "time" + + "github.com/alyraffauf/tg/atproto" + "github.com/alyraffauf/tg/internal/gitutil" + "github.com/alyraffauf/tg/knot" + "github.com/alyraffauf/tg/tangled" + "github.com/bluesky-social/indigo/atproto/atclient" + "github.com/spf13/cobra" +) + +var ( + repoCreateDescription string + repoCreateKnot string + repoCreateClone bool + repoCreatePushPath string + repoCreateRemote string +) + +var repoCreateCmd = &cobra.Command{ + Use: "create ", + Short: "Create a repository on Tangled", + Long: `Create a repository on Tangled. + +The repository is provisioned on a knot (default ` + knot.DefaultKnot + `) and a +sh.tangled.repo record is written to your PDS. The repository name is used as +the record key, matching the current Tangled schema. + +Use --clone to clone the new repository into the current directory, or +--push= to push an existing local repository at that path to the new +remote (and set its current branch as the default branch). + +Requires authentication (run "tg auth login" first).`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + + if auth == nil || !auth.IsAuthenticated() { + return fmt.Errorf("not logged in; run \"tg auth login\" first") + } + + pds, err := auth.APIClient(ctx) + if err != nil { + return fmt.Errorf("get auth client: %w", err) + } + did := pds.AccountDID.String() + + knotHost := repoCreateKnot + if knotHost == "" { + knotHost = knot.DefaultKnot + } + + uri, err := provisionRepo(ctx, pds, provisionRepoInput{ + KnotHost: knotHost, + OwnerDID: did, + Name: args[0], + Description: repoCreateDescription, + }) + if err != nil { + return err + } + + handle := ownerHandle(ctx, did) + fmt.Printf("Created repository %s/%s\n", handle, args[0]) + + if repoCreateClone { + if err := gitutil.CloneRepo(ctx, handle, args[0], args[0]); err != nil { + return fmt.Errorf("clone new repository: %w", err) + } + } + if repoCreatePushPath != "" { + if err := pushToNewRepo(ctx, pds, pushToNewRepoInput{ + KnotHost: knotHost, + RepoURI: uri, + Handle: handle, + RepoName: args[0], + PushPath: repoCreatePushPath, + RemoteName: repoCreateRemote, + }); err != nil { + return err + } + } + return nil + }, +} + +func init() { + repoCreateCmd.Flags().StringVar(&repoCreateDescription, "description", "", "Repository description") + repoCreateCmd.Flags().StringVar(&repoCreateKnot, "knot", "", "knot host to create on (default "+knot.DefaultKnot+")") + repoCreateCmd.Flags().BoolVar(&repoCreateClone, "clone", false, "Clone the new repository into the current directory") + repoCreateCmd.Flags().StringVar(&repoCreatePushPath, "push", "", "Push an existing local repository at this path to the new remote (e.g. .)") + repoCreateCmd.Flags().StringVar(&repoCreateRemote, "remote", "origin", "Remote name to use with --push") +} + +type provisionRepoInput struct { + KnotHost string + OwnerDID string + Name string + Description string +} + +// provisionRepo creates the repo on the knot and writes the sh.tangled.repo +// record to the PDS. +func provisionRepo(ctx context.Context, pds *atclient.APIClient, in provisionRepoInput) (string, error) { + token, err := atproto.GetServiceAuth(ctx, pds, "did:web:"+in.KnotHost, "sh.tangled.repo.create") + if err != nil { + return "", err + } + repoDid, err := knot.New(in.KnotHost, token).CreateRepo(ctx, knot.CreateRepoInput{ + Name: in.Name, + Rkey: in.Name, + }) + if err != nil { + return "", err + } + // Name omitted: it's the rkey, and the AppView derives it from there. + record := tangled.RepoRecord{ + Type: "sh.tangled.repo", + Knot: in.KnotHost, + CreatedAt: time.Now().UTC().Format(time.RFC3339), + RepoDid: repoDid, + } + if in.Description != "" { + record.Description = in.Description + } + uri, _, err := atproto.PutRecord(ctx, pds, atproto.PutRecordInput{ + Repo: in.OwnerDID, + Collection: "sh.tangled.repo", + Rkey: in.Name, + Record: record, + }) + if err != nil { + return "", err + } + return uri, nil +} + +type pushToNewRepoInput struct { + KnotHost string + RepoURI string + Handle string + RepoName string + PushPath string + RemoteName string +} + +// pushToNewRepo sets the default branch to the local repo's current branch, +// then pushes. Default-branch failure is warned, not fatal. Set before push so +// the knot's post-receive hook sees pushed == default and skips its PR +// suggestion. +func pushToNewRepo(ctx context.Context, pds *atclient.APIClient, in pushToNewRepoInput) error { + branch, err := setDefaultBranch(ctx, pds, setDefaultBranchInput{ + KnotHost: in.KnotHost, + RepoURI: in.RepoURI, + Dir: in.PushPath, + }) + if err != nil { + fmt.Fprintf(os.Stderr, "warning: could not set default branch: %v\n", err) + } else { + fmt.Printf("Set default branch to %s\n", branch) + } + if err := gitutil.PushNewRepo(ctx, gitutil.PushNewRepoParams{ + Dir: in.PushPath, + Handle: in.Handle, + Repo: in.RepoName, + RemoteName: in.RemoteName, + }); err != nil { + return fmt.Errorf("push to new repository: %w", err) + } + return nil +} + +type setDefaultBranchInput struct { + KnotHost string + RepoURI string + Dir string +} + +// setDefaultBranch repoints the default branch to the local repo's current +// branch. Mints a fresh token — the create token is lexicon-scoped and won't +// authorize setDefaultBranch. +func setDefaultBranch(ctx context.Context, pds *atclient.APIClient, in setDefaultBranchInput) (string, error) { + branch, err := gitutil.CurrentBranch(ctx, in.Dir) + if err != nil { + return "", err + } + token, err := atproto.GetServiceAuth(ctx, pds, "did:web:"+in.KnotHost, "sh.tangled.repo.setDefaultBranch") + if err != nil { + return "", err + } + if err := knot.New(in.KnotHost, token).SetDefaultBranch(ctx, knot.SetDefaultBranchInput{ + Repo: in.RepoURI, + DefaultBranch: branch, + }); err != nil { + return branch, err + } + return branch, nil +} + +// ownerHandle resolves an owner DID to a handle, falling back to the DID. +func ownerHandle(ctx context.Context, did string) string { + if ident, err := resolver.ResolveDID(ctx, did); err == nil { + return ident.Handle.String() + } + return did +} diff --git a/internal/cli/root.go b/internal/cli/root.go index e97bd8b..282ad67 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -51,8 +51,9 @@ func init() { prCmd.AddCommand(prCheckoutCmd) rootCmd.AddCommand(repoCmd) - repoCmd.AddCommand(repoListCmd) repoCmd.AddCommand(repoCloneCmd) + repoCmd.AddCommand(repoCreateCmd) + repoCmd.AddCommand(repoListCmd) } func initAuth() { diff --git a/internal/gitutil/branch.go b/internal/gitutil/branch.go new file mode 100644 index 0000000..3e85818 --- /dev/null +++ b/internal/gitutil/branch.go @@ -0,0 +1,24 @@ +package gitutil + +import ( + "context" + "fmt" + "os/exec" + "strings" +) + +// CurrentBranch returns the checked-out branch name at dir; errors if HEAD is +// detached. +func CurrentBranch(ctx context.Context, dir string) (string, error) { + cmd := exec.CommandContext(ctx, "git", "rev-parse", "--abbrev-ref", "HEAD") + cmd.Dir = dir + out, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("get current branch in %q: %w", dir, err) + } + branch := strings.TrimSpace(string(out)) + if branch == "" || branch == "HEAD" { + return "", fmt.Errorf("no current branch (detached HEAD) in %q", dir) + } + return branch, nil +} diff --git a/internal/gitutil/push_repo.go b/internal/gitutil/push_repo.go new file mode 100644 index 0000000..3a11d52 --- /dev/null +++ b/internal/gitutil/push_repo.go @@ -0,0 +1,26 @@ +package gitutil + +import ( + "context" + "fmt" +) + +type PushNewRepoParams struct { + Dir string // local repository to push from + Handle string // Tangled owner handle + Repo string // repository name + RemoteName string // git remote to add and push to +} + +// PushNewRepo adds a remote at Dir and pushes the current branch. +// Fails if RemoteName already exists. +func PushNewRepo(ctx context.Context, params PushNewRepoParams) error { + remoteURL := fmt.Sprintf("git@tangled.org:%s/%s", params.Handle, params.Repo) + if err := 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) + } + if err := runIn(params.Dir, ctx, "git", "push", "-u", params.RemoteName, "HEAD"); err != nil { + return fmt.Errorf("push to %q: %w", params.RemoteName, err) + } + return nil +} diff --git a/knot/client.go b/knot/client.go new file mode 100644 index 0000000..ba700ca --- /dev/null +++ b/knot/client.go @@ -0,0 +1,35 @@ +package knot + +import ( + "net/http" + + "github.com/bluesky-social/indigo/atproto/atclient" + "github.com/bluesky-social/indigo/atproto/syntax" +) + +// DefaultKnot is the public Tangled knot used when none is specified. +const DefaultKnot = "knot1.tangled.sh" + +// Client calls Tangled knot procedures, authenticated with a PDS-minted +// service-auth JWT (Bearer). +type Client struct { + *atclient.APIClient +} + +// New returns a Client for host, authenticated with a service-auth token. +func New(host, token string) *Client { + return &Client{ + APIClient: &atclient.APIClient{ + Host: "https://" + host, + Auth: bearerAuth(token), + }, + } +} + +// bearerAuth is a Bearer-token AuthMethod for service-auth JWTs. +type bearerAuth string + +func (b bearerAuth) DoWithAuth(c *http.Client, req *http.Request, _ syntax.NSID) (*http.Response, error) { + req.Header.Set("Authorization", "Bearer "+string(b)) + return c.Do(req) +} diff --git a/knot/create_repo.go b/knot/create_repo.go new file mode 100644 index 0000000..5773ce1 --- /dev/null +++ b/knot/create_repo.go @@ -0,0 +1,30 @@ +package knot + +import ( + "context" + "fmt" + + "github.com/bluesky-social/indigo/atproto/syntax" +) + +// CreateRepoInput is the argument to sh.tangled.repo.create. +type CreateRepoInput struct { + Name string `json:"name"` + Rkey string `json:"rkey"` + DefaultBranch string `json:"defaultBranch,omitempty"` +} + +// CreateRepo creates the repo via sh.tangled.repo.create, returning the minted +// repoDid. Use the repo name as the rkey (current schema). +func (c *Client) CreateRepo(ctx context.Context, input CreateRepoInput) (string, error) { + var out struct { + RepoDid *string `json:"repoDid,omitempty"` + } + if err := c.Post(ctx, syntax.NSID("sh.tangled.repo.create"), input, &out); err != nil { + return "", fmt.Errorf("create repo on knot: %w", err) + } + if out.RepoDid == nil || *out.RepoDid == "" { + return "", fmt.Errorf("knot did not return a repoDid") + } + return *out.RepoDid, nil +} diff --git a/knot/set_default_branch.go b/knot/set_default_branch.go new file mode 100644 index 0000000..b6b996d --- /dev/null +++ b/knot/set_default_branch.go @@ -0,0 +1,22 @@ +package knot + +import ( + "context" + "fmt" + + "github.com/bluesky-social/indigo/atproto/syntax" +) + +// SetDefaultBranchInput is the argument to sh.tangled.repo.setDefaultBranch. +type SetDefaultBranchInput struct { + Repo string `json:"repo"` // at:// URI of the sh.tangled.repo record + DefaultBranch string `json:"defaultBranch"` +} + +// SetDefaultBranch repoints the default branch (bare repo HEAD) on the knot. +func (c *Client) SetDefaultBranch(ctx context.Context, input SetDefaultBranchInput) error { + if err := c.Post(ctx, syntax.NSID("sh.tangled.repo.setDefaultBranch"), input, nil); err != nil { + return fmt.Errorf("set default branch to %q: %w", input.DefaultBranch, err) + } + return nil +}