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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ To help you start building real-world applications, we've created Kool Presets a

- **Node**: [NestJS](docs/03-Presets/NestJS.md), [AdonisJs](docs/03-Presets/AdonisJs.md), [Express.js](/docs/03-Presets/ExpressJS.md)
- **PHP**: [Laravel](docs/03-Presets/Laravel.md), [Laravel Octane](docs/03-Presets/Laravel+Octane.md), [Symfony](docs/03-Presets/Symfony.md), [CodeIgniter](docs/03-Presets/CodeIgniter.md)
- **Javascript**: [Next.js](docs/03-Presets/NextJS.md), [NuxtJS](docs/03-Presets/NuxtJS.md)
- **Javascript**: [Next.js](docs/03-Presets/NextJS.md), [NuxtJS](docs/03-Presets/NuxtJS.md), [Bun](docs/03-Presets/Bun.md)
- **Others**: [Hugo](docs/03-Presets/Hugo.md), [WordPress](docs/03-Presets/WordPress.md)

#### Monorepo structures
Expand Down
45 changes: 35 additions & 10 deletions commands/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import (
"kool-dev/kool/core/presets"
"kool-dev/kool/core/shell"
"os"
"path"
"path/filepath"

"github.com/spf13/cobra"
Expand Down Expand Up @@ -83,14 +82,16 @@ func (c *KoolCreate) Execute(args []string) (err error) {
}
}

// sets env variable CREATE_DIRECTORY so preset can use it
c.env.Set("CREATE_DIRECTORY", createDirectory)

if !c.parser.Exists(preset) {
err = fmt.Errorf("unknown preset %s", preset)
return
}

var projectDir string
if projectDir, err = c.prepareCreateTarget(createDirectory); err != nil {
return
}

c.Shell().Println("Creating new", preset, "project...")

