Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 30 additions & 6 deletions cmd/sst/mosaic.go
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down Expand Up @@ -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 == "" {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
48 changes: 48 additions & 0 deletions cmd/sst/mosaic_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
21 changes: 21 additions & 0 deletions platform/src/components/aws/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
34 changes: 34 additions & 0 deletions platform/test/components/service-alb.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading