Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 15 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -304,12 +304,23 @@ to both operations, it is present afterward.
**Human-readable** (default) — tabular output to stdout:

```
$ uploadcare file list --limit 3
UUID SIZE FILENAME STORED UPLOADED
a1b2c3d4-e5f6-7890-abcd-ef1234567890 1258000 photo.jpg true 2026-03-01T00:00:00Z
b2c3d4e5-f6a7-8901-bcde-f12345678901 348160 document.pdf false 2026-03-02T00:00:00Z
$ uploadcare file list --limit 2
UUID SIZE FILENAME STORED UPLOADED
a1b2c3d4-e5f6-7890-abcd-ef1234567890 1258000 vacation-p...nal-v3.jpg true 2026-03-01T00:00:00Z
b2c3d4e5-f6a7-8901-bcde-f12345678901 348160 document.pdf false 2026-03-02T00:00:00Z
```

Long filenames, URLs, and paths are shortened to keep one record per line,
marked with `...` and keeping the file extension visible. UUIDs and timestamps
are never shortened, so they stay copy-pasteable. Redirected or piped output is
never shortened:

```bash
uploadcare file list | less # full filenames
```

Use `--json` for values you intend to parse.

**JSON** — activated with `--json all` (all fields) or `--json field1,field2` (specific fields):

```bash
Expand Down
4 changes: 3 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@ go 1.26.1
require (
github.com/fatih/color v1.19.0
github.com/itchyny/gojq v0.12.18
github.com/rivo/uniseg v0.4.7
github.com/spf13/cobra v1.10.2
github.com/spf13/pflag v1.0.10
github.com/spf13/viper v1.21.0
github.com/uploadcare/uploadcare-go/v2 v2.1.0
go.yaml.in/yaml/v3 v3.0.4
golang.org/x/term v0.45.0
)

require (
Expand All @@ -26,6 +28,6 @@ require (
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.28.0 // indirect
)
8 changes: 6 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
Expand Down Expand Up @@ -58,8 +60,10 @@ github.com/uploadcare/uploadcare-go/v2 v2.1.0/go.mod h1:nVtcYFEeUnxMjXbEsXzDefko
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
Expand Down
14 changes: 5 additions & 9 deletions internal/cmd/file.go
Original file line number Diff line number Diff line change
Expand Up @@ -215,9 +215,9 @@ original_file_url, metadata, appdata (with --include-appdata).`,

var table *output.TableData
if includeAppData {
table = output.NewTableData("UUID", "SIZE", "FILENAME", "STORED", "UPLOADED", "APPDATA")
table = output.NewTableData("UUID", "SIZE", "FILENAME", "STORED", "UPLOADED", "APPDATA").Flexible(2)
} else {
table = output.NewTableData("UUID", "SIZE", "FILENAME", "STORED", "UPLOADED")
table = output.NewTableData("UUID", "SIZE", "FILENAME", "STORED", "UPLOADED").Flexible(2)
}
for _, f := range result.Files {
row := []string{
Expand Down Expand Up @@ -262,12 +262,12 @@ func runFileListAll(cmd *cobra.Command, svc service.FileService, listOpts servic
}
if includeAppData {
_, err := fmt.Fprintf(w, "%s\t%d\t%s\t%v\t%s\t%s\n",
f.UUID, f.Size, f.Filename, f.IsStored, formatTime(f.DatetimeUploaded),
f.UUID, f.Size, output.SanitizeCell(f.Filename), f.IsStored, formatTime(f.DatetimeUploaded),
truncateAppData(f.AppData, 50))
return err
}
_, err := fmt.Fprintf(w, "%s\t%d\t%s\t%v\t%s\n",
f.UUID, f.Size, f.Filename, f.IsStored, formatTime(f.DatetimeUploaded))
f.UUID, f.Size, output.SanitizeCell(f.Filename), f.IsStored, formatTime(f.DatetimeUploaded))
return err
})
}
Expand Down Expand Up @@ -299,11 +299,7 @@ func truncateAppData(data json.RawMessage, maxLen int) string {
if len(data) == 0 {
return ""
}
s := string(data)
if len(s) <= maxLen {
return s
}
return s[:maxLen] + "..."
return output.TruncateEnd(string(data), maxLen)
}