c.parser.PrepareExecutor(c.Shell())
Expand All @@ -101,17 +102,16 @@ func (c *KoolCreate) Execute(args []string) (err error) {

c.Shell().Println("Initializing", preset, "preset...")

if !path.IsAbs(createDirectory) {
if createDirectory, err = filepath.Abs(createDirectory); err != nil {
return
}
if _, err = os.Stat(projectDir); os.IsNotExist(err) {
err = fmt.Errorf("preset did not create folder %s", projectDir)
return
}

if err = os.Chdir(createDirectory); err != nil {
if err = os.Chdir(projectDir); err != nil {
return
}

c.env.Set("PWD", createDirectory)
c.env.Set("PWD", projectDir)

if err = c.parser.Install(preset); err != nil {
return
Expand All @@ -122,6 +122,31 @@ func (c *KoolCreate) Execute(args []string) (err error) {
return
}

// prepareCreateTarget makes CREATE_DIRECTORY a folder name relative to the
// current working directory so `kool docker` (which mounts cwd at /app) writes
// the new project onto the host. Absolute paths like /tmp/my-app otherwise
// land in the container's own /tmp and vanish when the container exits.
func (c *KoolCreate) prepareCreateTarget(createDirectory string) (projectDir string, err error) {
if projectDir, err = filepath.Abs(createDirectory); err != nil {
return
}
projectDir = filepath.Clean(projectDir)

parent := filepath.Dir(projectDir)
base := filepath.Base(projectDir)

if err = os.MkdirAll(parent, 0755); err != nil {
return
}
if err = os.Chdir(parent); err != nil {
return
}

c.env.Set("CREATE_DIRECTORY", base)
c.env.Set("PWD", parent)
return
}

// NewCreateCommand initializes new kool create command
func NewCreateCommand(create *KoolCreate) (createCmd *cobra.Command) {
createCmd = &cobra.Command{
Expand Down
26 changes: 26 additions & 0 deletions commands/create_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"kool-dev/kool/core/presets"
"kool-dev/kool/core/shell"
"os"
"path/filepath"
"strings"
"testing"
)
Expand Down Expand Up @@ -117,3 +118,28 @@ func TestErrInstallCreateCommand(t *testing.T) {
// return to original folder
_ = os.Chdir(cwd)
}

func TestCreateCommandUsesRelativeCreateDirectory(t *testing.T) {
f := newFakeKoolCreate()
f.parser.(*presets.FakeParser).MockExists = true

cwd, _ := os.Getwd()
defer func() { _ = os.Chdir(cwd) }()

parent := t.TempDir()
dest := filepath.Join(parent, "my-app")
if err := os.Mkdir(dest, 0755); err != nil {
t.Fatal(err)
}

cmd := NewCreateCommand(f)
cmd.SetArgs([]string{"laravel", dest})

if err := cmd.Execute(); err != nil {
t.Errorf("unexpected error executing create command; error: %v", err)
}

if got := f.env.Get("CREATE_DIRECTORY"); got != "my-app" {
t.Errorf("CREATE_DIRECTORY should be the folder name for docker mounts, got %q", got)
}
}
10 changes: 9 additions & 1 deletion commands/docker.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ type KoolDockerFlags struct {
Volumes []string
Publish []string
Network []string
User string
}

// KoolDocker holds handlers and functions to implement the docker command logic
Expand All @@ -39,7 +40,7 @@ func AddKoolDocker(root *cobra.Command) {
func NewKoolDocker() *KoolDocker {
return &KoolDocker{
*newDefaultKoolService(),
&KoolDockerFlags{[]string{}, []string{}, []string{}, []string{}},
&KoolDockerFlags{[]string{}, []string{}, []string{}, []string{}, ""},
environment.NewEnvStorage(),
builder.NewCommand("docker", "run", "--init", "--rm", "-w", "/app", "-i"),
}
Expand All @@ -58,6 +59,12 @@ func (d *KoolDocker) Execute(args []string) (err error) {
d.dockerRun.AppendArgs("--env", "ASUSER="+asuser)
}

// run the container as the given user/UID (e.g. to keep created files
// owned by the host user on images that do not honor kool's ASUSER)
if d.Flags.User != "" {
d.dockerRun.AppendArgs("--user", d.Flags.User)
}

for _, envVar := range environment.AgentEnvVars(d.envStorage) {
d.dockerRun.AppendArgs("--env", envVar)
}
Expand Down Expand Up @@ -111,6 +118,7 @@ the [COMMAND] to provide optional arguments required by the COMMAND.`,
cmd.Flags().StringArrayVarP(&docker.Flags.Volumes, "volume", "v", []string{}, "Bind mount a volume.")
cmd.Flags().StringArrayVarP(&docker.Flags.Publish, "publish", "p", []string{}, "Publish a container's port(s) to the host.")
cmd.Flags().StringArrayVarP(&docker.Flags.Network, "network", "n", []string{}, "Connect a container to a network.")
cmd.Flags().StringVarP(&docker.Flags.User, "user", "u", "", "Username or UID (format: <name|uid>[:<group|gid>]) to run the container as.")

//After a non-flag arg, stop parsing flags
cmd.Flags().SetInterspersed(false)
Expand Down
26 changes: 24 additions & 2 deletions commands/docker_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import (
func newFakeKoolDocker() *KoolDocker {
return &KoolDocker{
*(newDefaultKoolService().Fake()),
&KoolDockerFlags{[]string{}, []string{}, []string{}, []string{}},
&KoolDockerFlags{[]string{}, []string{}, []string{}, []string{}, ""},
environment.NewFakeEnvStorage(),
&builder.FakeCommand{MockCmd: "docker"},
}
Expand All @@ -23,7 +23,7 @@ func newFakeKoolDocker() *KoolDocker {
func newFailedFakeKoolDocker() *KoolDocker {
return &KoolDocker{
*(newDefaultKoolService().Fake()),
&KoolDockerFlags{[]string{}, []string{}, []string{}, []string{}},
&KoolDockerFlags{[]string{}, []string{}, []string{}, []string{}, ""},
environment.NewFakeEnvStorage(),
&builder.FakeCommand{MockCmd: "docker", MockInteractiveError: errors.New("error docker")},
}
Expand All @@ -50,6 +50,10 @@ func TestNewKoolDocker(t *testing.T) {
if len(k.Flags.Publish) > 0 {
t.Errorf("bad default value for Publish flag on default KoolDocker instance")
}

if k.Flags.User != "" {
t.Errorf("bad default value for User flag on default KoolDocker instance")
}
}

if _, ok := k.dockerRun.(*builder.DefaultCommand); !ok {
Expand Down Expand Up @@ -169,6 +173,24 @@ func TestEnvFlagNewDockerCommand(t *testing.T) {
}
}

func TestUserFlagNewDockerCommand(t *testing.T) {
f := newFakeKoolDocker()
f.shell.(*shell.FakeShell).MockIsTerminal = false
cmd := NewDockerCommand(f)

cmd.SetArgs([]string{"--user=1000", "image"})

if err := cmd.Execute(); err != nil {
t.Errorf("unexpected error executing docker command; error: %v", err)
}

argsAppend := f.dockerRun.(*builder.FakeCommand).ArgsAppend

if len(argsAppend) != 4 || argsAppend[0] != "--user" || argsAppend[1] != "1000" {
t.Errorf("bad arguments to KoolDocker.dockerRun Command with User flag: %v", argsAppend)
}
}

func TestAgentEnvNewDockerCommand(t *testing.T) {
f := newFakeKoolDocker()
f.shell.(*shell.FakeShell).MockIsTerminal = false
Expand Down
68 changes: 50 additions & 18 deletions commands/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -236,15 +236,33 @@ func (s *KoolStatus) getServices() (services []string, err error) {
return
}

parsedServices := strings.Split(strings.ReplaceAll(output, "\r\n", "\n"), "\n")
for _, s := range parsedServices {
if s != "" {
services = append(services, s)
services = parseComposeServices(output)
return
}

func composeTokenLines(output string) (tokens []string) {
for _, line := range strings.Split(strings.ReplaceAll(output, "\r\n", "\n"), "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.ContainsAny(line, " \t") {
continue
}
tokens = append(tokens, line)
}
return
}

func parseComposeServices(output string) []string {
return composeTokenLines(output)
}

func firstComposeToken(output string) string {
tokens := composeTokenLines(output)
if len(tokens) == 0 {
return ""
}
return tokens[0]
}

func (s *KoolStatus) fetchServiceInfo(project, service string, chStatus chan *statusService, wg *sync.WaitGroup) {
var isRunning bool

Expand All @@ -262,24 +280,34 @@ func (s *KoolStatus) fetchServiceInfo(project, service string, chStatus chan *st
func (s *KoolStatus) fetchLegacyServiceInfo(service string, chStatus chan *statusService, wg *sync.WaitGroup) {
defer wg.Done()
status := &statusService{service: service, running: "Not running"}
var serviceID string
if serviceID, status.err = s.Shell().Exec(s.getServiceIDCmd, service); status.err == nil && serviceID != "" {
status.state, status.ports = s.getStatusPort(serviceID)
if strings.HasPrefix(status.state, "Up") {
status.running = "Running"
var output string
if output, status.err = s.Shell().Exec(s.getServiceIDCmd, service); status.err == nil {
serviceID := firstComposeToken(output)
if serviceID != "" {
status.state, status.ports = s.getStatusPort(serviceID)
if strings.HasPrefix(status.state, "Up") {
status.running = "Running"
}
}
}
chStatus <- status
}

func (s *KoolStatus) getServiceInfo(project, service string) (isRunning bool, status, port string, err error) {
var serviceID string
if serviceID, err = s.Shell().Exec(s.getProjectServiceIDCmd, "--filter", "label=com.docker.compose.project="+project, "--filter", "label=com.docker.compose.service="+service, "--filter", "label=com.docker.compose.oneoff=False"); err == nil && serviceID != "" {
serviceID = strings.Fields(serviceID)[0]
status, port = s.getStatusPort(serviceID)
if strings.HasPrefix(status, "Up") {
isRunning = true
}
var output string
if output, err = s.Shell().Exec(s.getProjectServiceIDCmd, "--filter", "label=com.docker.compose.project="+project, "--filter", "label=com.docker.compose.service="+service, "--filter", "label=com.docker.compose.oneoff=False"); err != nil {
return
}

// docker compose may mix warnings into combined output; keep only the container id
serviceID := firstComposeToken(output)
if serviceID == "" {
return
}

status, port = s.getStatusPort(serviceID)
if strings.HasPrefix(status, "Up") {
isRunning = true
}
return
}
Expand All @@ -288,10 +316,14 @@ func (s *KoolStatus) getCurrentServiceInfo(service string) (bool, string, string
if workspacesEnabled(s.env) {
return s.getServiceInfo(currentProject(s.env), service)
}
serviceID, err := s.Shell().Exec(s.getServiceIDCmd, service)
if err != nil || serviceID == "" {
output, err := s.Shell().Exec(s.getServiceIDCmd, service)
if err != nil {
return false, "", "", err
}
serviceID := firstComposeToken(output)
if serviceID == "" {
return false, "", "", nil
}
status, port := s.getStatusPort(serviceID)
return strings.HasPrefix(status, "Up"), status, port, nil
}
Expand Down
14 changes: 14 additions & 0 deletions commands/status_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -358,3 +358,17 @@ cache | Not running | | output`
t.Errorf("Expected '%s', got '%s'", expected, output)
}
}

func TestParseComposeServicesIgnoresWarnings(t *testing.T) {
out := "time=\"2026-08-25T17:03:52-03:00\" level=warning msg=\"the attribute `version` is obsolete\"\napp\n"
services := parseComposeServices(out)

if len(services) != 1 || services[0] != "app" {
t.Errorf("expected only 'app', got %v", services)
}

idOut := "time=\"2026-08-25T17:03:52-03:00\" level=warning msg=\"the attribute `version` is obsolete\"\nabc123def456\n"
if got := firstComposeToken(idOut); got != "abc123def456" {
t.Errorf("expected container id, got %q", got)
}
}
2 changes: 0 additions & 2 deletions docs/01-Getting-Started/4-Starting-new-project.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,6 @@ $ touch docker-compose.yml
Copy/paste into this **docker-compose.yml** file a simple, generic Docker Compose configuration for an `app` service container:

```yaml
version: "3.8" # optional since v1.27.0
services:
app:
image: kooldev/php:8.0-nginx
Expand Down Expand Up @@ -174,7 +173,6 @@ scripts:
Here's a more extensible, **Kool-optimized** setup for **docker-compose.yml** into which you can easily add additional services (i.e. database, cache, etc):

```yaml
version: "3.8" # optional since v1.27.0
services:
app:
image: kooldev/php:8.0-nginx
Expand Down
1 change: 1 addition & 0 deletions docs/03-Presets/0-About-Presets.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ By leveraging Kool Presets, you not only reduce the learning curve associated wi

- [Next.js](/docs/03-Presets/NextJS.md)
- [NuxtJS](/docs/03-Presets/NuxtJS.md)
- [Bun](/docs/03-Presets/Bun.md)

## Others

Expand Down
Loading
Loading