88 "fmt"
99 "io"
1010 "os"
11+ "path/filepath"
1112 "strings"
1213
1314 "github.com/spf13/cobra"
@@ -60,7 +61,7 @@ func detectHook(r io.Reader) *hookContext {
6061}
6162
6263func newValidateCmd () * cobra.Command {
63- var sidecarID , identityFile , workdir string
64+ var sidecarID , identityFile , workdir , orgID string
6465 var dryRun , list , save , remote bool
6566 var inlineCmd , projectDir string
6667
@@ -135,13 +136,35 @@ func newValidateCmd() *cobra.Command {
135136 return mapValidateError (validate .RunDryRun (cfg , name , statusFn ))
136137 }
137138
139+ // allRemote is true when the caller explicitly targets the sidecar
140+ // (--remote or --sidecar-id), meaning every command runs there.
141+ // Per-command routing only applies when the sidecar is resolved implicitly.
142+ allRemote := remote || sidecarID != ""
143+
144+ image := resolveImage (name , cfg )
145+
138146 if remote {
139- if err := resolveSidecarID (& sidecarID ); err != nil {
147+ // --remote: force all commands to sidecar, creating one if needed.
148+ if err := resolveOrCreateSidecarID (cmd .Context (), & sidecarID , orgID , image , workDir , streams ); err != nil {
140149 return err
141150 }
151+ statusFn (iostream .LevelInfo , fmt .Sprintf ("running all commands on sidecar %s" , sidecarID ))
152+ } else if cfg .HasRemoteCommands () {
153+ // Per-command remote: use active sidecar if available.
154+ if active , err := sidecar .LoadActive (); err == nil && active != nil {
155+ sidecarID = active .SidecarID
156+ statusFn (iostream .LevelInfo , fmt .Sprintf ("using sidecar %s for remote commands" , sidecarID ))
157+ } else if hook != nil {
158+ // In Stop hook context: auto-create a sandbox if possible.
159+ if err := resolveOrCreateSidecarID (cmd .Context (), & sidecarID , orgID , image , workDir , streams ); err != nil {
160+ streams .ErrPrintf ("warning: no sandbox available (%v); run 'chunk config set orgID <id>' to enable remote validation, running locally instead\n " , err )
161+ }
162+ } else {
163+ statusFn (iostream .LevelWarn , "no active sidecar found — remote commands will run locally" )
164+ }
142165 }
143166
144- execErr := runValidate (cmd .Context (), workDir , name , inlineCmd , save , sidecarID , identityFile , workdir , cfg , statusFn , streams )
167+ execErr := runValidate (cmd .Context (), workDir , name , inlineCmd , save , sidecarID , identityFile , workdir , allRemote , cfg , statusFn , streams )
145168
146169 if hook != nil {
147170 maxAttempts := cfg .StopHookMaxAttempts
@@ -154,8 +177,9 @@ func newValidateCmd() *cobra.Command {
154177 },
155178 }
156179
157- cmd .Flags ().BoolVar (& remote , "remote" , false , "Run on active sidecar (reads .chunk/sidecar.json) " )
180+ cmd .Flags ().BoolVar (& remote , "remote" , false , "Run on active sidecar, or create one if none is set " )
158181 cmd .Flags ().StringVar (& sidecarID , "sidecar-id" , "" , "Sidecar ID for remote execution" )
182+ cmd .Flags ().StringVar (& orgID , "org-id" , "" , "Organization ID (used when creating a new sidecar)" )
159183 cmd .Flags ().StringVar (& identityFile , "identity-file" , "" , "SSH identity file (uses ssh-agent or ~/.ssh/chunk_ai when omitted)" )
160184 cmd .Flags ().StringVar (& workdir , "workdir" , "" , "Working directory on sidecar (reads from sidecar.json, defaults to ./workspace)" )
161185 cmd .Flags ().BoolVar (& dryRun , "dry-run" , false , "Show commands without executing" )
@@ -169,8 +193,10 @@ func newValidateCmd() *cobra.Command {
169193
170194// runValidate dispatches to the appropriate Run* function based on the
171195// provided options. It is shared by both direct and hook invocations.
172- func runValidate (ctx context.Context , workDir , name , inlineCmd string , save bool , sidecarID , identityFile , workdir string , cfg * config.ProjectConfig , statusFn iostream.StatusFunc , streams iostream.Streams ) error {
173- // --cmd: inline command
196+ // allRemote is true when --remote is passed explicitly (all commands run on the
197+ // sidecar); false means only commands with Remote:true are routed to the sidecar.
198+ func runValidate (ctx context.Context , workDir , name , inlineCmd string , save bool , sidecarID , identityFile , workdir string , allRemote bool , cfg * config.ProjectConfig , statusFn iostream.StatusFunc , streams iostream.Streams ) error {
199+ // --cmd: inline command (always local in per-command mode)
174200 if inlineCmd != "" {
175201 cmdName := name
176202 if cmdName == "" {
@@ -182,23 +208,65 @@ func runValidate(ctx context.Context, workDir, name, inlineCmd string, save bool
182208 }
183209 streams .ErrPrintf ("%s\n " , ui .Success (fmt .Sprintf ("Saved %s to .chunk/config.json" , cmdName )))
184210 }
185- if sidecarID != "" {
211+ if sidecarID != "" && allRemote {
186212 execFn , dest , err := openSSHSession (ctx , sidecarID , identityFile , workdir , streams )
187213 if err != nil {
188214 return err
189215 }
190- return validate .RunRemoteInline (ctx , execFn , cmdName , inlineCmd , dest , streams )
216+ return validate .RunRemoteInline (ctx , execFn , cmdName , inlineCmd , dest , statusFn , streams )
191217 }
192218 return validate .RunInline (ctx , workDir , cmdName , inlineCmd , statusFn , streams )
193219 }
194220
195- // Remote execution
196- if sidecarID != "" {
221+ // All-remote execution (--remote flag): send everything to the sidecar.
222+ if sidecarID != "" && allRemote {
197223 execFn , dest , err := openSSHSession (ctx , sidecarID , identityFile , workdir , streams )
198224 if err != nil {
199225 return err
200226 }
201- return validate .RunRemote (ctx , execFn , cfg , name , dest , streams )
227+ return validate .RunRemote (ctx , execFn , cfg , name , dest , statusFn , streams )
228+ }
229+
230+ // Per-command remote routing: commands with Remote:true go to the sidecar,
231+ // the rest run locally.
232+ if sidecarID != "" {
233+ if name != "" {
234+ if cmd := cfg .FindCommand (name ); cmd != nil && cmd .Remote {
235+ statusFn (iostream .LevelInfo , fmt .Sprintf ("running %s on sidecar %s" , name , sidecarID ))
236+ execFn , dest , err := openSSHSession (ctx , sidecarID , identityFile , workdir , streams )
237+ if err != nil {
238+ return err
239+ }
240+ return validate .RunRemote (ctx , execFn , cfg , name , dest , statusFn , streams )
241+ }
242+ statusFn (iostream .LevelInfo , fmt .Sprintf ("running %s locally (not marked remote)" , name ))
243+ // Named command is not marked remote; fall through to local execution.
244+ } else {
245+ remoteCfg , localCfg := splitByRemote (cfg )
246+ if len (remoteCfg .Commands ) > 0 {
247+ names := commandNames (remoteCfg .Commands )
248+ statusFn (iostream .LevelInfo , fmt .Sprintf ("running on sidecar %s: %s" , sidecarID , names ))
249+ }
250+ if len (localCfg .Commands ) > 0 {
251+ statusFn (iostream .LevelInfo , fmt .Sprintf ("running locally: %s" , commandNames (localCfg .Commands )))
252+ }
253+ var runErr error
254+ if len (remoteCfg .Commands ) > 0 {
255+ execFn , dest , err := openSSHSession (ctx , sidecarID , identityFile , workdir , streams )
256+ if err != nil {
257+ streams .ErrPrintf ("warning: could not reach sidecar (%v); running %s locally instead\n " , err , commandNames (remoteCfg .Commands ))
258+ localCfg .Commands = append (remoteCfg .Commands , localCfg .Commands ... )
259+ } else {
260+ runErr = validate .RunRemote (ctx , execFn , remoteCfg , "" , dest , statusFn , streams )
261+ }
262+ }
263+ if len (localCfg .Commands ) > 0 {
264+ if err := mapValidateError (validate .RunAll (ctx , workDir , localCfg , statusFn , streams )); err != nil {
265+ runErr = errors .Join (runErr , err )
266+ }
267+ }
268+ return runErr
269+ }
202270 }
203271
204272 // Named command
@@ -270,6 +338,108 @@ func openSSHSession(ctx context.Context, sidecarID, identityFile, workdir string
270338 return execFn , dest , nil
271339}
272340
341+ // splitByRemote partitions cfg.Commands into two configs: one containing only
342+ // commands with Remote:true, and one containing the rest.
343+ func splitByRemote (cfg * config.ProjectConfig ) (remote , local * config.ProjectConfig ) {
344+ remote = & config.ProjectConfig {}
345+ local = & config.ProjectConfig {}
346+ for _ , cmd := range cfg .Commands {
347+ if cmd .Remote {
348+ remote .Commands = append (remote .Commands , cmd )
349+ } else {
350+ local .Commands = append (local .Commands , cmd )
351+ }
352+ }
353+ return remote , local
354+ }
355+
356+ // commandNames returns a comma-separated list of command names.
357+ func commandNames (cmds []config.Command ) string {
358+ names := make ([]string , len (cmds ))
359+ for i , c := range cmds {
360+ names [i ] = c .Name
361+ }
362+ return strings .Join (names , ", " )
363+ }
364+
365+ // resolveImage returns the sidecar image to use for sandbox creation.
366+ // A per-command sidecarImage takes precedence over the project-level default.
367+ func resolveImage (name string , cfg * config.ProjectConfig ) string {
368+ if name != "" && cfg != nil {
369+ if cmd := cfg .FindCommand (name ); cmd != nil && cmd .SidecarImage != "" {
370+ return cmd .SidecarImage
371+ }
372+ }
373+ if cfg != nil && cfg .Validation != nil {
374+ return cfg .Validation .SidecarImage
375+ }
376+ return ""
377+ }
378+
379+ // resolveOrCreateSidecarID fills sidecarID from the active sidecar, or creates
380+ // a new sandbox when none is configured.
381+ func resolveOrCreateSidecarID (ctx context.Context , sidecarID * string , orgID , image , workDir string , streams iostream.Streams ) error {
382+ if * sidecarID != "" {
383+ return nil
384+ }
385+ active , err := sidecar .LoadActive ()
386+ if err != nil {
387+ return & userError {msg : "Could not load the active sidecar." , suggestion : configFilePermHint , err : err }
388+ }
389+ if active != nil {
390+ * sidecarID = active .SidecarID
391+ return nil
392+ }
393+ streams .ErrPrintf ("No active sidecar found, creating a new sandbox...\n " )
394+ client , err := ensureCircleCIClient (ctx , streams , tui .PromptHidden )
395+ if err != nil {
396+ return err
397+ }
398+ // Fallback: read org ID from project config if not provided via flag or env.
399+ if orgID == "" {
400+ if projCfg , loadErr := config .LoadProjectConfig (workDir ); loadErr == nil && projCfg .OrgID != "" {
401+ orgID = projCfg .OrgID
402+ }
403+ }
404+ resolvedOrgID , err := resolveOrgID (orgID , orgPicker (ctx , client ))
405+ if err != nil {
406+ return err
407+ }
408+ provider := os .Getenv (config .EnvSidecarProvider )
409+ if provider == "" {
410+ provider = defaultProvider
411+ }
412+ sandboxName := filepath .Base (workDir ) + "-validate"
413+ sc , err := sidecar .Create (ctx , client , resolvedOrgID , sandboxName , provider , image )
414+ if err != nil {
415+ if authErr := notAuthorized ("create sidecars" , err ); authErr != nil {
416+ return authErr
417+ }
418+ return & userError {
419+ msg : "Could not create a sandbox." ,
420+ suggestion : "Check your network connection or run 'chunk sidecar create' manually." ,
421+ err : err ,
422+ }
423+ }
424+ if saveErr := sidecar .SaveActive (sidecar.ActiveSidecar {SidecarID : sc .ID , Name : sc .Name }); saveErr != nil {
425+ streams .ErrPrintf ("warning: could not save active sidecar: %v\n " , saveErr )
426+ }
427+ // Persist the org ID so future sandbox creation skips the picker.
428+ projCfg , loadErr := config .LoadProjectConfig (workDir )
429+ if loadErr != nil {
430+ projCfg = & config.ProjectConfig {}
431+ }
432+ if projCfg .OrgID == "" {
433+ projCfg .OrgID = resolvedOrgID
434+ if saveErr := config .SaveProjectConfig (workDir , projCfg ); saveErr != nil {
435+ streams .ErrPrintf ("warning: could not save org ID to project config: %v\n " , saveErr )
436+ }
437+ }
438+ streams .ErrPrintf ("%s\n " , ui .Success (fmt .Sprintf ("Created sandbox %s (%s)" , sc .Name , sc .ID )))
439+ * sidecarID = sc .ID
440+ return nil
441+ }
442+
273443func mapValidateError (err error ) error {
274444 if errors .Is (err , validate .ErrNotConfigured ) {
275445 return & userError {
0 commit comments