func formatTime(t time.Time) string {
Expand Down
4 changes: 2 additions & 2 deletions internal/cmd/file_batch.go
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,7 @@ func runBatchCommand(
return err
}
} else {
table := output.NewTableData("UUID", "FILENAME", "STATUS")
table := output.NewTableData("UUID", "FILENAME", "STATUS").Flexible(1, 2)
for _, f := range merged.Files {
table.AddRow(f.UUID, f.Filename, "ok")
}
Expand Down Expand Up @@ -261,7 +261,7 @@ func runBatchDryRun(
return err
}
} else {
table := output.NewTableData("UUID", "FILENAME", "STATUS")
table := output.NewTableData("UUID", "FILENAME", "STATUS").Flexible(1, 2)
for _, e := range entries {
table.AddRow(e.UUID, e.Filename, e.Status)
}
Expand Down
2 changes: 1 addition & 1 deletion internal/cmd/file_download.go
Original file line number Diff line number Diff line change
Expand Up @@ -616,7 +616,7 @@ func expandNameTemplate(tmpl string, f *service.File, effects string) (string, e
}

func downloadRowsTable(rows []downloadRow) *output.TableData {
t := output.NewTableData("UUID", "PATH", "SIZE", "STATUS")
t := output.NewTableData("UUID", "PATH", "SIZE", "STATUS").Flexible(1)
for _, r := range rows {
t.AddRow(r.UUID, r.Path, strconv.FormatInt(r.Size, 10), r.Status)
}
Expand Down
6 changes: 3 additions & 3 deletions internal/cmd/file_search.go
Original file line number Diff line number Diff line change
Expand Up @@ -278,7 +278,7 @@ func fileSearchTable(files []service.File, includeAppData bool) *output.TableDat
if includeAppData {
headers = append(headers, "APPDATA")
}
table := output.NewTableData(headers...)
table := output.NewTableData(headers...).Flexible(2)
for _, file := range files {
row := []string{file.UUID, strconv.FormatInt(file.Size, 10), file.Filename, file.MimeType, formatTime(file.DatetimeUploaded)}
if includeAppData {
Expand All @@ -301,12 +301,12 @@ func runFileSearchAll(cmd *cobra.Command, svc service.FileService, searchOpts se
}
if includeAppData {
_, err := fmt.Fprintf(cmd.OutOrStdout(), "%s\t%d\t%s\t%s\t%s\t%s\n",
file.UUID, file.Size, file.Filename, file.MimeType, formatTime(file.DatetimeUploaded),
file.UUID, file.Size, output.SanitizeCell(file.Filename), file.MimeType, formatTime(file.DatetimeUploaded),
truncateAppData(file.AppData, 50))
return err
}
_, err := fmt.Fprintf(cmd.OutOrStdout(), "%s\t%d\t%s\t%s\t%s\n",
file.UUID, file.Size, file.Filename, file.MimeType, formatTime(file.DatetimeUploaded))
file.UUID, file.Size, output.SanitizeCell(file.Filename), file.MimeType, formatTime(file.DatetimeUploaded))
return err
})
if err != nil {
Expand Down
44 changes: 43 additions & 1 deletion internal/cmd/file_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -650,7 +650,8 @@ func TestTruncateAppData(t *testing.T) {
{"empty", nil, 50, ""},
{"short", json.RawMessage(`{"ok":true}`), 50, `{"ok":true}`},
{"exact", json.RawMessage(`12345`), 5, `12345`},
{"over", json.RawMessage(`{"uc_clamav_virus_scan":{"data":{"infected":false}}}`), 20, `{"uc_clamav_virus_sc...`},
{"over", json.RawMessage(`{"uc_clamav_virus_scan":{"data":{"infected":false}}}`), 20, `{"uc_clamav_virus...`},
{"multibyte", json.RawMessage(`{"note":"日本語テキストです"}`), 12, `{"note":"...`},
}

for _, tt := range tests {
Expand All @@ -662,3 +663,44 @@ func TestTruncateAppData(t *testing.T) {
})
}
}

