Skip to content

Commit c37d3a4

Browse files
committed
Fix exec command to work in non-TTY environments
That allows us to run "beach exec" in non-interactive TTYs such as the one used by Claude and other AI agents.
1 parent 7f2ac0a commit c37d3a4

File tree

2 files changed

+159
-136
lines changed

2 files changed

+159
-136
lines changed

cmd/beach/cmd/exec.go

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,15 @@ package cmd
1616

1717
import (
1818
"fmt"
19+
"os"
20+
"strings"
21+
"syscall"
22+
"unsafe"
23+
1924
"github.com/flownative/localbeach/pkg/beachsandbox"
2025
"github.com/flownative/localbeach/pkg/exec"
2126
log "github.com/sirupsen/logrus"
2227
"github.com/spf13/cobra"
23-
"strings"
2428
)
2529

2630
// execCmd represents the exec command
@@ -43,17 +47,36 @@ func handleExecRun(cmd *cobra.Command, args []string) {
4347
return
4448
}
4549

46-
commandArgs := []string{"exec", "-ti", sandbox.ProjectName + "_php"}
50+
// Check if stdin is a TTY using syscall (more reliable than Mode check)
51+
var termios syscall.Termios
52+
_, _, errno := syscall.Syscall6(syscall.SYS_IOCTL, os.Stdin.Fd(), syscall.TIOCGETA, uintptr(unsafe.Pointer(&termios)), 0, 0, 0)
53+
isTTY := errno == 0
54+
55+
// Build Docker exec command with appropriate flags
56+
commandArgs := []string{"exec"}
57+
if isTTY {
58+
commandArgs = append(commandArgs, "-t", "-i")
59+
}
60+
// Note: No -i flag when not TTY since stdin isn't connected in RunCommand
61+
commandArgs = append(commandArgs, sandbox.ProjectName+"_php")
4762
if len(args) > 0 {
4863
commandArgs = append(commandArgs, "bash", "-l", "-c", strings.Trim(fmt.Sprint(args), "[]"))
4964
} else {
5065
commandArgs = append(commandArgs, "bash")
5166
}
5267

53-
err = exec.RunInteractiveCommand("docker", commandArgs)
54-
if err != nil {
55-
log.Fatal(err)
56-
return
68+
// Use the appropriate execution method based on TTY detection
69+
if isTTY {
70+
err = exec.RunInteractiveCommand("docker", commandArgs)
71+
if err != nil {
72+
log.Fatal(err)
73+
return
74+
}
75+
} else {
76+
output, err := exec.RunCommand("docker", commandArgs)
77+
fmt.Print(output)
78+
if err != nil {
79+
os.Exit(1)
80+
}
5781
}
58-
return
5982
}

cmd/beach/cmd/resource-download.go

Lines changed: 129 additions & 129 deletions
Original file line numberDiff line numberDiff line change
@@ -15,29 +15,29 @@
1515
package cmd
1616

1717
import (
18-
"cloud.google.com/go/storage"
19-
"context"
20-
"errors"
21-
"fmt"
22-
"github.com/flownative/localbeach/pkg/beachsandbox"
23-
log "github.com/sirupsen/logrus"
24-
"github.com/spf13/cobra"
25-
"google.golang.org/api/iterator"
26-
"google.golang.org/api/option"
27-
"io"
28-
"os"
29-
"path/filepath"
30-
"hash/crc32"
18+
"cloud.google.com/go/storage"
19+
"context"
20+
"errors"
21+
"fmt"
22+
"github.com/flownative/localbeach/pkg/beachsandbox"
23+
log "github.com/sirupsen/logrus"
24+
"github.com/spf13/cobra"
25+
"google.golang.org/api/iterator"
26+
"google.golang.org/api/option"
27+
"hash/crc32"
28+
"io"
29+
"os"
30+
"path/filepath"
3131
)
3232

3333
var sourceBucketName, targetResourcesPath string
3434
var synchronize bool
3535

