diff --git a/README.md b/README.md index f9ea0be..f635ed2 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,27 @@ tg pr close tg pr merge ``` +### Strings + +```bash +# Create a string from a file +tg string create hello.md --description "A greeting" + +# Create from standard input (requires --filename) +printf '# hello\n' | tg string create --filename hello.md + +# List your strings, or another user's +tg string list +tg string list microcosm.blue + +# View one of your strings, or another user's +tg string view +tg string view microcosm.blue + +# Delete one of your strings +tg string delete +``` + ### Other commands `tg repo edit`, `tg repo set-default-branch`, `tg repo delete --yes`, `tg repo fork`, diff --git a/internal/cli/atproto_auth.go b/internal/cli/atproto_auth.go index 5a148a8..c9c0cc4 100644 --- a/internal/cli/atproto_auth.go +++ b/internal/cli/atproto_auth.go @@ -6,6 +6,7 @@ import ( "fmt" "github.com/alyraffauf/tg/atproto" + "github.com/bluesky-social/indigo/atproto/atclient" ) func authenticatedATProto(ctx context.Context) (*atproto.ATProto, string, error) { @@ -18,3 +19,20 @@ func authenticatedATProto(ctx context.Context) (*atproto.ATProto, string, error) } return &atproto.ATProto{Client: client}, did.String(), nil } + +// publicAccountReader resolves handle to an unauthenticated client on its +// PDS for read-only queries of account-owned records (strings, public +// keys), returning the client and the owner's DID. +func publicAccountReader(ctx context.Context, handle string) (*atproto.ATProto, string, error) { + ident, err := resolver.ResolveHandle(ctx, handle) + if err != nil { + return nil, "", fmt.Errorf("resolve handle %q: %w", handle, err) + } + + pdsURL, err := resolver.ResolvePDS(ctx, ident.DID.String()) + if err != nil { + return nil, "", fmt.Errorf("resolve PDS for %q: %w", handle, err) + } + + return &atproto.ATProto{Client: &atclient.APIClient{Host: pdsURL}}, ident.DID.String(), nil +} diff --git a/internal/cli/output.go b/internal/cli/output.go index aceb0ef..e06eea1 100644 --- a/internal/cli/output.go +++ b/internal/cli/output.go @@ -54,6 +54,24 @@ type sshKeyItem struct { URI string `json:"uri"` } +type stringItem struct { + Rkey string `json:"rkey"` + URI string `json:"uri"` + Filename string `json:"filename"` + Description string `json:"description,omitempty"` + CreatedAt string `json:"createdAt"` +} + +type stringViewResult struct { + Rkey string `json:"rkey"` + URI string `json:"uri"` + Filename string `json:"filename"` + Author author `json:"author"` + Description string `json:"description,omitempty"` + Contents string `json:"contents"` + CreatedAt string `json:"createdAt"` +} + // viewResult is a single issue or pull request. SourceBranch and // TargetBranch are only populated (and only emitted as JSON) for pulls. type viewResult struct { diff --git a/internal/cli/root.go b/internal/cli/root.go index 7831d03..9d1525e 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -91,6 +91,12 @@ func init() { sshKeyCmd.AddCommand(sshKeyListCmd) sshKeyCmd.AddCommand(sshKeyDeleteCmd) + rootCmd.AddCommand(stringCmd) + stringCmd.AddCommand(stringCreateCmd) + stringCmd.AddCommand(stringListCmd) + stringCmd.AddCommand(stringViewCmd) + stringCmd.AddCommand(stringDeleteCmd) + rootCmd.AddCommand(browseCmd) rootCmd.AddCommand(completionCmd) rootCmd.AddCommand(manCmd) diff --git a/internal/cli/ssh_key_list.go b/internal/cli/ssh_key_list.go index 7cbb777..5d32f68 100644 --- a/internal/cli/ssh_key_list.go +++ b/internal/cli/ssh_key_list.go @@ -5,7 +5,6 @@ import ( "fmt" "github.com/alyraffauf/tg/atproto" - "github.com/bluesky-social/indigo/atproto/atclient" "github.com/spf13/cobra" ) @@ -25,18 +24,12 @@ If no argument is given, lists the authenticated user's keys return err } - ident, err := resolver.ResolveHandle(ctx, handle) + atClient, did, err := publicAccountReader(ctx, handle) if err != nil { - return fmt.Errorf("resolve handle %q: %w", handle, err) - } - - pdsURL, err := resolver.ResolvePDS(ctx, ident.DID.String()) - if err != nil { - return fmt.Errorf("resolve PDS for %q: %w", handle, err) + return err } - atClient := &atproto.ATProto{Client: &atclient.APIClient{Host: pdsURL}} - records, err := atClient.ListAllRecords(ctx, ident.DID.String(), "sh.tangled.publicKey", atproto.ListRecordsOpts{Limit: defaultListLimit}) + records, err := atClient.ListAllRecords(ctx, did, "sh.tangled.publicKey", atproto.ListRecordsOpts{Limit: defaultListLimit}) if err != nil { return fmt.Errorf("list SSH keys for %q: %w", handle, err) } diff --git a/internal/cli/string.go b/internal/cli/string.go new file mode 100644 index 0000000..627dea3 --- /dev/null +++ b/internal/cli/string.go @@ -0,0 +1,23 @@ +package cli + +import ( + "github.com/spf13/cobra" +) + +// stringCollection is the NSID of tangled string records. +const stringCollection = "sh.tangled.string" + +// stringRecord is the value of a sh.tangled.string record. Description may +// be empty. +type stringRecord struct { + Type string `json:"$type"` + Filename string `json:"filename"` + Description string `json:"description"` + Contents string `json:"contents"` + CreatedAt string `json:"createdAt"` +} + +var stringCmd = &cobra.Command{ + Use: "string", + Short: "Manage strings on Tangled", +} diff --git a/internal/cli/string_create.go b/internal/cli/string_create.go new file mode 100644 index 0000000..084a259 --- /dev/null +++ b/internal/cli/string_create.go @@ -0,0 +1,113 @@ +package cli + +import ( + "fmt" + "io" + "os" + "path/filepath" + "time" + "unicode/utf8" + + "github.com/alyraffauf/tg/atproto" + "github.com/bluesky-social/indigo/atproto/syntax" + "github.com/spf13/cobra" +) + +const bytesPerMiB = 1 << 20 + +// maxStringContents caps string contents at 100 MiB, a client-side sanity +// limit for a text record. +const maxStringContents = 100 * bytesPerMiB + +var ( + stringCreateDescription string + stringCreateFilename string +) + +var stringCreateCmd = &cobra.Command{ + Use: "create []", + Short: "Create a string on your Tangled account", + Long: `Create a string on your Tangled account. + +Contents are read from the given file, or from standard input if no file +is given (or the file is "-"). When reading from standard input, +--filename is required. Contents must be valid UTF-8, at most 100 MiB. +Requires authentication (run "tg auth login" first).`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + + contents, filename, err := stringContents(os.Stdin, args, stringCreateFilename) + if err != nil { + return err + } + + atClient, did, err := authenticatedATProto(ctx) + if err != nil { + return err + } + + rkey := string(syntax.NewTIDNow(0)) + uri, _, err := atClient.PutRecord(ctx, atproto.PutRecordInput{ + Repo: did, + Collection: stringCollection, + Rkey: rkey, + Record: stringRecord{ + Type: stringCollection, + Filename: filename, + Description: stringCreateDescription, + Contents: contents, + CreatedAt: time.Now().UTC().Format(time.RFC3339), + }, + }) + if err != nil { + return fmt.Errorf("create string: %w", err) + } + + return output(createdRecordResult{Rkey: rkey, URI: uri}, func(result createdRecordResult) { + fmt.Printf("Created string %s\n", result.URI) + }) + }, +} + +// stringContents reads string contents from the file named in args (or stdin +// when absent or "-") and resolves the filename: the flag wins, then the +// file's basename. Contents must be non-empty, valid UTF-8, and within +// maxStringContents. +func stringContents(stdin io.Reader, args []string, filenameFlag string) (contents, filename string, err error) { + if len(args) == 0 || args[0] == "-" { + if filenameFlag == "" { + return "", "", fmt.Errorf("--filename is required when reading from standard input") + } + data, err := io.ReadAll(stdin) + if err != nil { + return "", "", fmt.Errorf("read standard input: %w", err) + } + contents, filename = string(data), filenameFlag + } else { + data, err := os.ReadFile(args[0]) + if err != nil { + return "", "", fmt.Errorf("read file %q: %w", args[0], err) + } + contents, filename = string(data), filenameFlag + if filename == "" { + filename = filepath.Base(args[0]) + } + } + + if contents == "" { + return "", "", fmt.Errorf("contents must not be empty") + } + if len(contents) > maxStringContents { + return "", "", fmt.Errorf("contents exceed the %d MiB limit", maxStringContents/bytesPerMiB) + } + if !utf8.ValidString(contents) { + return "", "", fmt.Errorf("contents must be valid UTF-8") + } + return contents, filename, nil +} + +func init() { + stringCreateCmd.Flags().StringVarP(&stringCreateDescription, "description", "d", "", "Description of the string") + stringCreateCmd.Flags().StringVarP(&stringCreateFilename, "filename", "f", "", "Filename for the string (defaults to the file's basename)") +} diff --git a/internal/cli/string_create_test.go b/internal/cli/string_create_test.go new file mode 100644 index 0000000..554d228 --- /dev/null +++ b/internal/cli/string_create_test.go @@ -0,0 +1,74 @@ +package cli + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestStringContents(t *testing.T) { + dir := t.TempDir() + file := filepath.Join(dir, "hello.md") + if err := os.WriteFile(file, []byte("# hello"), 0o600); err != nil { + t.Fatal(err) + } + empty := filepath.Join(dir, "empty.txt") + if err := os.WriteFile(empty, nil, 0o600); err != nil { + t.Fatal(err) + } + binary := filepath.Join(dir, "binary.bin") + if err := os.WriteFile(binary, []byte{0xff, 0xfe, 0xfd}, 0o600); err != nil { + t.Fatal(err) + } + oversize := filepath.Join(dir, "big.txt") + f, err := os.Create(oversize) + if err != nil { + t.Fatal(err) + } + if err := f.Truncate(maxStringContents + 1); err != nil { + f.Close() + t.Fatal(err) + } + if err := f.Close(); err != nil { + t.Fatal(err) + } + + tests := []struct { + name string + stdin string + args []string + filenameFlag string + wantContents string + wantFilename string + wantErr bool + }{ + {name: "file with basename", args: []string{file}, wantContents: "# hello", wantFilename: "hello.md"}, + {name: "file with flag override", args: []string{file}, filenameFlag: "custom.md", wantContents: "# hello", wantFilename: "custom.md"}, + {name: "missing file", args: []string{filepath.Join(dir, "nope")}, wantErr: true}, + {name: "empty file", args: []string{empty}, wantErr: true}, + {name: "non-UTF-8 file", args: []string{binary}, wantErr: true}, + {name: "oversize file", args: []string{oversize}, wantErr: true}, + {name: "stdin without filename", stdin: "# hello", wantErr: true}, + {name: "stdin with filename", stdin: "# from stdin", args: []string{"-"}, filenameFlag: "stdin.md", wantContents: "# from stdin", wantFilename: "stdin.md"}, + {name: "stdin with filename but empty", args: []string{"-"}, filenameFlag: "stdin.md", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + contents, filename, err := stringContents(strings.NewReader(tt.stdin), tt.args, tt.filenameFlag) + if (err != nil) != tt.wantErr { + t.Fatalf("stringContents() err = %v, wantErr %v", err, tt.wantErr) + } + if err != nil { + return + } + if contents != tt.wantContents { + t.Errorf("contents = %q, want %q", contents, tt.wantContents) + } + if filename != tt.wantFilename { + t.Errorf("filename = %q, want %q", filename, tt.wantFilename) + } + }) + } +} diff --git a/internal/cli/string_delete.go b/internal/cli/string_delete.go new file mode 100644 index 0000000..d91236d --- /dev/null +++ b/internal/cli/string_delete.go @@ -0,0 +1,31 @@ +package cli + +import ( + "fmt" + + "github.com/alyraffauf/tg/atproto" + "github.com/spf13/cobra" +) + +var stringDeleteCmd = &cobra.Command{ + Use: "delete ", + Short: "Delete a string from your Tangled account", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + atClient, did, err := authenticatedATProto(ctx) + if err != nil { + return err + } + if err := atClient.DeleteRecord(ctx, atproto.DeleteRecordInput{ + Repo: did, + Collection: stringCollection, + Rkey: args[0], + }); err != nil { + return fmt.Errorf("delete string: %w", err) + } + return output(deletedRecordResult{Rkey: args[0]}, func(result deletedRecordResult) { + fmt.Printf("Deleted string %s\n", result.Rkey) + }) + }, +} diff --git a/internal/cli/string_delete_test.go b/internal/cli/string_delete_test.go new file mode 100644 index 0000000..b596c15 --- /dev/null +++ b/internal/cli/string_delete_test.go @@ -0,0 +1,24 @@ +package cli + +import ( + "testing" +) + +func TestStringDeleteCmd(t *testing.T) { + if stringDeleteCmd == nil { + t.Fatal("stringDeleteCmd is nil") + } + if stringDeleteCmd.Use != "delete " { + t.Errorf("Use = %q, want %q", stringDeleteCmd.Use, "delete ") + } + // cobra.ExactArgs(1): zero args must error, one arg must succeed. + if err := stringDeleteCmd.Args(nil, []string{}); err == nil { + t.Error("expected error for zero args, got nil") + } + if err := stringDeleteCmd.Args(nil, []string{"3k2abc"}); err != nil { + t.Errorf("expected no error for one arg, got %v", err) + } + if err := stringDeleteCmd.Args(nil, []string{"a", "b"}); err == nil { + t.Error("expected error for two args, got nil") + } +} diff --git a/internal/cli/string_list.go b/internal/cli/string_list.go new file mode 100644 index 0000000..58de425 --- /dev/null +++ b/internal/cli/string_list.go @@ -0,0 +1,75 @@ +package cli + +import ( + "encoding/json" + "fmt" + + "github.com/alyraffauf/tg/atproto" + "github.com/spf13/cobra" +) + +var stringListCmd = &cobra.Command{ + Use: "list [handle]", + Short: "List strings on a Tangled account", + Long: `List strings on a Tangled account. + +If no argument is given, lists the authenticated user's strings +(run "tg auth login" first).`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + + handle, err := resolveHandleOrSelf(ctx, args) + if err != nil { + return err + } + + atClient, did, err := publicAccountReader(ctx, handle) + if err != nil { + return err + } + + records, err := atClient.ListAllRecords(ctx, did, stringCollection, atproto.ListRecordsOpts{Limit: defaultListLimit}) + if err != nil { + return fmt.Errorf("list strings for %q: %w", handle, err) + } + + items := buildStringItems(records) + return output(items, renderStringList) + }, +} + +func buildStringItems(records []atproto.RecordItem) []stringItem { + items := make([]stringItem, 0, len(records)) + for _, rec := range records { + var str stringRecord + data, err := json.Marshal(rec.Value) + if err != nil { + continue + } + if err := json.Unmarshal(data, &str); err != nil { + continue + } + // Records without a filename are not strings; skip them rather + // than rendering a blank row. + if str.Filename == "" { + continue + } + items = append(items, stringItem{ + Rkey: extractRKey(rec.URI), + URI: rec.URI, + Filename: str.Filename, + Description: str.Description, + CreatedAt: str.CreatedAt, + }) + } + return items +} + +func renderStringList(items []stringItem) { + rows := make([][]string, 0, len(items)) + for _, str := range items { + rows = append(rows, []string{str.Rkey, str.Filename, str.Description, shortDate(str.CreatedAt)}) + } + renderTable([]string{"RKEY", "FILENAME", "DESCRIPTION", "CREATED"}, rows, "No strings found.") +} diff --git a/internal/cli/string_list_test.go b/internal/cli/string_list_test.go new file mode 100644 index 0000000..eb4f093 --- /dev/null +++ b/internal/cli/string_list_test.go @@ -0,0 +1,47 @@ +package cli + +import ( + "testing" + + "github.com/alyraffauf/tg/atproto" +) + +func TestBuildStringItems(t *testing.T) { + records := []atproto.RecordItem{ + { + URI: "at://did:plc:abc/sh.tangled.string/3k2abc", + Value: map[string]any{ + "$type": "sh.tangled.string", + "filename": "test.d", + "description": "my test string", + "contents": "# hello", + "createdAt": "2026-07-18T23:15:54+03:00", + }, + }, + { + URI: "at://did:plc:abc/sh.tangled.string/3k2def", + Value: map[string]any{"not": "a string record"}, + }, + } + + items := buildStringItems(records) + + // Records without a filename are not strings and are skipped. + if len(items) != 1 { + t.Fatalf("buildStringItems() returned %d items, want 1", len(items)) + } + + first := items[0] + if first.Rkey != "3k2abc" { + t.Errorf("Rkey = %q, want %q", first.Rkey, "3k2abc") + } + if first.Filename != "test.d" { + t.Errorf("Filename = %q, want %q", first.Filename, "test.d") + } + if first.Description != "my test string" { + t.Errorf("Description = %q, want %q", first.Description, "my test string") + } + if first.CreatedAt != "2026-07-18T23:15:54+03:00" { + t.Errorf("CreatedAt = %q, want %q", first.CreatedAt, "2026-07-18T23:15:54+03:00") + } +} diff --git a/internal/cli/string_view.go b/internal/cli/string_view.go new file mode 100644 index 0000000..6b242dd --- /dev/null +++ b/internal/cli/string_view.go @@ -0,0 +1,74 @@ +package cli + +import ( + "encoding/json" + "fmt" + + "github.com/spf13/cobra" +) + +var stringViewCmd = &cobra.Command{ + Use: "view [handle]", + Short: "View a string on a Tangled account", + Long: `View a string by its rkey (the last segment of its at:// URI). + +If no handle is given, views the authenticated user's string +(run "tg auth login" first).`, + Args: cobra.RangeArgs(1, 2), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + rkey := args[0] + + handle, err := resolveHandleOrSelf(ctx, args[1:]) + if err != nil { + return err + } + + atClient, did, err := publicAccountReader(ctx, handle) + if err != nil { + return err + } + + found, err := atClient.GetRecord(ctx, did, stringCollection, rkey) + if err != nil { + return fmt.Errorf("get string %q for %q: %w", rkey, handle, err) + } + + record, err := decodeStringRecord(found.Value) + if err != nil { + return fmt.Errorf("decode string %q: %w", rkey, err) + } + + result := stringViewResult{ + Rkey: rkey, + URI: found.URI, + Filename: record.Filename, + Author: author{DID: did, Handle: handle}, + Description: record.Description, + Contents: record.Contents, + CreatedAt: record.CreatedAt, + } + return output(result, func(view stringViewResult) { + fmt.Printf("Filename: %s\n", view.Filename) + fmt.Printf("Author: %s\n", view.Author.Handle) + fmt.Printf("Created: %s\n", view.CreatedAt) + if view.Description != "" { + fmt.Printf("Description: %s\n", view.Description) + } + fmt.Printf("\n%s\n", view.Contents) + }) + }, +} + +// decodeStringRecord decodes a record value into a stringRecord. +func decodeStringRecord(value any) (stringRecord, error) { +data, err := json.Marshal(value) +if err != nil { + return stringRecord{}, fmt.Errorf("encode record: %w", err) +} +var record stringRecord +if err := json.Unmarshal(data, &record); err != nil { + return stringRecord{}, fmt.Errorf("decode record: %w", err) +} +return record, nil +} diff --git a/internal/cli/string_view_test.go b/internal/cli/string_view_test.go new file mode 100644 index 0000000..1abfff8 --- /dev/null +++ b/internal/cli/string_view_test.go @@ -0,0 +1,91 @@ +package cli + +import ( + "testing" +) + +func TestDecodeStringRecord(t *testing.T) { + tests := []struct { + name string + value any + want stringRecord + wantErr bool + }{ + { + name: "valid record", + value: map[string]any{ + "$type": "sh.tangled.string", + "filename": "hello.md", + "description": "a greeting", + "contents": "# hello", + "createdAt": "2026-07-18T23:15:54+03:00", + }, + want: stringRecord{ + Type: "sh.tangled.string", + Filename: "hello.md", + Description: "a greeting", + Contents: "# hello", + CreatedAt: "2026-07-18T23:15:54+03:00", + }, + }, + { + name: "record without description", + value: map[string]any{ + "$type": "sh.tangled.string", + "filename": "bare.md", + "contents": "no description", + "createdAt": "2026-07-18T12:00:00Z", + }, + want: stringRecord{ + Type: "sh.tangled.string", + Filename: "bare.md", + Contents: "no description", + CreatedAt: "2026-07-18T12:00:00Z", + }, + }, + { + name: "empty filename", + value: map[string]any{ + "$type": "sh.tangled.string", + "filename": "", + "contents": "empty filename", + "createdAt": "2026-07-18T12:00:00Z", + }, + want: stringRecord{ + Type: "sh.tangled.string", + Filename: "", + Contents: "empty filename", + CreatedAt: "2026-07-18T12:00:00Z", + }, + }, + { + name: "non-object value", + value: "not an object", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + record, err := decodeStringRecord(tt.value) + if (err != nil) != tt.wantErr { + t.Fatalf("decodeStringRecord() err = %v, wantErr %v", err, tt.wantErr) + } + if err != nil { + return + } + if record.Filename != tt.want.Filename { + t.Errorf("Filename = %q, want %q", record.Filename, tt.want.Filename) + } + if record.Description != tt.want.Description { + t.Errorf("Description = %q, want %q", record.Description, tt.want.Description) + } + if record.Contents != tt.want.Contents { + t.Errorf("Contents = %q, want %q", record.Contents, tt.want.Contents) + } + if record.CreatedAt != tt.want.CreatedAt { + t.Errorf("CreatedAt = %q, want %q", record.CreatedAt, tt.want.CreatedAt) + } + }) + } +}