diff --git a/git.go b/git.go index b4dc7b0..9daadca 100644 --- a/git.go +++ b/git.go @@ -2,6 +2,7 @@ package main import ( "context" + "errors" "fmt" "os" "os/exec" @@ -16,6 +17,8 @@ import ( "fiatjaf.com/nostr" "fiatjaf.com/nostr/nip19" "fiatjaf.com/nostr/nip34" + "fiatjaf.com/nostr/nip34/gitnaturalapi" + "fiatjaf.com/nostr/nip34/grasp" "github.com/AlecAivazis/survey/v2" "github.com/fatih/color" "github.com/urfave/cli/v3" @@ -459,6 +462,190 @@ aside from those, there is also: return nil }, }, + { + Name: "download", + Usage: "download a file from a NIP-34 repository", + ArgsUsage: " ", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "output", + Aliases: []string{"O"}, + Usage: "output path (use '-' for stdout)", + }, + &cli.StringFlag{ + Name: "ref", + Aliases: []string{"r"}, + Usage: "git ref/tag/branch/commit to read from", + }, + }, + Action: func(ctx context.Context, c *cli.Command) error { + args := c.Args() + if args.Len() < 2 { + return fmt.Errorf("missing repository and path") + } + + repo := args.Get(0) + path := args.Get(1) + outputPath := c.String("output") + ref := strings.TrimSpace(c.String("ref")) + + if outputPath == "" { + cleaned := strings.TrimRight(path, "/") + base := filepath.Base(cleaned) + if base == "." || base == "/" || base == "" { + return fmt.Errorf("cannot determine output filename from path '%s', use --output", path) + } + outputPath = base + } + + if outputPath != "-" { + if fi, err := os.Stat(outputPath); err == nil && fi.IsDir() { + return fmt.Errorf("output path '%s' is a directory", outputPath) + } + } + + var gitURLs []string + if strings.HasPrefix(repo, "http://") || strings.HasPrefix(repo, "https://") { + gitURLs = []string{strings.TrimRight(repo, "/")} + } else { + owner, identifier, relayHints, err := parseRepositoryAddress(ctx, repo) + if err != nil { + return fmt.Errorf("failed to parse repository address '%s': %w", repo, err) + } + + repo, _, _, state, err := fetchRepositoryAndState(ctx, owner, identifier, relayHints) + if err != nil { + var stateErr *StateErr + if ref == "" || !errors.As(err, &stateErr) { + return err + } + } + + if ref == "" && state != nil && state.HEAD != "" { + ref = state.HEAD + } + + for _, url := range repo.Clone { + if strings.HasPrefix(url, "http") { + gitURLs = append(gitURLs, url) + } + } + } + + if len(gitURLs) == 0 { + return fmt.Errorf("no HTTP git URLs found for repository") + } + + var lastErr error + for _, url := range gitURLs { + if lastErr != nil { + log("%s\n", color.HiRedString(lastErr.Error())) + } + lastErr = nil + + { + printUrl := color.BlueString(url) + if grasp.IsGraspURL(url) { + printUrl = color.HiYellowString(strings.Split(url, "/")[2]) + } + log("attempting download from %s... ", printUrl) + } + + info, err := gitnaturalapi.GetInfoRefs(url) + if err != nil { + lastErr = err + continue + } + + var commitHash string + + if ref == "" { + if symref, ok := info.Symrefs["HEAD"]; ok && symref != "" { + commitHash, _ = info.Refs[symref] + } else if head, ok := info.Refs["HEAD"]; ok && head != "" { + commitHash = head + } else { + lastErr = fmt.Errorf("could not resolve default ref for %s", url) + continue + } + } + + if gitHashRe.MatchString(ref) { + commitHash = ref + } else if strings.HasPrefix(ref, "refs/") { + if ch, ok := info.Refs[ref]; ok { + commitHash = ch + } + } else { + if ch, ok := info.Refs["refs/heads/"+ref]; ok { + commitHash = ch + } else if ch, ok := info.Refs["refs/tags/"+ref]; ok { + commitHash = ch + } else if sr, ok := info.Symrefs[ref]; ok && ch != "" { + commitHash, _ = info.Refs[sr] + } + } + + if commitHash == "" { + lastErr = fmt.Errorf("couldn't get a commit hash for ref '%s'", ref) + continue + } + + if !gitHashRe.MatchString(commitHash) { + lastErr = fmt.Errorf("couldn't invalid commit hash for ref '%s': '%s'", ref, commitHash) + continue + } + + entry, err := gitnaturalapi.GetObjectByPath(url, commitHash, path) + if err != nil { + lastErr = err + continue + } + if entry == nil { + lastErr = fmt.Errorf("path '%s' not found", path) + continue + } + if entry.IsDir { + lastErr = fmt.Errorf("path '%s' is a directory", path) + continue + } + + obj, err := gitnaturalapi.GetObject(url, entry.Hash) + if err != nil { + lastErr = fmt.Errorf("download error: %s", err) + continue + } + if obj == nil { + lastErr = fmt.Errorf("object for '%s' not found", path) + continue + } + if obj.Type != gitnaturalapi.ObjectTypeBlob { + lastErr = fmt.Errorf("object at '%s' is not a file", path) + continue + } + + if outputPath == "-" { + if _, err = os.Stdout.Write(obj.Data); err != nil { + log("\nprinted object %s to stdout\n", color.CyanString(obj.Hash)) + return err + } + } + + if err := os.WriteFile(outputPath, obj.Data, 0644); err != nil { + return fmt.Errorf("failed to write %s: %w", outputPath, err) + } + + log("\nsaved object %s to %s\n", color.CyanString(obj.Hash), color.GreenString(outputPath)) + return nil + } + + if lastErr != nil { + log("%s\n", color.HiRedString(lastErr.Error())) + } + + return fmt.Errorf("failed to download '%s' from '%s'", path, repo) + }, + }, { Name: "push", Usage: "push git changes", @@ -1769,7 +1956,10 @@ func ensureGitRepositoryMaintainer(ctx context.Context, kr nostr.Keyer, repo nip return pubkey, nil } -var patchPrefixRe = regexp.MustCompile(`(?i)^\[patch[^\]]*\]\s*`) +var ( + patchPrefixRe = regexp.MustCompile(`(?i)^\[patch[^\]]*\]\s*`) + gitHashRe = regexp.MustCompile(`^[0-9a-f]{7,64}$`) +) func patchSubjectPreview(evt nostr.RelayEvent, maxChars int) string { for _, line := range strings.Split(evt.Content, "\n") { diff --git a/go.mod b/go.mod index 15d2646..cd4b6f9 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/fiatjaf/nak go 1.25 require ( - fiatjaf.com/nostr v0.0.0-20260320232724-e675f04bd29a + fiatjaf.com/nostr v0.0.0-20260326203601-3acfbbca0aea github.com/AlecAivazis/survey/v2 v2.3.7 github.com/bep/debounce v1.2.1 github.com/btcsuite/btcd/btcec/v2 v2.3.6 diff --git a/go.sum b/go.sum index fbe1704..c0595f1 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,7 @@ fiatjaf.com/lib v0.3.6 h1:GRZNSxHI2EWdjSKVuzaT+c0aifLDtS16SzkeJaHyJfY= fiatjaf.com/lib v0.3.6/go.mod h1:UlHaZvPHj25PtKLh9GjZkUHRmQ2xZ8Jkoa4VRaLeeQ8= -fiatjaf.com/nostr v0.0.0-20260320232724-e675f04bd29a h1:lor1LcOjMUNZi5hafyXMmTz5J2kTrvS5I0hZMy3jOuU= -fiatjaf.com/nostr v0.0.0-20260320232724-e675f04bd29a/go.mod h1:iRKV8eYKzePA30MdbaYBpAv8pYQ6to8rDr3W+R2hJzM= +fiatjaf.com/nostr v0.0.0-20260326203601-3acfbbca0aea h1:NvAnNbYjz7oSsmhMi1BTV02F/RIb//39W1hUTvpUbEU= +fiatjaf.com/nostr v0.0.0-20260326203601-3acfbbca0aea/go.mod h1:ue7yw0zHfZj23Ml2kVSdBx0ENEaZiuvGxs/8VEN93FU= github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ= github.com/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1reCfTwbto0Fduo= github.com/FastFilter/xorfilter v0.2.1 h1:lbdeLG9BdpquK64ZsleBS8B4xO/QW1IM0gMzF7KaBKc=