3636
// resourceDownloadCmd represents the resource-download command
3737
var resourceDownloadCmd = &cobra.Command{
38-
Use: "resource-download",
39-
Short: "Download resources (assets) from a local Flow or Neos installation to Beach",
40-
Long: `resource-download
38+
Use: "resource-download",
39+
Short: "Download resources (assets) from a local Flow or Neos installation to Beach",
40+
Long: `resource-download
4141
4242
This command downloads Flow resources from a Beach instance to a local Flow or Neos project.
4343
@@ -56,127 +56,127 @@ Notes:
5656
- existing data in the local Neos instance will be left unchanged
5757
- older Beach instances may use a namespace called "beach"
5858
`,
59-
Args: cobra.ExactArgs(0),
60-
Run: handleResourceDownloadRun,
59+
Args: cobra.ExactArgs(0),
60+
Run: handleResourceDownloadRun,
6161
}
6262

6363
func init() {
64-
resourceDownloadCmd.Flags().StringVar(&instanceIdentifier, "instance", "", "instance identifier of the Beach instance to download from, eg. 'instance-123abc45-def6-7890-abcd-1234567890ab'")
65-
resourceDownloadCmd.Flags().StringVar(&projectNamespace, "namespace", "", "The project namespace of the Beach instance to download from, eg. 'beach-project-123abc45-def6-7890-abcd-1234567890ab'")
66-
resourceDownloadCmd.Flags().StringVar(&clusterIdentifier, "cluster", "", "The cluster identifier of the Beach instance to download from, eg. 'h9acc4'")
67-
resourceDownloadCmd.Flags().StringVar(&sourceBucketName, "bucket", "", "name of the bucket to download resources from")
68-
resourceDownloadCmd.Flags().StringVar(&targetResourcesPath, "resources-path", "", "custom path where to store the downloaded resources, e.g. 'Data/Persistent/Protected'")
69-
resourceDownloadCmd.Flags().BoolVar(&synchronize, "sync", false, "Skip unchanged existing files")
70-
71-
_ = resourceDownloadCmd.MarkFlagRequired("instance")
72-
_ = resourceDownloadCmd.MarkFlagRequired("namespace")
73-
rootCmd.AddCommand(resourceDownloadCmd)
64+
resourceDownloadCmd.Flags().StringVar(&instanceIdentifier, "instance", "", "instance identifier of the Beach instance to download from, eg. 'instance-123abc45-def6-7890-abcd-1234567890ab'")
65+
resourceDownloadCmd.Flags().StringVar(&projectNamespace, "namespace", "", "The project namespace of the Beach instance to download from, eg. 'beach-project-123abc45-def6-7890-abcd-1234567890ab'")
66+
resourceDownloadCmd.Flags().StringVar(&clusterIdentifier, "cluster", "", "The cluster identifier of the Beach instance to download from, eg. 'h9acc4'")
67+
resourceDownloadCmd.Flags().StringVar(&sourceBucketName, "bucket", "", "name of the bucket to download resources from")
68+
resourceDownloadCmd.Flags().StringVar(&targetResourcesPath, "resources-path", "", "custom path where to store the downloaded resources, e.g. 'Data/Persistent/Protected'")
69+
resourceDownloadCmd.Flags().BoolVar(&synchronize, "sync", false, "Skip unchanged existing files")
70+
71+
_ = resourceDownloadCmd.MarkFlagRequired("instance")
72+
_ = resourceDownloadCmd.MarkFlagRequired("namespace")
73+
rootCmd.AddCommand(resourceDownloadCmd)
7474
}
7575

7676
func handleResourceDownloadRun(cmd *cobra.Command, args []string) {
77-
sandbox, err := beachsandbox.GetActiveSandbox()
78-
if err != nil {
79-
log.Fatal("Could not activate sandbox: ", err)
80-
return
81-
}
82-
83-
if targetResourcesPath == "" {
84-
targetResourcesPath = sandbox.ProjectDataPersistentResourcesPath
85-
}
86-
87-
_, err = os.Stat(targetResourcesPath)
88-
if err != nil {
89-
log.Fatal(fmt.Sprintf("The path %v does not exist", targetResourcesPath))
90-
return
91-
}
92-
93-
err, bucketNameFromCredentials, privateKeyDecoded := retrieveCloudStorageCredentials(instanceIdentifier, projectNamespace, clusterIdentifier)
94-
if err != nil {
95-
log.Fatal(err)
96-
return
97-
}
98-
99-
if sourceBucketName == "" {
100-
sourceBucketName = bucketNameFromCredentials
101-
}
102-
103-
ctx := context.Background()
104-
client, err := storage.NewClient(ctx, option.WithCredentialsJSON(privateKeyDecoded))
105-
if err != nil {
106-
log.Fatal(fmt.Sprintf("Failed to initialize cloud storage client: %v", err))
107-
return
108-
}
109-
110-
log.Info(fmt.Sprintf("Downloading resources from bucket %v to local directory %v ...", sourceBucketName, targetResourcesPath))
111-
112-
bucket := client.Bucket(sourceBucketName)
113-
it := bucket.Objects(ctx, nil)
114-
for {
115-
attributes, err := it.Next()
116-
if errors.Is(err, iterator.Done) {
117-
break
118-
}
119-
if err != nil {
120-
log.Error(err)
121-
} else {
122-
source := bucket.Object(attributes.Name)
123-
targetPathAndFilename := filepath.Join(targetResourcesPath, getRelativePersistentResourcePathByHash(attributes.Name), filepath.Base(attributes.Name))
124-
125-
err = os.MkdirAll(filepath.Dir(targetPathAndFilename), 0755)
126-
if err != nil {
127-
log.Fatal(err)
128-
return
129-
}
130-
131-
if synchronize == true {
132-
if checkFileExists(targetPathAndFilename, attributes) {
133-
log.Debug("Skipped " + attributes.Name + " as it already exists");
134-
continue
77+
sandbox, err := beachsandbox.GetActiveSandbox()
78+
if err != nil {
79+
log.Fatal("Could not activate sandbox: ", err)
80+
return
81+
}
82+
83+
if targetResourcesPath == "" {
84+
targetResourcesPath = sandbox.ProjectDataPersistentResourcesPath
85+
}
86+
87+
_, err = os.Stat(targetResourcesPath)
88+
if err != nil {
89+
log.Fatal(fmt.Sprintf("The path %v does not exist", targetResourcesPath))
90+
return
91+
}
92+
93+
err, bucketNameFromCredentials, privateKeyDecoded := retrieveCloudStorageCredentials(instanceIdentifier, projectNamespace, clusterIdentifier)
94+
if err != nil {
95+
log.Fatal(err)
96+
return
97+
}
98+
99+
if sourceBucketName == "" {
100+
sourceBucketName = bucketNameFromCredentials
101+
}
102+
103+
ctx := context.Background()
104+
client, err := storage.NewClient(ctx, option.WithCredentialsJSON(privateKeyDecoded))
105+
if err != nil {
106+
log.Fatal(fmt.Sprintf("Failed to initialize cloud storage client: %v", err))
107+
return
108+
}
109+
110+
log.Info(fmt.Sprintf("Downloading resources from bucket %v to local directory %v ...", sourceBucketName, targetResourcesPath))
111+
112+
bucket := client.Bucket(sourceBucketName)
113+
it := bucket.Objects(ctx, nil)
114+
for {
115+
attributes, err := it.Next()
116+
if errors.Is(err, iterator.Done) {
117+
break
135118
}
136-
}
137-
138-
file, err := os.OpenFile(targetPathAndFilename, os.O_RDWR|os.O_CREATE, 0644)
139-
if err != nil {
140-
log.Fatal(err)
141-
return
142-
}
143-
reader, err := source.NewReader(ctx)
144-
if err != nil {
145-
log.Fatal(err)
146-
return
147-
}
148-
if _, err := io.Copy(file, reader); err != nil {
149-
log.Fatal(err)
150-
return
151-
}
152-
if err := reader.Close(); err != nil {
153-
log.Fatal(err)
154-
return
155-
}
156-
log.Debug("Downloaded " + attributes.Name)
157-
}
158-
}
159-
160-
log.Info("Done")
161-
return
119+
if err != nil {
120+
log.Error(err)
121+
} else {
122+
source := bucket.Object(attributes.Name)
123+
targetPathAndFilename := filepath.Join(targetResourcesPath, getRelativePersistentResourcePathByHash(attributes.Name), filepath.Base(attributes.Name))
124+
125+
err = os.MkdirAll(filepath.Dir(targetPathAndFilename), 0755)
126+
if err != nil {
127+
log.Fatal(err)
128+
return
129+
}
130+
131+
if synchronize == true {
132+
if checkFileExists(targetPathAndFilename, attributes) {
133+
log.Debug("Skipped " + attributes.Name + " as it already exists")
134+
continue
135+
}
136+
}
137+
138+
file, err := os.OpenFile(targetPathAndFilename, os.O_RDWR|os.O_CREATE, 0644)
139+
if err != nil {
140+
log.Fatal(err)
141+
return
142+
}
143+
reader, err := source.NewReader(ctx)
144+
if err != nil {
145+
log.Fatal(err)
146+
return
147+
}
148+
if _, err := io.Copy(file, reader); err != nil {
149+
log.Fatal(err)
150+
return
151+
}
152+
if err := reader.Close(); err != nil {
153+
log.Fatal(err)
154+
return
155+
}
156+
log.Debug("Downloaded " + attributes.Name)
157+
}
158+
}
159+
160+
log.Info("Done")
161+
return
162162
}
163163

164164
func checkFileExists(targetPathAndFilename string, attributes *storage.ObjectAttrs) bool {
165-
if _, err := os.Stat(targetPathAndFilename); err == nil {
166-
file, err := os.Open(targetPathAndFilename)
167-
if err != nil {
168-
return false
169-
}
170-
defer file.Close()
171-
172-
crc32c := crc32.New(crc32.MakeTable(crc32.Castagnoli))
173-
if _, err := io.Copy(crc32c, file); err != nil {
174-
return false
175-
}
176-
177-
if crc32c.Sum32() == attributes.CRC32C {
178-
return true
179-
}
180-
}
181-
return false
165+
if _, err := os.Stat(targetPathAndFilename); err == nil {
166+
file, err := os.Open(targetPathAndFilename)
167+
if err != nil {
168+
return false
169+
}
170+
defer file.Close()
171+
172+
crc32c := crc32.New(crc32.MakeTable(crc32.Castagnoli))
173+
if _, err := io.Copy(crc32c, file); err != nil {
174+
return false
175+
}
176+
177+
if crc32c.Sum32() == attributes.CRC32C {
178+
return true
179+
}
180+
}
181+
return false
182182
}

0 commit comments

Comments
 (0)