// A filename may come back from the API with a literal tab or newline in it.
// Left alone it splits the record across lines and, in the streaming path,
// shifts every field after it. See issue #4.
func TestFileList_ControlCharsInFilename(t *testing.T) {
f := testFile()
f.Filename = "two\nlines\tand\ttabs.jpg"

for _, tt := range []struct {
name string
args []string
lines int
}{
{"table", []string{"file", "list"}, 2},
{"stream", []string{"file", "list", "--page-all"}, 1},
} {
t.Run(tt.name, func(t *testing.T) {
mock := &mockFileService{
listFunc: func(ctx context.Context, opts service.FileListOptions) (*service.FileListResult, error) {
return &service.FileListResult{Files: []service.File{*f}, Total: 1}, nil
},
iterateFunc: func(ctx context.Context, opts service.FileListOptions, fn func(service.File) error) error {
return fn(*f)
},
}

stdout, _, err := executeCommand(t, newTestRoot(mock), tt.args...)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

got := strings.Split(strings.TrimRight(stdout, "\n"), "\n")
if len(got) != tt.lines {
t.Errorf("expected %d lines, got %d:\n%s", tt.lines, len(got), stdout)
}
if strings.Count(got[len(got)-1], "\t") > 4 {
t.Errorf("filename must not introduce extra fields:\n%q", got[len(got)-1])
}
})
}
}
4 changes: 2 additions & 2 deletions internal/cmd/file_upload.go
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,7 @@ is_ready, datetime_uploaded, original_file_url, metadata, tags.`,
return formatter.Format(cmd.OutOrStdout(), results)
}

table := output.NewTableData("UUID", "SIZE", "FILENAME")
table := output.NewTableData("UUID", "SIZE", "FILENAME").Flexible(2)
for _, r := range results {
table.AddRow(r.UUID, strconv.FormatInt(r.Size, 10), r.Filename)
}
Expand Down Expand Up @@ -270,7 +270,7 @@ func runUploadDryRun(cmd *cobra.Command, entries []uploadFileEntry, tags []strin
if len(tags) > 0 {
headers = append(headers, "TAGS")
}
table := output.NewTableData(headers...)
table := output.NewTableData(headers...).Flexible(0)
for _, e := range dryEntries {
row := []string{e.Path, strconv.FormatInt(e.Size, 10), e.ContentType}
if len(tags) > 0 {
Expand Down
4 changes: 2 additions & 2 deletions internal/cmd/file_upload_from_url.go
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ is_ready, datetime_uploaded, original_file_url, metadata, tags.`,
return formatter.Format(cmd.OutOrStdout(), results)
}

table := output.NewTableData("UUID", "SIZE", "FILENAME")
table := output.NewTableData("UUID", "SIZE", "FILENAME").Flexible(2)
for _, r := range results {
table.AddRow(r.UUID, strconv.FormatInt(r.Size, 10), r.Filename)
}
Expand Down Expand Up @@ -197,7 +197,7 @@ func runUploadFromURLDryRun(cmd *cobra.Command, urls, tags []string, opts output
if len(tags) > 0 {
headers = append(headers, "TAGS")
}
table := output.NewTableData(headers...)
table := output.NewTableData(headers...).Flexible(0)
for _, e := range entries {
row := []string{e.URL, e.Status}
if len(tags) > 0 {
Expand Down
2 changes: 1 addition & 1 deletion internal/cmd/metadata.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ is specified. Returns "No metadata found" when the file has no metadata.`,
return nil
}

table := output.NewTableData("KEY", "VALUE")
table := output.NewTableData("KEY", "VALUE").Flexible(0, 1)
for k, v := range meta {
table.AddRow(k, v)
}
Expand Down
2 changes: 1 addition & 1 deletion internal/cmd/project.go
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,7 @@ filesize_limit, autostore_enabled.`,
return formatter.Format(cmd.OutOrStdout(), result.Projects)
}

table := output.NewTableData("PUB_KEY", "NAME", "SHARED", "BLOCKED")
table := output.NewTableData("PUB_KEY", "NAME", "SHARED", "BLOCKED").Flexible(1)
for _, p := range result.Projects {
blocked := ""
if p.IsBlocked != nil && *p.IsBlocked {
Expand Down
1 change: 1 addition & 0 deletions internal/cmd/schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ No authentication required.`,
"All timestamps are in RFC 3339 / UTC format.",
"For batch operations (file store, file delete), exit code 1 means partial success — check the 'problems' field in JSON output.",
"When piping between commands, use --json uuid or --jq '.uuid' to emit just the UUID for --from-stdin consumption.",
"Human-readable tables shorten long filenames, URLs, and paths to fit the terminal, marking the cut with '...'. Piped or redirected output is never shortened; use --json for values you intend to parse.",
"For file download, use --output-dir for multiple UUIDs or --from-stdin; --output - streams bytes to stdout and cannot be combined with --json.",
"File search requires at least one query or filter. Search results can lag recent file, metadata, and tag changes. The API serves at most the first 1000 matches of a search.",
"Tag flags are repeatable. Tag update applies all --delete values before all --add values. Tag mutations with --dry-run add \"status\": \"would change\" to their JSON output.",
Expand Down
2 changes: 1 addition & 1 deletion internal/cmd/webhook.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ datetime_created, datetime_updated.`,
return formatter.Format(cmd.OutOrStdout(), webhooks)
}

table := output.NewTableData("ID", "TARGET_URL", "EVENT", "ACTIVE", "CREATED")
table := output.NewTableData("ID", "TARGET_URL", "EVENT", "ACTIVE", "CREATED").Flexible(1)
for _, w := range webhooks {
table.AddRow(
strconv.Itoa(w.ID),
Expand Down
Loading
Loading