diff --git a/cmd/sst/mosaic.go b/cmd/sst/mosaic.go index c81c79b813..f1f5beb037 100644 --- a/cmd/sst/mosaic.go +++ b/cmd/sst/mosaic.go @@ -111,9 +111,12 @@ func CmdMosaic(c *cli.Cli) error { <-processExited fmt.Println("\n[restarting]") } - fields, _ := shellquote.Split(nextEnv.Command) - if len(args) > 0 { - fields = args + fields := args + if len(fields) == 0 { + fields, err = parseCommand(nextEnv.Command) + if err != nil { + return err + } } cmd = process.Command( fields[0], @@ -330,9 +333,12 @@ func CmdMosaic(c *cli.Cli) error { switch evt := unknown.(type) { case *project.CompleteEvent: for _, d := range evt.Devs { - if d.Command == "" { + if strings.TrimSpace(d.Command) == "" { continue } + if _, err := parseCommand(d.Command); err != nil { + return fmt.Errorf("invalid dev command for %q: %w", d.Name, err) + } dir := filepath.Join(cwd, d.Directory) title := d.Title if title == "" { @@ -448,11 +454,14 @@ func CmdMosaic(c *cli.Cli) error { switch evt := unknown.(type) { case *project.CompleteEvent: for _, d := range evt.Devs { - if d.Command == "" { + if strings.TrimSpace(d.Command) == "" { continue } + words, err := parseCommand(d.Command) + if err != nil { + return fmt.Errorf("invalid dev command for %q: %w", d.Name, err) + } dir := filepath.Join(cwd, d.Directory) - words, _ := shellquote.Split(d.Command) title := d.Title if title == "" { title = d.Name @@ -480,6 +489,21 @@ func CmdMosaic(c *cli.Cli) error { return err } +func parseCommand(command string) ([]string, error) { + if strings.TrimSpace(command) == "" { + return nil, fmt.Errorf("command is empty") + } + + fields, err := shellquote.Split(command) + if err != nil { + return nil, err + } + if len(fields) == 0 { + return nil, fmt.Errorf("command is empty") + } + return fields, nil +} + func diff(a map[string]string, b map[string]string) bool { if len(a) != len(b) { return true diff --git a/cmd/sst/mosaic_test.go b/cmd/sst/mosaic_test.go new file mode 100644 index 0000000000..41281a54c3 --- /dev/null +++ b/cmd/sst/mosaic_test.go @@ -0,0 +1,48 @@ +package main + +import ( + "reflect" + "testing" +) + +func TestParseCommand(t *testing.T) { + tests := []struct { + name string + command string + want []string + wantErr bool + }{ + { + name: "splits shell arguments", + command: `npm run dev -- --name "my service"`, + want: []string{"npm", "run", "dev", "--", "--name", "my service"}, + }, + { + name: "rejects empty command", + command: "", + wantErr: true, + }, + { + name: "rejects whitespace command", + command: " \t ", + wantErr: true, + }, + { + name: "rejects malformed quoting", + command: `npm run "dev`, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseCommand(tt.command) + if (err != nil) != tt.wantErr { + t.Fatalf("parseCommand(%q) error = %v, wantErr %t", tt.command, err, tt.wantErr) + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("parseCommand(%q) = %q, want %q", tt.command, got, tt.want) + } + }) + } +} diff --git a/platform/src/components/aws/service.ts b/platform/src/components/aws/service.ts index 3ea034162a..1a3859c4b9 100644 --- a/platform/src/components/aws/service.ts +++ b/platform/src/components/aws/service.ts @@ -2803,8 +2803,29 @@ export class Service extends Component implements Link.Linkable { } function registerReceiver() { + if ( + args.containers && + args.dev && + typeof args.dev === "object" && + args.dev.command + ) { + new DevCommand(`${name}Dev`, { + link: args.link, + dev: args.dev, + aws: { + role: taskRole.arn, + }, + }); + } + all([containers]).apply(([val]) => { for (const container of val) { + if ( + !container.dev || + typeof container.dev !== "object" || + !container.dev.command + ) + continue; const title = val.length == 1 ? name : `${name}${container.name}`; new DevCommand(`${title}Dev`, { link: args.link, diff --git a/platform/test/components/service-alb.test.ts b/platform/test/components/service-alb.test.ts index 2a08450708..88a5a4e7a8 100644 --- a/platform/test/components/service-alb.test.ts +++ b/platform/test/components/service-alb.test.ts @@ -270,6 +270,40 @@ describe("Service with external ALB", function () { }); describe("multiple containers", () => { + it("registers dev commands only for containers with a dev command", async () => { + createdResources.length = 0; + // @ts-ignore — Service checks $dev to decide dev mode + global.$dev = true; + + try { + new Service("DevMultiContainerService", { + cluster, + dev: { command: "npm run dev" }, + containers: [ + { + name: "api", + image: { context: "./api" }, + }, + { + name: "sidecar", + image: { context: "./worker" }, + }, + ], + }); + + await new Promise((resolve) => setTimeout(resolve, 0)); + } finally { + // @ts-ignore — Restore the default used by the other tests + global.$dev = false; + } + + const devCommands = createdResources.filter( + (resource) => resource.type === "sst:sst:DevCommand", + ); + expect(devCommands).toHaveLength(1); + expect(devCommands[0].name).toBe("DevMultiContainerServiceDev"); + }); + it("creates service with container field in rules", async () => { const service = new Service("MultiContainerService", { cluster,