55 "context"
66 "encoding/json"
77 "fmt"
8+ "io"
89 "net"
910 "net/http"
1011 "os"
@@ -24,10 +25,11 @@ import (
2425type Client struct {
2526 app string
2627 region string
28+ logw io.Writer
2729}
2830
2931// NewClient creates a new Fly.io backend client.
30- func NewClient (app , region string ) (* Client , error ) {
32+ func NewClient (app , region string , logw io. Writer ) (* Client , error ) {
3133 if _ , err := exec .LookPath ("fly" ); err != nil {
3234 return nil , fmt .Errorf ("fly CLI not found (install from https://fly.io/docs/flyctl/install/): %w" , err )
3335 }
@@ -43,7 +45,16 @@ func NewClient(app, region string) (*Client, error) {
4345 if region == "" {
4446 region = "syd"
4547 }
46- return & Client {app : app , region : region }, nil
48+ if logw == nil {
49+ logw = os .Stderr
50+ }
51+ return & Client {app : app , region : region , logw : logw }, nil
52+ }
53+
54+ func (c * Client ) logf (format string , args ... any ) {
55+ if c .logw != nil {
56+ fmt .Fprintf (c .logw , format , args ... )
57+ }
4758}
4859
4960// Close is a no-op.
@@ -204,40 +215,40 @@ func (c *Client) Run(ctx context.Context, opts backend.RunOptions) error {
204215 imageRef := fmt .Sprintf ("registry.fly.io/%s:%s" , c .app , opts .Image )
205216
206217 // 1. Create machine with sleep infinity (keeps it running for sync + reconnect)
207- fmt . Fprintf ( os . Stderr , " → Creating machine...\n " )
218+ c . logf ( " → Creating machine...\n " )
208219 machineID , err := c .createMachine (ctx , imageRef , opts )
209220 if err != nil {
210221 return err
211222 }
212- fmt . Fprintf ( os . Stderr , " → Machine created: %s\n " , machineID )
223+ c . logf ( " → Machine created: %s\n " , machineID )
213224
214225 // 2. Wait for machine to start
215- fmt . Fprintf ( os . Stderr , " → Waiting for machine to start...\n " )
226+ c . logf ( " → Waiting for machine to start...\n " )
216227 if err := c .waitForState (ctx , machineID , "started" , 5 * time .Minute ); err != nil {
217228 c .destroyMachine (ctx , machineID )
218229 return err
219230 }
220231
221232 // 3. Wait for SSH to be ready
222- fmt . Fprintf ( os . Stderr , " → Waiting for SSH...\n " )
233+ c . logf ( " → Waiting for SSH...\n " )
223234 if err := c .waitForSSH (ctx , machineID , 60 * time .Second ); err != nil {
224235 c .destroyMachine (ctx , machineID )
225236 return err
226237 }
227238
228239 // 4. Start file sync (mutagen: initial sync + continuous bidirectional)
229- fmt . Fprintf ( os . Stderr , " → Syncing files...\n " )
240+ c . logf ( " → Syncing files...\n " )
230241 stopSync , err := c .startMutagenSync (ctx , machineID , opts .MountsRO , opts .MountsRW , opts .CleanMountPaths )
231242 if err != nil {
232243 c .destroyMachine (ctx , machineID )
233244 return fmt .Errorf ("file sync failed: %w" , err )
234245 }
235- fmt . Fprintf ( os . Stderr , " → Files synced\n " )
246+ c . logf ( " → Files synced\n " )
236247
237248 // 5. Run pre-run hooks (skip mount wait since files are already synced)
238249 hooks := filterMountWait (opts .PreRunHooks )
239250 if len (hooks ) > 0 {
240- fmt . Fprintf ( os . Stderr , " → Running pre-run hooks...\n " )
251+ c . logf ( " → Running pre-run hooks...\n " )
241252 hookScript := strings .Join (hooks , " && " )
242253 if err := c .sshExec (ctx , machineID , hookScript ); err != nil {
243254 stopSync ()
@@ -247,7 +258,7 @@ func (c *Client) Run(ctx context.Context, opts backend.RunOptions) error {
247258 }
248259
249260 // 6. Connect interactively with the tool command (via tmux)
250- fmt . Fprintf ( os . Stderr , " → Connecting...\n " )
261+ c . logf ( " → Connecting...\n " )
251262 connectErr := c .connectInteractive (ctx , machineID , opts )
252263
253264 // 7. Check if the tmux session is still running (user detached vs tool exited).
@@ -256,16 +267,16 @@ func (c *Client) Run(ctx context.Context, opts backend.RunOptions) error {
256267 tmuxAlive := c .isTmuxSessionAlive (ctx , machineID )
257268
258269 // 8. Stop continuous sync (flushes final changes)
259- fmt . Fprintf ( os . Stderr , " → Syncing final changes...\n " )
270+ c . logf ( " → Syncing final changes...\n " )
260271 stopSync ()
261272
262273 if tmuxAlive {
263- fmt . Fprintf ( os . Stderr , " → Detached. Machine %s still running — use 'silo reconnect %s --backend fly' to reattach.\n " , machineID , opts .Name )
274+ c . logf ( " → Detached. Machine %s still running — use 'silo reconnect %s --backend fly' to reattach.\n " , machineID , opts .Name )
264275 return nil
265276 }
266277
267278 // Tool exited, destroy the machine
268- fmt . Fprintf ( os . Stderr , " → Destroying machine...\n " )
279+ c . logf ( " → Destroying machine...\n " )
269280 destroyCtx , cancel := context .WithTimeout (context .Background (), 30 * time .Second )
270281 defer cancel ()
271282 c .destroyMachine (destroyCtx , machineID )
@@ -297,15 +308,15 @@ func (c *Client) Reconnect(ctx context.Context, name string, opts backend.RunOpt
297308 }
298309
299310 // Re-sync files (don't clean mount paths — tool is already running)
300- fmt . Fprintf ( os . Stderr , " → Syncing files...\n " )
311+ c . logf ( " → Syncing files...\n " )
301312 stopSync , err := c .startMutagenSync (ctx , machineID , opts .MountsRO , opts .MountsRW , nil )
302313 if err != nil {
303314 return fmt .Errorf ("file sync failed: %w" , err )
304315 }
305- fmt . Fprintf ( os . Stderr , " → Files synced\n " )
316+ c . logf ( " → Files synced\n " )
306317
307318 // Reattach to the tmux session
308- fmt . Fprintf ( os . Stderr , " → Reconnecting...\n " )
319+ c . logf ( " → Reconnecting...\n " )
309320 user := currentUsername ()
310321 if user == "" {
311322 user = "root"
@@ -317,16 +328,16 @@ func (c *Client) Reconnect(ctx context.Context, name string, opts backend.RunOpt
317328 tmuxAlive := c .isTmuxSessionAlive (ctx , machineID )
318329
319330 // Stop sync (flush final changes)
320- fmt . Fprintf ( os . Stderr , " → Syncing final changes...\n " )
331+ c . logf ( " → Syncing final changes...\n " )
321332 stopSync ()
322333
323334 if tmuxAlive {
324- fmt . Fprintf ( os . Stderr , " → Detached. Machine %s still running — use 'silo reconnect %s --backend fly' to reattach.\n " , machineID , name )
335+ c . logf ( " → Detached. Machine %s still running — use 'silo reconnect %s --backend fly' to reattach.\n " , machineID , name )
325336 return nil
326337 }
327338
328339 // Tool exited, destroy the machine
329- fmt . Fprintf ( os . Stderr , " → Destroying machine...\n " )
340+ c . logf ( " → Destroying machine...\n " )
330341 destroyCtx , cancel := context .WithTimeout (context .Background (), 30 * time .Second )
331342 defer cancel ()
332343 c .destroyMachine (destroyCtx , machineID )
@@ -759,7 +770,7 @@ func (c *Client) startMutagenSync(ctx context.Context, machineID string, mountsR
759770 return nil , fmt .Errorf ("mutagen is required for the fly backend (install from https://mutagen.io): %w" , err )
760771 }
761772
762- progress := newSyncProgress (os . Stderr )
773+ progress := newSyncProgress (c . logw )
763774
764775 // Create SSH tunnel for mutagen (fly proxy + issued certs + ControlMaster)
765776 progress .setPhase ("Establishing SSH tunnel..." )
@@ -896,7 +907,7 @@ func (c *Client) startMutagenSync(ctx context.Context, machineID string, mountsR
896907 }
897908 localPath = target
898909 } else {
899- fmt . Fprintf ( os . Stderr , " mutagen: warning: cannot resolve %s: %v\n " , m .path , err )
910+ c . logf ( " mutagen: warning: cannot resolve %s: %v\n " , m .path , err )
900911 }
901912 }
902913
0 commit comments