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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,8 @@ ti db create-db-cluster --db-cluster-type starter --db-cluster-name my-distribut
- `ti <command> help`
- `ti <command> <subcommand> help`

Structured commands output JSON by default. Use `--output text` for command-specific tables or readable key-value output; it never falls back to JSON. When combined with `--query`, scalar lists are printed one item per line and object lists are printed as tables. Commands that intentionally stream raw bytes preserve those bytes and reject `--query`.

<details>
<summary>All commands</summary>

Expand Down
18 changes: 18 additions & 0 deletions e2e/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -684,6 +684,11 @@ func TestFSRemoteInventoryAndIDCredentialSelectionAcrossCommandFamilies(t *testi
list.wantStdoutNotContains("drive9_")
list.wantStdoutNotContains("default_file_system_name")
list.wantStdoutNotContains("is_default")
textList := runTIWithInput(t, bin, "", baseEnv, "--profile", "stage", "fs", "list-file-systems", "--output", "text")
textList.wantExitCode(0)
textList.wantStdoutContains("FILE_SYSTEM_ID")
textList.wantStdoutContains("tenant-aws-us-east-1")
textList.wantStdoutNotContains(`"file_system_id"`)
westList := runTIWithInput(t, bin, "", baseEnv, "--profile", "stage", "--region", "aws-us-west-2", "fs", "list-file-systems")
westList.wantExitCode(0)
westList.wantStdoutContains(`"file_system_id": "tenant-aws-us-west-2"`)
Expand All @@ -693,6 +698,10 @@ func TestFSRemoteInventoryAndIDCredentialSelectionAcrossCommandFamilies(t *testi
describe.wantStdoutContains(`"file_system_id": "tenant-aws-us-west-2"`)
describe.wantStdoutContains(`"region_code": "aws-us-west-2"`)
describe.wantStdoutNotContains("drive9_")
textDescribe := runTIWithInput(t, bin, "", baseEnv, "--profile", "stage", "--region", "aws-us-west-2", "fs", "describe-file-system", "--file-system-id", "tenant-aws-us-west-2", "--output", "text")
textDescribe.wantExitCode(0)
textDescribe.wantStdoutContains("File system ID: tenant-aws-us-west-2")
textDescribe.wantStdoutNotContains(`"file_system_id"`)
callsBeforeMissingSelectorCommands := len(readFakeDrive9Calls(t, recordPath))
for _, args := range [][]string{
{"fs", "list-files", "--path", "/"},
Expand Down Expand Up @@ -1031,6 +1040,11 @@ func TestFSFileSystemTokenLifecycle(t *testing.T) {
scoped.wantStdoutContains(`"scope_kind": "fs_scoped"`)
scoped.wantStdoutContains(`"prefix": "/workspace"`)
scoped.wantStdoutContains(scopedToken)
scopedText := runTIWithInput(t, bin, "", env, "--profile", "stage", "fs", "generate-file-system-scoped-token", "--file-system-id", "tenant-tokens", "--fs-token", generatedToken, "--subject", "e2e-agent", "--ttl", "1h", "--allow", "/workspace:read,list", "--output", "text")
scopedText.wantExitCode(0)
scopedText.wantStdoutContains("File system ID: tenant-tokens")
scopedText.wantStdoutContains("PREFIX")
scopedText.wantStdoutNotContains(`"scope_kind"`)

bearerListEnv := append(append([]string{}, env...), "TI_FS_TOKEN="+generatedToken)
bearerListed := runTIWithInput(t, bin, "", bearerListEnv, "--profile", "stage", "fs", "list-file-system-tokens", "--file-system-id", "tenant-tokens")
Expand All @@ -1048,6 +1062,10 @@ func TestFSFileSystemTokenLifecycle(t *testing.T) {
enabled := runTIWithInput(t, bin, "", env, "--profile", "stage", "fs", "enable-file-system-token", "--file-system-id", "tenant-tokens", "--token-id", "token-e2e")
enabled.wantExitCode(0)
enabled.wantStdoutContains(`"status": "active"`)
enabledText := runTIWithInput(t, bin, "", env, "--profile", "stage", "fs", "enable-file-system-token", "--file-system-id", "tenant-tokens", "--token-id", "token-e2e", "--output", "text")
enabledText.wantExitCode(0)
enabledText.wantStdoutContains("Status: active")
enabledText.wantStdoutNotContains(`"status"`)

refreshed := runTIWithInput(t, bin, "", env, "--profile", "stage", "fs", "refresh-file-system-token", "--file-system-id", "tenant-tokens", "--query", "fs_token", "--output", "text")
refreshed.wantExitCode(0)
Expand Down
4 changes: 4 additions & 0 deletions internal/db/connectionstring/connectionstring.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ type Result struct {
ConnectionString string `json:"connection_string"`
}

func (r Result) Human() string {
return r.ConnectionString
}

func Build(input Input) (Result, error) {
format := input.Format
if format == "" {
Expand Down
17 changes: 17 additions & 0 deletions internal/db/sqlaccess/sqlaccess.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"fmt"
"math/big"
"strings"
"text/tabwriter"

apiiam "github.com/tidbcloud/ti-cli/internal/api/iam"
"github.com/tidbcloud/ti-cli/internal/apperr"
Expand Down Expand Up @@ -40,6 +41,22 @@ type RoleUserStatus struct {
Status string `json:"status"`
}

func (r Result) Human() string {
var out strings.Builder
_, _ = fmt.Fprintf(&out, "Cluster ID: %s\n", r.ClusterID)
writer := tabwriter.NewWriter(&out, 0, 0, 2, ' ', 0)
_, _ = fmt.Fprintln(writer, "ACCESS_MODE\tUSERNAME\tBUILTIN_ROLE\tAUTH_METHOD\tSTATUS")
for _, plan := range Plans() {
user, ok := r.Users[string(plan.Mode)]
if !ok {
continue
}
_, _ = fmt.Fprintf(writer, "%s\t%s\t%s\t%s\t%s\n", user.AccessMode, user.Username, user.BuiltinRole, user.AuthMethod, user.Status)
}
_ = writer.Flush()
return strings.TrimRight(out.String(), "\n")
}

type Plan struct {
Mode sqlcred.AccessMode
Suffix string
Expand Down
28 changes: 28 additions & 0 deletions internal/fs/control.go
Original file line number Diff line number Diff line change
Expand Up @@ -433,6 +433,34 @@ func (r FileSystemResult) Human() string {
if r.CredentialsStored {
lines = append(lines, "Credentials: stored locally")
}
if r.FSToken != "" {
lines = append(lines, "FS token: "+r.FSToken)
}
return strings.Join(lines, "\n")
}

func (r ListFileSystemsResult) Human() string {
var out strings.Builder
writer := tabwriter.NewWriter(&out, 0, 0, 2, ' ', 0)
_, _ = fmt.Fprintln(writer, "FILE_SYSTEM_ID\tREGION\tSTATUS\tKIND\tLOCAL_TOKEN")
for _, fileSystem := range r.FileSystems {
_, _ = fmt.Fprintf(writer, "%s\t%s\t%s\t%s\t%t\n", fileSystem.FileSystemID, fileSystem.RegionCode, fileSystem.Status, fileSystem.Kind, fileSystem.HasLocalToken)
}
_ = writer.Flush()
return strings.TrimRight(out.String(), "\n")
}

func (r DescribeFileSystemResult) Human() string {
lines := []string{
"File system ID: " + r.FileSystemID,
"Region: " + r.RegionCode,
"Status: " + r.Status,
"Kind: " + r.Kind,
fmt.Sprintf("Local token: %t", r.HasLocalToken),
}
if r.Quota != nil {
lines = append(lines, fmt.Sprintf("Quota: %v", r.Quota))
}
return strings.Join(lines, "\n")
}

Expand Down
50 changes: 50 additions & 0 deletions internal/fs/git.go
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,56 @@ type GitDeleteResult struct {
Status string `json:"status"`
}

func (r GitWorkspaceCloneResult) Human() string {
lines := []string{
"Operation: " + r.Operation,
"Workspace ID: " + r.Workspace.WorkspaceID,
"Target path: " + r.TargetPath,
"Remote path: " + r.RemotePath,
"Head commit: " + r.HeadCommit,
fmt.Sprintf("Tree entries: %d", r.TreeEntries),
}
if r.BranchName != "" {
lines = append(lines, "Branch: "+r.BranchName)
}
if r.Hydrate != nil {
lines = append(lines, "Hydrate: "+r.Hydrate.Operation)
}
return strings.Join(lines, "\n")
}

func (r GitHydrateResult) Human() string {
return strings.Join([]string{
"Operation: " + r.Operation,
"Workspace ID: " + r.WorkspaceID,
"Target path: " + r.TargetPath,
"Commit: " + r.CommitSHA,
fmt.Sprintf("Files: %d", r.Files),
fmt.Sprintf("Objects: %d", r.Objects),
fmt.Sprintf("Skipped: %d", r.Skipped),
"Duration: " + r.Duration.String(),
}, "\n")
}

func (r GitRestoreResult) Human() string {
return strings.Join([]string{
"Operation: " + r.Operation,
"Workspace ID: " + r.WorkspaceID,
"Target path: " + r.TargetPath,
"Status: " + r.Status,
fmt.Sprintf("State restored: %t", r.StateRestored),
fmt.Sprintf("Object packs: %d", r.ObjectPacks),
}, "\n")
}

func (r GitWorktreeRemoveResult) Human() string {
return strings.Join([]string{"Operation: " + r.Operation, "Workspace ID: " + r.WorkspaceID, "Remote path: " + r.RemotePath, "Status: " + r.Status}, "\n")
}

func (r GitDeleteResult) Human() string {
return strings.Join([]string{"Operation: " + r.Operation, "ID: " + r.ID, "Status: " + r.Status}, "\n")
}

type mountedGitTarget struct {
MountPoint string
MountRel string
Expand Down
23 changes: 23 additions & 0 deletions internal/fs/pack.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,29 @@ type UnpackFileSystemResult struct {
CreatedAt time.Time `json:"created_at"`
}

func (r PackFileSystemResult) Human() string {
return strings.Join([]string{
"Status: " + r.Status,
"Archive path: " + r.ArchivePath,
"Local root: " + r.LocalRoot,
"Remote root: " + r.RemoteRoot,
fmt.Sprintf("Entries: %d", r.Entries),
fmt.Sprintf("Archive bytes: %d", r.ArchiveSizeBytes),
fmt.Sprintf("Uploaded bytes: %d", r.UploadedBytes),
}, "\n")
}

func (r UnpackFileSystemResult) Human() string {
return strings.Join([]string{
"Status: " + r.Status,
"Archive path: " + r.ArchivePath,
"Local root: " + r.LocalRoot,
"Remote root: " + r.RemoteRoot,
fmt.Sprintf("Entries: %d", r.Entries),
fmt.Sprintf("Replaced: %t", r.Replaced),
}, "\n")
}

type packProfileConfig struct {
Name string
PackPaths []string
Expand Down
75 changes: 75 additions & 0 deletions internal/fs/tokenmgmt/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -1104,3 +1104,78 @@ func (r ListResult) Human() string {
_ = w.Flush()
return strings.TrimRight(out.String(), "\n")
}

func (r GenerateResult) Human() string {
lines := []string{
"File system ID: " + r.FileSystemID,
"Token ID: " + r.TokenID,
"Token name: " + r.TokenName,
"Scope: " + r.ScopeKind,
"Status: " + r.Status,
"Expires: " + formatTokenExpiry(r.ExpiresAt),
fmt.Sprintf("Credentials stored: %t", r.CredentialsStored),
"FS token: " + r.FSToken,
}
if r.PreviousTokenNote != "" {
lines = append(lines, "Note: "+r.PreviousTokenNote)
}
return strings.Join(lines, "\n")
}

func (r GenerateScopedResult) Human() string {
var out strings.Builder
_, _ = fmt.Fprintf(&out, "File system ID: %s\nToken ID: %s\nScope: %s\nExpires: %s\nCredentials stored: %t\nFS token: %s\n", r.FileSystemID, r.TokenID, r.ScopeKind, formatTokenExpiry(r.ExpiresAt), r.CredentialsStored, r.FSToken)
if r.Subject != "" {
_, _ = fmt.Fprintf(&out, "Subject: %s\n", r.Subject)
}
if r.PreviousTokenNote != "" {
_, _ = fmt.Fprintf(&out, "Note: %s\n", r.PreviousTokenNote)
}
if len(r.Scopes) > 0 {
writer := tabwriter.NewWriter(&out, 0, 0, 2, ' ', 0)
_, _ = fmt.Fprintln(writer, "PREFIX\tOPERATIONS")
for _, scope := range r.Scopes {
_, _ = fmt.Fprintf(writer, "%s\t%s\n", scope.Prefix, strings.Join(scope.Ops, ","))
}
_ = writer.Flush()
}
return strings.TrimRight(out.String(), "\n")
}

func (r MutationResult) Human() string {
lines := []string{
"File system ID: " + r.FileSystemID,
"Token ID: " + r.TokenID,
"Status: " + r.Status,
fmt.Sprintf("Local credentials updated: %t", r.LocalCredentialsUpdated),
}
if r.LocalCredentialsReason != "" {
lines = append(lines, "Local credentials: "+r.LocalCredentialsReason)
}
if r.CacheConvergenceNote != "" {
lines = append(lines, "Note: "+r.CacheConvergenceNote)
}
return strings.Join(lines, "\n")
}

func (r RefreshResult) Human() string {
lines := []string{
"File system ID: " + r.FileSystemID,
"Token ID: " + r.TokenID,
"Scope: " + r.ScopeKind,
"Expires: " + formatTokenExpiry(r.ExpiresAt),
fmt.Sprintf("Credentials stored: %t", r.CredentialsStored),
"FS token: " + r.FSToken,
}
if r.RecoveryPath != "" {
lines = append(lines, "Recovery path: "+r.RecoveryPath)
}
return strings.Join(lines, "\n")
}

func formatTokenExpiry(expiresAt *time.Time) string {
if expiresAt == nil {
return "never"
}
return expiresAt.UTC().Format(time.RFC3339)
}
90 changes: 90 additions & 0 deletions internal/output/command_contract_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package output_test

import (
"bytes"
"strings"
"testing"

configure "github.com/tidbcloud/ti-cli/internal/config/configure"
"github.com/tidbcloud/ti-cli/internal/db"
"github.com/tidbcloud/ti-cli/internal/db/connectionstring"
"github.com/tidbcloud/ti-cli/internal/db/sqlresult"
"github.com/tidbcloud/ti-cli/internal/dryrun"
"github.com/tidbcloud/ti-cli/internal/fs"
"github.com/tidbcloud/ti-cli/internal/fs/tokenmgmt"
"github.com/tidbcloud/ti-cli/internal/output"
"github.com/tidbcloud/ti-cli/internal/update"
)

func TestRegisteredCommandResultsSupportTextOutput(t *testing.T) {
results := map[string]any{
"configure": configure.Result{},
"update check": update.CheckResult{},
"update apply": update.ApplyResult{},
"dry run": dryrun.Result{},
"db list clusters": db.ListClustersResult{},
"db cluster": db.ClusterResult{},
"db list branches": db.ListBranchesResult{},
"db branch": db.BranchResult{},
"db create sql users": db.PrepareQueryAccessResult{},
"db connection string": connectionstring.Result{},
"db execute sql": sqlresult.Result{},
"fs create": fs.FileSystemResult{},
"fs list file systems": fs.ListFileSystemsResult{},
"fs describe file system": fs.DescribeFileSystemResult{},
"fs delete": fs.DeleteResult{},
"fs import token": fs.ImportFileSystemTokenResult{},
"fs check": fs.CheckResult{},
"fs file operation": fs.FileOperationResult{},
"fs list files": fs.ListFilesResult{},
"fs describe file": fs.DescribeFileResult{},
"fs search files": fs.SearchFilesResult{},
"fs layer": fs.LayerResult{},
"fs list layers": fs.LayerListResult{},
"fs layer entries": fs.LayerEntriesResult{},
"fs layer entry": fs.LayerEntryResult{},
"fs layer checkpoint": fs.LayerCheckpointResult{},
"fs layer events": fs.LayerEventsResult{},
"fs layer action": fs.LayerActionResult{},
"fs layer commit": fs.LayerCommitResult{},
"fs pack": fs.PackFileSystemResult{},
"fs unpack": fs.UnpackFileSystemResult{},
"fs mount": fs.MountResult{},
"fs unmount": fs.UnmountResult{},
"fs drain": fs.DrainResult{},
"fs generate owner token": tokenmgmt.GenerateResult{},
"fs generate scoped token": tokenmgmt.GenerateScopedResult{},
"fs list tokens": tokenmgmt.ListResult{},
"fs mutate token": tokenmgmt.MutationResult{},
"fs refresh token": tokenmgmt.RefreshResult{},
"fs vault secret": fs.VaultSecretResult{},
"fs vault read secret": fs.VaultReadSecretResult{},
"fs vault list secrets": fs.VaultListSecretsResult{},
"fs vault delete": fs.VaultDeleteResult{},
"fs vault token": fs.VaultTokenResult{},
"fs vault audit": fs.VaultAuditResult{},
"fs journal": fs.JournalResult{},
"fs journal entries": fs.JournalEntriesResult{},
"fs journal search": fs.JournalSearchResult{},
"fs journal append": fs.JournalAppendResult{},
"fs journal verify": fs.JournalVerifyResult{},
"fs git clone or add worktree": fs.GitWorkspaceCloneResult{},
"fs git hydrate": fs.GitHydrateResult{},
"fs git restore": fs.GitRestoreResult{},
"fs git remove worktree": fs.GitWorktreeRemoveResult{},
"fs git delete internal resource": fs.GitDeleteResult{},
}

for name, result := range results {
t.Run(name, func(t *testing.T) {
var rendered bytes.Buffer
if err := output.Render(&rendered, result, output.Options{Format: output.FormatText}); err != nil {
t.Fatalf("render %T: %v", result, err)
}
trimmed := strings.TrimSpace(rendered.String())
if strings.HasPrefix(trimmed, "{") || strings.HasPrefix(trimmed, "[") {
t.Fatalf("text output fell back to JSON:\n%s", rendered.String())
}
})
}
}
Loading