Skip to content

Commit ceb13fb

Browse files
committed
feat: update --pull semantics for target outpus
1 parent ab48fc0 commit ceb13fb

File tree

4 files changed

+164
-47
lines changed

4 files changed

+164
-47
lines changed

pkg/cmd/build.go

Lines changed: 109 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,11 @@ import (
88
"fmt"
99
"io"
1010
"net/http"
11+
"net/url"
1112
"os"
1213
"os/exec"
1314
"path/filepath"
15+
"strings"
1416
"time"
1517

1618
"github.com/stainless-api/stainless-api-cli/pkg/jsonflag"
@@ -436,9 +438,7 @@ func pullBuildOutputs(ctx context.Context, client stainless.Client, res stainles
436438

437439
// Pull each target
438440
for i, target := range targets {
439-
targetDir := fmt.Sprintf("%s-%s", res.Project, target)
440-
441-
targetGroup := Progress("[%d/%d] Pulling %s → %s", i+1, len(targets), target, targetDir)
441+
targetGroup := Progress("[%d/%d] Pulling %s", i+1, len(targets), target)
442442

443443
// Get the output details
444444
outputRes, err := client.Builds.TargetOutputs.Get(
@@ -456,13 +456,27 @@ func pullBuildOutputs(ctx context.Context, client stainless.Client, res stainles
456456
}
457457

458458
// Handle based on output type
459-
err = pullOutput(outputRes.Output, outputRes.URL, outputRes.Ref, targetDir)
459+
err = pullOutput(outputRes.Output, outputRes.URL, outputRes.Ref)
460460
if err != nil {
461461
targetGroup.Error("Failed to pull %s: %v", target, err)
462462
continue
463463
}
464464

465-
targetGroup.Success("Successfully pulled to %s", targetDir)
465+
// Get the appropriate success message based on output type
466+
if outputRes.Output == "git" {
467+
// Extract repository name from git URL for success message
468+
repoName := filepath.Base(outputRes.URL)
469+
if strings.HasSuffix(repoName, ".git") {
470+
repoName = strings.TrimSuffix(repoName, ".git")
471+
}
472+
if repoName == "" || repoName == "." || repoName == "/" {
473+
repoName = "repository"
474+
}
475+
targetGroup.Success("Successfully pulled to %s", repoName)
476+
} else {
477+
filename := extractFilenameFromURL(outputRes.URL)
478+
targetGroup.Success("Successfully downloaded %s", filename)
479+
}
466480

467481
if i < len(targets)-1 {
468482
fmt.Fprintf(os.Stderr, "\n")
@@ -472,23 +486,89 @@ func pullBuildOutputs(ctx context.Context, client stainless.Client, res stainles
472486
return nil
473487
}
474488

475-
// pullOutput handles downloading or cloning a build target output
476-
func pullOutput(output, url, ref, targetDir string) error {
477-
// Remove existing directory if it exists
478-
if _, err := os.Stat(targetDir); err == nil {
479-
Info("Removing existing directory %s", targetDir)
480-
if err := os.RemoveAll(targetDir); err != nil {
481-
return fmt.Errorf("failed to remove existing directory %s: %v", targetDir, err)
489+
// extractFilenameFromURL extracts the filename from just the URL path (without query parameters)
490+
func extractFilenameFromURL(urlStr string) string {
491+
// Parse URL to remove query parameters
492+
parsedURL, err := url.Parse(urlStr)
493+
if err != nil {
494+
// If URL parsing fails, use the original approach
495+
filename := filepath.Base(urlStr)
496+
if filename == "." || filename == "/" || filename == "" {
497+
return "download"
482498
}
499+
return filename
483500
}
484501

485-
// Create a fresh directory for the output
486-
if err := os.MkdirAll(targetDir, 0755); err != nil {
487-
return fmt.Errorf("failed to create directory %s: %v", targetDir, err)
502+
// Extract filename from URL path (without query parameters)
503+
filename := filepath.Base(parsedURL.Path)
504+
if filename == "." || filename == "/" || filename == "" {
505+
return "download"
488506
}
489507

508+
return filename
509+
}
510+
511+
// extractFilename extracts the filename from a URL and HTTP response headers
512+
func extractFilename(urlStr string, resp *http.Response) string {
513+
// First, try to get filename from Content-Disposition header
514+
if contentDisp := resp.Header.Get("Content-Disposition"); contentDisp != "" {
515+
// Parse Content-Disposition header for filename
516+
// Format: attachment; filename="example.txt" or attachment; filename=example.txt
517+
if strings.Contains(contentDisp, "filename=") {
518+
parts := strings.Split(contentDisp, "filename=")
519+
if len(parts) > 1 {
520+
filename := strings.TrimSpace(parts[1])
521+
// Remove quotes if present
522+
filename = strings.Trim(filename, `"`)
523+
// Remove any additional parameters after semicolon
524+
if idx := strings.Index(filename, ";"); idx != -1 {
525+
filename = filename[:idx]
526+
}
527+
filename = strings.TrimSpace(filename)
528+
if filename != "" {
529+
return filename
530+
}
531+
}
532+
}
533+
}
534+
535+
// Fallback to URL path parsing
536+
return extractFilenameFromURL(urlStr)
537+
}
538+
539+
// pullOutput handles downloading or cloning a build target output
540+
func pullOutput(output, url, ref string) error {
490541
switch output {
491542
case "git":
543+
// Extract repository name from git URL for directory name
544+
// Handle formats like:
545+
// - https://github.com/owner/repo.git
546+
// - https://github.com/owner/repo
547+
// - [email protected]:owner/repo.git
548+
targetDir := filepath.Base(url)
549+
550+
// Remove .git suffix if present
551+
if strings.HasSuffix(targetDir, ".git") {
552+
targetDir = strings.TrimSuffix(targetDir, ".git")
553+
}
554+
555+
// Handle empty or invalid names
556+
if targetDir == "" || targetDir == "." || targetDir == "/" {
557+
targetDir = "repository"
558+
}
559+
560+
// Remove existing directory if it exists
561+
if _, err := os.Stat(targetDir); err == nil {
562+
Info("Removing existing directory %s", targetDir)
563+
if err := os.RemoveAll(targetDir); err != nil {
564+
return fmt.Errorf("failed to remove existing directory %s: %v", targetDir, err)
565+
}
566+
}
567+
568+
// Create a fresh directory for the output
569+
if err := os.MkdirAll(targetDir, 0755); err != nil {
570+
return fmt.Errorf("failed to create directory %s: %v", targetDir, err)
571+
}
492572
// Clone the repository
493573
gitGroup := Info("Cloning repository")
494574
gitGroup.Property("ref", ref)
@@ -511,18 +591,9 @@ func pullOutput(output, url, ref, targetDir string) error {
511591
}
512592

513593
case "url":
514-
// Download the tar file
515-
downloadGroup := Info("Downloading archive")
594+
// Download the file directly to current directory
595+
downloadGroup := Info("Downloading file")
516596
downloadGroup.Property("url", url)
517-
downloadGroup.Property("target", targetDir)
518-
519-
// Create a temporary file for the tar download
520-
tmpFile, err := os.CreateTemp("", "stainless-*.tar.gz")
521-
if err != nil {
522-
return fmt.Errorf("failed to create temporary file: %v", err)
523-
}
524-
defer os.Remove(tmpFile.Name())
525-
defer tmpFile.Close()
526597

527598
// Download the file
528599
resp, err := http.Get(url)
@@ -535,19 +606,21 @@ func pullOutput(output, url, ref, targetDir string) error {
535606
return fmt.Errorf("download failed with status: %s", resp.Status)
536607
}
537608

538-
// Copy the response body to the temporary file
539-
_, err = io.Copy(tmpFile, resp.Body)
609+
// Extract filename from URL and Content-Disposition header
610+
filename := extractFilename(url, resp)
611+
downloadGroup.Property("filename", filename)
612+
613+
// Create the output file in current directory
614+
outFile, err := os.Create(filename)
540615
if err != nil {
541-
return fmt.Errorf("failed to save downloaded file: %v", err)
616+
return fmt.Errorf("failed to create output file: %v", err)
542617
}
543-
tmpFile.Close()
618+
defer outFile.Close()
544619

545-
// Extract the tar file
546-
cmd := exec.Command("tar", "-xzf", tmpFile.Name(), "-C", targetDir)
547-
cmd.Stdout = nil // Suppress tar output
548-
cmd.Stderr = nil
549-
if err := cmd.Run(); err != nil {
550-
return fmt.Errorf("tar extraction failed: %v", err)
620+
// Copy the response body to the output file
621+
_, err = io.Copy(outFile, resp.Body)
622+
if err != nil {
623+
return fmt.Errorf("failed to save downloaded file: %v", err)
551624
}
552625

553626
default:

pkg/cmd/buildtargetoutput.go

Lines changed: 51 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,15 @@ var buildsTargetOutputsRetrieve = cli.Command{
2727
Path: "build_id",
2828
},
2929
},
30+
&cli.StringFlag{
31+
Name: "project",
32+
Usage: "Project name (required when build-id is not provided)",
33+
},
34+
&cli.StringFlag{
35+
Name: "branch",
36+
Usage: "Branch name (defaults to main if not provided)",
37+
Value: "main",
38+
},
3039
&jsonflag.JSONStringFlag{
3140
Name: "target",
3241
Config: jsonflag.JSONConfig{
@@ -54,7 +63,19 @@ var buildsTargetOutputsRetrieve = cli.Command{
5463

5564
func handleBuildsTargetOutputsRetrieve(ctx context.Context, cmd *cli.Command) error {
5665
cc := getAPICommandContext(cmd)
57-
params := stainless.BuildTargetOutputGetParams{}
66+
67+
buildID := cmd.String("build-id")
68+
if buildID == "" {
69+
latestBuildID, err := getLatestBuildID(ctx, cc.client, cmd.String("project"), cmd.String("branch"))
70+
if err != nil {
71+
return fmt.Errorf("failed to get latest build: %v", err)
72+
}
73+
buildID = latestBuildID
74+
}
75+
76+
params := stainless.BuildTargetOutputGetParams{
77+
BuildID: buildID,
78+
}
5879
res, err := cc.client.Builds.TargetOutputs.Get(
5980
context.TODO(),
6081
params,
@@ -67,13 +88,36 @@ func handleBuildsTargetOutputsRetrieve(ctx context.Context, cmd *cli.Command) er
6788
fmt.Printf("%s\n", ColorizeJSON(res.RawJSON(), os.Stdout))
6889

6990
if cmd.Bool("pull") {
70-
build, err := cc.client.Builds.Get(ctx, cmd.String("build-id"))
71-
if err != nil {
72-
return err
73-
}
74-
targetDir := fmt.Sprintf("%s-%s", build.Project, cmd.String("target"))
75-
return pullOutput(res.Output, res.URL, res.Ref, targetDir)
91+
return pullOutput(res.Output, res.URL, res.Ref)
7692
}
7793

7894
return nil
7995
}
96+
97+
func getLatestBuildID(ctx context.Context, client stainless.Client, project, branch string) (string, error) {
98+
if project == "" {
99+
return "", fmt.Errorf("project is required when build-id is not provided")
100+
}
101+
102+
params := stainless.BuildListParams{
103+
Project: stainless.String(project),
104+
Limit: stainless.Float(1.0),
105+
}
106+
if branch != "" {
107+
params.Branch = stainless.String(branch)
108+
}
109+
110+
res, err := client.Builds.List(
111+
ctx,
112+
params,
113+
)
114+
if err != nil {
115+
return "", err
116+
}
117+
118+
if len(res.Data) == 0 {
119+
return "", fmt.Errorf("no builds found for project %s", project)
120+
}
121+
122+
return res.Data[0].ID, nil
123+
}

pkg/cmd/workspaceconfig.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ func (config *WorkspaceConfig) Load(configPath string) error {
5757
return fmt.Errorf("failed to open workspace config file %s: %w", configPath, err)
5858
}
5959
defer file.Close()
60-
60+
6161
// Check if file is empty
6262
info, err := file.Stat()
6363
if err != nil {
@@ -67,7 +67,7 @@ func (config *WorkspaceConfig) Load(configPath string) error {
6767
// File is empty, treat as if no config exists
6868
return nil
6969
}
70-
70+
7171
if err := json.NewDecoder(file).Decode(config); err != nil {
7272
return fmt.Errorf("failed to parse workspace config file %s: %w", configPath, err)
7373
}

pkg/jsonflag/json_flag.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -268,10 +268,10 @@ func (v *jsonFileValue) Set(filePath string) error {
268268
if err != nil {
269269
return fmt.Errorf("failed to read file %q: %w", filePath, err)
270270
}
271-
271+
272272
// Store the file path in the destination
273273
*v.destination = filePath
274-
274+
275275
// Register the file content with the global registry
276276
globalRegistry.Mutate(v.config.Kind, v.config.Path, string(content))
277277
return nil

0 commit comments

Comments
 (0)