diff --git a/README.md b/README.md index 133caec1..073786c6 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ Whether you want a browser-based VS Code session, a Jupyter notebook environment - [Built-in Tools](#built-in-tools) - [Quick Examples](#quick-examples) - [Customization](#customization) +- [Boothfile](#boothfile) - [Guarantees & Limits](#guarantees--limits) - [How It Works](#how-it-works) - [`booth` Manual](#booth-manual) @@ -116,18 +117,21 @@ CodingBooth provides a command-line interface with the following structure: | `--verbose` | Enable debug output | | `--config ` | Use custom config file | | `--code ` | Set code directory | +| `--boothfile ` | Use specific Boothfile (compiles to Dockerfile) | +| `--emit-dockerfile` | Print generated Dockerfile without building | +| `--strict` | Treat Boothfile warnings as errors | | `--help`, `-h` | Show help information | ### Wrapper vs Binary Commands The `booth` script is a **wrapper** that manages the underlying `codingbooth` binary. They have separate help and version commands: -| Command | What it shows | -|-------------------|--------------------------------------------| -| `./booth help` | Wrapper help (install, update, cache, etc.)| -| `./booth --help` | Binary help (run flags, variants, etc.) | -| `./booth version` | Wrapper + binary version info | -| `./booth --version`| Binary version only | +| Command | What it shows | +|--------------------|---------------------------------------------| +| `./booth help` | Wrapper help (install, update, cache, etc.) | +| `./booth --help` | Binary help (run flags, variants, etc.) | +| `./booth version` | Wrapper + binary version info | +| `./booth --version`| Binary version only | > 💡 **Tip:** Use `booth help` to learn about managing CodingBooth installations. Use `booth --help` to see runtime options for launching containers. @@ -390,7 +394,10 @@ All booth configuration lives in a single `.booth/` folder in your project root: my-project/ └── .booth/ ├── config.toml # Launcher configuration - ├── Dockerfile # Custom Docker build (optional) + ├── Boothfile # Simplified build script (optional, preferred) + ├── Dockerfile # Custom Docker build (optional, fallback) + ├── setups/ # Custom setup scripts (optional) + │ └── myapp--setup.sh ├── home/ # Team-shared home directory files (optional) │ └── .config/ └── tools/ # Managed by booth wrapper (auto-created) @@ -400,14 +407,164 @@ my-project/ | File | Purpose | |---------------------------|---------------------------------------------------------| | `config.toml` | Defines variant, ports, run-args, build-args, cmds | +| `Boothfile` | Simplified build script (compiles to Dockerfile) | | `Dockerfile` | Custom image build extending a base variant | +| `setups/` | Custom setup scripts for `setup` command in Boothfile | | `home/` | Team-shared dotfiles copied to `/home/coder/` at startup | | `tools/codingbooth.lock` | Version lock file; binaries cached in `~/.cache/codingbooth/` | +> 💡 **Tip:** When both `Boothfile` and `Dockerfile` exist, Boothfile takes precedence. Use `--dockerfile` to force using the Dockerfile. + > ⚠️ **Note on `cmds`:** When you pass commands via CLI (`-- `), they **override** the `cmds` in config.toml (they don't append). --- +## Boothfile + +Boothfile is a simplified, script-like format for defining your container environment. It compiles to a Dockerfile automatically, hiding the boilerplate while preserving full control. + +### Why Boothfile? + +Instead of writing this Dockerfile: + +```dockerfile +# syntax=docker/dockerfile:1 +ARG BOOTH_VARIANT_TAG=base +ARG BOOTH_VERSION_TAG=latest +FROM nawaman/codingbooth:${BOOTH_VARIANT_TAG}-${BOOTH_VERSION_TAG} +SHELL ["/bin/bash","-o","pipefail","-lc"] +USER root +WORKDIR /opt/codingbooth/setups + +RUN python--setup.sh 3.12 +RUN pip--install.sh django +ENV APP_ENV=production +``` + +Write this Boothfile: + +```text +# syntax=codingbooth/boothfile:1 + +setup python 3.12 +install pip django +env APP_ENV=production +``` + +### Boothfile Commands + +| Command | Example | Compiles to | +|---------|---------|-------------| +| `run` | `run apt-get update` | `RUN apt-get update` | +| `setup` | `setup python 3.12` | `RUN python--setup.sh 3.12` | +| `install` | `install pip django` | `RUN pip--install.sh django` | +| `copy` | `copy ./config /opt` | `COPY ./config /opt` | +| `env` | `env DEBUG=true` | `ENV DEBUG=true` | +| `arg` | `arg VERSION=1.0` | `ARG VERSION=1.0` | +| `workdir` | `workdir /app` | `WORKDIR /app` | +| `expose` | `expose 8080` | `EXPOSE 8080` | +| `label` | `label maintainer="me"` | `LABEL maintainer="me"` | + +### Multi-line Commands (Heredocs) + +Boothfile supports three heredoc modes for multi-line commands: + +```text +# Verbatim - passes through to Docker heredoc +run <` | Use a specific Boothfile | +| `--emit-dockerfile` | Print generated Dockerfile without building | +| `--strict` | Treat warnings as errors | + +### File Precedence + +When no flags are given, CodingBooth looks for files in this order: +1. `.booth/Boothfile` (preferred) +2. `.booth/Dockerfile` (fallback) + +Use `--dockerfile ` to force using a specific Dockerfile. + +### Complete Example + +```text +# syntax=codingbooth/boothfile:1 + +# Data engineering environment + +# System dependencies +run apt-get update && apt-get install -y libpq-dev + +# Languages +setup python 3.12 +setup jdk 21 temurin + +# Python packages +install pip django psycopg2-binary + +# Project config +copy ./config /opt/config +env DJANGO_SETTINGS_MODULE=myproject.settings +``` + +> 📖 For the full Boothfile specification, see [docs/plans/Boothfile.md](docs/plans/Boothfile.md). + +--- + ## Guarantees & Limits - ✅ **Host file ownership:** All files in your project folder remain owned by your host user — no "root-owned" files. diff --git a/booth b/booth index 84e09673..8b7a8fe8 100755 --- a/booth +++ b/booth @@ -51,23 +51,23 @@ detect_nested_booth() { # We're inside a CodingBooth container if [[ "${BOOTH_IN_BOOTH:-}" != "true" ]]; then cat >&2 <<'EOF' -╔═══════════════════════════════════════════════════════════════════════════╗ -║ Running booth inside a booth container ║ -╠═══════════════════════════════════════════════════════════════════════════╣ -║ You appear to be running 'booth' from inside a CodingBooth container. ║ -║ This is usually accidental - the booth script is visible here because ║ -║ your project folder is mounted at /home/coder/code. ║ -║ ║ -║ If you intentionally want to run a nested booth (booth-in-booth), set: ║ -║ ║ -║ export BOOTH_IN_BOOTH=true ║ -║ ║ -║ AND specify a different port (not the current container's port): ║ -║ ║ -║ ./booth --port NEXT ... ║ -║ ./booth --port 11000 ... ║ -║ ║ -╚═══════════════════════════════════════════════════════════════════════════╝ +╔════════════════════════════════════════════════════════════════════════╗ +║ Running booth inside a booth container ║ +╠════════════════════════════════════════════════════════════════════════╣ +║ You appear to be running 'booth' from inside a CodingBooth container. ║ +║ This is usually accidental - the booth script is visible here because ║ +║ your project folder is mounted at /home/coder/code. ║ +║ ║ +║ If you intentionally want to run a nested booth (booth-in-booth), set: ║ +║ ║ +║ export BOOTH_IN_BOOTH=true ║ +║ ║ +║ AND specify a different port (not the current container's port): ║ +║ ║ +║ ./booth --port NEXT ... ║ +║ ./booth --port 11000 ... ║ +║ ║ +╚════════════════════════════════════════════════════════════════════════╝ EOF exit 1 fi @@ -102,11 +102,11 @@ EOF ╔═══════════════════════════════════════════════════════════════════════════╗ ║ Port conflict in nested booth ║ ╠═══════════════════════════════════════════════════════════════════════════╣ -║ BOOTH_IN_BOOTH=true is set, but no port was specified. ║ +║ BOOTH_IN_BOOTH=true is set, but no port was specified. ║ ║ ║ ║ You must specify a different port to avoid conflicts with this container: ║ -║ Current container's host port: ${host_port:-"(not set)"} -║ Current container's code port: ${code_port} +║ Current container's host port: ${host_port:-"(not set)"} ║ +║ Current container's code port: ${code_port} ║ ║ ║ ║ Use one of: ║ ║ ./booth --port NEXT ... (auto-find next available port) ║ @@ -123,31 +123,31 @@ EOF if [[ "$requested_port" != "NEXT" && "$requested_port" != "RANDOM" ]]; then if [[ "$requested_port" == "$host_port" ]]; then cat >&2 <&2 <] [--boothfile ] [--strict] + codePath := "." + boothfilePath := "" + strict := false + + args := os.Args[2:] // Skip "codingbooth" and "emit-dockerfile" + for i := 0; i < len(args); i++ { + switch args[i] { + case "--code": + if i+1 < len(args) { + codePath = args[i+1] + i++ + } else { + fmt.Fprintln(os.Stderr, "Error: --code requires a path argument") + os.Exit(1) + } + case "--boothfile": + if i+1 < len(args) { + boothfilePath = args[i+1] + i++ + } else { + fmt.Fprintln(os.Stderr, "Error: --boothfile requires a path argument") + os.Exit(1) + } + case "--strict": + strict = true + default: + fmt.Fprintf(os.Stderr, "Error: unknown option: %s\n", args[i]) + fmt.Fprintln(os.Stderr, "Usage: codingbooth emit-dockerfile [--code ] [--boothfile ] [--strict]") + os.Exit(1) + } + } + + // Determine Boothfile path + if boothfilePath == "" { + // Auto-detect from code path + boothfilePath = filepath.Join(codePath, ".booth", "Boothfile") + } + + // Check if Boothfile exists + if _, err := os.Stat(boothfilePath); os.IsNotExist(err) { + fmt.Fprintf(os.Stderr, "Error: Boothfile not found at '%s'\n", boothfilePath) + os.Exit(1) + } + + // Read Boothfile + content, err := os.ReadFile(boothfilePath) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: failed to read Boothfile '%s': %v\n", boothfilePath, err) + os.Exit(1) + } + + // Parse + parser := boothfile.NewParser() + if strict { + parser = boothfile.NewStrictParser() + } + parseResult := parser.ParseString(string(content)) + + // Check for parse errors + if parseResult.HasErrors() { + fmt.Fprintf(os.Stderr, "Error: Boothfile compilation failed:\n") + for _, e := range parseResult.Errors { + fmt.Fprintf(os.Stderr, " %s\n", e.Error()) + } + os.Exit(1) + } + + // Show warnings + if parseResult.HasWarnings() { + for _, w := range parseResult.Warnings { + fmt.Fprintf(os.Stderr, "Warning: %s\n", w.Error()) + } + } + + // Compile with custom setups directory if it exists + customSetupsDir := filepath.Join(codePath, ".booth", "setups") + compilerOpts := boothfile.CompilerOptions{ + CustomSetupsDir: ".booth/setups", + HasCustomSetups: isDir(customSetupsDir), + } + compiler := boothfile.NewCompilerWithOptions(compilerOpts) + compileResult := compiler.Compile(parseResult) + + // Check for compile errors + if compileResult.HasErrors() { + fmt.Fprintf(os.Stderr, "Error: Boothfile compilation failed:\n") + for _, e := range compileResult.Errors { + fmt.Fprintf(os.Stderr, " %s\n", e.Error()) + } + os.Exit(1) + } + + // Print to stdout + fmt.Print(compileResult.Dockerfile) +} + +// isDir checks if a path is a directory. +func isDir(path string) bool { + info, err := os.Stat(path) + if err != nil { + return false + } + return info.IsDir() +} diff --git a/cli/src/cmd/codingbooth/help.go b/cli/src/cmd/codingbooth/help.go index 88f1abb7..7400c6a8 100644 --- a/cli/src/cmd/codingbooth/help.go +++ b/cli/src/cmd/codingbooth/help.go @@ -30,6 +30,7 @@ USAGE: %s remove [--name ] [--force] (remove booth container(s)) %s prune [--yes] (remove stopped booth containers) %s example (manage examples) + %s emit-dockerfile [options] (compile Boothfile to Dockerfile) BOOTSTRAP OPTIONS (CLI or defaults; evaluated before environmental variable and config file): --code Host code path to mount at /home/coder/code @@ -46,7 +47,9 @@ GENERAL RUN OPTIONS: --dryrun Print docker commands without executing them --verbose Print extra debugging information -IMAGE SELECTION (precedence: --image > --dockerfile > prebuilt): +IMAGE SELECTION (precedence: --image > --dockerfile > --boothfile > prebuilt): + --boothfile Build from a Boothfile (compiled to Dockerfile) + Auto-detected at .booth/Boothfile if present. --dockerfile Build locally from a Dockerfile (file or directory) If a directory is provided, it looks for .booth/Dockerfile. --image Use an existing local or remote image (e.g. repo/name:tag) @@ -59,6 +62,7 @@ IMAGE SELECTION (precedence: --image > --dockerfile > prebuilt): Aliases: default | ide | desktop | desktop-xfce | desktop-kde --version Prebuilt version tag (default: latest) + --strict Treat Boothfile warnings as errors BUILD OPTIONS (only when using --dockerfile): --build-arg Add a Docker build-arg (repeatable) @@ -134,5 +138,6 @@ EXAMPLES: scriptName, scriptName, scriptName, + scriptName, ) } diff --git a/cli/src/cmd/codingbooth/main.go b/cli/src/cmd/codingbooth/main.go index 45eb5735..44370f65 100644 --- a/cli/src/cmd/codingbooth/main.go +++ b/cli/src/cmd/codingbooth/main.go @@ -49,6 +49,9 @@ func main() { case "example": runExample(version) return + case "emit-dockerfile": + emitDockerfile() + return default: // If it starts with --, treat as run with options if len(command) > 0 && command[0] == '-' { diff --git a/cli/src/pkg/appctx/app_config.go b/cli/src/pkg/appctx/app_config.go index 57358598..b28bdd53 100644 --- a/cli/src/pkg/appctx/app_config.go +++ b/cli/src/pkg/appctx/app_config.go @@ -37,9 +37,12 @@ type AppConfig struct { // -------------------- // Image configuration // -------------------- - Dockerfile string `toml:"dockerfile,omitempty" envconfig:"CB_DOCKERFILE"` - Image string `toml:"image,omitempty" envconfig:"CB_IMAGE"` - Variant string `toml:"variant,omitempty" envconfig:"CB_VARIANT" default:"default"` + Dockerfile string `toml:"dockerfile,omitempty" envconfig:"CB_DOCKERFILE"` + Boothfile string `toml:"boothfile,omitempty" envconfig:"CB_BOOTHFILE"` + Image string `toml:"image,omitempty" envconfig:"CB_IMAGE"` + Variant string `toml:"variant,omitempty" envconfig:"CB_VARIANT" default:"default"` + EmitDockerfile bool `toml:"emit-dockerfile,omitempty" envconfig:"CB_EMIT_DOCKERFILE" default:"false"` + Strict bool `toml:"strict,omitempty" envconfig:"CB_STRICT" default:"false"` // -------------------- // Runtime values @@ -113,8 +116,11 @@ func (config AppConfig) String() string { fmt.Fprintf(&str, "# Image Configuration -----------\n") fmt.Fprintf(&str, " Dockerfile: %q\n", config.Dockerfile) + fmt.Fprintf(&str, " Boothfile: %q\n", config.Boothfile) fmt.Fprintf(&str, " Image: %q\n", config.Image) fmt.Fprintf(&str, " Variant: %q\n", config.Variant) + fmt.Fprintf(&str, " EmitDockerfile: %t\n", config.EmitDockerfile) + fmt.Fprintf(&str, " Strict: %t\n", config.Strict) fmt.Fprintf(&str, "# Runtime values ----------------\n") fmt.Fprintf(&str, " ProjectName: %q\n", config.ProjectName) diff --git a/cli/src/pkg/appctx/app_context.go b/cli/src/pkg/appctx/app_context.go index 6a228aba..a57600b4 100644 --- a/cli/src/pkg/appctx/app_context.go +++ b/cli/src/pkg/appctx/app_context.go @@ -86,9 +86,12 @@ func (ctx AppContext) Pull() bool { return ctx.values.Config.Pull } func (ctx AppContext) Dind() bool { return ctx.values.Config.Dind } // Image Configuration -func (ctx AppContext) Dockerfile() string { return ctx.values.Config.Dockerfile } -func (ctx AppContext) Image() string { return ctx.values.Config.Image } -func (ctx AppContext) Variant() string { return ctx.values.Config.Variant } +func (ctx AppContext) Dockerfile() string { return ctx.values.Config.Dockerfile } +func (ctx AppContext) Boothfile() string { return ctx.values.Config.Boothfile } +func (ctx AppContext) Image() string { return ctx.values.Config.Image } +func (ctx AppContext) Variant() string { return ctx.values.Config.Variant } +func (ctx AppContext) EmitDockerfile() bool { return ctx.values.Config.EmitDockerfile } +func (ctx AppContext) Strict() bool { return ctx.values.Config.Strict } // Runtime values func (ctx AppContext) ProjectName() string { return ctx.values.Config.ProjectName } diff --git a/cli/src/pkg/booth/ensure_docker_image.go b/cli/src/pkg/booth/ensure_docker_image.go index 67350dc0..169b6fb8 100644 --- a/cli/src/pkg/booth/ensure_docker_image.go +++ b/cli/src/pkg/booth/ensure_docker_image.go @@ -10,6 +10,7 @@ import ( "path/filepath" "github.com/nawaman/codingbooth/src/pkg/appctx" + "github.com/nawaman/codingbooth/src/pkg/boothfile" "github.com/nawaman/codingbooth/src/pkg/docker" "github.com/nawaman/codingbooth/src/pkg/ilist" ) @@ -74,12 +75,12 @@ func EnsureDockerImage(ctx appctx.AppContext) appctx.AppContext { return ctx } -// normalizeDockerFile normalizes the DOCKER_FILE path. +// normalizeDockerFile normalizes the DOCKER_FILE path, handling Boothfile compilation. +// Returns the Dockerfile path (either existing or generated from Boothfile). func normalizeDockerFile(ctx appctx.AppContext) string { - dockerFile := ctx.Dockerfile() - - // If DOCKER_FILE is set - if dockerFile != "" { + // If --dockerfile is explicitly set, use it (takes precedence over Boothfile detection) + if ctx.Dockerfile() != "" { + dockerFile := ctx.Dockerfile() // If it's a directory, check for Dockerfile in .booth/ first if isDir(dockerFile) { dockerfile := filepath.Join(dockerFile, ".booth", "Dockerfile") @@ -90,9 +91,24 @@ func normalizeDockerFile(ctx appctx.AppContext) string { return dockerFile } - // If DOCKER_FILE is unset, check code path for Dockerfile + // If --boothfile is explicitly set, compile it + if ctx.Boothfile() != "" { + return compileBoothfile(ctx, ctx.Boothfile()) + } + + // Auto-detect: check code path for Boothfile first, then Dockerfile if ctx.Code() != "" && isDir(ctx.Code()) { - // Prefer new location (.booth/Dockerfile) + // Check for Boothfile first (higher precedence) + boothfilePath := filepath.Join(ctx.Code(), ".booth", "Boothfile") + if isFile(boothfilePath) { + if ctx.Dockerfile() != "" { + // Both exist and --dockerfile wasn't explicit - warn + fmt.Fprintf(os.Stderr, "Warning: Both Boothfile and Dockerfile exist. Using Boothfile.\n") + } + return compileBoothfile(ctx, boothfilePath) + } + + // Fall back to Dockerfile dockerfile := filepath.Join(ctx.Code(), ".booth", "Dockerfile") if isFile(dockerfile) { return dockerfile @@ -102,6 +118,76 @@ func normalizeDockerFile(ctx appctx.AppContext) string { return "" } +// compileBoothfile compiles a Boothfile to a Dockerfile. +// Returns the path to the generated Dockerfile. +func compileBoothfile(ctx appctx.AppContext, boothfilePath string) string { + if !ctx.SilenceBuild() { + fmt.Fprintf(os.Stderr, "Info: compiling Boothfile '%s'...\n", boothfilePath) + } + + // Read Boothfile + content, err := os.ReadFile(boothfilePath) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: failed to read Boothfile '%s': %v\n", boothfilePath, err) + os.Exit(1) + } + + // Parse + parser := boothfile.NewParser() + if ctx.Strict() { + parser = boothfile.NewStrictParser() + } + parseResult := parser.ParseString(string(content)) + + // Check for parse errors + if parseResult.HasErrors() { + fmt.Fprintf(os.Stderr, "Error: Boothfile compilation failed:\n") + for _, e := range parseResult.Errors { + fmt.Fprintf(os.Stderr, " %s\n", e.Error()) + } + os.Exit(1) + } + + // Show warnings + if parseResult.HasWarnings() { + for _, w := range parseResult.Warnings { + fmt.Fprintf(os.Stderr, "Warning: %s\n", w.Error()) + } + } + + // Compile with custom setups directory if it exists + customSetupsDir := filepath.Join(ctx.Code(), ".booth", "setups") + compilerOpts := boothfile.CompilerOptions{ + CustomSetupsDir: ".booth/setups", + HasCustomSetups: isDir(customSetupsDir), + } + compiler := boothfile.NewCompilerWithOptions(compilerOpts) + compileResult := compiler.Compile(parseResult) + + // Check for compile errors + if compileResult.HasErrors() { + fmt.Fprintf(os.Stderr, "Error: Boothfile compilation failed:\n") + for _, e := range compileResult.Errors { + fmt.Fprintf(os.Stderr, " %s\n", e.Error()) + } + os.Exit(1) + } + + // Write to a temporary file in .booth/ + generatedPath := filepath.Join(ctx.Code(), ".booth", ".Dockerfile.generated") + err = os.WriteFile(generatedPath, []byte(compileResult.Dockerfile), 0644) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: failed to write generated Dockerfile: %v\n", err) + os.Exit(1) + } + + if ctx.Verbose() { + fmt.Fprintf(os.Stderr, "Generated Dockerfile: %s\n", generatedPath) + } + + return generatedPath +} + // buildLocalImage builds a local Docker image. func buildLocalImage(ctx appctx.AppContext) { if !ctx.SilenceBuild() { diff --git a/cli/src/pkg/booth/init/initialize_app_context.go b/cli/src/pkg/booth/init/initialize_app_context.go index 73344d77..b57e2216 100644 --- a/cli/src/pkg/booth/init/initialize_app_context.go +++ b/cli/src/pkg/booth/init/initialize_app_context.go @@ -251,6 +251,18 @@ func parseArgs(args ilist.List[string], cfg *appctx.AppConfig) error { cfg.Dockerfile = v i += 2 + case "--boothfile": + v, err := needValue(args, i, arg) + if err != nil { + return err + } + cfg.Boothfile = v + i += 2 + + case "--strict": + cfg.Strict = true + i++ + // Build case "--build-arg": v, err := needValue(args, i, arg) diff --git a/cli/src/pkg/boothfile/compiler.go b/cli/src/pkg/boothfile/compiler.go new file mode 100644 index 00000000..45a9da7f --- /dev/null +++ b/cli/src/pkg/boothfile/compiler.go @@ -0,0 +1,420 @@ +// Copyright 2025-2026 : Nawa Manusitthipol +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. + +package boothfile + +import ( + "fmt" + "strings" +) + +// DefaultRepo is the default Docker repository for CodingBooth images. +const DefaultRepo = "nawaman/codingbooth" + +// DefaultVariant is the default variant when not specified. +const DefaultVariant = "base" + +// DefaultVersion is the default version tag when not specified. +const DefaultVersion = "latest" + +// ProjectSetupsPath is the destination path for custom setup scripts in the container. +const ProjectSetupsPath = "/home/coder/.booth/setups" + +// CompilerOptions contains options for the Boothfile compiler. +type CompilerOptions struct { + // CustomSetupsDir is the source path for custom setup scripts (e.g., ".booth/setups") + CustomSetupsDir string + + // HasCustomSetups indicates whether the CustomSetupsDir exists and should be copied. + // If true, the compiler will add COPY and ENV PATH instructions. + HasCustomSetups bool +} + +// Compiler compiles parsed Boothfile commands into a Dockerfile. +type Compiler struct { + options CompilerOptions +} + +// NewCompiler creates a new Boothfile compiler with default options. +func NewCompiler() *Compiler { + return &Compiler{ + options: CompilerOptions{}, + } +} + +// NewCompilerWithOptions creates a new Boothfile compiler with the given options. +func NewCompilerWithOptions(options CompilerOptions) *Compiler { + return &Compiler{ + options: options, + } +} + +// CompileResult contains the result of compiling a Boothfile. +type CompileResult struct { + Dockerfile string + Errors []ParseError + Warnings []ParseError +} + +// HasErrors returns true if there were compilation errors. +func (cr CompileResult) HasErrors() bool { + return len(cr.Errors) > 0 +} + +// Compile compiles a ParseResult into a Dockerfile string. +func (c *Compiler) Compile(parseResult ParseResult) CompileResult { + result := CompileResult{ + Errors: append([]ParseError{}, parseResult.Errors...), + Warnings: append([]ParseError{}, parseResult.Warnings...), + } + + // If there were parse errors, don't compile + if parseResult.HasErrors() { + return result + } + + var sb strings.Builder + + // Write prologue + c.writePrologue(&sb) + + // Compile each command + for _, cmd := range parseResult.Commands { + // Handle blank lines specially - emit empty line for readability + if cmd.Type == CommandBlank { + sb.WriteString("\n") + continue + } + + line, err := c.compileCommand(cmd) + if err != nil { + result.Errors = append(result.Errors, *err) + continue + } + if line != "" { + sb.WriteString(line) + sb.WriteString("\n") + } + } + + result.Dockerfile = sb.String() + return result +} + +// writePrologue writes the fixed Dockerfile prologue. +func (c *Compiler) writePrologue(sb *strings.Builder) { + prologue := `# syntax=docker/dockerfile:1.7 +ARG BOOTH_VARIANT_TAG=base +ARG BOOTH_VERSION_TAG=latest +FROM nawaman/codingbooth:${BOOTH_VARIANT_TAG}-${BOOTH_VERSION_TAG} + +ARG BOOTH_VARIANT_TAG=base +ARG BOOTH_VERSION_TAG=latest + +` + sb.WriteString(prologue) + + // If custom setups directory exists, copy it and prepend to PATH + if c.options.HasCustomSetups && c.options.CustomSetupsDir != "" { + sb.WriteString(fmt.Sprintf("COPY %s/ %s/\n", c.options.CustomSetupsDir, ProjectSetupsPath)) + sb.WriteString(fmt.Sprintf("ENV PATH=%s:$PATH\n", ProjectSetupsPath)) + sb.WriteString("\n") + } +} + +// compileCommand compiles a single command to Dockerfile instruction(s). +func (c *Compiler) compileCommand(cmd Command) (string, *ParseError) { + switch cmd.Type { + case CommandBlank: + // Blank lines pass through for readability + return "", nil + + case CommandComment: + // Pass through comments (except syntax directive which is in prologue) + raw := strings.TrimSpace(cmd.Raw) + if strings.HasPrefix(raw, "# syntax=") { + return "", nil + } + return raw, nil + + case CommandRun: + return c.compileRun(cmd) + + case CommandRunHeredoc: + return c.compileRunHeredoc(cmd) + + case CommandCopy: + return c.compileCopy(cmd) + + case CommandEnv: + return c.compileEnv(cmd) + + case CommandWorkdir: + return c.compileWorkdir(cmd) + + case CommandExpose: + return c.compileExpose(cmd) + + case CommandLabel: + return c.compileLabel(cmd) + + case CommandArg: + return c.compileArg(cmd) + + case CommandSetup: + return c.compileSetup(cmd) + + case CommandInstall: + return c.compileInstall(cmd) + + case CommandDocker: + return c.compileDocker(cmd) + + default: + return "", &ParseError{ + LineNumber: cmd.LineNumber, + Message: fmt.Sprintf("Unknown command type: %v", cmd.Type), + } + } +} + +// compileRun compiles a simple run command. +func (c *Compiler) compileRun(cmd Command) (string, *ParseError) { + if len(cmd.Args) == 0 { + return "", &ParseError{ + LineNumber: cmd.LineNumber, + Message: "run command requires arguments", + } + } + return "RUN " + strings.Join(cmd.Args, " "), nil +} + +// compileRunHeredoc compiles a heredoc run command. +func (c *Compiler) compileRunHeredoc(cmd Command) (string, *ParseError) { + switch cmd.HeredocMode { + case HeredocVerbatim: + return c.compileHeredocVerbatim(cmd) + case HeredocAndJoin: + return c.compileHeredocJoin(cmd, " && ") + case HeredocSemiJoin: + return c.compileHeredocJoin(cmd, "; ") + default: + return "", &ParseError{ + LineNumber: cmd.LineNumber, + Message: fmt.Sprintf("Unknown heredoc mode: %v", cmd.HeredocMode), + } + } +} + +// compileHeredocVerbatim compiles a verbatim heredoc (pass-through to Docker). +func (c *Compiler) compileHeredocVerbatim(cmd Command) (string, *ParseError) { + var sb strings.Builder + sb.WriteString("RUN <<") + sb.WriteString(cmd.HeredocDelimiter) + sb.WriteString("\n") + for _, line := range cmd.HeredocContent { + sb.WriteString(line) + sb.WriteString("\n") + } + sb.WriteString(cmd.HeredocDelimiter) + return sb.String(), nil +} + +// compileHeredocJoin compiles a heredoc with line joining. +func (c *Compiler) compileHeredocJoin(cmd Command, joiner string) (string, *ParseError) { + // Process content: collapse continuations, skip blanks/comments, join + lines := c.processHeredocContent(cmd.HeredocContent) + + if len(lines) == 0 { + return "", &ParseError{ + LineNumber: cmd.LineNumber, + Message: "heredoc block is empty after processing", + } + } + + // Format with continuation backslashes for readability + if len(lines) == 1 { + return "RUN " + lines[0], nil + } + + var sb strings.Builder + sb.WriteString("RUN ") + for i, line := range lines { + sb.WriteString(line) + if i < len(lines)-1 { + sb.WriteString(" \\") + sb.WriteString("\n ") + sb.WriteString(strings.TrimSuffix(joiner, " ")) + sb.WriteString(" ") + } + } + return sb.String(), nil +} + +// processHeredocContent processes heredoc lines for && or ; joining. +func (c *Compiler) processHeredocContent(content []string) []string { + // Step 1: Collapse line continuations + collapsed := make([]string, 0) + var current strings.Builder + + for _, line := range content { + trimmed := strings.TrimRight(line, " \t") + if strings.HasSuffix(trimmed, "\\") { + // Continuation - append without the backslash + current.WriteString(strings.TrimSuffix(trimmed, "\\")) + current.WriteString(" ") + } else { + current.WriteString(trimmed) + if current.Len() > 0 { + collapsed = append(collapsed, current.String()) + } + current.Reset() + } + } + // Don't forget any remaining content + if current.Len() > 0 { + collapsed = append(collapsed, current.String()) + } + + // Step 2: Filter out blank lines and comments + result := make([]string, 0) + for _, line := range collapsed { + trimmed := strings.TrimSpace(line) + if trimmed == "" { + continue + } + if strings.HasPrefix(trimmed, "#") { + continue + } + result = append(result, trimmed) + } + + return result +} + +// compileCopy compiles a copy command. +func (c *Compiler) compileCopy(cmd Command) (string, *ParseError) { + if len(cmd.Args) < 2 { + return "", &ParseError{ + LineNumber: cmd.LineNumber, + Message: "copy command requires source and destination", + } + } + return "COPY " + strings.Join(cmd.Args, " "), nil +} + +// compileEnv compiles an env command. +func (c *Compiler) compileEnv(cmd Command) (string, *ParseError) { + if len(cmd.Args) == 0 { + return "", &ParseError{ + LineNumber: cmd.LineNumber, + Message: "env command requires KEY=value", + } + } + return "ENV " + strings.Join(cmd.Args, " "), nil +} + +// compileWorkdir compiles a workdir command. +func (c *Compiler) compileWorkdir(cmd Command) (string, *ParseError) { + if len(cmd.Args) == 0 { + return "", &ParseError{ + LineNumber: cmd.LineNumber, + Message: "workdir command requires a path", + } + } + return "WORKDIR " + cmd.Args[0], nil +} + +// compileExpose compiles an expose command. +func (c *Compiler) compileExpose(cmd Command) (string, *ParseError) { + if len(cmd.Args) == 0 { + return "", &ParseError{ + LineNumber: cmd.LineNumber, + Message: "expose command requires a port number", + } + } + return "EXPOSE " + strings.Join(cmd.Args, " "), nil +} + +// compileLabel compiles a label command. +func (c *Compiler) compileLabel(cmd Command) (string, *ParseError) { + if len(cmd.Args) == 0 { + return "", &ParseError{ + LineNumber: cmd.LineNumber, + Message: "label command requires key=value", + } + } + return "LABEL " + strings.Join(cmd.Args, " "), nil +} + +// compileArg compiles an arg command. +func (c *Compiler) compileArg(cmd Command) (string, *ParseError) { + if len(cmd.Args) == 0 { + return "", &ParseError{ + LineNumber: cmd.LineNumber, + Message: "arg command requires NAME or NAME=default", + } + } + return "ARG " + strings.Join(cmd.Args, " "), nil +} + +// compileSetup compiles a setup command. +// Custom setup scripts are found via PATH (project setups are prepended in prologue). +func (c *Compiler) compileSetup(cmd Command) (string, *ParseError) { + if len(cmd.Args) == 0 { + return "", &ParseError{ + LineNumber: cmd.LineNumber, + Message: "setup command requires a tool name", + } + } + + toolName := cmd.Args[0] + scriptArgs := cmd.Args[1:] + + // Build RUN command - script is found via PATH + runCmd := fmt.Sprintf("RUN %s--setup.sh", toolName) + if len(scriptArgs) > 0 { + runCmd += " " + strings.Join(scriptArgs, " ") + } + + return runCmd, nil +} + +// compileInstall compiles an install command. +// Custom install scripts are found via PATH (project setups are prepended in prologue). +func (c *Compiler) compileInstall(cmd Command) (string, *ParseError) { + if len(cmd.Args) < 2 { + return "", &ParseError{ + LineNumber: cmd.LineNumber, + Message: "install command requires a tool and at least one package", + } + } + + toolName := cmd.Args[0] + packages := cmd.Args[1:] + + // Build RUN command - script is found via PATH + return fmt.Sprintf("RUN %s--install.sh %s", toolName, strings.Join(packages, " ")), nil +} + +// compileDocker compiles a DOCKER escape hatch command. +func (c *Compiler) compileDocker(cmd Command) (string, *ParseError) { + if len(cmd.Args) == 0 { + return "", &ParseError{ + LineNumber: cmd.LineNumber, + Message: "DOCKER escape hatch requires a Dockerfile instruction", + } + } + // Pass through verbatim (DOCKER prefix already stripped by parser) + return cmd.Args[0], nil +} + +// CompileString is a convenience function to parse and compile a Boothfile string. +func CompileString(content string) CompileResult { + parser := NewParser() + parseResult := parser.ParseString(content) + + compiler := NewCompiler() + return compiler.Compile(parseResult) +} diff --git a/cli/src/pkg/boothfile/compiler_test.go b/cli/src/pkg/boothfile/compiler_test.go new file mode 100644 index 00000000..f08f353a --- /dev/null +++ b/cli/src/pkg/boothfile/compiler_test.go @@ -0,0 +1,490 @@ +// Copyright 2025-2026 : Nawa Manusitthipol +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. + +package boothfile + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCompiler_Prologue(t *testing.T) { + content := `# syntax=codingbooth/boothfile:1 +` + result := CompileString(content) + + assert.False(t, result.HasErrors(), "errors: %v", result.Errors) + + // Check prologue components + assert.Contains(t, result.Dockerfile, "# syntax=docker/dockerfile:1.7") + assert.Contains(t, result.Dockerfile, "ARG BOOTH_VARIANT_TAG=base") + assert.Contains(t, result.Dockerfile, "ARG BOOTH_VERSION_TAG=latest") + assert.Contains(t, result.Dockerfile, "FROM nawaman/codingbooth:${BOOTH_VARIANT_TAG}-${BOOTH_VERSION_TAG}") + // SHELL, USER, and WORKDIR are already set in the base image +} + +func TestCompiler_Run(t *testing.T) { + t.Run("simple run", func(t *testing.T) { + content := `# syntax=codingbooth/boothfile:1 +run apt-get update +` + result := CompileString(content) + + assert.False(t, result.HasErrors()) + assert.Contains(t, result.Dockerfile, "RUN apt-get update") + }) + + t.Run("run with chain", func(t *testing.T) { + content := `# syntax=codingbooth/boothfile:1 +run apt-get update && apt-get install -y curl +` + result := CompileString(content) + + assert.False(t, result.HasErrors()) + assert.Contains(t, result.Dockerfile, "RUN apt-get update && apt-get install -y curl") + }) +} + +func TestCompiler_HeredocVerbatim(t *testing.T) { + content := `# syntax=codingbooth/boothfile:1 +run < 0 +} + +// HasWarnings returns true if there were parsing warnings. +func (pr ParseResult) HasWarnings() bool { + return len(pr.Warnings) > 0 +} + +// Parser parses Boothfiles into structured commands. +type Parser struct { + strict bool +} + +// NewParser creates a new Boothfile parser. +func NewParser() *Parser { + return &Parser{strict: false} +} + +// NewStrictParser creates a new Boothfile parser in strict mode. +// In strict mode, warnings are treated as errors. +func NewStrictParser() *Parser { + return &Parser{strict: true} +} + +// Regex patterns for parsing +var ( + // Matches: run < 0 { + // Check if # is inside quotes (simple heuristic) + beforeHash := trimmed[:idx] + if strings.Count(beforeHash, `"`)%2 == 0 && strings.Count(beforeHash, `'`)%2 == 0 { + withoutComment = strings.TrimSpace(beforeHash) + } + } + + match := commandPattern.FindStringSubmatch(withoutComment) + if match == nil { + return nil, &ParseError{ + LineNumber: lineNumber, + Message: fmt.Sprintf("Invalid command syntax: %s", trimmed), + } + } + + cmdName := strings.ToLower(match[1]) + argsStr := match[2] + + // Map command name to type + cmdType := p.mapCommandType(cmdName) + if cmdType == CommandUnknown { + // Check for common typos + suggestion := p.suggestCommand(cmdName) + hint := "" + if suggestion != "" { + hint = fmt.Sprintf("Did you mean '%s'?", suggestion) + } + return nil, &ParseError{ + LineNumber: lineNumber, + Message: fmt.Sprintf("Unknown command: %s", cmdName), + Hint: hint, + } + } + + // Parse arguments based on command type + args := p.parseArgs(argsStr, cmdType) + + return &Command{ + Type: cmdType, + LineNumber: lineNumber, + Raw: raw, + Args: args, + }, nil +} + +// mapCommandType maps a command name to its type. +func (p *Parser) mapCommandType(name string) CommandType { + switch name { + case "run": + return CommandRun + case "copy": + return CommandCopy + case "env": + return CommandEnv + case "workdir": + return CommandWorkdir + case "expose": + return CommandExpose + case "label": + return CommandLabel + case "arg": + return CommandArg + case "setup": + return CommandSetup + case "install": + return CommandInstall + default: + return CommandUnknown + } +} + +// suggestCommand returns a suggestion for a misspelled command. +func (p *Parser) suggestCommand(name string) string { + commands := []string{"run", "copy", "env", "workdir", "expose", "label", "arg", "setup", "install"} + + for _, cmd := range commands { + // Simple Levenshtein-like check: if most characters match + if len(name) > 0 && len(cmd) > 0 { + if strings.HasPrefix(cmd, name[:1]) && abs(len(cmd)-len(name)) <= 2 { + // Check character overlap + matches := 0 + for i := 0; i < len(name) && i < len(cmd); i++ { + if name[i] == cmd[i] { + matches++ + } + } + if matches >= len(name)-2 || matches >= len(cmd)-2 { + return cmd + } + } + } + } + return "" +} + +// parseArgs parses the argument string for a command. +func (p *Parser) parseArgs(argsStr string, cmdType CommandType) []string { + argsStr = strings.TrimSpace(argsStr) + if argsStr == "" { + return []string{} + } + + // For most commands, split on whitespace + // But preserve quoted strings + return splitArgs(argsStr) +} + +// splitArgs splits an argument string, respecting quotes. +func splitArgs(s string) []string { + var args []string + var current strings.Builder + inQuote := false + quoteChar := rune(0) + + for _, r := range s { + switch { + case (r == '"' || r == '\'') && !inQuote: + inQuote = true + quoteChar = r + current.WriteRune(r) + case r == quoteChar && inQuote: + inQuote = false + quoteChar = 0 + current.WriteRune(r) + case (r == ' ' || r == '\t') && !inQuote: + if current.Len() > 0 { + args = append(args, current.String()) + current.Reset() + } + default: + current.WriteRune(r) + } + } + + if current.Len() > 0 { + args = append(args, current.String()) + } + + return args +} + +func abs(n int) int { + if n < 0 { + return -n + } + return n +} diff --git a/cli/src/pkg/boothfile/parser_test.go b/cli/src/pkg/boothfile/parser_test.go new file mode 100644 index 00000000..cae6a7fa --- /dev/null +++ b/cli/src/pkg/boothfile/parser_test.go @@ -0,0 +1,562 @@ +// Copyright 2025-2026 : Nawa Manusitthipol +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. + +package boothfile + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParser_SyntaxDirective(t *testing.T) { + t.Run("valid syntax directive", func(t *testing.T) { + content := `# syntax=codingbooth/boothfile:1 +` + p := NewParser() + result := p.ParseString(content) + + assert.False(t, result.HasErrors(), "should not have errors: %v", result.Errors) + require.Len(t, result.Commands, 1) + assert.Equal(t, CommandComment, result.Commands[0].Type) + }) + + t.Run("syntax directive with blank lines before", func(t *testing.T) { + content := ` + +# syntax=codingbooth/boothfile:1 +` + p := NewParser() + result := p.ParseString(content) + + assert.False(t, result.HasErrors(), "should not have errors: %v", result.Errors) + require.Len(t, result.Commands, 3) + assert.Equal(t, CommandBlank, result.Commands[0].Type) + assert.Equal(t, CommandBlank, result.Commands[1].Type) + assert.Equal(t, CommandComment, result.Commands[2].Type) + }) + + t.Run("missing syntax directive", func(t *testing.T) { + content := `run echo hello +` + p := NewParser() + result := p.ParseString(content) + + assert.True(t, result.HasErrors()) + assert.Contains(t, result.Errors[0].Message, "Missing syntax directive") + }) + + t.Run("invalid syntax version", func(t *testing.T) { + content := `# syntax=codingbooth/boothfile:2 +` + p := NewParser() + result := p.ParseString(content) + + assert.True(t, result.HasErrors()) + assert.Contains(t, result.Errors[0].Message, "Invalid syntax directive") + }) +} + +func TestParser_Comments(t *testing.T) { + t.Run("full line comment", func(t *testing.T) { + content := `# syntax=codingbooth/boothfile:1 +# This is a comment +` + p := NewParser() + result := p.ParseString(content) + + assert.False(t, result.HasErrors()) + require.Len(t, result.Commands, 2) + assert.Equal(t, CommandComment, result.Commands[1].Type) + }) + + t.Run("inline comment", func(t *testing.T) { + content := `# syntax=codingbooth/boothfile:1 +run echo hello # this is inline +` + p := NewParser() + result := p.ParseString(content) + + assert.False(t, result.HasErrors()) + require.Len(t, result.Commands, 2) + assert.Equal(t, CommandRun, result.Commands[1].Type) + assert.Equal(t, []string{"echo", "hello"}, result.Commands[1].Args) + }) + + t.Run("hash in quoted string not treated as comment", func(t *testing.T) { + content := `# syntax=codingbooth/boothfile:1 +run echo "hello#world" +` + p := NewParser() + result := p.ParseString(content) + + assert.False(t, result.HasErrors()) + require.Len(t, result.Commands, 2) + assert.Equal(t, CommandRun, result.Commands[1].Type) + assert.Contains(t, result.Commands[1].Args, `"hello#world"`) + }) +} + +func TestParser_BlankLines(t *testing.T) { + content := `# syntax=codingbooth/boothfile:1 + +run echo hello + +run echo world +` + p := NewParser() + result := p.ParseString(content) + + assert.False(t, result.HasErrors()) + blankCount := 0 + for _, cmd := range result.Commands { + if cmd.Type == CommandBlank { + blankCount++ + } + } + assert.Equal(t, 2, blankCount) +} + +func TestParser_RunCommand(t *testing.T) { + t.Run("simple run", func(t *testing.T) { + content := `# syntax=codingbooth/boothfile:1 +run apt-get update +` + p := NewParser() + result := p.ParseString(content) + + assert.False(t, result.HasErrors()) + require.Len(t, result.Commands, 2) + cmd := result.Commands[1] + assert.Equal(t, CommandRun, cmd.Type) + assert.Equal(t, []string{"apt-get", "update"}, cmd.Args) + }) + + t.Run("run with && chain", func(t *testing.T) { + content := `# syntax=codingbooth/boothfile:1 +run apt-get update && apt-get install -y curl +` + p := NewParser() + result := p.ParseString(content) + + assert.False(t, result.HasErrors()) + cmd := result.Commands[1] + assert.Equal(t, CommandRun, cmd.Type) + assert.Contains(t, cmd.Args, "&&") + }) +} + +func TestParser_HeredocVerbatim(t *testing.T) { + content := `# syntax=codingbooth/boothfile:1 +run < + CommandRunHeredoc // run < + CommandEnv // env KEY=value + CommandWorkdir // workdir /path + CommandExpose // expose 8080 + CommandLabel // label key=value + CommandArg // arg NAME=default + CommandSetup // setup [args...] + CommandInstall // install + CommandDocker // DOCKER +) +``` + +### Command Structure + +```go +type Command struct { + Type CommandType + Args []string // Parsed arguments + Raw string // Original line text + LineNumber int + HeredocMode HeredocMode // For heredoc commands + HeredocDelimiter string // e.g., "END" + HeredocContent []string // Lines within heredoc +} +``` + +### Heredoc Modes + +| Syntax | Mode | Behavior | +|---------------|-----------|----------------------------------| +| `run < │ +│ CommandSetup → RUN --setup.sh │ +│ CommandInstall → RUN --install.sh │ +│ CommandCopy → COPY │ +│ CommandEnv → ENV KEY=value │ +│ CommandDocker → │ +│ ... │ +└─────────────────────┬─────────────────────┘ + │ + ▼ + CompileResult +``` + +### Command Compilation + +| Boothfile | Generated Dockerfile | +|----------------------------|--------------------------------| +| `run apt-get update` | `RUN apt-get update` | +| `setup python 3.12` | `RUN python--setup.sh 3.12` | +| `install pip django` | `RUN pip--install.sh django` | +| `copy ./src /app` | `COPY ./src /app` | +| `env FOO=bar` | `ENV FOO=bar` | +| `DOCKER HEALTHCHECK ...` | `HEALTHCHECK ...` | + +### Heredoc Processing + +**Verbatim mode** (`run <] [--boothfile ] [--strict] +``` + +### Run Integration + +During normal `codingbooth run`: + +1. `normalizeDockerFile()` checks for Boothfile +2. If found, `compileBoothfile()` generates `.booth/.Dockerfile.generated` +3. Docker build uses the generated file + +--- + +## Implementation Files + +| File | Purpose | +|------------------------------------------------------------------------------------------------|------------------------------| +| [`cli/src/pkg/boothfile/parser.go`](../../cli/src/pkg/boothfile/parser.go) | Tokenizer and parser | +| [`cli/src/pkg/boothfile/compiler.go`](../../cli/src/pkg/boothfile/compiler.go) | Dockerfile generator | +| [`cli/src/pkg/booth/ensure_docker_image.go`](../../cli/src/pkg/booth/ensure_docker_image.go) | Build integration | +| [`cli/src/cmd/codingbooth/emit.go`](../../cli/src/cmd/codingbooth/emit.go) | `emit-dockerfile` subcommand | + +### Key Functions + +| Function | Location | Purpose | +|------------------------|------------------------|----------------------------| +| `Parser.ParseString()` | parser.go | Parse Boothfile content | +| `Compiler.Compile()` | compiler.go | Generate Dockerfile | +| `compileBoothfile()` | ensure_docker_image.go | Integration entry point | +| `emitDockerfile()` | emit.go | CLI subcommand handler | + +--- + +## Test Coverage + +| Test Type | Location | Count | +|--------------------|-------------------------------------|-------------------------| +| Go unit tests | `cli/src/pkg/boothfile/*_test.go` | Parser + compiler tests | +| Shell unit tests | `tests/boothfile/` | 30 tests | +| Integration tests | `tests/complex/test-boothfile-*/` | 5 tests | +| Dryrun tests | `tests/dryrun/test014-015` | 2 tests | + +--- + +## Related Documentation + +- [Boothfile Design Plan](../plans/Boothfile.md) — Original design document +- [Variants](VARIANTS.md) — Base image variants + +--- + +## Summary + +Boothfile provides: + +- **Concise syntax** — Write intent, not boilerplate +- **Transparent compilation** — Always inspect with `emit-dockerfile` +- **Extensibility** — Custom scripts via `.booth/setups/` +- **Full compatibility** — Generates standard Dockerfile +- **Order preservation** — What you write is what you get + +The implementation consists of a parser (lexer + AST builder) and compiler (code generator), integrated into the CodingBooth CLI for seamless image building. diff --git a/docs/plans/Boothfile.md b/docs/plans/Boothfile.md index 031abe2c..1cb80f67 100644 --- a/docs/plans/Boothfile.md +++ b/docs/plans/Boothfile.md @@ -71,15 +71,13 @@ install pip django not: ```dockerfile -# syntax=docker/dockerfile:1 +# syntax=docker/dockerfile:1.7 ARG BOOTH_VARIANT_TAG=base ARG BOOTH_VERSION_TAG=latest FROM nawaman/codingbooth:${BOOTH_VARIANT_TAG}-${BOOTH_VERSION_TAG} -SHELL ["/bin/bash","-o","pipefail","-lc"] -USER root + ARG BOOTH_VARIANT_TAG=base ARG BOOTH_VERSION_TAG=latest -WORKDIR /opt/codingbooth/setups RUN python--setup.sh 3.13 RUN pip--install.sh django @@ -227,10 +225,11 @@ copy ./config /opt/config - BuildKit frontend image (see Section 5.2) - Setup script validation with suggestions -- Additional package managers (`gem install`, `cargo install`, etc.) - Editor tooling / syntax highlighting - Alternative compilation targets (Podman, Buildah) +Note: Additional package managers (`gem`, `cargo`, `conda`, `yarn`, etc.) already work via the generic `install` command — any `install ` generates `RUN --install.sh `. + --- ## 4. Relationship to Dockerfile @@ -244,12 +243,12 @@ Using a Dockerfile directly with `booth` (via `--dockerfile`) continues to work ### 4.1 File Selection Precedence -| Scenario | Behavior | -|----------|----------| -| No flags given | Look for `.booth/Boothfile` first, then `.booth/Dockerfile`. Use whichever is found. Error if neither exists. | -| `--dockerfile ` | Use the specified Dockerfile directly. Error if it does not exist. | -| `--boothfile ` | Use the specified Boothfile (compile to Dockerfile). Error if it does not exist. | -| `--boothfile ` and `--dockerfile ` | Use the Boothfile. Emit a **warning** that both were given and Boothfile takes precedence. | +| Scenario | Behavior | +|-------------------------------------------------|-----------------------------------------------------------------------------------------------------------------| +| No flags given | Look for `.booth/Boothfile` first, then `.booth/Dockerfile`. Use whichever is found. Error if neither exists. | +| `--dockerfile ` | Use the specified Dockerfile directly. Error if it does not exist. | +| `--boothfile ` | Use the specified Boothfile (compile to Dockerfile). Error if it does not exist. | +| `--boothfile ` and `--dockerfile ` | Use the Boothfile. Emit a **warning** that both were given and Boothfile takes precedence. | ### 4.2 The `--emit-dockerfile` Flag @@ -315,23 +314,19 @@ The `:1` tag indicates major version 1 of the Boothfile syntax. All phases (1, 2 Every Boothfile compiles with a fixed prologue that is required for CodingBooth to function. The prologue is **always the same** regardless of Boothfile content: ```dockerfile -# syntax=docker/dockerfile:1 +# syntax=docker/dockerfile:1.7 ARG BOOTH_VARIANT_TAG=base ARG BOOTH_VERSION_TAG=latest FROM nawaman/codingbooth:${BOOTH_VARIANT_TAG}-${BOOTH_VERSION_TAG} -SHELL ["/bin/bash","-o","pipefail","-lc"] -USER root - ARG BOOTH_VARIANT_TAG=base ARG BOOTH_VERSION_TAG=latest - -WORKDIR /opt/codingbooth/setups ``` Key points: -- The Boothfile's `# syntax=codingbooth/boothfile:1` is replaced by `# syntax=docker/dockerfile:1` in the generated Dockerfile +- The Boothfile's `# syntax=codingbooth/boothfile:1` is replaced by `# syntax=docker/dockerfile:1.7` in the generated Dockerfile +- `SHELL`, `USER root`, and `WORKDIR` are already set in the base image, so they don't need to be repeated - `BOOTH_VARIANT_TAG` and `BOOTH_VERSION_TAG` default to `base` and `latest` but are overridden at build time by `config.toml` / CLI flags — **Boothfile never specifies these** - These lines are generated automatically and are not user-editable in Boothfile - The prologue may evolve over time without breaking Boothfiles @@ -394,9 +389,9 @@ Blank lines are ignored and can be used freely for readability. ### 8.4 Compilation behavior -Comments are **stripped** during compilation and do not appear in the generated Dockerfile. +Comments are **preserved** in the generated Dockerfile for readability. This makes it easier to understand the structure of the generated output. -Future consideration: A flag like `--preserve-comments` could emit comments as Dockerfile `# ...` lines for traceability. +The only exception is the `# syntax=codingbooth/boothfile:1` directive, which is transformed into `# syntax=docker/dockerfile:1.7` in the prologue. --- @@ -425,16 +420,97 @@ RUN apt-get update && apt-get install -y graphviz #### Multi-line commands -Complex commands spanning multiple lines use heredoc-style syntax: +Complex commands spanning multiple lines use heredoc-style syntax with explicit mode selection: + +| Syntax | Behavior | Use case | +|---------------|-------------------------|-----------------------------------------------------| +| `run </booth_entry` (e.g., `/home/coder/code` for the base variant). This is intentional — build-time concerns are separate from runtime user experience. + +Example: +```text +workdir /tmp/build +run make install # This runs in /tmp/build +``` + +When the user logs in, they will still be in `/home/coder/code` (or whatever the variant's entry script sets), not `/tmp/build`. + ### 9.5 `expose` (Phase 1+) Declares a port. @@ -545,6 +630,44 @@ Compiles to: ARG NODE_VERSION=20 ``` +#### Using variables + +Variables defined with `arg` can be used anywhere with `${name}` syntax: + +```text +arg NODE_VERSION=20 +arg PYTHON_VERSION=3.12 + +setup nodejs ${NODE_VERSION} +setup python ${PYTHON_VERSION} +``` + +Compiles to: + +```dockerfile +ARG NODE_VERSION=20 +ARG PYTHON_VERSION=3.12 +RUN nodejs--setup.sh ${NODE_VERSION} +RUN python--setup.sh ${PYTHON_VERSION} +``` + +Docker expands the variables at build time. Override defaults with: + +```bash +booth build --build-arg NODE_VERSION=22 +``` + +#### Naming conventions + +Variable names can be any valid identifier. Uppercase is conventional (e.g., `NODE_VERSION`) but not required: + +```text +arg node_version=20 # works fine +arg NODE_VERSION=20 # conventional +``` + +Boothfile does not enforce a naming convention — use whatever fits your project's style. + ### 9.8 Dependency Contract Setup scripts (run as root) and install scripts (run as user) **must not silently install their own dependencies**. If a required dependency is missing, the script must **error at build time** with a clear message. @@ -824,19 +947,14 @@ env APP_ENV=production Compiles to (via `booth build --emit-dockerfile`): ```dockerfile -# syntax=docker/dockerfile:1 +# syntax=docker/dockerfile:1.7 ARG BOOTH_VARIANT_TAG=base ARG BOOTH_VERSION_TAG=latest FROM nawaman/codingbooth:${BOOTH_VARIANT_TAG}-${BOOTH_VERSION_TAG} -SHELL ["/bin/bash","-o","pipefail","-lc"] -USER root - ARG BOOTH_VARIANT_TAG=base ARG BOOTH_VERSION_TAG=latest -WORKDIR /opt/codingbooth/setups - RUN apt-get update && apt-get install -y graphviz libpq-dev RUN python--setup.sh 3.12 RUN jdk--setup.sh 21 temurin @@ -874,7 +992,7 @@ Boothfile makes it pleasant to write. | Boothfile | Generated Dockerfile | Phase | |----------------------------------------|-----------------------------------------------|-------| -| `# syntax=codingbooth/boothfile:1` | `# syntax=docker/dockerfile:1` + prologue | 1 | +| `# syntax=codingbooth/boothfile:1` | `# syntax=docker/dockerfile:1.7` + prologue | 1 | | `# comment` | (stripped) | 1 | | `run apt-get install -y foo` | `RUN apt-get install -y foo` | 1 | | `run <&1) +output=$("../../../codingbooth" --variant base --port 10000 -- 'java -version' 2>&1) echo "$output" echo "" diff --git a/examples/workspaces/all-java-example/.cb-tests/test002-jenv-on-host.sh b/examples/workspaces/all-java-example/.cb-tests/test002-jenv-on-host.sh index 73e12938..3b818116 100755 --- a/examples/workspaces/all-java-example/.cb-tests/test002-jenv-on-host.sh +++ b/examples/workspaces/all-java-example/.cb-tests/test002-jenv-on-host.sh @@ -14,14 +14,6 @@ RED='\033[0;31m' GREEN='\033[0;32m' NC='\033[0m' # No Color -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$SCRIPT_DIR/../../../.." -if [ -x "$REPO_ROOT/codingbooth" ]; then - BOOTH="$REPO_ROOT/codingbooth" -else - BOOTH="$REPO_ROOT/booth" -fi - echo "=== Testing jenv Installation ===" echo "" @@ -29,7 +21,7 @@ failed=0 # Test 1: jenv versions runs successfully echo "Testing 'jenv versions'..." -if output=$("$BOOTH" --variant base -- 'jenv versions' 2>&1); then +if output=$("../../../codingbooth" --variant base -- 'jenv versions' 2>&1); then echo "$output" echo "" echo -e "${GREEN}✓${NC} 'jenv versions' completed successfully" @@ -41,7 +33,7 @@ echo "" # Test 2: jenv version returns current version echo "Testing 'jenv version'..." -if version_output=$("$BOOTH" --variant base --port 10100 -- 'jenv version' 2>&1); then +if version_output=$("../../../codingbooth" --variant base --port 10100 -- 'jenv version' 2>&1); then echo "$version_output" # Check that output contains a version number pattern (e.g., "25" or "25.0.1") if echo "$version_output" | grep -qE '[0-9]+(\.[0-9]+)?'; then diff --git a/examples/workspaces/all-java-example/.cb-tests/test003-source-call--on-host.sh b/examples/workspaces/all-java-example/.cb-tests/test003-source-call--on-host.sh index 34255ea2..65eb96a6 100755 --- a/examples/workspaces/all-java-example/.cb-tests/test003-source-call--on-host.sh +++ b/examples/workspaces/all-java-example/.cb-tests/test003-source-call--on-host.sh @@ -14,19 +14,11 @@ RED='\033[0;31m' GREEN='\033[0;32m' NC='\033[0m' # No Color -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$SCRIPT_DIR/../../../.." -if [ -x "$REPO_ROOT/codingbooth" ]; then - BOOTH="$REPO_ROOT/codingbooth" -else - BOOTH="$REPO_ROOT/booth" -fi - echo "=== Testing jbang Source Execution ===" echo "" # Run jbang with inline Java source -output=$("$BOOTH" --variant base --port 10200 -- ' +output=$("../../../codingbooth" --variant base --port 10200 -- ' cat > /tmp/Test.java << "EOFJAVA" import java.nio.file.*; import java.util.Arrays; diff --git a/examples/workspaces/all-java-example/booth b/examples/workspaces/all-java-example/booth index d48b5ed0..84e09673 100755 --- a/examples/workspaces/all-java-example/booth +++ b/examples/workspaces/all-java-example/booth @@ -43,8 +43,8 @@ VERBOSE="${VERBOSE:-true}" # Users can explicitly allow nested execution by setting BOOTH_IN_BOOTH=true and using a different port. detect_nested_booth() { # Check if we're inside a CodingBooth container - # Markers: /opt/codingbooth/ directory exists OR CB_CONTAINER_NAME is set - if [[ ! -d "/opt/codingbooth" && -z "${CB_CONTAINER_NAME:-}" ]]; then + # Markers: /opt/codingbooth/ directory exists OR BOOTH_CONTAINER_NAME is set + if [[ ! -d "/opt/codingbooth" && -z "${BOOTH_CONTAINER_NAME:-}" ]]; then return 0 # Not inside a container, continue normally fi @@ -93,8 +93,8 @@ EOF done # Get current container's ports - local host_port="${CB_HOST_PORT:-}" - local code_port="${CB_CODE_PORT:-10000}" + local host_port="${BOOTH_HOST_PORT:-}" + local code_port="${BOOTH_CODE_PORT:-10000}" # If no port specified, error out - we need an explicit different port if [[ -z "$requested_port" ]]; then diff --git a/examples/workspaces/aws-example/.booth/Boothfile b/examples/workspaces/aws-example/.booth/Boothfile new file mode 100644 index 00000000..e064b0ba --- /dev/null +++ b/examples/workspaces/aws-example/.booth/Boothfile @@ -0,0 +1,9 @@ +# syntax=codingbooth/boothfile:1 + +# AWS Example +# Installs AWS CLI on top of the base workspace image + +setup aws-cli + +setup claude-code +setup antigravity diff --git a/examples/workspaces/aws-example/.booth/tools/codingbooth.lock b/examples/workspaces/aws-example/.booth/tools/codingbooth.lock index 27a347f0..a38437f5 100644 --- a/examples/workspaces/aws-example/.booth/tools/codingbooth.lock +++ b/examples/workspaces/aws-example/.booth/tools/codingbooth.lock @@ -1,3 +1,3 @@ version=0.16.0 -downloaded_at=2026-02-03T06:59:32Z +downloaded_at=2026-02-04T21:52:37Z cache=shared diff --git a/examples/workspaces/aws-example/.cb-tests/test001-aws-connection-on-host.sh b/examples/workspaces/aws-example/.cb-tests/test001-aws-connection-on-host.sh index 2e1e7885..1c1265f2 100755 --- a/examples/workspaces/aws-example/.cb-tests/test001-aws-connection-on-host.sh +++ b/examples/workspaces/aws-example/.cb-tests/test001-aws-connection-on-host.sh @@ -17,7 +17,8 @@ YELLOW='\033[0;33m' NC='\033[0m' # No Color SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -cd "$SCRIPT_DIR" +EXAMPLE_DIR="$(dirname "$SCRIPT_DIR")" +cd "$EXAMPLE_DIR" echo "=== Testing AWS Connection ===" echo "" diff --git a/examples/workspaces/aws-example/booth b/examples/workspaces/aws-example/booth index d48b5ed0..84e09673 100755 --- a/examples/workspaces/aws-example/booth +++ b/examples/workspaces/aws-example/booth @@ -43,8 +43,8 @@ VERBOSE="${VERBOSE:-true}" # Users can explicitly allow nested execution by setting BOOTH_IN_BOOTH=true and using a different port. detect_nested_booth() { # Check if we're inside a CodingBooth container - # Markers: /opt/codingbooth/ directory exists OR CB_CONTAINER_NAME is set - if [[ ! -d "/opt/codingbooth" && -z "${CB_CONTAINER_NAME:-}" ]]; then + # Markers: /opt/codingbooth/ directory exists OR BOOTH_CONTAINER_NAME is set + if [[ ! -d "/opt/codingbooth" && -z "${BOOTH_CONTAINER_NAME:-}" ]]; then return 0 # Not inside a container, continue normally fi @@ -93,8 +93,8 @@ EOF done # Get current container's ports - local host_port="${CB_HOST_PORT:-}" - local code_port="${CB_CODE_PORT:-10000}" + local host_port="${BOOTH_HOST_PORT:-}" + local code_port="${BOOTH_CODE_PORT:-10000}" # If no port specified, error out - we need an explicit different port if [[ -z "$requested_port" ]]; then diff --git a/examples/workspaces/boothfile-example/.booth/Boothfile b/examples/workspaces/boothfile-example/.booth/Boothfile new file mode 100644 index 00000000..22541e9b --- /dev/null +++ b/examples/workspaces/boothfile-example/.booth/Boothfile @@ -0,0 +1,38 @@ +# syntax=codingbooth/boothfile:1 + +# Boothfile Example - Web Application Development Environment +# This file demonstrates the Boothfile DSL syntax + +# Build-time arguments with defaults +arg PY_VERSION=3.12 +arg NODE_VERSION=20 + +# Python setup +setup python ${PY_VERSION} + +# Node.js setup +setup nodejs ${NODE_VERSION} + +# Install Python packages +install pip flask requests pytest + +# Install Node packages +install npm typescript prettier + +# Environment variables +env FLASK_APP=app.py +env FLASK_ENV=development +env NODE_ENV=development + +# VS Code extensions +setup python-code-extension + +# Expose common development ports +expose 5000 +expose 3000 + +# Custom initialization with heredoc (and-join mode) +run &&< /tmp/booth-init-time.txt +SETUP diff --git a/examples/workspaces/boothfile-example/.booth/config.toml b/examples/workspaces/boothfile-example/.booth/config.toml new file mode 100644 index 00000000..96c47738 --- /dev/null +++ b/examples/workspaces/boothfile-example/.booth/config.toml @@ -0,0 +1 @@ +variant = "base" diff --git a/examples/workspaces/boothfile-example/.booth/tools/codingbooth.lock b/examples/workspaces/boothfile-example/.booth/tools/codingbooth.lock new file mode 100644 index 00000000..cf3ebe9f --- /dev/null +++ b/examples/workspaces/boothfile-example/.booth/tools/codingbooth.lock @@ -0,0 +1,3 @@ +version=0.16.0 +downloaded_at=2026-02-04T21:52:39Z +cache=shared diff --git a/examples/workspaces/boothfile-example/.cb-tests/tags.txt b/examples/workspaces/boothfile-example/.cb-tests/tags.txt new file mode 100644 index 00000000..d663f5e9 --- /dev/null +++ b/examples/workspaces/boothfile-example/.cb-tests/tags.txt @@ -0,0 +1,2 @@ +boothfile +emit-dockerfile diff --git a/examples/workspaces/boothfile-example/.cb-tests/test001-emit-dockerfile.sh b/examples/workspaces/boothfile-example/.cb-tests/test001-emit-dockerfile.sh new file mode 100755 index 00000000..955c4b5b --- /dev/null +++ b/examples/workspaces/boothfile-example/.cb-tests/test001-emit-dockerfile.sh @@ -0,0 +1,55 @@ +#!/bin/bash +# Copyright 2025-2026 : Nawa Manusitthipol +# Licensed under the Apache License, Version 2.0 + +# Test that --emit-dockerfile generates correct output from Boothfile + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +EXAMPLE_DIR="$(dirname "$SCRIPT_DIR")" + +# Find the codingbooth binary +if [ -f "$EXAMPLE_DIR/booth" ]; then + CB_BIN="$EXAMPLE_DIR/booth" +elif command -v codingbooth &> /dev/null; then + CB_BIN="codingbooth" +else + echo "SKIP: codingbooth binary not found" + exit 0 +fi + +# Generate Dockerfile from Boothfile +OUTPUT=$("$CB_BIN" --code "$EXAMPLE_DIR" --emit-dockerfile 2>/dev/null) + +# Verify key elements are present +EXPECTED_PATTERNS=( + "# syntax=docker/dockerfile:1" + "FROM nawaman/codingbooth" + "ARG PY_VERSION=3.12" + "ARG NODE_VERSION=20" + "RUN python--setup.sh" + "RUN nodejs--setup.sh" + "RUN pip--install.sh flask requests pytest" + "RUN npm--install.sh typescript prettier" + "ENV FLASK_APP=app.py" + "EXPOSE 5000" + "EXPOSE 3000" +) + +FAILED=0 +for pattern in "${EXPECTED_PATTERNS[@]}"; do + if ! echo "$OUTPUT" | grep -qF "$pattern"; then + echo "FAIL: Missing expected pattern: $pattern" + FAILED=1 + fi +done + +if [ "$FAILED" -eq 0 ]; then + echo "PASS: Boothfile generates correct Dockerfile" +else + echo "---" + echo "Generated output:" + echo "$OUTPUT" + exit 1 +fi diff --git a/examples/workspaces/boothfile-example/README.md b/examples/workspaces/boothfile-example/README.md new file mode 100644 index 00000000..a759a49b --- /dev/null +++ b/examples/workspaces/boothfile-example/README.md @@ -0,0 +1,65 @@ +# Boothfile Example + +This example demonstrates using a **Boothfile** instead of a Dockerfile to configure the CodingBooth environment. + +## What is a Boothfile? + +A Boothfile is a higher-level DSL (Domain-Specific Language) that compiles to a Dockerfile. It provides a simpler, more intuitive syntax for common development environment setup tasks. + +## Key Features Demonstrated + +### 1. Setup Commands +``` +setup python 3.12 +setup nodejs 20 +``` +Automatically calls the appropriate setup scripts (`python--setup.sh`, `nodejs--setup.sh`). + +### 2. Install Commands +``` +install pip flask requests pytest +install npm typescript prettier +``` +Calls package manager install scripts (`pip--install.sh`, `npm--install.sh`). + +### 3. Build Arguments +``` +arg PY_VERSION=3.12 +``` +Defines build-time variables that can be used with `${PY_VERSION}` syntax. + +### 4. Environment Variables +``` +env FLASK_APP=app.py +env FLASK_ENV=development +``` +Sets environment variables in the container. + +### 5. Heredoc Support +``` +run &&< /tmp/booth-init-time.txt +SETUP +``` +Multi-line commands with `&&` joining. + +## Usage + +```bash +cd examples/workspaces/boothfile-example +booth # start the environment +python app.py # run the Flask app +``` + +## Generate Dockerfile + +To see the generated Dockerfile without running: + +```bash +codingbooth --code . --emit-dockerfile +``` + +## Purpose + +This example shows how Boothfile simplifies environment configuration compared to writing raw Dockerfiles. The DSL handles common patterns automatically while still allowing full Docker capabilities when needed. diff --git a/examples/workspaces/boothfile-example/app.py b/examples/workspaces/boothfile-example/app.py new file mode 100644 index 00000000..11ab1fe0 --- /dev/null +++ b/examples/workspaces/boothfile-example/app.py @@ -0,0 +1,25 @@ +# Copyright 2025-2026 : Nawa Manusitthipol +# Licensed under the Apache License, Version 2.0 + +"""Simple Flask application demonstrating Boothfile environment.""" + +from flask import Flask, jsonify + +app = Flask(__name__) + + +@app.route("/") +def home(): + return jsonify({ + "message": "Hello from Boothfile example!", + "description": "This app runs in a CodingBooth environment configured with Boothfile" + }) + + +@app.route("/health") +def health(): + return jsonify({"status": "healthy"}) + + +if __name__ == "__main__": + app.run(host="0.0.0.0", port=5000, debug=True) diff --git a/examples/workspaces/boothfile-example/booth b/examples/workspaces/boothfile-example/booth new file mode 100755 index 00000000..84e09673 --- /dev/null +++ b/examples/workspaces/boothfile-example/booth @@ -0,0 +1,960 @@ +#!/bin/bash +# Copyright 2025-2026 : Nawa Manusitthipol +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. + + +# CodingBooth Wrapper (booth) +# Downloads, verifies, and runs the platform-specific CodingBooth binary. +# Install: curl -fsSL https://github.com/NawaMan/WorkSpace/releases/download/latest/booth | bash + +set -euo pipefail +trap 'status=$?; echo "❌ Error on line $LINENO (exit $status)" >&2; exit "$status"' ERR + +# --- PIPE INSTALL DETECTION --- +# Detect if running via pipe (curl ... | bash) +# When piped, $0 is the shell name, not a script path +if [[ "$0" == "bash" || "$0" == "-bash" || "$0" == "/bin/bash" || \ + "$0" == "sh" || "$0" == "-sh" || "$0" == "/bin/sh" || \ + "$0" == "zsh" || "$0" == "-zsh" || "$0" == "/bin/zsh" ]]; then + echo "Installing CodingBooth wrapper..." + curl -fsSL -o booth https://github.com/NawaMan/WorkSpace/releases/download/latest/booth + chmod +x booth + echo "" + echo "" + echo "" + ./booth install + echo "" + echo "✅ CodingBooth has been installed." + echo "" + echo "" + echo "" + echo "Run './booth help' to see available commands." + echo "" + ./booth help + exit 0 +fi + +VERSION=0.8.0 +VERBOSE="${VERBOSE:-true}" + +# --- NESTED BOOTH DETECTION --- +# Detect if running inside a CodingBooth container and prevent accidental nested execution. +# Users can explicitly allow nested execution by setting BOOTH_IN_BOOTH=true and using a different port. +detect_nested_booth() { + # Check if we're inside a CodingBooth container + # Markers: /opt/codingbooth/ directory exists OR BOOTH_CONTAINER_NAME is set + if [[ ! -d "/opt/codingbooth" && -z "${BOOTH_CONTAINER_NAME:-}" ]]; then + return 0 # Not inside a container, continue normally + fi + + # We're inside a CodingBooth container + if [[ "${BOOTH_IN_BOOTH:-}" != "true" ]]; then + cat >&2 <<'EOF' +╔═══════════════════════════════════════════════════════════════════════════╗ +║ Running booth inside a booth container ║ +╠═══════════════════════════════════════════════════════════════════════════╣ +║ You appear to be running 'booth' from inside a CodingBooth container. ║ +║ This is usually accidental - the booth script is visible here because ║ +║ your project folder is mounted at /home/coder/code. ║ +║ ║ +║ If you intentionally want to run a nested booth (booth-in-booth), set: ║ +║ ║ +║ export BOOTH_IN_BOOTH=true ║ +║ ║ +║ AND specify a different port (not the current container's port): ║ +║ ║ +║ ./booth --port NEXT ... ║ +║ ./booth --port 11000 ... ║ +║ ║ +╚═══════════════════════════════════════════════════════════════════════════╝ +EOF + exit 1 + fi + + # BOOTH_IN_BOOTH=true is set, now check port + local requested_port="" + local args=("$@") + local i=0 + while [[ $i -lt ${#args[@]} ]]; do + case "${args[$i]}" in + --port) + if [[ $((i+1)) -lt ${#args[@]} ]]; then + requested_port="${args[$((i+1))]}" + fi + break + ;; + --port=*) + requested_port="${args[$i]#--port=}" + break + ;; + esac + ((i++)) || true + done + + # Get current container's ports + local host_port="${BOOTH_HOST_PORT:-}" + local code_port="${BOOTH_CODE_PORT:-10000}" + + # If no port specified, error out - we need an explicit different port + if [[ -z "$requested_port" ]]; then + cat >&2 <&2 <&2 <&2; exit 1 ;; + *) version_arg="$1"; shift ;; + esac + done + # Validate cache mode + if [[ "$cache_mode" != "local" && "$cache_mode" != "shared" ]]; then + echo "Error: Invalid cache mode '$cache_mode'. Use 'local' or 'shared'." >&2 + exit 1 + fi + DownloadBooth "$version_arg" "$cache_mode" + exit 0 + ;; + tools-cache) + shift # Remove 'tools-cache' + case "${1:-list}" in + list) ToolsCacheList ;; + clean) shift; ToolsCacheClean "$@" ;; + *) echo "Unknown tools-cache command: $1" >&2; exit 1 ;; + esac + exit 0 + ;; + run) [[ "${1-}" == "run" ]] && shift ; ;; + *) ;; + esac + + ### --- RUN MODE --- ### + local tools_dir=".booth/tools" + local lock_file="$tools_dir/codingbooth.lock" + + # Read version and cache mode from lock file + if [[ ! -f "$lock_file" ]]; then + echo "CodingBooth is not installed." + echo "Please run: $0 install" + exit 1 + fi + + local lock_version lock_cache + lock_version=$(grep '^version=' "$lock_file" 2>/dev/null | cut -d= -f2-) + lock_cache=$(grep '^cache=' "$lock_file" 2>/dev/null | cut -d= -f2- || echo "shared") + + # Handle legacy lock files without cache= line + if [[ -z "$lock_cache" ]]; then + lock_cache="shared" + fi + + if [[ -z "$lock_version" ]]; then + echo "Invalid lock file: missing version" + echo "Please run: $0 install" + exit 1 + fi + + # Detect platform + local platform binary_name + if ! platform=$(detect_platform); then + echo "Error: Failed to detect platform" >&2 + exit 1 + fi + binary_name=$(get_binary_name "$platform") + + # Find binary directory (local first, then shared cache) + local binary_dir sha_file dest + if ! binary_dir=$(find_binary_dir "$lock_version" "$platform"); then + # Binary not found, auto-download + echo "Binary missing, downloading version $lock_version..." + DownloadBooth "$lock_version" "$lock_cache" + if ! binary_dir=$(find_binary_dir "$lock_version" "$platform"); then + echo "Failed to download binary" + exit 1 + fi + fi + + sha_file="$binary_dir/codingbooth.sha256" + dest="$binary_dir/$binary_name" + + # Verify binary exists + if [[ ! -f "$dest" || ! -f "$sha_file" ]]; then + echo "CodingBooth binary or checksum missing for platform: $platform" + echo "Please run: $0 install" + exit 1 + fi + + # Ensure binary is newer than checksum + if [[ "$dest" -ot "$sha_file" ]]; then + echo "Binary appears older than its checksum file." + echo "Run: $0 update to restore the official release." + exit 1 + fi + + # Verify SHA256 for this platform's binary + local expected_sha256 actual_sha256 + expected_sha256=$(grep " $binary_name\$" "$sha_file" 2>/dev/null | awk '{print $1}') + if [[ -z "$expected_sha256" ]]; then + echo "No SHA256 entry found for $binary_name" + echo "Run: $0 update to restore the official release." + exit 1 + fi + + actual_sha256=$(hash_sha256 "$dest" | awk '{print $1}') + if [[ "$expected_sha256" != "$actual_sha256" ]]; then + echo "Binary ($binary_name) failed SHA256 verification." + echo "Run: $0 update to restore the official release." + exit 1 + fi + + # Create/update symlink named 'booth' so the binary displays correct name + local booth_link="$binary_dir/booth" + if [[ ! -L "$booth_link" ]] || [[ "$(readlink "$booth_link")" != "$binary_name" ]]; then + ln -sf "$binary_name" "$booth_link" 2>/dev/null || true + fi + + # Execute via symlink if available, otherwise direct + if [[ -L "$booth_link" ]]; then + exec "$booth_link" "$@" + else + exec "$dest" "$@" + fi +} + +function PrintHelp() { + cat < [args...] + +Purpose: + This script is the *CodingBooth Wrapper*. + - It downloads, verifies, and runs the CodingBooth binary. + - Binaries are cached in a shared location (default) or per-project. + +Wrapper commands: + install [VERSION] Download binaries to shared cache (default) + install --cache=shared [VER] Download binaries to shared cache (explicit) + install --cache=local [VER] Download binaries to .booth/tools/ (project-local) + update [VERSION] Re-download binaries (force refresh) + uninstall Remove project lock file and local binaries + + tools-cache list Show cached binary versions and sizes + tools-cache clean Interactively remove cached versions + tools-cache clean --all Remove all cached versions + tools-cache clean VER Remove specific version + + run [ARGS...] Run booth with ARGS (after integrity checks) + shell-config Add 'booth' command to your shell (bash/zsh) + version Show version information + help Show this help message + +Cache modes: + --cache=shared Store in user cache, shared across projects (default) + --cache=local Store in .booth/tools/, project-specific + +Cache locations (for --cache=shared): + Linux: ~/.cache/codingbooth/ + macOS: ~/Library/Caches/codingbooth/ + Windows: %LOCALAPPDATA%\\codingbooth\\ + +Notes: + - Lock file (.booth/tools/codingbooth.lock) is version-controlled + - Binaries are auto-downloaded when lock file exists but binary missing + - Use --cache=local for CI/CD or portable/air-gapped environments + - Set VERBOSE=true for extra logs during install + +Help/Version disambiguation: + ./booth help Show this wrapper help (install, update, cache commands) + ./booth --help Show codingbooth binary help (run flags, variants, etc.) + ./booth version Show wrapper version + binary version info + ./booth --version Show codingbooth binary version only +EOF +} + +function ShellConfig() { + local booth_func='unalias booth 2>/dev/null; booth() { d="$PWD"; while [[ "$d" != "/" && ! -x "$d/booth" ]]; do d="${d%/*}"; done; if [[ -x "$d/booth" ]]; then "$d/booth" "$@"; else echo "booth not found" >&2; fi; }' + + # All supported shell rc files + local rc_files=( + "$HOME/.bashrc" + "$HOME/.zshrc" + "$HOME/.bash_profile" + "$HOME/.profile" + ) + + local added=() + local skipped=() + local not_found=() + + for rc_file in "${rc_files[@]}"; do + if [[ ! -f "$rc_file" ]]; then + not_found+=("$rc_file") + continue + fi + + # Check if already configured (idempotent) + if grep -q 'booth()' "$rc_file" 2>/dev/null; then + skipped+=("$rc_file") + continue + fi + + # Add the function + { + echo "" + echo "# CodingBooth - run 'booth' from any subdirectory" + echo "$booth_func" + } >> "$rc_file" + + added+=("$rc_file") + done + + # Report results + if [[ ${#added[@]} -gt 0 ]]; then + echo "✓ Added booth function to:" + for f in "${added[@]}"; do + echo " $f" + done + fi + + if [[ ${#skipped[@]} -gt 0 ]]; then + echo "✓ Already configured (skipped):" + for f in "${skipped[@]}"; do + echo " $f" + done + fi + + if [[ ${#added[@]} -eq 0 && ${#skipped[@]} -eq 0 ]]; then + echo "No shell config files found." + echo "" + echo "You can manually add this to your shell config:" + echo " $booth_func" + exit 1 + fi + + echo "" + if [[ ${#added[@]} -gt 0 ]]; then + echo "To activate, restart your terminal or run:" + echo " source ~/.bashrc # for bash" + echo " source ~/.zshrc # for zsh" + echo "" + fi + echo "Then you can run 'booth' from any subdirectory of a CodingBooth project." +} + +function PrintVersion() { + cat <<'EOF' + ____ _ _ ____ _ _ + / ___|___ __| (_)_ __ __ _| __ ) ___ ___ | |_| |__ +| | / _ \ / _` | | '_ \ / _` | _ \ / _ \ / _ \| __| '_ \ +| |__| (_) | (_| | | | | | (_| | |_) | (_) | (_) | |_| | | | + \____\___/ \__,_|_|_| |_|\__, |____/ \___/ \___/ \__|_| |_| + |___/ +EOF + echo "CodingBooth Wrapper: $VERSION" + + local tools_dir=".booth/tools" + local lock_file="$tools_dir/codingbooth.lock" + + # Detect current platform + local platform binary_name + platform=$(detect_platform 2>/dev/null || echo "unknown") + binary_name=$(get_binary_name "$platform") + + # Check if lock file exists + if [[ ! -f "$lock_file" ]]; then + echo "CodingBooth: not installed" + echo "Platform: $platform" + echo "Shared cache: $BOOTH_CACHE_DIR" + exit 0 + fi + + # Read lock file + local lock_version lock_cache + lock_version=$(grep '^version=' "$lock_file" 2>/dev/null | cut -d= -f2- || echo "unknown") + lock_cache=$(grep '^cache=' "$lock_file" 2>/dev/null | cut -d= -f2- || echo "shared") + + # Find binary + local binary_dir TOOL + if binary_dir=$(find_binary_dir "$lock_version" "$platform" 2>/dev/null); then + TOOL="$binary_dir/$binary_name" + else + echo "CodingBooth: $lock_version (binary missing)" + echo "Platform: $platform" + echo "Cache mode: $lock_cache" + exit 0 + fi + + [[ ! -x "$TOOL" ]] && chmod +x "$TOOL" 2>/dev/null || true + + local TOOL_VERSION + TOOL_VERSION=$("$TOOL" version 2>/dev/null || echo "unknown") + + echo "" + echo "$TOOL_VERSION" + echo "Platform: $platform" + echo "Cache mode: $lock_cache" + if [[ "$lock_cache" == "shared" ]]; then + echo "Binary location: $binary_dir" + fi +} + +# Portable SHA256 helper +function hash_sha256() { + if command -v sha256sum >/dev/null 2>&1; then sha256sum "$@" + elif command -v shasum >/dev/null 2>&1; then shasum -a 256 "$@" + else echo "Error: No SHA256 tool found (sha256sum or shasum)." >&2 ; return 1 + fi +} + +# Detect platform (OS-ARCH format) +function detect_platform() { + local os arch + + # Detect OS + case "$(uname -s)" in + Linux*) os="linux" ;; + Darwin*) os="darwin" ;; + MINGW*|MSYS*|CYGWIN*) os="windows" ;; + *) echo "Error: Unsupported OS: $(uname -s)" >&2; return 1 ;; + esac + + # Detect architecture + case "$(uname -m)" in + x86_64|amd64) arch="amd64" ;; + aarch64|arm64) arch="arm64" ;; + *) echo "Error: Unsupported architecture: $(uname -m)" >&2; return 1 ;; + esac + + echo "${os}-${arch}" +} + +# Get binary name for platform (adds .exe for Windows) +function get_binary_name() { + local platform="$1" + if [[ "$platform" == windows-* ]]; then + echo "codingbooth-${platform}.exe" + else + echo "codingbooth-${platform}" + fi +} + +# All supported platforms (5 total) +ALL_PLATFORMS=( + "linux-amd64" + "linux-arm64" + "darwin-amd64" + "darwin-arm64" + "windows-amd64" +) + +function UninstallBooth() { + local tools_dir=".booth/tools" + local sha_file="$tools_dir/codingbooth.sha256" + local lock_file="$tools_dir/codingbooth.lock" + + # Remove local binaries if they exist + for platform in "${ALL_PLATFORMS[@]}"; do + local binary_name + binary_name=$(get_binary_name "$platform") + rm -f "$tools_dir/$binary_name" + done + + rm -f "$sha_file" "$lock_file" + + rmdir "$tools_dir" 2>/dev/null || true + rmdir ".booth" 2>/dev/null || true + + echo "CodingBooth has been uninstalled from this project." + echo "To clean shared cache, run: $0 tools-cache clean" +} + +# Format bytes to human-readable size +format_size() { + local bytes=$1 + if command -v numfmt >/dev/null 2>&1; then + numfmt --to=iec-i --suffix=B "$bytes" 2>/dev/null || echo "${bytes}B" + else + # Fallback for systems without numfmt (e.g., macOS) + if [[ $bytes -ge 1073741824 ]]; then + echo "$(( bytes / 1073741824 ))GiB" + elif [[ $bytes -ge 1048576 ]]; then + echo "$(( bytes / 1048576 ))MiB" + elif [[ $bytes -ge 1024 ]]; then + echo "$(( bytes / 1024 ))KiB" + else + echo "${bytes}B" + fi + fi +} + +# Get directory size in bytes (portable) +get_dir_size() { + local dir="$1" + if [[ "$(uname -s)" == "Darwin" ]]; then + # macOS: du -sk gives size in KB + local kb + kb=$(du -sk "$dir" 2>/dev/null | cut -f1 || echo 0) + echo $((kb * 1024)) + else + # Linux: du -sb gives size in bytes + du -sb "$dir" 2>/dev/null | cut -f1 || echo 0 + fi +} + +function ToolsCacheList() { + local versions_dir="${BOOTH_CACHE_DIR}/versions" + + echo "" + if [[ ! -d "$versions_dir" ]]; then + echo "No cached versions found." + echo "" + echo "Cache location: $BOOTH_CACHE_DIR" + return 0 + fi + + echo "Cached binary versions:" + echo "" + + local total_size=0 + local version_count=0 + + for version_dir in "$versions_dir"/*/; do + [[ ! -d "$version_dir" ]] && continue + + local version + version=$(basename "$version_dir") + + # Calculate size + local size_bytes size_human + size_bytes=$(get_dir_size "$version_dir") + size_human=$(format_size "$size_bytes") + + # List platforms + local platforms=() + for bin in "$version_dir"/codingbooth-*; do + [[ -f "$bin" ]] || continue + local name + name=$(basename "$bin") + name=${name#codingbooth-} + name=${name%.exe} + platforms+=("$name") + done + + printf " %-12s %10s [%s]\n" "$version" "$size_human" "${platforms[*]}" + + total_size=$((total_size + size_bytes)) + : $((version_count++)) + done + + if [[ $version_count -eq 0 ]]; then + echo " (no versions cached)" + fi + + echo "" + local total_human + total_human=$(format_size "$total_size") + echo "Total: $total_human in $version_count version(s)" + echo "" + echo "Cache location: $BOOTH_CACHE_DIR" +} + +function ToolsCacheClean() { + local versions_dir="${BOOTH_CACHE_DIR}/versions" + + if [[ ! -d "$versions_dir" ]]; then + echo "No cached versions to clean." + return 0 + fi + + # Parse arguments + local clean_all=false + local target_version="" + + while [[ $# -gt 0 ]]; do + case "$1" in + --all) clean_all=true; shift ;; + --unused) echo "Warning: --unused not yet implemented"; shift ;; + -*) echo "Unknown option: $1" >&2; exit 1 ;; + *) target_version="$1"; shift ;; + esac + done + + if [[ "$clean_all" == "true" ]]; then + local size_before + size_before=$(get_dir_size "$versions_dir") + + rm -rf "$versions_dir" + + local size_human + size_human=$(format_size "$size_before") + echo "Removed all cached versions. Freed $size_human" + return 0 + fi + + if [[ -n "$target_version" ]]; then + local target_dir="$versions_dir/$target_version" + if [[ ! -d "$target_dir" ]]; then + echo "Version $target_version not found in cache." + return 1 + fi + + local size_before + size_before=$(get_dir_size "$target_dir") + + rm -rf "$target_dir" + + local size_human + size_human=$(format_size "$size_before") + echo "Removed version $target_version. Freed $size_human" + return 0 + fi + + # Interactive mode: list and prompt + ToolsCacheList + echo "" + read -rp "Enter version to remove (or 'all' or press Enter to cancel): " choice + + if [[ "$choice" == "all" ]]; then + ToolsCacheClean --all + elif [[ -n "$choice" ]]; then + ToolsCacheClean "$choice" + else + echo "No version selected." + fi +} + +function DownloadBooth() { + local CB_VERSION=${1:-latest} + local CACHE_MODE=${2:-shared} + + # Detect common mistake: using --version flag instead of positional argument + if [[ "$CB_VERSION" == --* ]]; then + echo "Error: Invalid version '$CB_VERSION'" >&2 + echo "Usage: $0 install [VERSION]" >&2 + echo "Example: $0 install 0.13.0" >&2 + exit 1 + fi + + local tools_dir=".booth/tools" + local lock_file="$tools_dir/codingbooth.lock" + + REPO_URL="https://github.com/NawaMan/WorkSpace" + DWLD_URL="${REPO_URL}/releases/download" + + # Download version.txt to get the actual version first + local actual_version="" + local VERSION_URL="${DWLD_URL}/${CB_VERSION}/version.txt" + if actual_version=$(curl -fsSL "$VERSION_URL" 2>/dev/null | tr -d ' \t\r\n'); then + [[ "$VERBOSE" == "true" ]] && echo " Version: $actual_version" + else + actual_version="$CB_VERSION" + fi + + # Determine target directory based on cache mode + local target_dir sha_file + if [[ "$CACHE_MODE" == "local" ]]; then + target_dir="$tools_dir" + sha_file="$tools_dir/codingbooth.sha256" + else + target_dir="$(get_cache_version_dir "$actual_version")" + sha_file="$target_dir/codingbooth.sha256" + fi + + mkdir -p "$target_dir" + mkdir -p "$tools_dir" # Always need tools dir for lock file + + # Create .gitignore based on cache mode + if [[ "$CACHE_MODE" == "local" ]]; then + cat > ".booth/.gitignore" <<'GITIGNORE' +# Binaries excluded - re-download from lock version +tools/codingbooth-* +tools/*.sha256 +GITIGNORE + else + cat > ".booth/.gitignore" <<'GITIGNORE' +# Lock file is version-controlled +# Binaries are in ~/.cache/codingbooth/ (not here) +GITIGNORE + fi + + # Clear previous SHA256 file (will rebuild with all binaries) + > "$sha_file" + + local current_platform + current_platform=$(detect_platform 2>/dev/null || echo "unknown") + local download_count=0 + local fail_count=0 + + if [[ "$CACHE_MODE" == "local" ]]; then + echo "Downloading CodingBooth binaries to project (--cache=local)..." + else + echo "Downloading CodingBooth binaries to shared cache..." + echo " Cache: $target_dir" + fi + + for platform in "${ALL_PLATFORMS[@]}"; do + local binary_name + binary_name=$(get_binary_name "$platform") + local dest="$target_dir/$binary_name" + local TOOL_URL="${DWLD_URL}/${CB_VERSION}/${binary_name}" + local SHA256_URL="${DWLD_URL}/${CB_VERSION}/${binary_name}.sha256" + + if [[ "$VERBOSE" == "true" ]]; then + echo " Checking: $binary_name" + else + echo -n " $platform ... " + fi + + local tmpsha256 + tmpsha256=$(mktemp "/tmp/booth.sha256.XXXXXX") + + # Download SHA256 first to check if we need to download the binary + if ! curl -fsSLo "$tmpsha256" "$SHA256_URL"; then + echo "FAILED (sha256 fetch)" + rm -f "$tmpsha256" + : $((fail_count++)) + continue + fi + + local expected_sha256 + expected_sha256=$(awk '{print $1}' "$tmpsha256") + if ! [[ "$expected_sha256" =~ ^[0-9a-fA-F]{64}$ ]]; then + echo "FAILED (malformed sha256)" + rm -f "$tmpsha256" + : $((fail_count++)) + continue + fi + + # Check if binary already exists with correct checksum + if [[ -f "$dest" ]]; then + local existing_sha256 + existing_sha256=$(hash_sha256 "$dest" | awk '{print $1}') + if [[ "$expected_sha256" == "$existing_sha256" ]]; then + # Binary already exists and is valid, skip download + printf '%s %s\n' "$expected_sha256" "$binary_name" >> "$sha_file" + rm -f "$tmpsha256" + : $((download_count++)) + if [[ "$VERBOSE" != "true" ]]; then + echo "✓ up-to-date" + else + echo " Already up-to-date: $binary_name" + fi + continue + fi + fi + + # Need to download - binary missing or checksum mismatch + if [[ "$VERBOSE" == "true" ]]; then + echo " Downloading: $binary_name" + fi + + local tmpfile + tmpfile=$(mktemp "/tmp/booth.XXXXXX") + + # Download binary + if ! curl -fsSLo "$tmpfile" "$TOOL_URL"; then + echo "FAILED (download)" + rm -f "$tmpfile" "$tmpsha256" + : $((fail_count++)) + continue + fi + + # Verify SHA256 + local actual_sha256 + actual_sha256=$(hash_sha256 "$tmpfile" | awk '{print $1}') + if [[ "$expected_sha256" != "$actual_sha256" ]]; then + echo "FAILED (sha256 mismatch)" + rm -f "$tmpfile" "$tmpsha256" + : $((fail_count++)) + continue + fi + + # Install verified binary with 744 permissions + mv -f "$tmpfile" "$dest" + chmod 744 "$dest" + + # Append to combined SHA256 file + printf '%s %s\n' "$actual_sha256" "$binary_name" >> "$sha_file" + + rm -f "$tmpsha256" + : $((download_count++)) + + if [[ "$VERBOSE" != "true" ]]; then + echo "OK" + fi + done + + if [[ $fail_count -gt 0 ]]; then + echo "Warning: $fail_count platform(s) failed to download" >&2 + fi + + if [[ $download_count -eq 0 ]]; then + echo "Error: No binaries were downloaded successfully" >&2 + return 1 + fi + + # Write lock file (always in .booth/tools/) + { + echo "version=${actual_version}" + echo "downloaded_at=$(date -u +"%Y-%m-%dT%H:%M:%SZ")" + echo "cache=${CACHE_MODE}" + } > "$lock_file" + + # Touch all binaries to be newer than checksum + for platform in "${ALL_PLATFORMS[@]}"; do + local binary_name + binary_name=$(get_binary_name "$platform") + local dest="$target_dir/$binary_name" + [[ -f "$dest" ]] && touch "$dest" + done + + echo "CodingBooth installed: downloaded, verified, and installed." + if [[ "$VERBOSE" == "true" ]]; then + echo "Lock file: $lock_file" + fi +} + +# Early handling of version/help/shell-config so they don't require curl +case "${COMMAND}" in + version) PrintVersion ; exit 0 ; ;; + help) PrintHelp ; exit 0 ; ;; + shell-config) ShellConfig ; exit 0 ; ;; +esac + +# Check for nested booth execution (running booth inside a booth container) +# This check runs for all commands except help/version/shell-config +detect_nested_booth "$@" + +# Need curl for install/run/update/uninstall +if ! command -v curl >/dev/null 2>&1; then + echo "Error: curl is required but was not found." >&2 + exit 1 +fi + +Main "$@" diff --git a/examples/workspaces/bun-example/.booth/Boothfile b/examples/workspaces/bun-example/.booth/Boothfile new file mode 100644 index 00000000..06087efc --- /dev/null +++ b/examples/workspaces/bun-example/.booth/Boothfile @@ -0,0 +1,7 @@ +# syntax=codingbooth/boothfile:1 + +# Bun Example +# Installs popular Bun global packages on top of the base workspace image + +setup bun +install bun typescript prettier cowsay figlet diff --git a/examples/workspaces/bun-example/.booth/tools/codingbooth.lock b/examples/workspaces/bun-example/.booth/tools/codingbooth.lock index c1335b90..683eb036 100644 --- a/examples/workspaces/bun-example/.booth/tools/codingbooth.lock +++ b/examples/workspaces/bun-example/.booth/tools/codingbooth.lock @@ -1,3 +1,3 @@ version=0.16.0 -downloaded_at=2026-02-03T06:59:34Z +downloaded_at=2026-02-04T21:52:41Z cache=shared diff --git a/examples/workspaces/bun-example/booth b/examples/workspaces/bun-example/booth index d48b5ed0..84e09673 100755 --- a/examples/workspaces/bun-example/booth +++ b/examples/workspaces/bun-example/booth @@ -43,8 +43,8 @@ VERBOSE="${VERBOSE:-true}" # Users can explicitly allow nested execution by setting BOOTH_IN_BOOTH=true and using a different port. detect_nested_booth() { # Check if we're inside a CodingBooth container - # Markers: /opt/codingbooth/ directory exists OR CB_CONTAINER_NAME is set - if [[ ! -d "/opt/codingbooth" && -z "${CB_CONTAINER_NAME:-}" ]]; then + # Markers: /opt/codingbooth/ directory exists OR BOOTH_CONTAINER_NAME is set + if [[ ! -d "/opt/codingbooth" && -z "${BOOTH_CONTAINER_NAME:-}" ]]; then return 0 # Not inside a container, continue normally fi @@ -93,8 +93,8 @@ EOF done # Get current container's ports - local host_port="${CB_HOST_PORT:-}" - local code_port="${CB_CODE_PORT:-10000}" + local host_port="${BOOTH_HOST_PORT:-}" + local code_port="${BOOTH_CODE_PORT:-10000}" # If no port specified, error out - we need an explicit different port if [[ -z "$requested_port" ]]; then diff --git a/examples/workspaces/conda-example/.booth/Boothfile b/examples/workspaces/conda-example/.booth/Boothfile new file mode 100644 index 00000000..a6afda8a --- /dev/null +++ b/examples/workspaces/conda-example/.booth/Boothfile @@ -0,0 +1,6 @@ +# syntax=codingbooth/boothfile:1 + +# Conda Example + +setup conda --python-version 3.12 +install conda numpy diff --git a/examples/workspaces/conda-example/.booth/tools/codingbooth.lock b/examples/workspaces/conda-example/.booth/tools/codingbooth.lock index a77217d5..f39b78f1 100644 --- a/examples/workspaces/conda-example/.booth/tools/codingbooth.lock +++ b/examples/workspaces/conda-example/.booth/tools/codingbooth.lock @@ -1,3 +1,3 @@ version=0.16.0 -downloaded_at=2026-02-03T06:59:35Z +downloaded_at=2026-02-04T21:52:42Z cache=shared diff --git a/examples/workspaces/conda-example/booth b/examples/workspaces/conda-example/booth index d48b5ed0..84e09673 100755 --- a/examples/workspaces/conda-example/booth +++ b/examples/workspaces/conda-example/booth @@ -43,8 +43,8 @@ VERBOSE="${VERBOSE:-true}" # Users can explicitly allow nested execution by setting BOOTH_IN_BOOTH=true and using a different port. detect_nested_booth() { # Check if we're inside a CodingBooth container - # Markers: /opt/codingbooth/ directory exists OR CB_CONTAINER_NAME is set - if [[ ! -d "/opt/codingbooth" && -z "${CB_CONTAINER_NAME:-}" ]]; then + # Markers: /opt/codingbooth/ directory exists OR BOOTH_CONTAINER_NAME is set + if [[ ! -d "/opt/codingbooth" && -z "${BOOTH_CONTAINER_NAME:-}" ]]; then return 0 # Not inside a container, continue normally fi @@ -93,8 +93,8 @@ EOF done # Get current container's ports - local host_port="${CB_HOST_PORT:-}" - local code_port="${CB_CODE_PORT:-10000}" + local host_port="${BOOTH_HOST_PORT:-}" + local code_port="${BOOTH_CODE_PORT:-10000}" # If no port specified, error out - we need an explicit different port if [[ -z "$requested_port" ]]; then diff --git a/examples/workspaces/deno-example/.booth/Boothfile b/examples/workspaces/deno-example/.booth/Boothfile new file mode 100644 index 00000000..4f06294f --- /dev/null +++ b/examples/workspaces/deno-example/.booth/Boothfile @@ -0,0 +1,7 @@ +# syntax=codingbooth/boothfile:1 + +# Deno Example +# Installs Deno and some tools on top of the base workspace image + +setup deno +install deno -Agf npm:cowsay diff --git a/examples/workspaces/deno-example/.booth/tools/codingbooth.lock b/examples/workspaces/deno-example/.booth/tools/codingbooth.lock index 9165c78b..6fe43195 100644 --- a/examples/workspaces/deno-example/.booth/tools/codingbooth.lock +++ b/examples/workspaces/deno-example/.booth/tools/codingbooth.lock @@ -1,3 +1,3 @@ version=0.16.0 -downloaded_at=2026-02-03T06:59:37Z +downloaded_at=2026-02-04T21:52:44Z cache=shared diff --git a/examples/workspaces/deno-example/booth b/examples/workspaces/deno-example/booth index d48b5ed0..84e09673 100755 --- a/examples/workspaces/deno-example/booth +++ b/examples/workspaces/deno-example/booth @@ -43,8 +43,8 @@ VERBOSE="${VERBOSE:-true}" # Users can explicitly allow nested execution by setting BOOTH_IN_BOOTH=true and using a different port. detect_nested_booth() { # Check if we're inside a CodingBooth container - # Markers: /opt/codingbooth/ directory exists OR CB_CONTAINER_NAME is set - if [[ ! -d "/opt/codingbooth" && -z "${CB_CONTAINER_NAME:-}" ]]; then + # Markers: /opt/codingbooth/ directory exists OR BOOTH_CONTAINER_NAME is set + if [[ ! -d "/opt/codingbooth" && -z "${BOOTH_CONTAINER_NAME:-}" ]]; then return 0 # Not inside a container, continue normally fi @@ -93,8 +93,8 @@ EOF done # Get current container's ports - local host_port="${CB_HOST_PORT:-}" - local code_port="${CB_CODE_PORT:-10000}" + local host_port="${BOOTH_HOST_PORT:-}" + local code_port="${BOOTH_CODE_PORT:-10000}" # If no port specified, error out - we need an explicit different port if [[ -z "$requested_port" ]]; then diff --git a/examples/workspaces/dind-example/.booth/Boothfile b/examples/workspaces/dind-example/.booth/Boothfile new file mode 100644 index 00000000..2fa55810 --- /dev/null +++ b/examples/workspaces/dind-example/.booth/Boothfile @@ -0,0 +1,9 @@ +# syntax=codingbooth/boothfile:1 + +# Docker-in-Docker Example + +setup dind +setup docker-buildx + +setup claude-code +setup antigravity diff --git a/examples/workspaces/dind-example/.booth/config.toml b/examples/workspaces/dind-example/.booth/config.toml index feea4bf2..df66cf85 100644 --- a/examples/workspaces/dind-example/.booth/config.toml +++ b/examples/workspaces/dind-example/.booth/config.toml @@ -1,8 +1,8 @@ variant = "desktop-xfce" dind = true run-args = [ - "-p", "8080:8080", - "-p", "3000:3000", + "-p", "8888:8888", + "-p", "3333:3333", # Claude Code credentials (home-seeding) "-v", "~/.claude.json:/etc/cb-home-seed/.claude.json:ro", diff --git a/examples/workspaces/dind-example/.booth/tools/codingbooth.lock b/examples/workspaces/dind-example/.booth/tools/codingbooth.lock index 4d572919..df10645b 100644 --- a/examples/workspaces/dind-example/.booth/tools/codingbooth.lock +++ b/examples/workspaces/dind-example/.booth/tools/codingbooth.lock @@ -1,3 +1,3 @@ version=0.16.0 -downloaded_at=2026-02-03T06:59:38Z +downloaded_at=2026-02-04T21:52:45Z cache=shared diff --git a/examples/workspaces/dind-example/.cb-tests/test001-running-dind-on-host.sh b/examples/workspaces/dind-example/.cb-tests/test001-running-dind-on-host.sh index dfb22c46..36c0e9b8 100755 --- a/examples/workspaces/dind-example/.cb-tests/test001-running-dind-on-host.sh +++ b/examples/workspaces/dind-example/.cb-tests/test001-running-dind-on-host.sh @@ -114,7 +114,7 @@ sleep 1 # Test curl fails from host echo "Testing curl fails after stop..." -if curl -s --max-time 2 "http://localhost:${SERVER_PORT}" 2>/dev/null | grep -q "Hello"; then +if curl -s --max-time 5 "http://localhost:${SERVER_PORT}" 2>/dev/null | grep -q "Hello"; then fail "Server should not be accessible after stop" else pass "Server not accessible from host (expected)" diff --git a/examples/workspaces/dind-example/booth b/examples/workspaces/dind-example/booth index d48b5ed0..84e09673 100755 --- a/examples/workspaces/dind-example/booth +++ b/examples/workspaces/dind-example/booth @@ -43,8 +43,8 @@ VERBOSE="${VERBOSE:-true}" # Users can explicitly allow nested execution by setting BOOTH_IN_BOOTH=true and using a different port. detect_nested_booth() { # Check if we're inside a CodingBooth container - # Markers: /opt/codingbooth/ directory exists OR CB_CONTAINER_NAME is set - if [[ ! -d "/opt/codingbooth" && -z "${CB_CONTAINER_NAME:-}" ]]; then + # Markers: /opt/codingbooth/ directory exists OR BOOTH_CONTAINER_NAME is set + if [[ ! -d "/opt/codingbooth" && -z "${BOOTH_CONTAINER_NAME:-}" ]]; then return 0 # Not inside a container, continue normally fi @@ -93,8 +93,8 @@ EOF done # Get current container's ports - local host_port="${CB_HOST_PORT:-}" - local code_port="${CB_CODE_PORT:-10000}" + local host_port="${BOOTH_HOST_PORT:-}" + local code_port="${BOOTH_CODE_PORT:-10000}" # If no port specified, error out - we need an explicit different port if [[ -z "$requested_port" ]]; then diff --git a/examples/workspaces/elixir-example/.booth/Boothfile b/examples/workspaces/elixir-example/.booth/Boothfile new file mode 100644 index 00000000..f87b28a1 --- /dev/null +++ b/examples/workspaces/elixir-example/.booth/Boothfile @@ -0,0 +1,13 @@ +# syntax=codingbooth/boothfile:1 + +# Elixir Example +# Uses apt packages for simplicity (faster than building from source) + +# Install Erlang and Elixir from apt (simpler than kerl for examples) +run &&</dev/null 2>&1; then +if curl -s --max-time 5 "http://localhost:${SERVER_PORT}" >/dev/null 2>&1; then fail "Server should not be running initially" else pass "Server not running (expected)" @@ -37,15 +37,40 @@ fi # Test 2: Start the server in background echo "Starting server..." -./start-server.sh > /dev/null 2>&1 & +./start-server.sh > /tmp/server.log 2>&1 & SERVER_PID=$! -sleep 2 +echo " Server start PID: $SERVER_PID" + +# Wait for server to start with retries +MAX_RETRIES=10 +RETRY_DELAY=1 +echo " Waiting for server to start (max ${MAX_RETRIES}s)..." + +for i in $(seq 1 $MAX_RETRIES); do + if curl -s --max-time 5 "http://localhost:${SERVER_PORT}" | grep -q "Hello"; then + echo " Server ready after ${i}s" + break + fi + if [ $i -eq $MAX_RETRIES ]; then + echo " Server did not start within ${MAX_RETRIES}s" + else + sleep $RETRY_DELAY + fi +done # Test 3: Verify server IS running echo "Checking server is running..." -if curl -s --max-time 5 "http://localhost:${SERVER_PORT}" | grep -q "Hello"; then +RESPONSE=$(curl -s --max-time 5 "http://localhost:${SERVER_PORT}" 2>&1 || true) +if echo "$RESPONSE" | grep -q "Hello"; then pass "Server is running and responding" else + echo " Response: ${RESPONSE:-}" + echo " Server log:" + cat /tmp/server.log 2>/dev/null | head -20 || echo " " + echo " Process check:" + ps aux | grep -E "python.*http.server" | grep -v grep || echo " " + echo " Port check:" + python3 -c "import socket; s=socket.socket(); s.settimeout(1); exit(0 if s.connect_ex(('localhost', ${SERVER_PORT})) == 0 else 1)" 2>/dev/null && echo " port ${SERVER_PORT} is listening" || echo " " fail "Server should be running and responding" fi @@ -56,7 +81,7 @@ sleep 1 # Test 5: Verify server is NOT running after stop echo "Checking server stopped..." -if curl -s --max-time 2 "http://localhost:${SERVER_PORT}" >/dev/null 2>&1; then +if curl -s --max-time 5 "http://localhost:${SERVER_PORT}" >/dev/null 2>&1; then fail "Server should not be running after stop" else pass "Server stopped (expected)" diff --git a/examples/workspaces/server-example/.cb-tests/test-on-host.sh b/examples/workspaces/server-example/.cb-tests/test-on-host.sh index c5f9fd30..2f6101de 100755 --- a/examples/workspaces/server-example/.cb-tests/test-on-host.sh +++ b/examples/workspaces/server-example/.cb-tests/test-on-host.sh @@ -67,7 +67,7 @@ sleep 1 # Test 5: Verify server is NOT accessible after booth stops echo "Checking server not accessible after booth stop..." -if curl -s --max-time 2 "http://localhost:${SERVER_PORT}" >/dev/null 2>&1; then +if curl -s --max-time 5 "http://localhost:${SERVER_PORT}" >/dev/null 2>&1; then fail "Server should not be accessible after booth stop" else pass "Server not accessible (expected)" diff --git a/examples/workspaces/server-example/.cb-tests/test001-server-on-host.sh b/examples/workspaces/server-example/.cb-tests/test001-server-on-host.sh index 4e7cc119..87fb2f05 100755 --- a/examples/workspaces/server-example/.cb-tests/test001-server-on-host.sh +++ b/examples/workspaces/server-example/.cb-tests/test001-server-on-host.sh @@ -69,14 +69,40 @@ echo # Start server for host tests echo "Starting server for host tests..." -docker exec "$CONTAINER_NAME" bash -c "cd /home/coder/code && ./start-server.sh > /dev/null 2>&1 &" -sleep 2 +docker exec "$CONTAINER_NAME" bash -c "cd /home/coder/code && ./start-server.sh > /tmp/server.log 2>&1 &" + +# Wait for server to start with retries +MAX_RETRIES=10 +RETRY_DELAY=1 +echo " Waiting for server to start (max ${MAX_RETRIES}s)..." + +for i in $(seq 1 $MAX_RETRIES); do + if curl -s --max-time 5 "http://localhost:${SERVER_PORT}" | grep -q "Hello"; then + echo " Server ready after ${i}s" + break + fi + if [ $i -eq $MAX_RETRIES ]; then + echo " Server did not respond within ${MAX_RETRIES}s" + else + sleep $RETRY_DELAY + fi +done # Test curl from host echo "Testing curl from host..." -if curl -s --max-time 5 "http://localhost:${SERVER_PORT}" | grep -q "Hello"; then +RESPONSE=$(curl -s --max-time 5 "http://localhost:${SERVER_PORT}" 2>&1 || true) +if echo "$RESPONSE" | grep -q "Hello"; then pass "Server accessible from host at port ${SERVER_PORT}" else + echo " Response: ${RESPONSE:-}" + echo " Container server log:" + docker exec "$CONTAINER_NAME" cat /tmp/server.log 2>/dev/null | head -20 || echo " " + echo " Container process check:" + docker exec "$CONTAINER_NAME" ps aux 2>/dev/null | grep -E "python.*http.server" | grep -v grep || echo " " + echo " Container port check:" + docker exec "$CONTAINER_NAME" python3 -c "import socket; s=socket.socket(); s.settimeout(1); exit(0 if s.connect_ex(('localhost', 8080)) == 0 else 1)" 2>/dev/null && echo " port 8080 is listening" || echo " " + echo " Host port mapping check:" + docker port "$CONTAINER_NAME" 2>/dev/null || echo " " fail "Server should be accessible from host" fi @@ -87,7 +113,7 @@ sleep 1 # Test curl fails from host echo "Testing curl fails after stop..." -if curl -s --max-time 2 "http://localhost:${SERVER_PORT}" 2>/dev/null | grep -q "Hello"; then +if curl -s --max-time 5 "http://localhost:${SERVER_PORT}" 2>/dev/null | grep -q "Hello"; then fail "Server should not be accessible after stop" else pass "Server not accessible from host (expected)" diff --git a/examples/workspaces/server-example/booth b/examples/workspaces/server-example/booth index d48b5ed0..84e09673 100755 --- a/examples/workspaces/server-example/booth +++ b/examples/workspaces/server-example/booth @@ -43,8 +43,8 @@ VERBOSE="${VERBOSE:-true}" # Users can explicitly allow nested execution by setting BOOTH_IN_BOOTH=true and using a different port. detect_nested_booth() { # Check if we're inside a CodingBooth container - # Markers: /opt/codingbooth/ directory exists OR CB_CONTAINER_NAME is set - if [[ ! -d "/opt/codingbooth" && -z "${CB_CONTAINER_NAME:-}" ]]; then + # Markers: /opt/codingbooth/ directory exists OR BOOTH_CONTAINER_NAME is set + if [[ ! -d "/opt/codingbooth" && -z "${BOOTH_CONTAINER_NAME:-}" ]]; then return 0 # Not inside a container, continue normally fi @@ -93,8 +93,8 @@ EOF done # Get current container's ports - local host_port="${CB_HOST_PORT:-}" - local code_port="${CB_CODE_PORT:-10000}" + local host_port="${BOOTH_HOST_PORT:-}" + local code_port="${BOOTH_CODE_PORT:-10000}" # If no port specified, error out - we need an explicit different port if [[ -z "$requested_port" ]]; then diff --git a/examples/workspaces/urlwhitelist-example/.booth/Boothfile b/examples/workspaces/urlwhitelist-example/.booth/Boothfile new file mode 100644 index 00000000..fc057631 --- /dev/null +++ b/examples/workspaces/urlwhitelist-example/.booth/Boothfile @@ -0,0 +1,12 @@ +# syntax=codingbooth/boothfile:1 + +# URL Whitelist Example +# Restricts internet access to whitelisted domains only +# Copyright 2025-2026 : Nawa Manusitthipol +# Licensed under the Apache License, Version 2.0 + +# Install network whitelist (uses custom setup from .booth/setups/) +setup network-whitelist + +setup claude-code +setup antigravity diff --git a/examples/workspaces/urlwhitelist-example/.booth/tools/codingbooth.lock b/examples/workspaces/urlwhitelist-example/.booth/tools/codingbooth.lock index 83b6f05d..2b04ce1e 100644 --- a/examples/workspaces/urlwhitelist-example/.booth/tools/codingbooth.lock +++ b/examples/workspaces/urlwhitelist-example/.booth/tools/codingbooth.lock @@ -1,3 +1,3 @@ version=0.16.0 -downloaded_at=2026-02-03T07:00:12Z +downloaded_at=2026-02-04T21:53:21Z cache=shared diff --git a/examples/workspaces/urlwhitelist-example/booth b/examples/workspaces/urlwhitelist-example/booth index d48b5ed0..84e09673 100755 --- a/examples/workspaces/urlwhitelist-example/booth +++ b/examples/workspaces/urlwhitelist-example/booth @@ -43,8 +43,8 @@ VERBOSE="${VERBOSE:-true}" # Users can explicitly allow nested execution by setting BOOTH_IN_BOOTH=true and using a different port. detect_nested_booth() { # Check if we're inside a CodingBooth container - # Markers: /opt/codingbooth/ directory exists OR CB_CONTAINER_NAME is set - if [[ ! -d "/opt/codingbooth" && -z "${CB_CONTAINER_NAME:-}" ]]; then + # Markers: /opt/codingbooth/ directory exists OR BOOTH_CONTAINER_NAME is set + if [[ ! -d "/opt/codingbooth" && -z "${BOOTH_CONTAINER_NAME:-}" ]]; then return 0 # Not inside a container, continue normally fi @@ -93,8 +93,8 @@ EOF done # Get current container's ports - local host_port="${CB_HOST_PORT:-}" - local code_port="${CB_CODE_PORT:-10000}" + local host_port="${BOOTH_HOST_PORT:-}" + local code_port="${BOOTH_CODE_PORT:-10000}" # If no port specified, error out - we need an explicit different port if [[ -z "$requested_port" ]]; then diff --git a/examples/workspaces/urlwhitelist-example/setups/network-whitelist--setup.sh b/examples/workspaces/urlwhitelist-example/setups/network-whitelist--setup.sh deleted file mode 100755 index e4eca7ee..00000000 --- a/examples/workspaces/urlwhitelist-example/setups/network-whitelist--setup.sh +++ /dev/null @@ -1,568 +0,0 @@ -#!/usr/bin/env bash -# Copyright 2025-2026 : Nawa Manusitthipol -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. - -set -Eeuo pipefail -trap 'echo "❌ Error on line $LINENO"; exit 1' ERR - -# -------------------------- -# Root setup -# -------------------------- -[ "$EUID" -eq 0 ] || { echo "❌ Run as root (use sudo)"; exit 1; } - -# This script will always be installed by root. -HOME=/root - - -# --- Defaults --- -LEVEL=40 # Early - before language setups so proxy is ready - -STARTUP_FILE="/usr/share/startup.d/${LEVEL}-cb-network-whitelist--startup.sh" -PROFILE_FILE="/etc/profile.d/${LEVEL}-cb-network-whitelist--profile.sh" - -# Utility commands -WHITELIST_ADD_CMD="/usr/local/bin/network-whitelist-add" -WHITELIST_LIST_CMD="/usr/local/bin/network-whitelist-list" -WHITELIST_RELOAD_CMD="/usr/local/bin/network-whitelist-reload" -WHITELIST_STATUS_CMD="/usr/local/bin/network-whitelist-status" - -# Config locations -TINYPROXY_CONF="/etc/tinyproxy/tinyproxy.conf" -TINYPROXY_FILTER="/etc/tinyproxy/whitelist.txt" -DEFAULT_WHITELIST="/etc/tinyproxy/default-whitelist.txt" -USER_WHITELIST_NAME=".network-whitelist" - -PROXY_PORT=18888 - -# ==== Things to do once at the call time by root. ==== - -# ---- Install tinyproxy ---- -echo "📦 Installing tinyproxy..." -export DEBIAN_FRONTEND=noninteractive -apt-get update -qq -apt-get install -y --no-install-recommends tinyproxy procps - -# ---- Create default whitelist (common package registries) ---- -echo "📝 Creating default whitelist..." -cat > "${DEFAULT_WHITELIST}" <<'WHITELIST_EOF' -# ============================================ -# Default Network Whitelist for CodingBooth -# ============================================ -# This file contains domains that are allowed -# through the network proxy by default. -# ============================================ - -# --- NPM / Node.js --- -registry.npmjs.org -npmjs.org -npmjs.com -yarnpkg.com -registry.yarnpkg.com -nodejs.org -unpkg.com - -# --- Python / PyPI --- -pypi.org -pypi.python.org -files.pythonhosted.org -pythonhosted.org -python.org - -# --- Maven / Java --- -repo.maven.apache.org -repo1.maven.org -central.maven.org -maven.org -jcenter.bintray.com -plugins.gradle.org -services.gradle.org -downloads.gradle-dn.com - -# --- Go --- -proxy.golang.org -sum.golang.org -golang.org -go.dev -pkg.go.dev - -# --- Rust / Cargo --- -crates.io -static.crates.io -index.crates.io -static.rust-lang.org - -# --- Ruby / Gems --- -rubygems.org -api.rubygems.org - -# --- PHP / Composer --- -packagist.org -repo.packagist.org -getcomposer.org - -# --- Docker --- -registry-1.docker.io -docker.io -auth.docker.io -index.docker.io -hub.docker.com -production.cloudflare.docker.com - -# --- GitHub --- -github.com -api.github.com -raw.githubusercontent.com -objects.githubusercontent.com -codeload.github.com -github-releases.githubusercontent.com -github-production-release-asset-*.s3.amazonaws.com - -# --- GitLab --- -gitlab.com -registry.gitlab.com - -# --- Common CDNs --- -cdn.jsdelivr.net -cdnjs.cloudflare.com -unpkg.com - -# --- Ubuntu/Debian packages --- -archive.ubuntu.com -security.ubuntu.com -ppa.launchpad.net -deb.debian.org -security.debian.org - -# --- Alpine packages --- -dl-cdn.alpinelinux.org - -# --- VS Code extensions --- -marketplace.visualstudio.com -.*\.gallery\.vsassets\.io -update.code.visualstudio.com -vscode.blob.core.windows.net - -# --- JetBrains --- -download.jetbrains.com -plugins.jetbrains.com -data.services.jetbrains.com - -# --- Homebrew --- -brew.sh -formulae.brew.sh -ghcr.io - -# --- Misc tools --- -astral.sh -get.docker.com -deno.land -bun.sh -WHITELIST_EOF -chmod 644 "${DEFAULT_WHITELIST}" - -# ---- Configure tinyproxy ---- -echo "⚙️ Configuring tinyproxy..." - -# Backup original config -[ -f "${TINYPROXY_CONF}.orig" ] || cp "${TINYPROXY_CONF}" "${TINYPROXY_CONF}.orig" - -cat > "${TINYPROXY_CONF}" < "${STARTUP_FILE}" <<'EOF' -#!/usr/bin/env bash -set -euo pipefail - -TINYPROXY_FILTER="/etc/tinyproxy/whitelist.txt" -DEFAULT_WHITELIST="/etc/tinyproxy/default-whitelist.txt" -USER_WHITELIST="$HOME/.network-whitelist" -PROXY_PORT=18888 - -# Network whitelist is always enabled when this setup is installed -echo "🔒 Setting up network whitelist proxy..." - -# ============================================================================ -# EXPERIMENTAL: iptables-based enforcement -# This prevents ACCIDENTAL bypassing of the proxy (e.g., scripts that don't -# respect HTTP_PROXY environment variables). -# -# WARNING: This is NOT a security boundary! A bad actor with shell access can: -# - Modify iptables rules (if they have sudo) -# - Kill tinyproxy and modify whitelist files -# - Use other ports (8080, 8443, etc.) -# - Use non-HTTP protocols (SSH tunnels, raw TCP) -# -# This feature provides defense-in-depth, not true isolation. -# Requires: --cap-add=NET_ADMIN in docker run args -# ============================================================================ -if command -v iptables >/dev/null 2>&1; then - # Check if we have CAP_NET_ADMIN (try a harmless iptables command) - if sudo iptables -L OUTPUT -n >/dev/null 2>&1; then - echo "🔥 Setting up iptables firewall rules (EXPERIMENTAL - not a security boundary)..." - - # Flush existing OUTPUT rules to start fresh - sudo iptables -F OUTPUT 2>/dev/null || true - - # Allow all localhost traffic (proxy runs here) - sudo iptables -A OUTPUT -o lo -j ACCEPT - - # Allow established connections (for proxy responses) - sudo iptables -A OUTPUT -m state --state ESTABLISHED,RELATED -j ACCEPT - - # Allow DNS (needed for domain resolution) - sudo iptables -A OUTPUT -p udp --dport 53 -j ACCEPT - sudo iptables -A OUTPUT -p tcp --dport 53 -j ACCEPT - - # Block direct HTTP/HTTPS - must go through proxy - # NOTE: Only blocks ports 80/443. Other ports (8080, 8443, etc.) are NOT blocked. - sudo iptables -A OUTPUT -p tcp --dport 80 -j REJECT --reject-with tcp-reset - sudo iptables -A OUTPUT -p tcp --dport 443 -j REJECT --reject-with tcp-reset - - echo "✅ Firewall rules applied - direct HTTP/HTTPS on ports 80/443 blocked" - echo " (Note: This is experimental and can be bypassed by determined users)" - else - echo "ℹ️ No CAP_NET_ADMIN - firewall enforcement disabled" - echo " Add '--cap-add=NET_ADMIN' to run-args for experimental enforcement" - fi -else - echo "ℹ️ iptables not available - firewall enforcement disabled" -fi - -# Combine default and user whitelists -{ - echo "# Combined whitelist - generated at startup" - echo "# Do not edit directly - use network-whitelist-add or edit ~/.network-whitelist" - echo "" - - # Default whitelist - if [ -f "$DEFAULT_WHITELIST" ]; then - echo "# === Default whitelist ===" - grep -v '^\s*#' "$DEFAULT_WHITELIST" | grep -v '^\s*$' || true - fi - - # User whitelist - if [ -f "$USER_WHITELIST" ]; then - echo "" - echo "# === User whitelist ($USER_WHITELIST) ===" - grep -v '^\s*#' "$USER_WHITELIST" | grep -v '^\s*$' || true - fi -} | sort -u > "/tmp/whitelist.txt.tmp" - -# Need sudo to update the filter file -sudo mv "/tmp/whitelist.txt.tmp" "${TINYPROXY_FILTER}" -sudo chmod 644 "${TINYPROXY_FILTER}" - -# Start or reload tinyproxy -if pgrep -x tinyproxy > /dev/null 2>&1; then - echo "🔄 Reloading tinyproxy..." - sudo pkill -HUP tinyproxy || true -else - echo "🚀 Starting tinyproxy..." - sudo tinyproxy -c /etc/tinyproxy/tinyproxy.conf -fi - -# Verify it's running -sleep 1 -if pgrep -x tinyproxy > /dev/null 2>&1; then - echo "✅ Network whitelist proxy is running on port ${PROXY_PORT}" -else - echo "⚠️ Warning: tinyproxy may not have started correctly" -fi -EOF -chmod 755 "${STARTUP_FILE}" - -# ---- Create profile file: sets proxy environment variables ---- -cat > "${PROFILE_FILE}" <<'EOF' -# Profile: Network Whitelist Proxy -# Sets HTTP_PROXY/HTTPS_PROXY - always enabled when this setup is installed - -PROXY_PORT=18888 - -export HTTP_PROXY="http://127.0.0.1:${PROXY_PORT}" -export HTTPS_PROXY="http://127.0.0.1:${PROXY_PORT}" -export http_proxy="http://127.0.0.1:${PROXY_PORT}" -export https_proxy="http://127.0.0.1:${PROXY_PORT}" - -# NO_PROXY for localhost connections -export NO_PROXY="localhost,127.0.0.1,::1" -export no_proxy="localhost,127.0.0.1,::1" -EOF -chmod 644 "${PROFILE_FILE}" - -# ---- Create utility: network-whitelist-add ---- -cat > "${WHITELIST_ADD_CMD}" <<'EOF' -#!/usr/bin/env bash -set -euo pipefail - -USER_WHITELIST="$HOME/.network-whitelist" - -if [ $# -eq 0 ]; then - echo "Usage: network-whitelist-add [domain2] ..." - echo "" - echo "Add domains to your personal network whitelist." - echo "Changes take effect after running 'network-whitelist-reload'." - echo "" - echo "Examples:" - echo " network-whitelist-add example.com" - echo " network-whitelist-add api.example.com cdn.example.com" - exit 1 -fi - -# Create user whitelist if it doesn't exist -if [ ! -f "$USER_WHITELIST" ]; then - cat > "$USER_WHITELIST" <<'HEADER' -# Personal Network Whitelist -# Add one domain per line (wildcards supported: *.example.com) -# Lines starting with # are comments -# Run 'network-whitelist-reload' after editing - -HEADER -fi - -# Add each domain -for domain in "$@"; do - # Check if already in whitelist - if grep -qxF "$domain" "$USER_WHITELIST" 2>/dev/null; then - echo "ℹ️ '$domain' is already in whitelist" - else - echo "$domain" >> "$USER_WHITELIST" - echo "✅ Added '$domain' to whitelist" - fi -done - -echo "" -echo "Run 'network-whitelist-reload' to apply changes." -EOF -chmod 755 "${WHITELIST_ADD_CMD}" - -# ---- Create utility: network-whitelist-list ---- -cat > "${WHITELIST_LIST_CMD}" <<'EOF' -#!/usr/bin/env bash -set -euo pipefail - -DEFAULT_WHITELIST="/etc/tinyproxy/default-whitelist.txt" -USER_WHITELIST="$HOME/.network-whitelist" -COMBINED_WHITELIST="/etc/tinyproxy/whitelist.txt" - -show_help() { - echo "Usage: network-whitelist-list [--default | --user | --combined | --all]" - echo "" - echo "Options:" - echo " --default Show default whitelist (system-wide)" - echo " --user Show your personal whitelist" - echo " --combined Show the active combined whitelist" - echo " --all Show all whitelists (default)" - echo "" -} - -show_file() { - local file="$1" - local label="$2" - echo "=== $label ===" - if [ -f "$file" ]; then - cat "$file" - else - echo "(file does not exist)" - fi - echo "" -} - -case "${1:-all}" in - --help|-h) - show_help - ;; - --default) - show_file "$DEFAULT_WHITELIST" "Default Whitelist" - ;; - --user) - show_file "$USER_WHITELIST" "User Whitelist (~/.network-whitelist)" - ;; - --combined) - show_file "$COMBINED_WHITELIST" "Combined Active Whitelist" - ;; - --all|all) - show_file "$DEFAULT_WHITELIST" "Default Whitelist" - show_file "$USER_WHITELIST" "User Whitelist (~/.network-whitelist)" - ;; - *) - echo "Unknown option: $1" - show_help - exit 1 - ;; -esac -EOF -chmod 755 "${WHITELIST_LIST_CMD}" - -# ---- Create utility: network-whitelist-reload ---- -cat > "${WHITELIST_RELOAD_CMD}" <<'EOF' -#!/usr/bin/env bash -set -euo pipefail - -TINYPROXY_FILTER="/etc/tinyproxy/whitelist.txt" -DEFAULT_WHITELIST="/etc/tinyproxy/default-whitelist.txt" -USER_WHITELIST="$HOME/.network-whitelist" - -echo "🔄 Reloading network whitelist..." - -# Combine whitelists -{ - echo "# Combined whitelist - regenerated $(date)" - echo "" - - if [ -f "$DEFAULT_WHITELIST" ]; then - echo "# === Default whitelist ===" - grep -v '^\s*#' "$DEFAULT_WHITELIST" | grep -v '^\s*$' || true - fi - - if [ -f "$USER_WHITELIST" ]; then - echo "" - echo "# === User whitelist ===" - grep -v '^\s*#' "$USER_WHITELIST" | grep -v '^\s*$' || true - fi -} | sort -u > "/tmp/whitelist.txt.tmp" - -sudo mv "/tmp/whitelist.txt.tmp" "${TINYPROXY_FILTER}" -sudo chmod 644 "${TINYPROXY_FILTER}" - -# Reload tinyproxy -if pgrep -x tinyproxy > /dev/null 2>&1; then - sudo pkill -HUP tinyproxy - echo "✅ Whitelist reloaded and proxy signaled" -else - echo "⚠️ Proxy not running. Starting it..." - sudo tinyproxy -c /etc/tinyproxy/tinyproxy.conf - echo "✅ Proxy started with new whitelist" -fi -EOF -chmod 755 "${WHITELIST_RELOAD_CMD}" - -# ---- Create utility: network-whitelist-status ---- -cat > "${WHITELIST_STATUS_CMD}" <<'EOF' -#!/usr/bin/env bash -set -euo pipefail - -USER_WHITELIST="$HOME/.network-whitelist" -DEFAULT_WHITELIST="/etc/tinyproxy/default-whitelist.txt" -PROXY_PORT=18888 - -echo "=== Network Whitelist Status ===" -echo "" -echo "Status: ENABLED (always on when installed)" -echo "" - -# Check proxy -if pgrep -x tinyproxy > /dev/null 2>&1; then - echo "Proxy: Running (port ${PROXY_PORT})" -else - echo "Proxy: NOT RUNNING" - echo " Run 'network-whitelist-reload' to start the proxy." -fi - -echo "" - -# Count domains -default_count=0 -user_count=0 -if [ -f "$DEFAULT_WHITELIST" ]; then - default_count=$(grep -v '^\s*#' "$DEFAULT_WHITELIST" | grep -v '^\s*$' | wc -l || echo 0) -fi -if [ -f "$USER_WHITELIST" ]; then - user_count=$(grep -v '^\s*#' "$USER_WHITELIST" | grep -v '^\s*$' | wc -l || echo 0) -fi - -echo "Whitelisted domains:" -echo " Default: ${default_count} domains" -echo " User: ${user_count} domains" -echo "" - -# Show environment -echo "Environment:" -echo " HTTP_PROXY: ${HTTP_PROXY:-not set}" -echo " HTTPS_PROXY: ${HTTPS_PROXY:-not set}" -echo "" -echo "Use 'network-whitelist-list' to see all whitelisted domains." -EOF -chmod 755 "${WHITELIST_STATUS_CMD}" - -# Note: network-whitelist-enable and network-whitelist-disable are intentionally -# not provided. The whitelist is always enabled when this setup is installed. -# This prevents bad actors from simply disabling the security feature. - -# ---- Allow coder user to manage tinyproxy via sudo without password ---- -echo "🔐 Configuring sudo permissions for tinyproxy..." -cat > /etc/sudoers.d/tinyproxy-whitelist <<'EOF' -# Allow coder to manage tinyproxy for network whitelist -coder ALL=(ALL) NOPASSWD: /usr/bin/tinyproxy * -coder ALL=(ALL) NOPASSWD: /usr/bin/pkill -HUP tinyproxy -coder ALL=(ALL) NOPASSWD: /usr/bin/pkill tinyproxy -coder ALL=(ALL) NOPASSWD: /bin/mv /tmp/whitelist.txt.tmp /etc/tinyproxy/whitelist.txt -coder ALL=(ALL) NOPASSWD: /bin/chmod 644 /etc/tinyproxy/whitelist.txt -EOF -chmod 440 /etc/sudoers.d/tinyproxy-whitelist - -echo "" -echo "✅ .... Network Whitelist is installed ...." -echo "• Startup file : ${STARTUP_FILE}" -echo "• Profile file : ${PROFILE_FILE}" -echo "• Default whitelist: ${DEFAULT_WHITELIST}" -echo "• User whitelist : ~/.network-whitelist" -echo "" -echo "Commands:" -echo " network-whitelist-status - Show current status" -echo " network-whitelist-list - List whitelisted domains" -echo " network-whitelist-add - Add domain to user whitelist" -echo " network-whitelist-reload - Apply whitelist changes" -echo "" -echo "NOTE: Network whitelist is ALWAYS ENABLED when this setup is installed." -echo " Internet access is restricted to whitelisted domains only." -echo "" -echo "To add custom domains, either:" -echo " 1. Run: network-whitelist-add example.com" -echo " 2. Edit: ~/.network-whitelist (one domain per line)" -echo " 3. For team defaults: add .booth/home/.network-whitelist to your repo" -echo "" diff --git a/examples/workspaces/zig-example/.booth/Boothfile b/examples/workspaces/zig-example/.booth/Boothfile new file mode 100644 index 00000000..7b0bb49e --- /dev/null +++ b/examples/workspaces/zig-example/.booth/Boothfile @@ -0,0 +1,5 @@ +# syntax=codingbooth/boothfile:1 + +# Zig Example + +setup zig diff --git a/examples/workspaces/zig-example/.booth/tools/codingbooth.lock b/examples/workspaces/zig-example/.booth/tools/codingbooth.lock index 80040c6a..c7ab5c3b 100644 --- a/examples/workspaces/zig-example/.booth/tools/codingbooth.lock +++ b/examples/workspaces/zig-example/.booth/tools/codingbooth.lock @@ -1,3 +1,3 @@ version=0.16.0 -downloaded_at=2026-02-03T07:16:55Z +downloaded_at=2026-02-04T21:53:22Z cache=shared diff --git a/examples/workspaces/zig-example/booth b/examples/workspaces/zig-example/booth index d48b5ed0..84e09673 100755 --- a/examples/workspaces/zig-example/booth +++ b/examples/workspaces/zig-example/booth @@ -43,8 +43,8 @@ VERBOSE="${VERBOSE:-true}" # Users can explicitly allow nested execution by setting BOOTH_IN_BOOTH=true and using a different port. detect_nested_booth() { # Check if we're inside a CodingBooth container - # Markers: /opt/codingbooth/ directory exists OR CB_CONTAINER_NAME is set - if [[ ! -d "/opt/codingbooth" && -z "${CB_CONTAINER_NAME:-}" ]]; then + # Markers: /opt/codingbooth/ directory exists OR BOOTH_CONTAINER_NAME is set + if [[ ! -d "/opt/codingbooth" && -z "${BOOTH_CONTAINER_NAME:-}" ]]; then return 0 # Not inside a container, continue normally fi @@ -93,8 +93,8 @@ EOF done # Get current container's ports - local host_port="${CB_HOST_PORT:-}" - local code_port="${CB_CODE_PORT:-10000}" + local host_port="${BOOTH_HOST_PORT:-}" + local code_port="${BOOTH_CODE_PORT:-10000}" # If no port specified, error out - we need an explicit different port if [[ -z "$requested_port" ]]; then diff --git a/tests/boothfile/run-boothfile-tests.sh b/tests/boothfile/run-boothfile-tests.sh new file mode 100755 index 00000000..a83f8030 --- /dev/null +++ b/tests/boothfile/run-boothfile-tests.sh @@ -0,0 +1,38 @@ +#!/bin/bash +# Copyright 2025-2026 : Nawa Manusitthipol +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. + +# Boothfile compilation test runner +# Tests that Boothfiles compile correctly to Dockerfiles + +failed=0 +failed_tests=() +total_tests=0 + +for f in test0*.sh ; do + if [ -f "$f" ]; then + echo "$f" + total_tests=$((total_tests + 1)) + + if ! ./"$f"; then + failed=1 + failed_tests+=("$f") + fi + echo "" + fi +done + +num_failed=${#failed_tests[@]} + +if [ $failed -eq 0 ]; then + echo "All $total_tests boothfile tests passed." +else + echo "$num_failed out of $total_tests boothfile tests FAILED." + echo "Failed tests:" + for t in "${failed_tests[@]}"; do + echo " - $t" + done +fi + +exit $failed diff --git a/tests/boothfile/test001--minimal-boothfile.sh b/tests/boothfile/test001--minimal-boothfile.sh new file mode 100755 index 00000000..bf3bc60c --- /dev/null +++ b/tests/boothfile/test001--minimal-boothfile.sh @@ -0,0 +1,53 @@ +#!/bin/bash +# Copyright 2025-2026 : Nawa Manusitthipol +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. + +set -euo pipefail + +source ../common--source.sh + +# Test: Minimal Boothfile (only syntax line) generates prologue only + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +TEST_DIR=$(mktemp -d) +trap "rm -rf $TEST_DIR" EXIT + +mkdir -p "$TEST_DIR/.booth" +cat > "$TEST_DIR/.booth/Boothfile" << 'EOF' +# syntax=codingbooth/boothfile:1 +EOF + +ACTUAL=$(run_coding_booth emit-dockerfile --code "$TEST_DIR" 2>/dev/null) + +EXPECT_CONTAINS=( + "# syntax=docker/dockerfile:1.7" + "ARG BOOTH_VARIANT_TAG=base" + "ARG BOOTH_VERSION_TAG=latest" + "FROM nawaman/codingbooth:" +) + +ALL_PASSED=true + +for expected in "${EXPECT_CONTAINS[@]}"; do + if echo "$ACTUAL" | grep -qF "$expected"; then + print_test_result "true" "$0" "001" "Output contains: $expected" + else + print_test_result "false" "$0" "001" "Output contains: $expected" + ALL_PASSED=false + fi +done + +# Ensure no RUN/COPY/ENV commands beyond prologue +if echo "$ACTUAL" | grep -qE "^RUN |^COPY |^ENV "; then + print_test_result "false" "$0" "001" "Minimal boothfile should not have RUN/COPY/ENV commands" + ALL_PASSED=false +else + print_test_result "true" "$0" "001" "Minimal boothfile has no extra commands" +fi + +if [[ "$ALL_PASSED" != "true" ]]; then + echo "Actual output:" + echo "$ACTUAL" + exit 1 +fi diff --git a/tests/boothfile/test002--syntax-directive.sh b/tests/boothfile/test002--syntax-directive.sh new file mode 100755 index 00000000..6cf050a6 --- /dev/null +++ b/tests/boothfile/test002--syntax-directive.sh @@ -0,0 +1,47 @@ +#!/bin/bash +# Copyright 2025-2026 : Nawa Manusitthipol +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. + +set -euo pipefail + +source ../common--source.sh + +# Test: Syntax directive is required and transforms to docker/dockerfile:1.7 + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +TEST_DIR=$(mktemp -d) +trap "rm -rf $TEST_DIR" EXIT + +mkdir -p "$TEST_DIR/.booth" +cat > "$TEST_DIR/.booth/Boothfile" << 'EOF' +# syntax=codingbooth/boothfile:1 + +run echo "hello" +EOF + +ACTUAL=$(run_coding_booth emit-dockerfile --code "$TEST_DIR" 2>/dev/null) + +ALL_PASSED=true + +# Should have docker syntax, not boothfile syntax +if echo "$ACTUAL" | grep -qF "# syntax=docker/dockerfile:1.7"; then + print_test_result "true" "$0" "002" "Output has docker/dockerfile:1.7 syntax" +else + print_test_result "false" "$0" "002" "Output has docker/dockerfile:1.7 syntax" + ALL_PASSED=false +fi + +# Should NOT have boothfile syntax in output +if echo "$ACTUAL" | grep -qF "codingbooth/boothfile"; then + print_test_result "false" "$0" "002" "Output should not contain boothfile syntax" + ALL_PASSED=false +else + print_test_result "true" "$0" "002" "Output does not contain boothfile syntax" +fi + +if [[ "$ALL_PASSED" != "true" ]]; then + echo "Actual output:" + echo "$ACTUAL" + exit 1 +fi diff --git a/tests/boothfile/test003--full-line-comments.sh b/tests/boothfile/test003--full-line-comments.sh new file mode 100755 index 00000000..a541d77d --- /dev/null +++ b/tests/boothfile/test003--full-line-comments.sh @@ -0,0 +1,65 @@ +#!/bin/bash +# Copyright 2025-2026 : Nawa Manusitthipol +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. + +set -euo pipefail + +source ../common--source.sh + +# Test: Full-line comments are preserved in output (for readability) + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +TEST_DIR=$(mktemp -d) +trap "rm -rf $TEST_DIR" EXIT + +mkdir -p "$TEST_DIR/.booth" +cat > "$TEST_DIR/.booth/Boothfile" << 'EOF' +# syntax=codingbooth/boothfile:1 + +# This is a full-line comment +# Another comment here +run echo "test" +# Comment after command +EOF + +ACTUAL=$(run_coding_booth emit-dockerfile --code "$TEST_DIR" 2>/dev/null) + +ALL_PASSED=true + +# Comments should be preserved (except syntax directive which transforms) +if echo "$ACTUAL" | grep -qF "This is a full-line comment"; then + print_test_result "true" "$0" "003" "Full-line comments are preserved" +else + print_test_result "false" "$0" "003" "Full-line comments are preserved" + ALL_PASSED=false +fi + +if echo "$ACTUAL" | grep -qF "Another comment here"; then + print_test_result "true" "$0" "003" "All comments are preserved" +else + print_test_result "false" "$0" "003" "All comments are preserved" + ALL_PASSED=false +fi + +# The run command should still be present +if echo "$ACTUAL" | grep -qF 'RUN echo "test"'; then + print_test_result "true" "$0" "003" "Commands are preserved" +else + print_test_result "false" "$0" "003" "Commands are preserved" + ALL_PASSED=false +fi + +# The syntax directive comment should NOT be preserved (it's transformed) +if echo "$ACTUAL" | grep -qF "# syntax=codingbooth/boothfile"; then + print_test_result "false" "$0" "003" "Syntax directive should be transformed" + ALL_PASSED=false +else + print_test_result "true" "$0" "003" "Syntax directive is transformed" +fi + +if [[ "$ALL_PASSED" != "true" ]]; then + echo "Actual output:" + echo "$ACTUAL" + exit 1 +fi diff --git a/tests/boothfile/test004--inline-comments.sh b/tests/boothfile/test004--inline-comments.sh new file mode 100755 index 00000000..73fc6b4f --- /dev/null +++ b/tests/boothfile/test004--inline-comments.sh @@ -0,0 +1,47 @@ +#!/bin/bash +# Copyright 2025-2026 : Nawa Manusitthipol +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. + +set -euo pipefail + +source ../common--source.sh + +# Test: Inline comments are stripped from output + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +TEST_DIR=$(mktemp -d) +trap "rm -rf $TEST_DIR" EXIT + +mkdir -p "$TEST_DIR/.booth" +cat > "$TEST_DIR/.booth/Boothfile" << 'EOF' +# syntax=codingbooth/boothfile:1 + +env MY_VAR=value # This is an inline comment +EOF + +ACTUAL=$(run_coding_booth emit-dockerfile --code "$TEST_DIR" 2>/dev/null) + +ALL_PASSED=true + +# The env command should be present +if echo "$ACTUAL" | grep -qF "ENV MY_VAR=value"; then + print_test_result "true" "$0" "004" "ENV command is preserved" +else + print_test_result "false" "$0" "004" "ENV command is preserved" + ALL_PASSED=false +fi + +# Inline comment should be stripped +if echo "$ACTUAL" | grep -qF "inline comment"; then + print_test_result "false" "$0" "004" "Inline comments should be stripped" + ALL_PASSED=false +else + print_test_result "true" "$0" "004" "Inline comments are stripped" +fi + +if [[ "$ALL_PASSED" != "true" ]]; then + echo "Actual output:" + echo "$ACTUAL" + exit 1 +fi diff --git a/tests/boothfile/test005--blank-lines.sh b/tests/boothfile/test005--blank-lines.sh new file mode 100755 index 00000000..40d152cd --- /dev/null +++ b/tests/boothfile/test005--blank-lines.sh @@ -0,0 +1,51 @@ +#!/bin/bash +# Copyright 2025-2026 : Nawa Manusitthipol +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. + +set -euo pipefail + +source ../common--source.sh + +# Test: Blank lines are ignored + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +TEST_DIR=$(mktemp -d) +trap "rm -rf $TEST_DIR" EXIT + +mkdir -p "$TEST_DIR/.booth" +cat > "$TEST_DIR/.booth/Boothfile" << 'EOF' +# syntax=codingbooth/boothfile:1 + + +env VAR1=one + + +env VAR2=two + +EOF + +ACTUAL=$(run_coding_booth emit-dockerfile --code "$TEST_DIR" 2>/dev/null) + +ALL_PASSED=true + +# Both env commands should be present +if echo "$ACTUAL" | grep -qF "ENV VAR1=one"; then + print_test_result "true" "$0" "005" "ENV VAR1 is present" +else + print_test_result "false" "$0" "005" "ENV VAR1 is present" + ALL_PASSED=false +fi + +if echo "$ACTUAL" | grep -qF "ENV VAR2=two"; then + print_test_result "true" "$0" "005" "ENV VAR2 is present" +else + print_test_result "false" "$0" "005" "ENV VAR2 is present" + ALL_PASSED=false +fi + +if [[ "$ALL_PASSED" != "true" ]]; then + echo "Actual output:" + echo "$ACTUAL" + exit 1 +fi diff --git a/tests/boothfile/test006--run-single-line.sh b/tests/boothfile/test006--run-single-line.sh new file mode 100755 index 00000000..c8ae30a2 --- /dev/null +++ b/tests/boothfile/test006--run-single-line.sh @@ -0,0 +1,38 @@ +#!/bin/bash +# Copyright 2025-2026 : Nawa Manusitthipol +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. + +set -euo pipefail + +source ../common--source.sh + +# Test: run command - single line + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +TEST_DIR=$(mktemp -d) +trap "rm -rf $TEST_DIR" EXIT + +mkdir -p "$TEST_DIR/.booth" +cat > "$TEST_DIR/.booth/Boothfile" << 'EOF' +# syntax=codingbooth/boothfile:1 + +run apt-get update && apt-get install -y curl +EOF + +ACTUAL=$(run_coding_booth emit-dockerfile --code "$TEST_DIR" 2>/dev/null) + +ALL_PASSED=true + +if echo "$ACTUAL" | grep -qF "RUN apt-get update && apt-get install -y curl"; then + print_test_result "true" "$0" "006" "run compiles to RUN" +else + print_test_result "false" "$0" "006" "run compiles to RUN" + ALL_PASSED=false +fi + +if [[ "$ALL_PASSED" != "true" ]]; then + echo "Actual output:" + echo "$ACTUAL" + exit 1 +fi diff --git a/tests/boothfile/test007--run-heredoc-verbatim.sh b/tests/boothfile/test007--run-heredoc-verbatim.sh new file mode 100755 index 00000000..0ea203c6 --- /dev/null +++ b/tests/boothfile/test007--run-heredoc-verbatim.sh @@ -0,0 +1,57 @@ +#!/bin/bash +# Copyright 2025-2026 : Nawa Manusitthipol +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. + +set -euo pipefail + +source ../common--source.sh + +# Test: run with verbatim heredoc (run < "$TEST_DIR/.booth/Boothfile" << 'EOF' +# syntax=codingbooth/boothfile:1 + +run </dev/null) + +ALL_PASSED=true + +# Should produce Docker heredoc +if echo "$ACTUAL" | grep -qF "RUN < "$TEST_DIR/.booth/Boothfile" << 'EOF' +# syntax=codingbooth/boothfile:1 + +run &&</dev/null) + +ALL_PASSED=true + +# Should join lines with && +if echo "$ACTUAL" | grep -qF "&&"; then + print_test_result "true" "$0" "008" "And-join heredoc produces && joined commands" +else + print_test_result "false" "$0" "008" "And-join heredoc produces && joined commands" + ALL_PASSED=false +fi + +if echo "$ACTUAL" | grep -qF "apt-get update"; then + print_test_result "true" "$0" "008" "First command is present" +else + print_test_result "false" "$0" "008" "First command is present" + ALL_PASSED=false +fi + +if echo "$ACTUAL" | grep -qF "rm -rf /var/lib/apt/lists/*"; then + print_test_result "true" "$0" "008" "Last command is present" +else + print_test_result "false" "$0" "008" "Last command is present" + ALL_PASSED=false +fi + +if [[ "$ALL_PASSED" != "true" ]]; then + echo "Actual output:" + echo "$ACTUAL" + exit 1 +fi diff --git a/tests/boothfile/test009--run-heredoc-semi-join.sh b/tests/boothfile/test009--run-heredoc-semi-join.sh new file mode 100755 index 00000000..e52559d2 --- /dev/null +++ b/tests/boothfile/test009--run-heredoc-semi-join.sh @@ -0,0 +1,50 @@ +#!/bin/bash +# Copyright 2025-2026 : Nawa Manusitthipol +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. + +set -euo pipefail + +source ../common--source.sh + +# Test: run with semicolon-join heredoc (run ;< "$TEST_DIR/.booth/Boothfile" << 'EOF' +# syntax=codingbooth/boothfile:1 + +run ;</dev/null) + +ALL_PASSED=true + +# Should have first command and join with ; +if echo "$ACTUAL" | grep -qF "rm -f /tmp/optional"; then + print_test_result "true" "$0" "009" "First command is present" +else + print_test_result "false" "$0" "009" "First command is present" + ALL_PASSED=false +fi + +# Should have semicolon joiner +if echo "$ACTUAL" | grep -qE '; echo "done"'; then + print_test_result "true" "$0" "009" "Semi-join with semicolon" +else + print_test_result "false" "$0" "009" "Semi-join with semicolon" + ALL_PASSED=false +fi + +if [[ "$ALL_PASSED" != "true" ]]; then + echo "Actual output:" + echo "$ACTUAL" + exit 1 +fi diff --git a/tests/boothfile/test010--copy.sh b/tests/boothfile/test010--copy.sh new file mode 100755 index 00000000..c0bbe16c --- /dev/null +++ b/tests/boothfile/test010--copy.sh @@ -0,0 +1,46 @@ +#!/bin/bash +# Copyright 2025-2026 : Nawa Manusitthipol +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. + +set -euo pipefail + +source ../common--source.sh + +# Test: copy command + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +TEST_DIR=$(mktemp -d) +trap "rm -rf $TEST_DIR" EXIT + +mkdir -p "$TEST_DIR/.booth" +cat > "$TEST_DIR/.booth/Boothfile" << 'EOF' +# syntax=codingbooth/boothfile:1 + +copy ./config /opt/config +copy requirements.txt /tmp/requirements.txt +EOF + +ACTUAL=$(run_coding_booth emit-dockerfile --code "$TEST_DIR" 2>/dev/null) + +ALL_PASSED=true + +if echo "$ACTUAL" | grep -qF "COPY ./config /opt/config"; then + print_test_result "true" "$0" "010" "copy compiles to COPY (first)" +else + print_test_result "false" "$0" "010" "copy compiles to COPY (first)" + ALL_PASSED=false +fi + +if echo "$ACTUAL" | grep -qF "COPY requirements.txt /tmp/requirements.txt"; then + print_test_result "true" "$0" "010" "copy compiles to COPY (second)" +else + print_test_result "false" "$0" "010" "copy compiles to COPY (second)" + ALL_PASSED=false +fi + +if [[ "$ALL_PASSED" != "true" ]]; then + echo "Actual output:" + echo "$ACTUAL" + exit 1 +fi diff --git a/tests/boothfile/test011--env.sh b/tests/boothfile/test011--env.sh new file mode 100755 index 00000000..7e553a25 --- /dev/null +++ b/tests/boothfile/test011--env.sh @@ -0,0 +1,46 @@ +#!/bin/bash +# Copyright 2025-2026 : Nawa Manusitthipol +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. + +set -euo pipefail + +source ../common--source.sh + +# Test: env command + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +TEST_DIR=$(mktemp -d) +trap "rm -rf $TEST_DIR" EXIT + +mkdir -p "$TEST_DIR/.booth" +cat > "$TEST_DIR/.booth/Boothfile" << 'EOF' +# syntax=codingbooth/boothfile:1 + +env MY_VAR=value +env APP_ENV=production +EOF + +ACTUAL=$(run_coding_booth emit-dockerfile --code "$TEST_DIR" 2>/dev/null) + +ALL_PASSED=true + +if echo "$ACTUAL" | grep -qF "ENV MY_VAR=value"; then + print_test_result "true" "$0" "011" "env compiles to ENV (first)" +else + print_test_result "false" "$0" "011" "env compiles to ENV (first)" + ALL_PASSED=false +fi + +if echo "$ACTUAL" | grep -qF "ENV APP_ENV=production"; then + print_test_result "true" "$0" "011" "env compiles to ENV (second)" +else + print_test_result "false" "$0" "011" "env compiles to ENV (second)" + ALL_PASSED=false +fi + +if [[ "$ALL_PASSED" != "true" ]]; then + echo "Actual output:" + echo "$ACTUAL" + exit 1 +fi diff --git a/tests/boothfile/test012--workdir.sh b/tests/boothfile/test012--workdir.sh new file mode 100755 index 00000000..bbd30cae --- /dev/null +++ b/tests/boothfile/test012--workdir.sh @@ -0,0 +1,38 @@ +#!/bin/bash +# Copyright 2025-2026 : Nawa Manusitthipol +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. + +set -euo pipefail + +source ../common--source.sh + +# Test: workdir command + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +TEST_DIR=$(mktemp -d) +trap "rm -rf $TEST_DIR" EXIT + +mkdir -p "$TEST_DIR/.booth" +cat > "$TEST_DIR/.booth/Boothfile" << 'EOF' +# syntax=codingbooth/boothfile:1 + +workdir /app +EOF + +ACTUAL=$(run_coding_booth emit-dockerfile --code "$TEST_DIR" 2>/dev/null) + +ALL_PASSED=true + +if echo "$ACTUAL" | grep -qF "WORKDIR /app"; then + print_test_result "true" "$0" "012" "workdir compiles to WORKDIR" +else + print_test_result "false" "$0" "012" "workdir compiles to WORKDIR" + ALL_PASSED=false +fi + +if [[ "$ALL_PASSED" != "true" ]]; then + echo "Actual output:" + echo "$ACTUAL" + exit 1 +fi diff --git a/tests/boothfile/test013--expose.sh b/tests/boothfile/test013--expose.sh new file mode 100755 index 00000000..039604b8 --- /dev/null +++ b/tests/boothfile/test013--expose.sh @@ -0,0 +1,38 @@ +#!/bin/bash +# Copyright 2025-2026 : Nawa Manusitthipol +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. + +set -euo pipefail + +source ../common--source.sh + +# Test: expose command + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +TEST_DIR=$(mktemp -d) +trap "rm -rf $TEST_DIR" EXIT + +mkdir -p "$TEST_DIR/.booth" +cat > "$TEST_DIR/.booth/Boothfile" << 'EOF' +# syntax=codingbooth/boothfile:1 + +expose 8080 +EOF + +ACTUAL=$(run_coding_booth emit-dockerfile --code "$TEST_DIR" 2>/dev/null) + +ALL_PASSED=true + +if echo "$ACTUAL" | grep -qF "EXPOSE 8080"; then + print_test_result "true" "$0" "013" "expose compiles to EXPOSE" +else + print_test_result "false" "$0" "013" "expose compiles to EXPOSE" + ALL_PASSED=false +fi + +if [[ "$ALL_PASSED" != "true" ]]; then + echo "Actual output:" + echo "$ACTUAL" + exit 1 +fi diff --git a/tests/boothfile/test014--label.sh b/tests/boothfile/test014--label.sh new file mode 100755 index 00000000..978b5abe --- /dev/null +++ b/tests/boothfile/test014--label.sh @@ -0,0 +1,38 @@ +#!/bin/bash +# Copyright 2025-2026 : Nawa Manusitthipol +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. + +set -euo pipefail + +source ../common--source.sh + +# Test: label command + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +TEST_DIR=$(mktemp -d) +trap "rm -rf $TEST_DIR" EXIT + +mkdir -p "$TEST_DIR/.booth" +cat > "$TEST_DIR/.booth/Boothfile" << 'EOF' +# syntax=codingbooth/boothfile:1 + +label maintainer="team@example.com" +EOF + +ACTUAL=$(run_coding_booth emit-dockerfile --code "$TEST_DIR" 2>/dev/null) + +ALL_PASSED=true + +if echo "$ACTUAL" | grep -qF 'LABEL maintainer="team@example.com"'; then + print_test_result "true" "$0" "014" "label compiles to LABEL" +else + print_test_result "false" "$0" "014" "label compiles to LABEL" + ALL_PASSED=false +fi + +if [[ "$ALL_PASSED" != "true" ]]; then + echo "Actual output:" + echo "$ACTUAL" + exit 1 +fi diff --git a/tests/boothfile/test015--arg.sh b/tests/boothfile/test015--arg.sh new file mode 100755 index 00000000..4771f014 --- /dev/null +++ b/tests/boothfile/test015--arg.sh @@ -0,0 +1,38 @@ +#!/bin/bash +# Copyright 2025-2026 : Nawa Manusitthipol +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. + +set -euo pipefail + +source ../common--source.sh + +# Test: arg command + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +TEST_DIR=$(mktemp -d) +trap "rm -rf $TEST_DIR" EXIT + +mkdir -p "$TEST_DIR/.booth" +cat > "$TEST_DIR/.booth/Boothfile" << 'EOF' +# syntax=codingbooth/boothfile:1 + +arg NODE_VERSION=20 +EOF + +ACTUAL=$(run_coding_booth emit-dockerfile --code "$TEST_DIR" 2>/dev/null) + +ALL_PASSED=true + +if echo "$ACTUAL" | grep -qF "ARG NODE_VERSION=20"; then + print_test_result "true" "$0" "015" "arg compiles to ARG" +else + print_test_result "false" "$0" "015" "arg compiles to ARG" + ALL_PASSED=false +fi + +if [[ "$ALL_PASSED" != "true" ]]; then + echo "Actual output:" + echo "$ACTUAL" + exit 1 +fi diff --git a/tests/boothfile/test016--setup-no-params.sh b/tests/boothfile/test016--setup-no-params.sh new file mode 100755 index 00000000..ae450d2e --- /dev/null +++ b/tests/boothfile/test016--setup-no-params.sh @@ -0,0 +1,46 @@ +#!/bin/bash +# Copyright 2025-2026 : Nawa Manusitthipol +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. + +set -euo pipefail + +source ../common--source.sh + +# Test: setup command without parameters + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +TEST_DIR=$(mktemp -d) +trap "rm -rf $TEST_DIR" EXIT + +mkdir -p "$TEST_DIR/.booth" +cat > "$TEST_DIR/.booth/Boothfile" << 'EOF' +# syntax=codingbooth/boothfile:1 + +setup python +setup nodejs +EOF + +ACTUAL=$(run_coding_booth emit-dockerfile --code "$TEST_DIR" 2>/dev/null) + +ALL_PASSED=true + +if echo "$ACTUAL" | grep -qF "RUN python--setup.sh"; then + print_test_result "true" "$0" "016" "setup python compiles to RUN python--setup.sh" +else + print_test_result "false" "$0" "016" "setup python compiles to RUN python--setup.sh" + ALL_PASSED=false +fi + +if echo "$ACTUAL" | grep -qF "RUN nodejs--setup.sh"; then + print_test_result "true" "$0" "016" "setup nodejs compiles to RUN nodejs--setup.sh" +else + print_test_result "false" "$0" "016" "setup nodejs compiles to RUN nodejs--setup.sh" + ALL_PASSED=false +fi + +if [[ "$ALL_PASSED" != "true" ]]; then + echo "Actual output:" + echo "$ACTUAL" + exit 1 +fi diff --git a/tests/boothfile/test017--setup-with-params.sh b/tests/boothfile/test017--setup-with-params.sh new file mode 100755 index 00000000..8e5abc08 --- /dev/null +++ b/tests/boothfile/test017--setup-with-params.sh @@ -0,0 +1,46 @@ +#!/bin/bash +# Copyright 2025-2026 : Nawa Manusitthipol +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. + +set -euo pipefail + +source ../common--source.sh + +# Test: setup command with parameters + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +TEST_DIR=$(mktemp -d) +trap "rm -rf $TEST_DIR" EXIT + +mkdir -p "$TEST_DIR/.booth" +cat > "$TEST_DIR/.booth/Boothfile" << 'EOF' +# syntax=codingbooth/boothfile:1 + +setup python 3.12 +setup jdk 21 temurin +EOF + +ACTUAL=$(run_coding_booth emit-dockerfile --code "$TEST_DIR" 2>/dev/null) + +ALL_PASSED=true + +if echo "$ACTUAL" | grep -qF "RUN python--setup.sh 3.12"; then + print_test_result "true" "$0" "017" "setup python 3.12 compiles correctly" +else + print_test_result "false" "$0" "017" "setup python 3.12 compiles correctly" + ALL_PASSED=false +fi + +if echo "$ACTUAL" | grep -qF "RUN jdk--setup.sh 21 temurin"; then + print_test_result "true" "$0" "017" "setup jdk 21 temurin compiles correctly" +else + print_test_result "false" "$0" "017" "setup jdk 21 temurin compiles correctly" + ALL_PASSED=false +fi + +if [[ "$ALL_PASSED" != "true" ]]; then + echo "Actual output:" + echo "$ACTUAL" + exit 1 +fi diff --git a/tests/boothfile/test018--setup-custom-script.sh b/tests/boothfile/test018--setup-custom-script.sh new file mode 100755 index 00000000..6ba6aeca --- /dev/null +++ b/tests/boothfile/test018--setup-custom-script.sh @@ -0,0 +1,60 @@ +#!/bin/bash +# Copyright 2025-2026 : Nawa Manusitthipol +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. + +set -euo pipefail + +source ../common--source.sh + +# Test: custom setup script from .booth/setups/ auto-generates COPY + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +TEST_DIR=$(mktemp -d) +trap "rm -rf $TEST_DIR" EXIT + +mkdir -p "$TEST_DIR/.booth/setups" +cat > "$TEST_DIR/.booth/Boothfile" << 'EOF' +# syntax=codingbooth/boothfile:1 + +setup myapp +EOF + +cat > "$TEST_DIR/.booth/setups/myapp--setup.sh" << 'EOF' +#!/bin/bash +echo "Setting up myapp" +EOF + +ACTUAL=$(run_coding_booth emit-dockerfile --code "$TEST_DIR" 2>/dev/null) + +ALL_PASSED=true + +# Should have COPY for custom setups directory +if echo "$ACTUAL" | grep -qE "COPY .booth/setups/ /home/coder/.booth/setups/"; then + print_test_result "true" "$0" "018" "Custom setups directory copied" +else + print_test_result "false" "$0" "018" "Custom setups directory copied" + ALL_PASSED=false +fi + +# Should have PATH update +if echo "$ACTUAL" | grep -qF "ENV PATH=/home/coder/.booth/setups:\$PATH"; then + print_test_result "true" "$0" "018" "Custom setups added to PATH" +else + print_test_result "false" "$0" "018" "Custom setups added to PATH" + ALL_PASSED=false +fi + +# Should have RUN for the script +if echo "$ACTUAL" | grep -qF "RUN myapp--setup.sh"; then + print_test_result "true" "$0" "018" "Custom setup script generates RUN" +else + print_test_result "false" "$0" "018" "Custom setup script generates RUN" + ALL_PASSED=false +fi + +if [[ "$ALL_PASSED" != "true" ]]; then + echo "Actual output:" + echo "$ACTUAL" + exit 1 +fi diff --git a/tests/boothfile/test019--install-pip.sh b/tests/boothfile/test019--install-pip.sh new file mode 100755 index 00000000..98e9ab32 --- /dev/null +++ b/tests/boothfile/test019--install-pip.sh @@ -0,0 +1,38 @@ +#!/bin/bash +# Copyright 2025-2026 : Nawa Manusitthipol +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. + +set -euo pipefail + +source ../common--source.sh + +# Test: install pip command + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +TEST_DIR=$(mktemp -d) +trap "rm -rf $TEST_DIR" EXIT + +mkdir -p "$TEST_DIR/.booth" +cat > "$TEST_DIR/.booth/Boothfile" << 'EOF' +# syntax=codingbooth/boothfile:1 + +install pip django djangorestframework +EOF + +ACTUAL=$(run_coding_booth emit-dockerfile --code "$TEST_DIR" 2>/dev/null) + +ALL_PASSED=true + +if echo "$ACTUAL" | grep -qF "RUN pip--install.sh django djangorestframework"; then + print_test_result "true" "$0" "019" "install pip compiles to RUN pip--install.sh" +else + print_test_result "false" "$0" "019" "install pip compiles to RUN pip--install.sh" + ALL_PASSED=false +fi + +if [[ "$ALL_PASSED" != "true" ]]; then + echo "Actual output:" + echo "$ACTUAL" + exit 1 +fi diff --git a/tests/boothfile/test020--install-npm.sh b/tests/boothfile/test020--install-npm.sh new file mode 100755 index 00000000..c8981da4 --- /dev/null +++ b/tests/boothfile/test020--install-npm.sh @@ -0,0 +1,38 @@ +#!/bin/bash +# Copyright 2025-2026 : Nawa Manusitthipol +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. + +set -euo pipefail + +source ../common--source.sh + +# Test: install npm command + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +TEST_DIR=$(mktemp -d) +trap "rm -rf $TEST_DIR" EXIT + +mkdir -p "$TEST_DIR/.booth" +cat > "$TEST_DIR/.booth/Boothfile" << 'EOF' +# syntax=codingbooth/boothfile:1 + +install npm express typescript +EOF + +ACTUAL=$(run_coding_booth emit-dockerfile --code "$TEST_DIR" 2>/dev/null) + +ALL_PASSED=true + +if echo "$ACTUAL" | grep -qF "RUN npm--install.sh express typescript"; then + print_test_result "true" "$0" "020" "install npm compiles to RUN npm--install.sh" +else + print_test_result "false" "$0" "020" "install npm compiles to RUN npm--install.sh" + ALL_PASSED=false +fi + +if [[ "$ALL_PASSED" != "true" ]]; then + echo "Actual output:" + echo "$ACTUAL" + exit 1 +fi diff --git a/tests/boothfile/test021--install-brew.sh b/tests/boothfile/test021--install-brew.sh new file mode 100755 index 00000000..b92af184 --- /dev/null +++ b/tests/boothfile/test021--install-brew.sh @@ -0,0 +1,38 @@ +#!/bin/bash +# Copyright 2025-2026 : Nawa Manusitthipol +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. + +set -euo pipefail + +source ../common--source.sh + +# Test: install brew command + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +TEST_DIR=$(mktemp -d) +trap "rm -rf $TEST_DIR" EXIT + +mkdir -p "$TEST_DIR/.booth" +cat > "$TEST_DIR/.booth/Boothfile" << 'EOF' +# syntax=codingbooth/boothfile:1 + +install brew gcc +EOF + +ACTUAL=$(run_coding_booth emit-dockerfile --code "$TEST_DIR" 2>/dev/null) + +ALL_PASSED=true + +if echo "$ACTUAL" | grep -qF "RUN brew--install.sh gcc"; then + print_test_result "true" "$0" "021" "install brew compiles to RUN brew--install.sh" +else + print_test_result "false" "$0" "021" "install brew compiles to RUN brew--install.sh" + ALL_PASSED=false +fi + +if [[ "$ALL_PASSED" != "true" ]]; then + echo "Actual output:" + echo "$ACTUAL" + exit 1 +fi diff --git a/tests/boothfile/test022--install-custom-script.sh b/tests/boothfile/test022--install-custom-script.sh new file mode 100755 index 00000000..e93db968 --- /dev/null +++ b/tests/boothfile/test022--install-custom-script.sh @@ -0,0 +1,60 @@ +#!/bin/bash +# Copyright 2025-2026 : Nawa Manusitthipol +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. + +set -euo pipefail + +source ../common--source.sh + +# Test: custom install script from .booth/setups/ + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +TEST_DIR=$(mktemp -d) +trap "rm -rf $TEST_DIR" EXIT + +mkdir -p "$TEST_DIR/.booth/setups" +cat > "$TEST_DIR/.booth/Boothfile" << 'EOF' +# syntax=codingbooth/boothfile:1 + +install custom mypackage +EOF + +cat > "$TEST_DIR/.booth/setups/custom--install.sh" << 'EOF' +#!/bin/bash +echo "Installing $@" +EOF + +ACTUAL=$(run_coding_booth emit-dockerfile --code "$TEST_DIR" 2>/dev/null) + +ALL_PASSED=true + +# Should have COPY for custom setups directory +if echo "$ACTUAL" | grep -qE "COPY .booth/setups/ /home/coder/.booth/setups/"; then + print_test_result "true" "$0" "022" "Custom setups directory copied" +else + print_test_result "false" "$0" "022" "Custom setups directory copied" + ALL_PASSED=false +fi + +# Should have PATH update +if echo "$ACTUAL" | grep -qF "ENV PATH=/home/coder/.booth/setups:\$PATH"; then + print_test_result "true" "$0" "022" "Custom setups added to PATH" +else + print_test_result "false" "$0" "022" "Custom setups added to PATH" + ALL_PASSED=false +fi + +# Should have RUN for the script +if echo "$ACTUAL" | grep -qF "RUN custom--install.sh mypackage"; then + print_test_result "true" "$0" "022" "Custom install script generates RUN" +else + print_test_result "false" "$0" "022" "Custom install script generates RUN" + ALL_PASSED=false +fi + +if [[ "$ALL_PASSED" != "true" ]]; then + echo "Actual output:" + echo "$ACTUAL" + exit 1 +fi diff --git a/tests/boothfile/test023--arg-variable-substitution.sh b/tests/boothfile/test023--arg-variable-substitution.sh new file mode 100755 index 00000000..64c6ad85 --- /dev/null +++ b/tests/boothfile/test023--arg-variable-substitution.sh @@ -0,0 +1,64 @@ +#!/bin/bash +# Copyright 2025-2026 : Nawa Manusitthipol +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. + +set -euo pipefail + +source ../common--source.sh + +# Test: arg with ${VAR} substitution in subsequent commands + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +TEST_DIR=$(mktemp -d) +trap "rm -rf $TEST_DIR" EXIT + +mkdir -p "$TEST_DIR/.booth" +cat > "$TEST_DIR/.booth/Boothfile" << 'EOF' +# syntax=codingbooth/boothfile:1 + +arg NODE_VERSION=20 +arg PYTHON_VERSION=3.12 + +setup nodejs ${NODE_VERSION} +setup python ${PYTHON_VERSION} +EOF + +ACTUAL=$(run_coding_booth emit-dockerfile --code "$TEST_DIR" 2>/dev/null) + +ALL_PASSED=true + +if echo "$ACTUAL" | grep -qF "ARG NODE_VERSION=20"; then + print_test_result "true" "$0" "023" "ARG NODE_VERSION is present" +else + print_test_result "false" "$0" "023" "ARG NODE_VERSION is present" + ALL_PASSED=false +fi + +if echo "$ACTUAL" | grep -qF "ARG PYTHON_VERSION=3.12"; then + print_test_result "true" "$0" "023" "ARG PYTHON_VERSION is present" +else + print_test_result "false" "$0" "023" "ARG PYTHON_VERSION is present" + ALL_PASSED=false +fi + +# Variables should be passed through to RUN commands +if echo "$ACTUAL" | grep -qE 'RUN nodejs--setup.sh \$\{?NODE_VERSION\}?'; then + print_test_result "true" "$0" "023" "Variable substitution in setup nodejs" +else + print_test_result "false" "$0" "023" "Variable substitution in setup nodejs" + ALL_PASSED=false +fi + +if echo "$ACTUAL" | grep -qE 'RUN python--setup.sh \$\{?PYTHON_VERSION\}?'; then + print_test_result "true" "$0" "023" "Variable substitution in setup python" +else + print_test_result "false" "$0" "023" "Variable substitution in setup python" + ALL_PASSED=false +fi + +if [[ "$ALL_PASSED" != "true" ]]; then + echo "Actual output:" + echo "$ACTUAL" + exit 1 +fi diff --git a/tests/boothfile/test024--docker-escape-hatch.sh b/tests/boothfile/test024--docker-escape-hatch.sh new file mode 100755 index 00000000..73b3c2ba --- /dev/null +++ b/tests/boothfile/test024--docker-escape-hatch.sh @@ -0,0 +1,46 @@ +#!/bin/bash +# Copyright 2025-2026 : Nawa Manusitthipol +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. + +set -euo pipefail + +source ../common--source.sh + +# Test: DOCKER escape hatch passthrough + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +TEST_DIR=$(mktemp -d) +trap "rm -rf $TEST_DIR" EXIT + +mkdir -p "$TEST_DIR/.booth" +cat > "$TEST_DIR/.booth/Boothfile" << 'EOF' +# syntax=codingbooth/boothfile:1 + +DOCKER HEALTHCHECK CMD curl -f http://localhost/ || exit 1 +EOF + +ACTUAL=$(run_coding_booth emit-dockerfile --code "$TEST_DIR" 2>/dev/null) + +ALL_PASSED=true + +if echo "$ACTUAL" | grep -qF "HEALTHCHECK CMD curl -f http://localhost/ || exit 1"; then + print_test_result "true" "$0" "024" "DOCKER escape hatch passes through correctly" +else + print_test_result "false" "$0" "024" "DOCKER escape hatch passes through correctly" + ALL_PASSED=false +fi + +# Should NOT have "DOCKER" prefix in output +if echo "$ACTUAL" | grep -qF "DOCKER HEALTHCHECK"; then + print_test_result "false" "$0" "024" "DOCKER prefix should be stripped" + ALL_PASSED=false +else + print_test_result "true" "$0" "024" "DOCKER prefix is stripped" +fi + +if [[ "$ALL_PASSED" != "true" ]]; then + echo "Actual output:" + echo "$ACTUAL" + exit 1 +fi diff --git a/tests/boothfile/test025--order-preservation.sh b/tests/boothfile/test025--order-preservation.sh new file mode 100755 index 00000000..680513c6 --- /dev/null +++ b/tests/boothfile/test025--order-preservation.sh @@ -0,0 +1,71 @@ +#!/bin/bash +# Copyright 2025-2026 : Nawa Manusitthipol +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. + +set -euo pipefail + +source ../common--source.sh + +# Test: Commands emitted in exact source order + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +TEST_DIR=$(mktemp -d) +trap "rm -rf $TEST_DIR" EXIT + +mkdir -p "$TEST_DIR/.booth" +cat > "$TEST_DIR/.booth/Boothfile" << 'EOF' +# syntax=codingbooth/boothfile:1 + +env FIRST=1 +setup python 3.12 +env SECOND=2 +install pip django +env THIRD=3 +EOF + +ACTUAL=$(run_coding_booth emit-dockerfile --code "$TEST_DIR" 2>/dev/null) + +ALL_PASSED=true + +# Extract line numbers for each command +LINE_FIRST=$(echo "$ACTUAL" | grep -n "ENV FIRST=1" | cut -d: -f1) +LINE_PYTHON=$(echo "$ACTUAL" | grep -n "RUN python--setup.sh 3.12" | cut -d: -f1) +LINE_SECOND=$(echo "$ACTUAL" | grep -n "ENV SECOND=2" | cut -d: -f1) +LINE_PIP=$(echo "$ACTUAL" | grep -n "RUN pip--install.sh django" | cut -d: -f1) +LINE_THIRD=$(echo "$ACTUAL" | grep -n "ENV THIRD=3" | cut -d: -f1) + +# Verify order +if [[ -n "$LINE_FIRST" && -n "$LINE_PYTHON" && "$LINE_FIRST" -lt "$LINE_PYTHON" ]]; then + print_test_result "true" "$0" "025" "FIRST comes before python setup" +else + print_test_result "false" "$0" "025" "FIRST comes before python setup" + ALL_PASSED=false +fi + +if [[ -n "$LINE_PYTHON" && -n "$LINE_SECOND" && "$LINE_PYTHON" -lt "$LINE_SECOND" ]]; then + print_test_result "true" "$0" "025" "python setup comes before SECOND" +else + print_test_result "false" "$0" "025" "python setup comes before SECOND" + ALL_PASSED=false +fi + +if [[ -n "$LINE_SECOND" && -n "$LINE_PIP" && "$LINE_SECOND" -lt "$LINE_PIP" ]]; then + print_test_result "true" "$0" "025" "SECOND comes before pip install" +else + print_test_result "false" "$0" "025" "SECOND comes before pip install" + ALL_PASSED=false +fi + +if [[ -n "$LINE_PIP" && -n "$LINE_THIRD" && "$LINE_PIP" -lt "$LINE_THIRD" ]]; then + print_test_result "true" "$0" "025" "pip install comes before THIRD" +else + print_test_result "false" "$0" "025" "pip install comes before THIRD" + ALL_PASSED=false +fi + +if [[ "$ALL_PASSED" != "true" ]]; then + echo "Actual output:" + echo "$ACTUAL" + exit 1 +fi diff --git a/tests/boothfile/test026--missing-syntax-warning.sh b/tests/boothfile/test026--missing-syntax-warning.sh new file mode 100755 index 00000000..89444d57 --- /dev/null +++ b/tests/boothfile/test026--missing-syntax-warning.sh @@ -0,0 +1,38 @@ +#!/bin/bash +# Copyright 2025-2026 : Nawa Manusitthipol +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. + +set -euo pipefail + +source ../common--source.sh + +# Test: Missing syntax directive produces warning + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +TEST_DIR=$(mktemp -d) +trap "rm -rf $TEST_DIR" EXIT + +mkdir -p "$TEST_DIR/.booth" +cat > "$TEST_DIR/.booth/Boothfile" << 'EOF' +run echo "no syntax line" +EOF + +# Capture both stdout and stderr +OUTPUT=$(run_coding_booth emit-dockerfile --code "$TEST_DIR" 2>&1) || true + +ALL_PASSED=true + +# Should produce a warning about missing syntax +if echo "$OUTPUT" | grep -qi "syntax\|missing\|expected"; then + print_test_result "true" "$0" "026" "Warning about missing syntax directive" +else + print_test_result "false" "$0" "026" "Warning about missing syntax directive" + ALL_PASSED=false +fi + +if [[ "$ALL_PASSED" != "true" ]]; then + echo "Output:" + echo "$OUTPUT" + exit 1 +fi diff --git a/tests/boothfile/test027--unknown-command-error.sh b/tests/boothfile/test027--unknown-command-error.sh new file mode 100755 index 00000000..62a035e7 --- /dev/null +++ b/tests/boothfile/test027--unknown-command-error.sh @@ -0,0 +1,40 @@ +#!/bin/bash +# Copyright 2025-2026 : Nawa Manusitthipol +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. + +set -euo pipefail + +source ../common--source.sh + +# Test: Unknown command produces error with suggestion + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +TEST_DIR=$(mktemp -d) +trap "rm -rf $TEST_DIR" EXIT + +mkdir -p "$TEST_DIR/.booth" +cat > "$TEST_DIR/.booth/Boothfile" << 'EOF' +# syntax=codingbooth/boothfile:1 + +instal pip django +EOF + +# Capture both stdout and stderr, expect failure +OUTPUT=$(run_coding_booth emit-dockerfile --code "$TEST_DIR" 2>&1) || EXIT_CODE=$? + +ALL_PASSED=true + +# Should produce an error about unknown command +if echo "$OUTPUT" | grep -qi "unknown\|invalid\|unrecognized\|instal"; then + print_test_result "true" "$0" "027" "Error about unknown command" +else + print_test_result "false" "$0" "027" "Error about unknown command" + ALL_PASSED=false +fi + +if [[ "$ALL_PASSED" != "true" ]]; then + echo "Output:" + echo "$OUTPUT" + exit 1 +fi diff --git a/tests/boothfile/test028--unknown-setup-warning.sh b/tests/boothfile/test028--unknown-setup-warning.sh new file mode 100755 index 00000000..2478ae43 --- /dev/null +++ b/tests/boothfile/test028--unknown-setup-warning.sh @@ -0,0 +1,45 @@ +#!/bin/bash +# Copyright 2025-2026 : Nawa Manusitthipol +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. + +set -euo pipefail + +source ../common--source.sh + +# Test: Unknown setup script produces warning with suggestion + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +TEST_DIR=$(mktemp -d) +trap "rm -rf $TEST_DIR" EXIT + +mkdir -p "$TEST_DIR/.booth" +cat > "$TEST_DIR/.booth/Boothfile" << 'EOF' +# syntax=codingbooth/boothfile:1 + +setup pytohn +EOF + +# Capture both stdout and stderr +OUTPUT=$(run_coding_booth emit-dockerfile --code "$TEST_DIR" 2>&1) || true + +ALL_PASSED=true + +# Should produce a warning about unknown setup script (possibly with suggestion) +if echo "$OUTPUT" | grep -qi "unknown\|warning\|pytohn\|python"; then + print_test_result "true" "$0" "028" "Warning about unknown setup script" +else + # If no warning, just check it compiles (warning may be optional) + if echo "$OUTPUT" | grep -qF "RUN pytohn--setup.sh"; then + print_test_result "true" "$0" "028" "Unknown setup compiles (warning optional)" + else + print_test_result "false" "$0" "028" "Warning or compilation of unknown setup" + ALL_PASSED=false + fi +fi + +if [[ "$ALL_PASSED" != "true" ]]; then + echo "Output:" + echo "$OUTPUT" + exit 1 +fi diff --git a/tests/boothfile/test029--unclosed-heredoc-error.sh b/tests/boothfile/test029--unclosed-heredoc-error.sh new file mode 100755 index 00000000..385cd5f9 --- /dev/null +++ b/tests/boothfile/test029--unclosed-heredoc-error.sh @@ -0,0 +1,54 @@ +#!/bin/bash +# Copyright 2025-2026 : Nawa Manusitthipol +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. + +set -euo pipefail + +source ../common--source.sh + +# Test: Unclosed heredoc produces error + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +TEST_DIR=$(mktemp -d) +trap "rm -rf $TEST_DIR" EXIT + +mkdir -p "$TEST_DIR/.booth" +cat > "$TEST_DIR/.booth/Boothfile" << 'EOF' +# syntax=codingbooth/boothfile:1 + +run <&1) +EXIT_CODE=$? +set -e + +ALL_PASSED=true + +# Should produce an error about unclosed heredoc +if echo "$OUTPUT" | grep -qi "unclosed\|unterminated\|heredoc\|END\|delimiter"; then + print_test_result "true" "$0" "029" "Error about unclosed heredoc" +else + print_test_result "false" "$0" "029" "Error about unclosed heredoc" + ALL_PASSED=false +fi + +# Should exit with non-zero +if [[ "$EXIT_CODE" -ne 0 ]]; then + print_test_result "true" "$0" "029" "Non-zero exit code for unclosed heredoc" +else + print_test_result "false" "$0" "029" "Non-zero exit code for unclosed heredoc" + ALL_PASSED=false +fi + +if [[ "$ALL_PASSED" != "true" ]]; then + echo "Output:" + echo "$OUTPUT" + echo "Exit code: $EXIT_CODE" + exit 1 +fi diff --git a/tests/boothfile/test030--strict-mode.sh b/tests/boothfile/test030--strict-mode.sh new file mode 100755 index 00000000..be31e6ad --- /dev/null +++ b/tests/boothfile/test030--strict-mode.sh @@ -0,0 +1,52 @@ +#!/bin/bash +# Copyright 2025-2026 : Nawa Manusitthipol +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. + +set -euo pipefail + +source ../common--source.sh + +# Test: --strict mode treats warnings as errors + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +TEST_DIR=$(mktemp -d) +trap "rm -rf $TEST_DIR" EXIT + +mkdir -p "$TEST_DIR/.booth" +# Missing syntax directive should be a warning normally +cat > "$TEST_DIR/.booth/Boothfile" << 'EOF' +run echo "no syntax line" +EOF + +# Without --strict, should succeed (warning only) +set +e +OUTPUT_NORMAL=$(run_coding_booth emit-dockerfile --code "$TEST_DIR" 2>&1) +EXIT_NORMAL=$? +set -e + +# With --strict, should fail +set +e +OUTPUT_STRICT=$(run_coding_booth emit-dockerfile --code "$TEST_DIR" --strict 2>&1) +EXIT_STRICT=$? +set -e + +ALL_PASSED=true + +# In strict mode, should fail +if [[ "$EXIT_STRICT" -ne 0 ]]; then + print_test_result "true" "$0" "030" "--strict causes non-zero exit on warning" +else + # Some implementations might not have strict mode yet + print_test_result "false" "$0" "030" "--strict causes non-zero exit on warning (may not be implemented)" + ALL_PASSED=false +fi + +if [[ "$ALL_PASSED" != "true" ]]; then + echo "Normal output (exit $EXIT_NORMAL):" + echo "$OUTPUT_NORMAL" + echo "" + echo "Strict output (exit $EXIT_STRICT):" + echo "$OUTPUT_STRICT" + exit 1 +fi diff --git a/tests/common--source.sh b/tests/common--source.sh index 9b7fd4fa..6fb6e1fd 100644 --- a/tests/common--source.sh +++ b/tests/common--source.sh @@ -70,7 +70,8 @@ normalize_output() { -e "s/HOST_UID=[0-9]+/HOST_UID=XXXXX/g" \ -e "s/HOST_GID=[0-9]+/HOST_GID=XXXXX/g" \ -e "s/HOST_UID:[[:space:]]+[0-9]+/HOST_UID: XXXXX/g" \ - -e "s/HOST_GID:[[:space:]]+[0-9]+/HOST_GID: XXXXX/g" + -e "s/HOST_GID:[[:space:]]+[0-9]+/HOST_GID: XXXXX/g" \ + -e "s/cb\.created-at=[0-9T:-]+Z/cb.created-at=XXXXX/g" } script_relative_path() { diff --git a/tests/complex/test-boothfile-custom-setup/.booth/.Dockerfile.generated b/tests/complex/test-boothfile-custom-setup/.booth/.Dockerfile.generated new file mode 100644 index 00000000..3eab11b0 --- /dev/null +++ b/tests/complex/test-boothfile-custom-setup/.booth/.Dockerfile.generated @@ -0,0 +1,12 @@ +# syntax=docker/dockerfile:1.7 +ARG BOOTH_VARIANT_TAG=base +ARG BOOTH_VERSION_TAG=latest +FROM nawaman/codingbooth:${BOOTH_VARIANT_TAG}-${BOOTH_VERSION_TAG} + +ARG BOOTH_VARIANT_TAG=base +ARG BOOTH_VERSION_TAG=latest + +COPY .booth/setups/ /home/coder/.booth/setups/ +ENV PATH=/home/coder/.booth/setups:$PATH + +RUN mytool--setup.sh diff --git a/tests/complex/test-boothfile-custom-setup/.booth/Boothfile b/tests/complex/test-boothfile-custom-setup/.booth/Boothfile new file mode 100644 index 00000000..42a14fdf --- /dev/null +++ b/tests/complex/test-boothfile-custom-setup/.booth/Boothfile @@ -0,0 +1,4 @@ +# syntax=codingbooth/boothfile:1 + +# Test: Custom setup script from .booth/setups/ +setup mytool diff --git a/tests/complex/test-boothfile-custom-setup/.booth/config.toml b/tests/complex/test-boothfile-custom-setup/.booth/config.toml new file mode 100644 index 00000000..967c640d --- /dev/null +++ b/tests/complex/test-boothfile-custom-setup/.booth/config.toml @@ -0,0 +1,2 @@ +# Test config for boothfile-custom-setup +variant = "base" diff --git a/tests/complex/test-boothfile-custom-setup/.booth/setups/mytool--setup.sh b/tests/complex/test-boothfile-custom-setup/.booth/setups/mytool--setup.sh new file mode 100755 index 00000000..92285acf --- /dev/null +++ b/tests/complex/test-boothfile-custom-setup/.booth/setups/mytool--setup.sh @@ -0,0 +1,17 @@ +#!/bin/bash +# Custom setup script for testing + +# Create a marker file to prove this script ran +echo "MYTOOL_INSTALLED" > /tmp/mytool-marker.txt + +# Create a simple "command" in /usr/local/bin +cat > /usr/local/bin/mytool << 'SCRIPT' +#!/bin/bash +echo "mytool version 1.0.0 - custom setup works!" +SCRIPT +chmod +x /usr/local/bin/mytool + +# Set an environment variable via profile +cat > /etc/profile.d/70-cb-mytool--profile.sh << 'PROFILE' +export MYTOOL_HOME=/opt/mytool +PROFILE diff --git a/tests/complex/test-boothfile-custom-setup/test--boothfile-custom-setup.sh b/tests/complex/test-boothfile-custom-setup/test--boothfile-custom-setup.sh new file mode 100755 index 00000000..80164a2a --- /dev/null +++ b/tests/complex/test-boothfile-custom-setup/test--boothfile-custom-setup.sh @@ -0,0 +1,59 @@ +#!/bin/bash +# Copyright 2025-2026 : Nawa Manusitthipol +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. + +# ----------------------------------------------------------------------------- +# Test: Boothfile Custom Setup Script +# +# Verifies that custom setup scripts in .booth/setups/ are correctly +# copied into the image and executed during build. +# ----------------------------------------------------------------------------- + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +source ../../common--source.sh + +echo "=== Test: Boothfile Custom Setup Script ===" + +FAILED=0 + +# Test 1: The custom setup script ran (marker file exists) +# Use --silence-build to suppress build output, redirect stderr to /dev/null +MARKER=$(run_coding_booth --silence-build -- cat /tmp/mytool-marker.txt 2>/dev/null | tr -d '\r\n') + +if [[ "$MARKER" == "MYTOOL_INSTALLED" ]]; then + print_test_result "true" "$0" "1" "Custom setup script executed during build" +else + print_test_result "false" "$0" "1" "Custom setup script should have created marker file" + echo " Actual output: '$MARKER'" + FAILED=$((FAILED + 1)) +fi + +# Test 2: The custom command is available +TOOL_OUT=$(run_coding_booth --silence-build -- mytool 2>/dev/null | tr -d '\r\n') + +if echo "$TOOL_OUT" | grep -qF "mytool version 1.0.0"; then + print_test_result "true" "$0" "2" "Custom tool command is available" +else + print_test_result "false" "$0" "2" "mytool command should be available" + echo " Actual output: '$TOOL_OUT'" + FAILED=$((FAILED + 1)) +fi + +# Test 3: The profile script set the environment variable +# Wrap entire command in quotes so it's passed as single argument after -- +ENV_OUT=$(run_coding_booth --silence-build -- 'bash -lc "echo \$MYTOOL_HOME"' 2>/dev/null | tr -d '\r\n') + +if [[ "$ENV_OUT" == "/opt/mytool" ]]; then + print_test_result "true" "$0" "3" "Profile script set MYTOOL_HOME" +else + print_test_result "false" "$0" "3" "MYTOOL_HOME should be '/opt/mytool'" + echo " Actual output: '$ENV_OUT'" + FAILED=$((FAILED + 1)) +fi + +exit $FAILED diff --git a/tests/complex/test-boothfile-env-workdir/.booth/.Dockerfile.generated b/tests/complex/test-boothfile-env-workdir/.booth/.Dockerfile.generated new file mode 100644 index 00000000..696c1041 --- /dev/null +++ b/tests/complex/test-boothfile-env-workdir/.booth/.Dockerfile.generated @@ -0,0 +1,12 @@ +# syntax=docker/dockerfile:1.7 +ARG BOOTH_VARIANT_TAG=base +ARG BOOTH_VERSION_TAG=latest +FROM nawaman/codingbooth:${BOOTH_VARIANT_TAG}-${BOOTH_VERSION_TAG} + +ARG BOOTH_VARIANT_TAG=base +ARG BOOTH_VERSION_TAG=latest + +ENV MY_TEST_VAR=hello_from_boothfile +ENV ANOTHER_VAR=42 +WORKDIR /tmp/testdir +RUN mkdir -p /tmp/testdir diff --git a/tests/complex/test-boothfile-env-workdir/.booth/Boothfile b/tests/complex/test-boothfile-env-workdir/.booth/Boothfile new file mode 100644 index 00000000..57192e6a --- /dev/null +++ b/tests/complex/test-boothfile-env-workdir/.booth/Boothfile @@ -0,0 +1,7 @@ +# syntax=codingbooth/boothfile:1 + +# Test: env and workdir commands +env MY_TEST_VAR=hello_from_boothfile +env ANOTHER_VAR=42 +workdir /tmp/testdir +run mkdir -p /tmp/testdir diff --git a/tests/complex/test-boothfile-env-workdir/.booth/config.toml b/tests/complex/test-boothfile-env-workdir/.booth/config.toml new file mode 100644 index 00000000..62941243 --- /dev/null +++ b/tests/complex/test-boothfile-env-workdir/.booth/config.toml @@ -0,0 +1,2 @@ +# Test config for boothfile-env-workdir +variant = "base" diff --git a/tests/complex/test-boothfile-env-workdir/.booth/tools/codingbooth.lock b/tests/complex/test-boothfile-env-workdir/.booth/tools/codingbooth.lock new file mode 100644 index 00000000..f5aaee66 --- /dev/null +++ b/tests/complex/test-boothfile-env-workdir/.booth/tools/codingbooth.lock @@ -0,0 +1,3 @@ +version=0.16.0 +downloaded_at=2026-02-05T04:11:34Z +cache=shared diff --git a/tests/complex/test-boothfile-env-workdir/test--boothfile-env-workdir.sh b/tests/complex/test-boothfile-env-workdir/test--boothfile-env-workdir.sh new file mode 100755 index 00000000..7f61dc5b --- /dev/null +++ b/tests/complex/test-boothfile-env-workdir/test--boothfile-env-workdir.sh @@ -0,0 +1,56 @@ +#!/bin/bash +# Copyright 2025-2026 : Nawa Manusitthipol +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. + +# ----------------------------------------------------------------------------- +# Test: Boothfile env and workdir +# +# Verifies that env and workdir commands in Boothfile correctly set +# environment variables and working directory in the container. +# ----------------------------------------------------------------------------- + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +source ../../common--source.sh + +echo "=== Test: Boothfile env and workdir ===" + +FAILED=0 + +# Test 1: Environment variable is set +ENV_OUT=$(run_coding_booth --silence-build -- 'echo $MY_TEST_VAR' 2>/dev/null | tr -d '\r\n') + +if [[ "$ENV_OUT" == "hello_from_boothfile" ]]; then + print_test_result "true" "$0" "1" "MY_TEST_VAR is set correctly" +else + print_test_result "false" "$0" "1" "MY_TEST_VAR should be 'hello_from_boothfile'" + echo " Actual output: '$ENV_OUT'" + FAILED=$((FAILED + 1)) +fi + +# Test 2: Second environment variable is set +ENV_OUT2=$(run_coding_booth --silence-build -- 'echo $ANOTHER_VAR' 2>/dev/null | tr -d '\r\n') + +if [[ "$ENV_OUT2" == "42" ]]; then + print_test_result "true" "$0" "2" "ANOTHER_VAR is set correctly" +else + print_test_result "false" "$0" "2" "ANOTHER_VAR should be '42'" + echo " Actual output: '$ENV_OUT2'" + FAILED=$((FAILED + 1)) +fi + +# Test 3: Working directory exists (was created by run command) +DIR_EXISTS=$(run_coding_booth --silence-build -- 'test -d /tmp/testdir && echo "exists"' 2>/dev/null | tr -d '\r\n') + +if [[ "$DIR_EXISTS" == "exists" ]]; then + print_test_result "true" "$0" "3" "/tmp/testdir was created by run command" +else + print_test_result "false" "$0" "3" "/tmp/testdir should exist" + FAILED=$((FAILED + 1)) +fi + +exit $FAILED diff --git a/tests/complex/test-boothfile-multi-setup/.booth/.Dockerfile.generated b/tests/complex/test-boothfile-multi-setup/.booth/.Dockerfile.generated new file mode 100644 index 00000000..818d39bb --- /dev/null +++ b/tests/complex/test-boothfile-multi-setup/.booth/.Dockerfile.generated @@ -0,0 +1,11 @@ +# syntax=docker/dockerfile:1.7 +ARG BOOTH_VARIANT_TAG=base +ARG BOOTH_VERSION_TAG=latest +FROM nawaman/codingbooth:${BOOTH_VARIANT_TAG}-${BOOTH_VERSION_TAG} + +ARG BOOTH_VARIANT_TAG=base +ARG BOOTH_VERSION_TAG=latest + +RUN python--setup.sh 3.12 +RUN nodejs--setup.sh 20 +RUN pip--install.sh requests diff --git a/tests/complex/test-boothfile-multi-setup/.booth/Boothfile b/tests/complex/test-boothfile-multi-setup/.booth/Boothfile new file mode 100644 index 00000000..f8df64fc --- /dev/null +++ b/tests/complex/test-boothfile-multi-setup/.booth/Boothfile @@ -0,0 +1,8 @@ +# syntax=codingbooth/boothfile:1 + +# Test: Multiple setups in a single Boothfile +setup python 3.12 +setup nodejs 20 + +# Install packages via pip +install pip requests diff --git a/tests/complex/test-boothfile-multi-setup/.booth/config.toml b/tests/complex/test-boothfile-multi-setup/.booth/config.toml new file mode 100644 index 00000000..0d160605 --- /dev/null +++ b/tests/complex/test-boothfile-multi-setup/.booth/config.toml @@ -0,0 +1,2 @@ +# Test config for boothfile-multi-setup +variant = "base" diff --git a/tests/complex/test-boothfile-multi-setup/test--boothfile-multi-setup.sh b/tests/complex/test-boothfile-multi-setup/test--boothfile-multi-setup.sh new file mode 100755 index 00000000..8945ff14 --- /dev/null +++ b/tests/complex/test-boothfile-multi-setup/test--boothfile-multi-setup.sh @@ -0,0 +1,68 @@ +#!/bin/bash +# Copyright 2025-2026 : Nawa Manusitthipol +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. + +# ----------------------------------------------------------------------------- +# Test: Boothfile Multiple Setups +# +# Verifies that a Boothfile can install multiple tools (Python + Node.js) +# and pip packages in a single configuration. +# ----------------------------------------------------------------------------- + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +source ../../common--source.sh + +echo "=== Test: Boothfile Multiple Setups ===" + +FAILED=0 + +# Test 1: Python is installed +PYTHON_OUT=$(run_coding_booth --silence-build -- python3 --version 2>/dev/null | head -1) + +if echo "$PYTHON_OUT" | grep -qE "Python 3\.12"; then + print_test_result "true" "$0" "1" "Python 3.12 is installed" +else + print_test_result "false" "$0" "1" "Python 3.12 should be installed" + echo " Actual output: $PYTHON_OUT" + FAILED=$((FAILED + 1)) +fi + +# Test 2: Node.js is installed +NODE_OUT=$(run_coding_booth --silence-build -- node --version 2>/dev/null | head -1) + +if echo "$NODE_OUT" | grep -qE "v20\."; then + print_test_result "true" "$0" "2" "Node.js v20 is installed" +else + print_test_result "false" "$0" "2" "Node.js v20 should be installed" + echo " Actual output: $NODE_OUT" + FAILED=$((FAILED + 1)) +fi + +# Test 3: requests package is installed via pip +REQUESTS_OUT=$(run_coding_booth --silence-build -- 'python3 -c "import requests; print(requests.__version__)"' 2>/dev/null | head -1) + +if echo "$REQUESTS_OUT" | grep -qE "^[0-9]+\."; then + print_test_result "true" "$0" "3" "requests package is installed via pip" +else + print_test_result "false" "$0" "3" "requests package should be installed" + echo " Actual output: $REQUESTS_OUT" + FAILED=$((FAILED + 1)) +fi + +# Test 4: Both tools work together (run node and python in sequence) +COMBO_OUT=$(run_coding_booth --silence-build -- 'python3 --version && node --version' 2>/dev/null) + +if echo "$COMBO_OUT" | grep -qE "Python 3" && echo "$COMBO_OUT" | grep -qE "v20"; then + print_test_result "true" "$0" "4" "Both Python and Node.js work together" +else + print_test_result "false" "$0" "4" "Both tools should work together" + echo " Actual output: $COMBO_OUT" + FAILED=$((FAILED + 1)) +fi + +exit $FAILED diff --git a/tests/complex/test-boothfile-nodejs/.booth/.Dockerfile.generated b/tests/complex/test-boothfile-nodejs/.booth/.Dockerfile.generated new file mode 100644 index 00000000..37baf1fc --- /dev/null +++ b/tests/complex/test-boothfile-nodejs/.booth/.Dockerfile.generated @@ -0,0 +1,9 @@ +# syntax=docker/dockerfile:1.7 +ARG BOOTH_VARIANT_TAG=base +ARG BOOTH_VERSION_TAG=latest +FROM nawaman/codingbooth:${BOOTH_VARIANT_TAG}-${BOOTH_VERSION_TAG} + +ARG BOOTH_VARIANT_TAG=base +ARG BOOTH_VERSION_TAG=latest + +RUN nodejs--setup.sh 20 diff --git a/tests/complex/test-boothfile-nodejs/.booth/Boothfile b/tests/complex/test-boothfile-nodejs/.booth/Boothfile new file mode 100644 index 00000000..4f08f79c --- /dev/null +++ b/tests/complex/test-boothfile-nodejs/.booth/Boothfile @@ -0,0 +1,4 @@ +# syntax=codingbooth/boothfile:1 + +# Test: Node.js installation via Boothfile +setup nodejs 20 diff --git a/tests/complex/test-boothfile-nodejs/.booth/config.toml b/tests/complex/test-boothfile-nodejs/.booth/config.toml new file mode 100644 index 00000000..6c5c1550 --- /dev/null +++ b/tests/complex/test-boothfile-nodejs/.booth/config.toml @@ -0,0 +1,2 @@ +# Test config for boothfile-nodejs +variant = "base" diff --git a/tests/complex/test-boothfile-nodejs/test--boothfile-nodejs.sh b/tests/complex/test-boothfile-nodejs/test--boothfile-nodejs.sh new file mode 100755 index 00000000..c54dc705 --- /dev/null +++ b/tests/complex/test-boothfile-nodejs/test--boothfile-nodejs.sh @@ -0,0 +1,55 @@ +#!/bin/bash +# Copyright 2025-2026 : Nawa Manusitthipol +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. + +# ----------------------------------------------------------------------------- +# Test: Boothfile Node.js Installation +# +# Verifies that a Boothfile with `setup nodejs 20` correctly installs Node.js +# and makes it available in the container. +# ----------------------------------------------------------------------------- + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +source ../../common--source.sh + +echo "=== Test: Boothfile Node.js Installation ===" + +FAILED=0 + +# Test 1: Node.js is installed and accessible +ACTUAL=$(run_coding_booth --silence-build -- node --version 2>/dev/null | head -1) + +if echo "$ACTUAL" | grep -qE "v[0-9]+\."; then + print_test_result "true" "$0" "1" "Node.js is installed via Boothfile" +else + print_test_result "false" "$0" "1" "Node.js should be installed" + echo " Actual output: $ACTUAL" + FAILED=$((FAILED + 1)) +fi + +# Test 2: Node.js version matches requested (v20.x) +if echo "$ACTUAL" | grep -qE "v20\."; then + print_test_result "true" "$0" "2" "Node.js version is v20.x as specified" +else + print_test_result "false" "$0" "2" "Node.js version should be v20.x" + echo " Actual output: $ACTUAL" + FAILED=$((FAILED + 1)) +fi + +# Test 3: npm is also available +NPM_OUTPUT=$(run_coding_booth --silence-build -- npm --version 2>/dev/null | head -1) || true + +if echo "$NPM_OUTPUT" | grep -qE "^[0-9]+\."; then + print_test_result "true" "$0" "3" "npm is available with Node.js" +else + print_test_result "false" "$0" "3" "npm should be available" + echo " Actual output: $NPM_OUTPUT" + FAILED=$((FAILED + 1)) +fi + +exit $FAILED diff --git a/tests/complex/test-boothfile-python/.booth/.Dockerfile.generated b/tests/complex/test-boothfile-python/.booth/.Dockerfile.generated new file mode 100644 index 00000000..6dc6549f --- /dev/null +++ b/tests/complex/test-boothfile-python/.booth/.Dockerfile.generated @@ -0,0 +1,9 @@ +# syntax=docker/dockerfile:1.7 +ARG BOOTH_VARIANT_TAG=base +ARG BOOTH_VERSION_TAG=latest +FROM nawaman/codingbooth:${BOOTH_VARIANT_TAG}-${BOOTH_VERSION_TAG} + +ARG BOOTH_VARIANT_TAG=base +ARG BOOTH_VERSION_TAG=latest + +RUN python--setup.sh 3.12 diff --git a/tests/complex/test-boothfile-python/.booth/Boothfile b/tests/complex/test-boothfile-python/.booth/Boothfile new file mode 100644 index 00000000..2a9a758e --- /dev/null +++ b/tests/complex/test-boothfile-python/.booth/Boothfile @@ -0,0 +1,4 @@ +# syntax=codingbooth/boothfile:1 + +# Test: Python installation via Boothfile +setup python 3.12 diff --git a/tests/complex/test-boothfile-python/.booth/config.toml b/tests/complex/test-boothfile-python/.booth/config.toml new file mode 100644 index 00000000..7d7d98fd --- /dev/null +++ b/tests/complex/test-boothfile-python/.booth/config.toml @@ -0,0 +1,2 @@ +# Test config for boothfile-python +variant = "base" diff --git a/tests/complex/test-boothfile-python/test--boothfile-python.sh b/tests/complex/test-boothfile-python/test--boothfile-python.sh new file mode 100755 index 00000000..7ced590f --- /dev/null +++ b/tests/complex/test-boothfile-python/test--boothfile-python.sh @@ -0,0 +1,55 @@ +#!/bin/bash +# Copyright 2025-2026 : Nawa Manusitthipol +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. + +# ----------------------------------------------------------------------------- +# Test: Boothfile Python Installation +# +# Verifies that a Boothfile with `setup python 3.12` correctly installs Python +# and makes it available in the container. +# ----------------------------------------------------------------------------- + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +source ../../common--source.sh + +echo "=== Test: Boothfile Python Installation ===" + +FAILED=0 + +# Test 1: Python is installed and accessible +ACTUAL=$(run_coding_booth --silence-build -- python3 --version 2>/dev/null | head -1) + +if echo "$ACTUAL" | grep -qE "Python 3\."; then + print_test_result "true" "$0" "1" "Python is installed via Boothfile" +else + print_test_result "false" "$0" "1" "Python should be installed" + echo " Actual output: $ACTUAL" + FAILED=$((FAILED + 1)) +fi + +# Test 2: Python version matches requested (3.12.x) +if echo "$ACTUAL" | grep -qE "Python 3\.12"; then + print_test_result "true" "$0" "2" "Python version is 3.12.x as specified" +else + print_test_result "false" "$0" "2" "Python version should be 3.12.x" + echo " Actual output: $ACTUAL" + FAILED=$((FAILED + 1)) +fi + +# Test 3: pip is also available (setup script should include it) +PIP_OUTPUT=$(run_coding_booth --silence-build -- pip3 --version 2>/dev/null | head -1) || true + +if echo "$PIP_OUTPUT" | grep -qE "pip [0-9]+\.[0-9]+"; then + print_test_result "true" "$0" "3" "pip is available with Python" +else + print_test_result "false" "$0" "3" "pip should be available" + echo " Actual output: $PIP_OUTPUT" + FAILED=$((FAILED + 1)) +fi + +exit $FAILED diff --git a/tests/dryrun/boothfile-test/.booth/Boothfile b/tests/dryrun/boothfile-test/.booth/Boothfile new file mode 100644 index 00000000..2f53b586 --- /dev/null +++ b/tests/dryrun/boothfile-test/.booth/Boothfile @@ -0,0 +1,7 @@ +# syntax=codingbooth/boothfile:1 + +# Test Boothfile for --emit-dockerfile + +setup python 3.12 +install pip django +env APP_ENV=production diff --git a/tests/dryrun/test--config.toml b/tests/dryrun/test--config.toml index d29f8dfa..424d16ad 100644 --- a/tests/dryrun/test--config.toml +++ b/tests/dryrun/test--config.toml @@ -8,5 +8,5 @@ name = "test-container" pull = true variant = "base" verbose = true -version = "0.16.0" +version = "0.17.0--rc2" run-args = "-p;10005" diff --git a/tests/dryrun/test001--dryrun.sh b/tests/dryrun/test001--dryrun.sh index 471fc26c..64ca19b4 100755 --- a/tests/dryrun/test001--dryrun.sh +++ b/tests/dryrun/test001--dryrun.sh @@ -38,6 +38,11 @@ EXPECT="\ 🌐 Open: http://localhost:10000 ============================================================ +docker \\ + ps \\ + -a \\ + --filter 'name=^dryrun\$' \\ + --format '{{.Names}}' 📦 Running booth in foreground. 👉 Stop with Ctrl+C. The container will be removed (--rm) when stop. 👉 To open an interactive shell instead: 'codingbooth -- bash' @@ -51,34 +56,41 @@ docker \\ -e 'HOST_GID=${HOST_GID}' \\ -v ${HERE}:/home/coder/code \\ -w /home/coder/code \\ + --label 'cb.managed=true' \\ + --label 'cb.project=dryrun' \\ + --label 'cb.variant=base' \\ + --label 'cb.code-path=${HERE}' \\ + --label 'cb.created-at=XXXXX' \\ + --label 'cb.version=${VERSION}' \\ + --label 'cb.keep-alive=false' \\ -p 10000:10000 \\ - -e 'CB_SETUPS=/opt/codingbooth/setups' \\ - -e 'CB_CONTAINER_NAME=dryrun' \\ - -e 'CB_DAEMON=false' \\ - -e 'CB_HOST_PORT=10000' \\ - -e 'CB_IMAGE_NAME=nawaman/codingbooth:base-${VERSION}' \\ - -e 'CB_RUNMODE=FOREGROUND' \\ - -e 'CB_VARIANT_TAG=base' \\ - -e 'CB_VERBOSE=false' \\ - -e 'CB_VERSION_TAG=${VERSION}' \\ - -e 'CB_CODE_PATH=${HERE}' \\ - -e 'CB_CODE_PORT=10000' \\ - -e 'CB_VERSION=${VERSION}' \\ - -e 'CB_CONFIG_FILE=' \\ - -e 'CB_SCRIPT_NAME=codingbooth' \\ - -e 'CB_SCRIPT_DIR=${SCRIPT_DIR}' \\ - -e 'CB_LIB_DIR=${LIB_DIR}' \\ - -e 'CB_KEEP_ALIVE=false' \\ - -e 'CB_SILENCE_BUILD=false' \\ - -e 'CB_PULL=false' \\ - -e 'CB_DIND=false' \\ - -e 'CB_DOCKERFILE=' \\ - -e 'CB_PROJECT_NAME=dryrun' \\ - -e 'CB_TIMEZONE=America/Toronto' \\ - -e 'CB_PORT=NEXT' \\ - -e 'CB_ENV_FILE=' \\ - -e 'CB_HOST_UID=${HOST_UID}' \\ - -e 'CB_HOST_GID=${HOST_GID}' \\ + -e 'BOOTH_SETUPS=/opt/codingbooth/setups' \\ + -e 'BOOTH_CONTAINER_NAME=dryrun' \\ + -e 'BOOTH_DAEMON=false' \\ + -e 'BOOTH_HOST_PORT=10000' \\ + -e 'BOOTH_IMAGE_NAME=nawaman/codingbooth:base-${VERSION}' \\ + -e 'BOOTH_RUNMODE=FOREGROUND' \\ + -e 'BOOTH_VARIANT_TAG=base' \\ + -e 'BOOTH_VERBOSE=false' \\ + -e 'BOOTH_VERSION_TAG=${VERSION}' \\ + -e 'BOOTH_CODE_PATH=${HERE}' \\ + -e 'BOOTH_CODE_PORT=10000' \\ + -e 'BOOTH_VERSION=${VERSION}' \\ + -e 'BOOTH_CONFIG_FILE=' \\ + -e 'BOOTH_SCRIPT_NAME=codingbooth' \\ + -e 'BOOTH_SCRIPT_DIR=${SCRIPT_DIR}' \\ + -e 'BOOTH_LIB_DIR=${LIB_DIR}' \\ + -e 'BOOTH_KEEP_ALIVE=false' \\ + -e 'BOOTH_SILENCE_BUILD=false' \\ + -e 'BOOTH_PULL=false' \\ + -e 'BOOTH_DIND=false' \\ + -e 'BOOTH_DOCKERFILE=' \\ + -e 'BOOTH_PROJECT_NAME=dryrun' \\ + -e 'BOOTH_TIMEZONE=America/Toronto' \\ + -e 'BOOTH_PORT=NEXT' \\ + -e 'BOOTH_ENV_FILE=' \\ + -e 'BOOTH_HOST_UID=${HOST_UID}' \\ + -e 'BOOTH_HOST_GID=${HOST_GID}' \\ '--pull=never' \\ -e 'TZ=America/Toronto' \\ nawaman/codingbooth:base-${VERSION}" diff --git a/tests/dryrun/test002--help.sh b/tests/dryrun/test002--help.sh index b5e77ea8..ffe3db1d 100755 --- a/tests/dryrun/test002--help.sh +++ b/tests/dryrun/test002--help.sh @@ -17,7 +17,8 @@ if [[ "$OSTYPE" == "msys" || "$OSTYPE" == "cygwin" ]]; then CURRENT_PATH="$(pwd -W)" fi -ACTUAL=$(run_coding_booth --help | head -11) +# Just check the USAGE section (first 16 lines) - the full help is ~98 lines +ACTUAL=$(run_coding_booth --help | head -16) HERE="$PWD" VERSION="$(cat ../../version.txt)" @@ -30,10 +31,14 @@ USAGE: codingbooth help (show this help and exit) codingbooth run [options] [--] [command ...] (run the booth) codingbooth [options] [--] [command ...] (default action: run) + codingbooth list [--running|--stopped] [-q] (list booth-managed containers) + codingbooth start [--name |--code ] (start a stopped keep-alive booth) + codingbooth stop [--name ] [--force] (stop a running booth) + codingbooth restart [--name ] (restart a running booth) + codingbooth remove [--name ] [--force] (remove booth container(s)) + codingbooth prune [--yes] (remove stopped booth containers) codingbooth example (manage examples) - -BOOTSTRAP OPTIONS (CLI or defaults; evaluated before environmental variable and config file): - --code Host code path to mount at /home/coder/code" + codingbooth emit-dockerfile [options] (compile Boothfile to Dockerfile)" if diff -u <(echo "$EXPECT" | normalize_output) <(echo "$ACTUAL" | normalize_output); then print_test_result "true" "$0" "1" "Help output matches expected" diff --git a/tests/dryrun/test003--command.sh b/tests/dryrun/test003--command.sh index 4f446d85..0c3bf8b2 100755 --- a/tests/dryrun/test003--command.sh +++ b/tests/dryrun/test003--command.sh @@ -28,6 +28,11 @@ HERE="$CURRENT_PATH" VERSION="$(cat ../../version.txt)" EXPECT="\ +docker \\ + ps \\ + -a \\ + --filter 'name=^dryrun\$' \\ + --format '{{.Names}}' docker \\ run \\ -i \\ @@ -37,34 +42,41 @@ docker \\ -e 'HOST_GID=${HOST_GID}' \\ -v ${HERE}:/home/coder/code \\ -w /home/coder/code \\ + --label 'cb.managed=true' \\ + --label 'cb.project=dryrun' \\ + --label 'cb.variant=base' \\ + --label 'cb.code-path=${HERE}' \\ + --label 'cb.created-at=XXXXX' \\ + --label 'cb.version=${VERSION}' \\ + --label 'cb.keep-alive=false' \\ -p 10000:10000 \\ - -e 'CB_SETUPS=/opt/codingbooth/setups' \\ - -e 'CB_CONTAINER_NAME=dryrun' \\ - -e 'CB_DAEMON=false' \\ - -e 'CB_HOST_PORT=10000' \\ - -e 'CB_IMAGE_NAME=nawaman/codingbooth:base-${VERSION}' \\ - -e 'CB_RUNMODE=COMMAND' \\ - -e 'CB_VARIANT_TAG=base' \\ - -e 'CB_VERBOSE=false' \\ - -e 'CB_VERSION_TAG=${VERSION}' \\ - -e 'CB_CODE_PATH=${HERE}' \\ - -e 'CB_CODE_PORT=10000' \\ - -e 'CB_VERSION=${VERSION}' \\ - -e 'CB_CONFIG_FILE=' \\ - -e 'CB_SCRIPT_NAME=codingbooth' \\ - -e 'CB_SCRIPT_DIR=${SCRIPT_DIR}' \\ - -e 'CB_LIB_DIR=${LIB_DIR}' \\ - -e 'CB_KEEP_ALIVE=false' \\ - -e 'CB_SILENCE_BUILD=false' \\ - -e 'CB_PULL=false' \\ - -e 'CB_DIND=false' \\ - -e 'CB_DOCKERFILE=' \\ - -e 'CB_PROJECT_NAME=dryrun' \\ - -e 'CB_TIMEZONE=America/Toronto' \\ - -e 'CB_PORT=NEXT' \\ - -e 'CB_ENV_FILE=' \\ - -e 'CB_HOST_UID=${HOST_UID}' \\ - -e 'CB_HOST_GID=${HOST_GID}' \\ + -e 'BOOTH_SETUPS=/opt/codingbooth/setups' \\ + -e 'BOOTH_CONTAINER_NAME=dryrun' \\ + -e 'BOOTH_DAEMON=false' \\ + -e 'BOOTH_HOST_PORT=10000' \\ + -e 'BOOTH_IMAGE_NAME=nawaman/codingbooth:base-${VERSION}' \\ + -e 'BOOTH_RUNMODE=COMMAND' \\ + -e 'BOOTH_VARIANT_TAG=base' \\ + -e 'BOOTH_VERBOSE=false' \\ + -e 'BOOTH_VERSION_TAG=${VERSION}' \\ + -e 'BOOTH_CODE_PATH=${HERE}' \\ + -e 'BOOTH_CODE_PORT=10000' \\ + -e 'BOOTH_VERSION=${VERSION}' \\ + -e 'BOOTH_CONFIG_FILE=' \\ + -e 'BOOTH_SCRIPT_NAME=codingbooth' \\ + -e 'BOOTH_SCRIPT_DIR=${SCRIPT_DIR}' \\ + -e 'BOOTH_LIB_DIR=${LIB_DIR}' \\ + -e 'BOOTH_KEEP_ALIVE=false' \\ + -e 'BOOTH_SILENCE_BUILD=false' \\ + -e 'BOOTH_PULL=false' \\ + -e 'BOOTH_DIND=false' \\ + -e 'BOOTH_DOCKERFILE=' \\ + -e 'BOOTH_PROJECT_NAME=dryrun' \\ + -e 'BOOTH_TIMEZONE=America/Toronto' \\ + -e 'BOOTH_PORT=NEXT' \\ + -e 'BOOTH_ENV_FILE=' \\ + -e 'BOOTH_HOST_UID=${HOST_UID}' \\ + -e 'BOOTH_HOST_GID=${HOST_GID}' \\ '--pull=never' \\ -e 'TZ=America/Toronto' \\ nawaman/codingbooth:base-${VERSION} \\ diff --git a/tests/dryrun/test004--name.sh b/tests/dryrun/test004--name.sh index 4894c8d3..0639ffb0 100755 --- a/tests/dryrun/test004--name.sh +++ b/tests/dryrun/test004--name.sh @@ -28,6 +28,11 @@ HERE="$CURRENT_PATH" VERSION="$(cat ../../version.txt)" EXPECT="\ +docker \\ + ps \\ + -a \\ + --filter 'name=^test-container\$' \\ + --format '{{.Names}}' docker \\ run \\ -i \\ @@ -37,34 +42,41 @@ docker \\ -e 'HOST_GID=${HOST_GID}' \\ -v ${HERE}:/home/coder/code \\ -w /home/coder/code \\ + --label 'cb.managed=true' \\ + --label 'cb.project=dryrun' \\ + --label 'cb.variant=base' \\ + --label 'cb.code-path=${HERE}' \\ + --label 'cb.created-at=XXXXX' \\ + --label 'cb.version=${VERSION}' \\ + --label 'cb.keep-alive=false' \\ -p 10000:10000 \\ - -e 'CB_SETUPS=/opt/codingbooth/setups' \\ - -e 'CB_CONTAINER_NAME=test-container' \\ - -e 'CB_DAEMON=false' \\ - -e 'CB_HOST_PORT=10000' \\ - -e 'CB_IMAGE_NAME=nawaman/codingbooth:base-${VERSION}' \\ - -e 'CB_RUNMODE=COMMAND' \\ - -e 'CB_VARIANT_TAG=base' \\ - -e 'CB_VERBOSE=false' \\ - -e 'CB_VERSION_TAG=${VERSION}' \\ - -e 'CB_CODE_PATH=${HERE}' \\ - -e 'CB_CODE_PORT=10000' \\ - -e 'CB_VERSION=${VERSION}' \\ - -e 'CB_CONFIG_FILE=' \\ - -e 'CB_SCRIPT_NAME=codingbooth' \\ - -e 'CB_SCRIPT_DIR=${SCRIPT_DIR}' \\ - -e 'CB_LIB_DIR=${LIB_DIR}' \\ - -e 'CB_KEEP_ALIVE=false' \\ - -e 'CB_SILENCE_BUILD=false' \\ - -e 'CB_PULL=false' \\ - -e 'CB_DIND=false' \\ - -e 'CB_DOCKERFILE=' \\ - -e 'CB_PROJECT_NAME=dryrun' \\ - -e 'CB_TIMEZONE=America/Toronto' \\ - -e 'CB_PORT=NEXT' \\ - -e 'CB_ENV_FILE=' \\ - -e 'CB_HOST_UID=${HOST_UID}' \\ - -e 'CB_HOST_GID=${HOST_GID}' \\ + -e 'BOOTH_SETUPS=/opt/codingbooth/setups' \\ + -e 'BOOTH_CONTAINER_NAME=test-container' \\ + -e 'BOOTH_DAEMON=false' \\ + -e 'BOOTH_HOST_PORT=10000' \\ + -e 'BOOTH_IMAGE_NAME=nawaman/codingbooth:base-${VERSION}' \\ + -e 'BOOTH_RUNMODE=COMMAND' \\ + -e 'BOOTH_VARIANT_TAG=base' \\ + -e 'BOOTH_VERBOSE=false' \\ + -e 'BOOTH_VERSION_TAG=${VERSION}' \\ + -e 'BOOTH_CODE_PATH=${HERE}' \\ + -e 'BOOTH_CODE_PORT=10000' \\ + -e 'BOOTH_VERSION=${VERSION}' \\ + -e 'BOOTH_CONFIG_FILE=' \\ + -e 'BOOTH_SCRIPT_NAME=codingbooth' \\ + -e 'BOOTH_SCRIPT_DIR=${SCRIPT_DIR}' \\ + -e 'BOOTH_LIB_DIR=${LIB_DIR}' \\ + -e 'BOOTH_KEEP_ALIVE=false' \\ + -e 'BOOTH_SILENCE_BUILD=false' \\ + -e 'BOOTH_PULL=false' \\ + -e 'BOOTH_DIND=false' \\ + -e 'BOOTH_DOCKERFILE=' \\ + -e 'BOOTH_PROJECT_NAME=dryrun' \\ + -e 'BOOTH_TIMEZONE=America/Toronto' \\ + -e 'BOOTH_PORT=NEXT' \\ + -e 'BOOTH_ENV_FILE=' \\ + -e 'BOOTH_HOST_UID=${HOST_UID}' \\ + -e 'BOOTH_HOST_GID=${HOST_GID}' \\ '--pull=never' \\ -e 'TZ=America/Toronto' \\ nawaman/codingbooth:base-${VERSION} \\ diff --git a/tests/dryrun/test005-version.sh b/tests/dryrun/test005-version.sh index 1999c3b7..7406f70b 100755 --- a/tests/dryrun/test005-version.sh +++ b/tests/dryrun/test005-version.sh @@ -31,6 +31,11 @@ ACTUAL=$(printf "%s\n" "$ACTUAL") # Notice that there is not `-rm` EXPECT="\ +docker \\ + ps \\ + -a \\ + --filter 'name=^dryrun\$' \\ + --format '{{.Names}}' docker \\ run \\ -i \\ @@ -40,34 +45,41 @@ docker \\ -e 'HOST_GID=${HOST_GID}' \\ -v ${HERE}:/home/coder/code \\ -w /home/coder/code \\ + --label 'cb.managed=true' \\ + --label 'cb.project=dryrun' \\ + --label 'cb.variant=base' \\ + --label 'cb.code-path=${HERE}' \\ + --label 'cb.created-at=XXXXX' \\ + --label 'cb.version=${CB_VERSION}' \\ + --label 'cb.keep-alive=false' \\ -p 10000:10000 \\ - -e 'CB_SETUPS=/opt/codingbooth/setups' \\ - -e 'CB_CONTAINER_NAME=dryrun' \\ - -e 'CB_DAEMON=false' \\ - -e 'CB_HOST_PORT=10000' \\ - -e 'CB_IMAGE_NAME=nawaman/codingbooth:base-${VERSION}' \\ - -e 'CB_RUNMODE=COMMAND' \\ - -e 'CB_VARIANT_TAG=base' \\ - -e 'CB_VERBOSE=false' \\ - -e 'CB_VERSION_TAG=${VERSION}' \\ - -e 'CB_CODE_PATH=${HERE}' \\ - -e 'CB_CODE_PORT=10000' \\ - -e 'CB_VERSION=${CB_VERSION}' \\ - -e 'CB_CONFIG_FILE=' \\ - -e 'CB_SCRIPT_NAME=codingbooth' \\ - -e 'CB_SCRIPT_DIR=${SCRIPT_DIR}' \\ - -e 'CB_LIB_DIR=${LIB_DIR}' \\ - -e 'CB_KEEP_ALIVE=false' \\ - -e 'CB_SILENCE_BUILD=false' \\ - -e 'CB_PULL=false' \\ - -e 'CB_DIND=false' \\ - -e 'CB_DOCKERFILE=' \\ - -e 'CB_PROJECT_NAME=dryrun' \\ - -e 'CB_TIMEZONE=America/Toronto' \\ - -e 'CB_PORT=NEXT' \\ - -e 'CB_ENV_FILE=' \\ - -e 'CB_HOST_UID=${HOST_UID}' \\ - -e 'CB_HOST_GID=${HOST_GID}' \\ + -e 'BOOTH_SETUPS=/opt/codingbooth/setups' \\ + -e 'BOOTH_CONTAINER_NAME=dryrun' \\ + -e 'BOOTH_DAEMON=false' \\ + -e 'BOOTH_HOST_PORT=10000' \\ + -e 'BOOTH_IMAGE_NAME=nawaman/codingbooth:base-${VERSION}' \\ + -e 'BOOTH_RUNMODE=COMMAND' \\ + -e 'BOOTH_VARIANT_TAG=base' \\ + -e 'BOOTH_VERBOSE=false' \\ + -e 'BOOTH_VERSION_TAG=${VERSION}' \\ + -e 'BOOTH_CODE_PATH=${HERE}' \\ + -e 'BOOTH_CODE_PORT=10000' \\ + -e 'BOOTH_VERSION=${CB_VERSION}' \\ + -e 'BOOTH_CONFIG_FILE=' \\ + -e 'BOOTH_SCRIPT_NAME=codingbooth' \\ + -e 'BOOTH_SCRIPT_DIR=${SCRIPT_DIR}' \\ + -e 'BOOTH_LIB_DIR=${LIB_DIR}' \\ + -e 'BOOTH_KEEP_ALIVE=false' \\ + -e 'BOOTH_SILENCE_BUILD=false' \\ + -e 'BOOTH_PULL=false' \\ + -e 'BOOTH_DIND=false' \\ + -e 'BOOTH_DOCKERFILE=' \\ + -e 'BOOTH_PROJECT_NAME=dryrun' \\ + -e 'BOOTH_TIMEZONE=America/Toronto' \\ + -e 'BOOTH_PORT=NEXT' \\ + -e 'BOOTH_ENV_FILE=' \\ + -e 'BOOTH_HOST_UID=${HOST_UID}' \\ + -e 'BOOTH_HOST_GID=${HOST_GID}' \\ '--pull=never' \\ -e 'TZ=America/Toronto' \\ nawaman/codingbooth:base-${VERSION} \\ diff --git a/tests/dryrun/test006--port.sh b/tests/dryrun/test006--port.sh index c5c10b38..23c9c47d 100755 --- a/tests/dryrun/test006--port.sh +++ b/tests/dryrun/test006--port.sh @@ -32,6 +32,11 @@ VERSION="$(cat ../../version.txt)" # Notice that there is not `-rm` EXPECT="\ +docker \\ + ps \\ + -a \\ + --filter 'name=^dryrun\$' \\ + --format '{{.Names}}' docker \\ run \\ -i \\ @@ -41,34 +46,41 @@ docker \\ -e 'HOST_GID=${HOST_GID}' \\ -v ${HERE}:/home/coder/code \\ -w /home/coder/code \\ + --label 'cb.managed=true' \\ + --label 'cb.project=dryrun' \\ + --label 'cb.variant=base' \\ + --label 'cb.code-path=${HERE}' \\ + --label 'cb.created-at=XXXXX' \\ + --label 'cb.version=${VERSION}' \\ + --label 'cb.keep-alive=false' \\ -p ${PORT}:10000 \\ - -e 'CB_SETUPS=/opt/codingbooth/setups' \\ - -e 'CB_CONTAINER_NAME=dryrun' \\ - -e 'CB_DAEMON=false' \\ - -e 'CB_HOST_PORT=${PORT}' \\ - -e 'CB_IMAGE_NAME=nawaman/codingbooth:base-${VERSION}' \\ - -e 'CB_RUNMODE=COMMAND' \\ - -e 'CB_VARIANT_TAG=base' \\ - -e 'CB_VERBOSE=false' \\ - -e 'CB_VERSION_TAG=${VERSION}' \\ - -e 'CB_CODE_PATH=${HERE}' \\ - -e 'CB_CODE_PORT=10000' \\ - -e 'CB_VERSION=${VERSION}' \\ - -e 'CB_CONFIG_FILE=' \\ - -e 'CB_SCRIPT_NAME=codingbooth' \\ - -e 'CB_SCRIPT_DIR=${SCRIPT_DIR}' \\ - -e 'CB_LIB_DIR=${LIB_DIR}' \\ - -e 'CB_KEEP_ALIVE=false' \\ - -e 'CB_SILENCE_BUILD=false' \\ - -e 'CB_PULL=false' \\ - -e 'CB_DIND=false' \\ - -e 'CB_DOCKERFILE=' \\ - -e 'CB_PROJECT_NAME=dryrun' \\ - -e 'CB_TIMEZONE=America/Toronto' \\ - -e 'CB_PORT=${PORT}' \\ - -e 'CB_ENV_FILE=' \\ - -e 'CB_HOST_UID=${HOST_UID}' \\ - -e 'CB_HOST_GID=${HOST_GID}' \\ + -e 'BOOTH_SETUPS=/opt/codingbooth/setups' \\ + -e 'BOOTH_CONTAINER_NAME=dryrun' \\ + -e 'BOOTH_DAEMON=false' \\ + -e 'BOOTH_HOST_PORT=${PORT}' \\ + -e 'BOOTH_IMAGE_NAME=nawaman/codingbooth:base-${VERSION}' \\ + -e 'BOOTH_RUNMODE=COMMAND' \\ + -e 'BOOTH_VARIANT_TAG=base' \\ + -e 'BOOTH_VERBOSE=false' \\ + -e 'BOOTH_VERSION_TAG=${VERSION}' \\ + -e 'BOOTH_CODE_PATH=${HERE}' \\ + -e 'BOOTH_CODE_PORT=10000' \\ + -e 'BOOTH_VERSION=${VERSION}' \\ + -e 'BOOTH_CONFIG_FILE=' \\ + -e 'BOOTH_SCRIPT_NAME=codingbooth' \\ + -e 'BOOTH_SCRIPT_DIR=${SCRIPT_DIR}' \\ + -e 'BOOTH_LIB_DIR=${LIB_DIR}' \\ + -e 'BOOTH_KEEP_ALIVE=false' \\ + -e 'BOOTH_SILENCE_BUILD=false' \\ + -e 'BOOTH_PULL=false' \\ + -e 'BOOTH_DIND=false' \\ + -e 'BOOTH_DOCKERFILE=' \\ + -e 'BOOTH_PROJECT_NAME=dryrun' \\ + -e 'BOOTH_TIMEZONE=America/Toronto' \\ + -e 'BOOTH_PORT=${PORT}' \\ + -e 'BOOTH_ENV_FILE=' \\ + -e 'BOOTH_HOST_UID=${HOST_UID}' \\ + -e 'BOOTH_HOST_GID=${HOST_GID}' \\ '--pull=never' \\ -e 'TZ=America/Toronto' \\ nawaman/codingbooth:base-${VERSION} \\ diff --git a/tests/dryrun/test007--daemon.sh b/tests/dryrun/test007--daemon.sh index e53e81e1..ed19cda3 100755 --- a/tests/dryrun/test007--daemon.sh +++ b/tests/dryrun/test007--daemon.sh @@ -28,6 +28,11 @@ HERE="$CURRENT_PATH" VERSION="$(cat ../../version.txt)" EXPECT="\ +docker \\ + ps \\ + -a \\ + --filter 'name=^dryrun\$' \\ + --format '{{.Names}}' 📦 Running booth in daemon mode. 👉 Stop with Ctrl+C. The container will be removed (--rm) when stop. 👉 Visit 'http://localhost:10000' @@ -49,34 +54,41 @@ docker \\ -e 'HOST_GID=${HOST_GID}' \\ -v ${HERE}:/home/coder/code \\ -w /home/coder/code \\ + --label 'cb.managed=true' \\ + --label 'cb.project=dryrun' \\ + --label 'cb.variant=base' \\ + --label 'cb.code-path=${HERE}' \\ + --label 'cb.created-at=XXXXX' \\ + --label 'cb.version=${VERSION}' \\ + --label 'cb.keep-alive=false' \\ -p 10000:10000 \\ - -e 'CB_SETUPS=/opt/codingbooth/setups' \\ - -e 'CB_CONTAINER_NAME=dryrun' \\ - -e 'CB_DAEMON=true' \\ - -e 'CB_HOST_PORT=10000' \\ - -e 'CB_IMAGE_NAME=nawaman/codingbooth:base-${VERSION}' \\ - -e 'CB_RUNMODE=DAEMON' \\ - -e 'CB_VARIANT_TAG=base' \\ - -e 'CB_VERBOSE=false' \\ - -e 'CB_VERSION_TAG=${VERSION}' \\ - -e 'CB_CODE_PATH=${HERE}' \\ - -e 'CB_CODE_PORT=10000' \\ - -e 'CB_VERSION=${VERSION}' \\ - -e 'CB_CONFIG_FILE=' \\ - -e 'CB_SCRIPT_NAME=codingbooth' \\ - -e 'CB_SCRIPT_DIR=${SCRIPT_DIR}' \\ - -e 'CB_LIB_DIR=${LIB_DIR}' \\ - -e 'CB_KEEP_ALIVE=false' \\ - -e 'CB_SILENCE_BUILD=false' \\ - -e 'CB_PULL=false' \\ - -e 'CB_DIND=false' \\ - -e 'CB_DOCKERFILE=' \\ - -e 'CB_PROJECT_NAME=dryrun' \\ - -e 'CB_TIMEZONE=America/Toronto' \\ - -e 'CB_PORT=NEXT' \\ - -e 'CB_ENV_FILE=' \\ - -e 'CB_HOST_UID=${HOST_UID}' \\ - -e 'CB_HOST_GID=${HOST_GID}' \\ + -e 'BOOTH_SETUPS=/opt/codingbooth/setups' \\ + -e 'BOOTH_CONTAINER_NAME=dryrun' \\ + -e 'BOOTH_DAEMON=true' \\ + -e 'BOOTH_HOST_PORT=10000' \\ + -e 'BOOTH_IMAGE_NAME=nawaman/codingbooth:base-${VERSION}' \\ + -e 'BOOTH_RUNMODE=DAEMON' \\ + -e 'BOOTH_VARIANT_TAG=base' \\ + -e 'BOOTH_VERBOSE=false' \\ + -e 'BOOTH_VERSION_TAG=${VERSION}' \\ + -e 'BOOTH_CODE_PATH=${HERE}' \\ + -e 'BOOTH_CODE_PORT=10000' \\ + -e 'BOOTH_VERSION=${VERSION}' \\ + -e 'BOOTH_CONFIG_FILE=' \\ + -e 'BOOTH_SCRIPT_NAME=codingbooth' \\ + -e 'BOOTH_SCRIPT_DIR=${SCRIPT_DIR}' \\ + -e 'BOOTH_LIB_DIR=${LIB_DIR}' \\ + -e 'BOOTH_KEEP_ALIVE=false' \\ + -e 'BOOTH_SILENCE_BUILD=false' \\ + -e 'BOOTH_PULL=false' \\ + -e 'BOOTH_DIND=false' \\ + -e 'BOOTH_DOCKERFILE=' \\ + -e 'BOOTH_PROJECT_NAME=dryrun' \\ + -e 'BOOTH_TIMEZONE=America/Toronto' \\ + -e 'BOOTH_PORT=NEXT' \\ + -e 'BOOTH_ENV_FILE=' \\ + -e 'BOOTH_HOST_UID=${HOST_UID}' \\ + -e 'BOOTH_HOST_GID=${HOST_GID}' \\ '--pull=never' \\ -e 'TZ=America/Toronto' \\ nawaman/codingbooth:base-${VERSION} \\ diff --git a/tests/dryrun/test008--keep-alive.sh b/tests/dryrun/test008--keep-alive.sh index 0e1f379e..49fa0b05 100755 --- a/tests/dryrun/test008--keep-alive.sh +++ b/tests/dryrun/test008--keep-alive.sh @@ -30,6 +30,11 @@ VERSION="$(cat ../../version.txt)" # Notice that there is not `-rm` EXPECT="\ +docker \\ + ps \\ + -a \\ + --filter 'name=^dryrun\$' \\ + --format '{{.Names}}' docker \\ run \\ -i \\ @@ -38,34 +43,41 @@ docker \\ -e 'HOST_GID=${HOST_GID}' \\ -v ${HERE}:/home/coder/code \\ -w /home/coder/code \\ + --label 'cb.managed=true' \\ + --label 'cb.project=dryrun' \\ + --label 'cb.variant=base' \\ + --label 'cb.code-path=${HERE}' \\ + --label 'cb.created-at=XXXXX' \\ + --label 'cb.version=${VERSION}' \\ + --label 'cb.keep-alive=true' \\ -p 10000:10000 \\ - -e 'CB_SETUPS=/opt/codingbooth/setups' \\ - -e 'CB_CONTAINER_NAME=dryrun' \\ - -e 'CB_DAEMON=false' \\ - -e 'CB_HOST_PORT=10000' \\ - -e 'CB_IMAGE_NAME=nawaman/codingbooth:base-${VERSION}' \\ - -e 'CB_RUNMODE=COMMAND' \\ - -e 'CB_VARIANT_TAG=base' \\ - -e 'CB_VERBOSE=false' \\ - -e 'CB_VERSION_TAG=${VERSION}' \\ - -e 'CB_CODE_PATH=${HERE}' \\ - -e 'CB_CODE_PORT=10000' \\ - -e 'CB_VERSION=${VERSION}' \\ - -e 'CB_CONFIG_FILE=' \\ - -e 'CB_SCRIPT_NAME=codingbooth' \\ - -e 'CB_SCRIPT_DIR=${SCRIPT_DIR}' \\ - -e 'CB_LIB_DIR=${LIB_DIR}' \\ - -e 'CB_KEEP_ALIVE=true' \\ - -e 'CB_SILENCE_BUILD=false' \\ - -e 'CB_PULL=false' \\ - -e 'CB_DIND=false' \\ - -e 'CB_DOCKERFILE=' \\ - -e 'CB_PROJECT_NAME=dryrun' \\ - -e 'CB_TIMEZONE=America/Toronto' \\ - -e 'CB_PORT=NEXT' \\ - -e 'CB_ENV_FILE=' \\ - -e 'CB_HOST_UID=${HOST_UID}' \\ - -e 'CB_HOST_GID=${HOST_GID}' \\ + -e 'BOOTH_SETUPS=/opt/codingbooth/setups' \\ + -e 'BOOTH_CONTAINER_NAME=dryrun' \\ + -e 'BOOTH_DAEMON=false' \\ + -e 'BOOTH_HOST_PORT=10000' \\ + -e 'BOOTH_IMAGE_NAME=nawaman/codingbooth:base-${VERSION}' \\ + -e 'BOOTH_RUNMODE=COMMAND' \\ + -e 'BOOTH_VARIANT_TAG=base' \\ + -e 'BOOTH_VERBOSE=false' \\ + -e 'BOOTH_VERSION_TAG=${VERSION}' \\ + -e 'BOOTH_CODE_PATH=${HERE}' \\ + -e 'BOOTH_CODE_PORT=10000' \\ + -e 'BOOTH_VERSION=${VERSION}' \\ + -e 'BOOTH_CONFIG_FILE=' \\ + -e 'BOOTH_SCRIPT_NAME=codingbooth' \\ + -e 'BOOTH_SCRIPT_DIR=${SCRIPT_DIR}' \\ + -e 'BOOTH_LIB_DIR=${LIB_DIR}' \\ + -e 'BOOTH_KEEP_ALIVE=true' \\ + -e 'BOOTH_SILENCE_BUILD=false' \\ + -e 'BOOTH_PULL=false' \\ + -e 'BOOTH_DIND=false' \\ + -e 'BOOTH_DOCKERFILE=' \\ + -e 'BOOTH_PROJECT_NAME=dryrun' \\ + -e 'BOOTH_TIMEZONE=America/Toronto' \\ + -e 'BOOTH_PORT=NEXT' \\ + -e 'BOOTH_ENV_FILE=' \\ + -e 'BOOTH_HOST_UID=${HOST_UID}' \\ + -e 'BOOTH_HOST_GID=${HOST_GID}' \\ '--pull=never' \\ -e 'TZ=America/Toronto' \\ nawaman/codingbooth:base-${VERSION} \\ diff --git a/tests/dryrun/test009--workspace.sh b/tests/dryrun/test009--workspace.sh index 16efd78d..37a2a413 100755 --- a/tests/dryrun/test009--workspace.sh +++ b/tests/dryrun/test009--workspace.sh @@ -22,6 +22,7 @@ fi # The workspace is set to be non default WORKSPACE=".." +RESOLVED_WORKSPACE="$(cd ${WORKSPACE} && pwd)" export TIMEZONE="America/Toronto" @@ -32,6 +33,11 @@ VERSION="$(cat ../../version.txt)" # Notice that there is not `-rm` EXPECT="\ +docker \\ + ps \\ + -a \\ + --filter 'name=^tests\$' \\ + --format '{{.Names}}' docker \\ run \\ -i \\ @@ -39,36 +45,43 @@ docker \\ --name tests \\ -e 'HOST_UID=${HOST_UID}' \\ -e 'HOST_GID=${HOST_GID}' \\ - -v ${WORKSPACE}:/home/coder/code \\ + -v ${RESOLVED_WORKSPACE}:/home/coder/code \\ -w /home/coder/code \\ + --label 'cb.managed=true' \\ + --label 'cb.project=tests' \\ + --label 'cb.variant=base' \\ + --label 'cb.code-path=${RESOLVED_WORKSPACE}' \\ + --label 'cb.created-at=XXXXX' \\ + --label 'cb.version=${VERSION}' \\ + --label 'cb.keep-alive=false' \\ -p 10000:10000 \\ - -e 'CB_SETUPS=/opt/codingbooth/setups' \\ - -e 'CB_CONTAINER_NAME=tests' \\ - -e 'CB_DAEMON=false' \\ - -e 'CB_HOST_PORT=10000' \\ - -e 'CB_IMAGE_NAME=nawaman/codingbooth:base-${VERSION}' \\ - -e 'CB_RUNMODE=COMMAND' \\ - -e 'CB_VARIANT_TAG=base' \\ - -e 'CB_VERBOSE=false' \\ - -e 'CB_VERSION_TAG=${VERSION}' \\ - -e 'CB_CODE_PATH=..' \\ - -e 'CB_CODE_PORT=10000' \\ - -e 'CB_VERSION=${VERSION}' \\ - -e 'CB_CONFIG_FILE=' \\ - -e 'CB_SCRIPT_NAME=codingbooth' \\ - -e 'CB_SCRIPT_DIR=${SCRIPT_DIR}' \\ - -e 'CB_LIB_DIR=${LIB_DIR}' \\ - -e 'CB_KEEP_ALIVE=false' \\ - -e 'CB_SILENCE_BUILD=false' \\ - -e 'CB_PULL=false' \\ - -e 'CB_DIND=false' \\ - -e 'CB_DOCKERFILE=' \\ - -e 'CB_PROJECT_NAME=tests' \\ - -e 'CB_TIMEZONE=America/Toronto' \\ - -e 'CB_PORT=NEXT' \\ - -e 'CB_ENV_FILE=' \\ - -e 'CB_HOST_UID=${HOST_UID}' \\ - -e 'CB_HOST_GID=${HOST_GID}' \\ + -e 'BOOTH_SETUPS=/opt/codingbooth/setups' \\ + -e 'BOOTH_CONTAINER_NAME=tests' \\ + -e 'BOOTH_DAEMON=false' \\ + -e 'BOOTH_HOST_PORT=10000' \\ + -e 'BOOTH_IMAGE_NAME=nawaman/codingbooth:base-${VERSION}' \\ + -e 'BOOTH_RUNMODE=COMMAND' \\ + -e 'BOOTH_VARIANT_TAG=base' \\ + -e 'BOOTH_VERBOSE=false' \\ + -e 'BOOTH_VERSION_TAG=${VERSION}' \\ + -e 'BOOTH_CODE_PATH=${RESOLVED_WORKSPACE}' \\ + -e 'BOOTH_CODE_PORT=10000' \\ + -e 'BOOTH_VERSION=${VERSION}' \\ + -e 'BOOTH_CONFIG_FILE=' \\ + -e 'BOOTH_SCRIPT_NAME=codingbooth' \\ + -e 'BOOTH_SCRIPT_DIR=${SCRIPT_DIR}' \\ + -e 'BOOTH_LIB_DIR=${LIB_DIR}' \\ + -e 'BOOTH_KEEP_ALIVE=false' \\ + -e 'BOOTH_SILENCE_BUILD=false' \\ + -e 'BOOTH_PULL=false' \\ + -e 'BOOTH_DIND=false' \\ + -e 'BOOTH_DOCKERFILE=' \\ + -e 'BOOTH_PROJECT_NAME=tests' \\ + -e 'BOOTH_TIMEZONE=America/Toronto' \\ + -e 'BOOTH_PORT=NEXT' \\ + -e 'BOOTH_ENV_FILE=' \\ + -e 'BOOTH_HOST_UID=${HOST_UID}' \\ + -e 'BOOTH_HOST_GID=${HOST_GID}' \\ '--pull=never' \\ -e 'TZ=America/Toronto' \\ nawaman/codingbooth:base-${VERSION} \\ diff --git a/tests/dryrun/test011-variant.sh b/tests/dryrun/test011-variant.sh index 1c1ece3a..67e7ad8b 100755 --- a/tests/dryrun/test011-variant.sh +++ b/tests/dryrun/test011-variant.sh @@ -52,6 +52,11 @@ for entry in "${VARIANTS[@]}"; do # Notice that there is not `-rm` EXPECT="\ +docker \\ + ps \\ + -a \\ + --filter 'name=^dryrun\$' \\ + --format '{{.Names}}' docker \\ run \\ -i \\ @@ -61,34 +66,41 @@ docker \\ -e 'HOST_GID=${HOST_GID}' \\ -v ${HERE}:/home/coder/code \\ -w /home/coder/code \\ + --label 'cb.managed=true' \\ + --label 'cb.project=dryrun' \\ + --label 'cb.variant=${GOT_VARIANT}' \\ + --label 'cb.code-path=${HERE}' \\ + --label 'cb.created-at=XXXXX' \\ + --label 'cb.version=${VERSION}' \\ + --label 'cb.keep-alive=false' \\ -p 10000:10000 \\ - -e 'CB_SETUPS=/opt/codingbooth/setups' \\ - -e 'CB_CONTAINER_NAME=dryrun' \\ - -e 'CB_DAEMON=false' \\ - -e 'CB_HOST_PORT=10000' \\ - -e 'CB_IMAGE_NAME=nawaman/codingbooth:${GOT_VARIANT}-${VERSION}' \\ - -e 'CB_RUNMODE=COMMAND' \\ - -e 'CB_VARIANT_TAG=${GOT_VARIANT}' \\ - -e 'CB_VERBOSE=false' \\ - -e 'CB_VERSION_TAG=${VERSION}' \\ - -e 'CB_CODE_PATH=${HERE}' \\ - -e 'CB_CODE_PORT=10000' \\ - -e 'CB_VERSION=${VERSION}' \\ - -e 'CB_CONFIG_FILE=' \\ - -e 'CB_SCRIPT_NAME=codingbooth' \\ - -e 'CB_SCRIPT_DIR=${SCRIPT_DIR}' \\ - -e 'CB_LIB_DIR=${LIB_DIR}' \\ - -e 'CB_KEEP_ALIVE=false' \\ - -e 'CB_SILENCE_BUILD=false' \\ - -e 'CB_PULL=false' \\ - -e 'CB_DIND=false' \\ - -e 'CB_DOCKERFILE=' \\ - -e 'CB_PROJECT_NAME=dryrun' \\ - -e 'CB_TIMEZONE=America/Toronto' \\ - -e 'CB_PORT=NEXT' \\ - -e 'CB_ENV_FILE=' \\ - -e 'CB_HOST_UID=${HOST_UID}' \\ - -e 'CB_HOST_GID=${HOST_GID}' \\ + -e 'BOOTH_SETUPS=/opt/codingbooth/setups' \\ + -e 'BOOTH_CONTAINER_NAME=dryrun' \\ + -e 'BOOTH_DAEMON=false' \\ + -e 'BOOTH_HOST_PORT=10000' \\ + -e 'BOOTH_IMAGE_NAME=nawaman/codingbooth:${GOT_VARIANT}-${VERSION}' \\ + -e 'BOOTH_RUNMODE=COMMAND' \\ + -e 'BOOTH_VARIANT_TAG=${GOT_VARIANT}' \\ + -e 'BOOTH_VERBOSE=false' \\ + -e 'BOOTH_VERSION_TAG=${VERSION}' \\ + -e 'BOOTH_CODE_PATH=${HERE}' \\ + -e 'BOOTH_CODE_PORT=10000' \\ + -e 'BOOTH_VERSION=${VERSION}' \\ + -e 'BOOTH_CONFIG_FILE=' \\ + -e 'BOOTH_SCRIPT_NAME=codingbooth' \\ + -e 'BOOTH_SCRIPT_DIR=${SCRIPT_DIR}' \\ + -e 'BOOTH_LIB_DIR=${LIB_DIR}' \\ + -e 'BOOTH_KEEP_ALIVE=false' \\ + -e 'BOOTH_SILENCE_BUILD=false' \\ + -e 'BOOTH_PULL=false' \\ + -e 'BOOTH_DIND=false' \\ + -e 'BOOTH_DOCKERFILE=' \\ + -e 'BOOTH_PROJECT_NAME=dryrun' \\ + -e 'BOOTH_TIMEZONE=America/Toronto' \\ + -e 'BOOTH_PORT=NEXT' \\ + -e 'BOOTH_ENV_FILE=' \\ + -e 'BOOTH_HOST_UID=${HOST_UID}' \\ + -e 'BOOTH_HOST_GID=${HOST_GID}' \\ '--pull=never' \\ -e 'TZ=America/Toronto' \\ nawaman/codingbooth:${GOT_VARIANT}-${VERSION} \\ diff --git a/tests/dryrun/test014--emit-dockerfile.sh b/tests/dryrun/test014--emit-dockerfile.sh new file mode 100755 index 00000000..1941aac1 --- /dev/null +++ b/tests/dryrun/test014--emit-dockerfile.sh @@ -0,0 +1,49 @@ +#!/bin/bash +# Copyright 2025-2026 : Nawa Manusitthipol +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. + +set -euo pipefail + +source ../common--source.sh + +# Test --emit-dockerfile flag with a Boothfile + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +TEST_DIR="$SCRIPT_DIR/boothfile-test" + +# Run with --emit-dockerfile +ACTUAL=$(run_coding_booth emit-dockerfile --code "$TEST_DIR" 2>/dev/null) + +# Expected output should contain the Dockerfile prologue and compiled commands +EXPECT_CONTAINS=( + "# syntax=docker/dockerfile:1.7" + "ARG BOOTH_VARIANT_TAG=base" + "ARG BOOTH_VERSION_TAG=latest" + "FROM nawaman/codingbooth:" + "RUN python--setup.sh 3.12" + "RUN pip--install.sh django" + "ENV APP_ENV=production" +) + +ALL_PASSED=true + +for expected in "${EXPECT_CONTAINS[@]}"; do + if echo "$ACTUAL" | grep -qF "$expected"; then + print_test_result "true" "$0" "emit-dockerfile" "Output contains: $expected" + else + print_test_result "false" "$0" "emit-dockerfile" "Output contains: $expected" + ALL_PASSED=false + fi +done + +if [[ "$ALL_PASSED" != "true" ]]; then + echo "-------------------------------------------------------------------------------" + echo "Actual output:" + echo "$ACTUAL" + echo "-------------------------------------------------------------------------------" + exit 1 +fi + +# Test that it exits cleanly (exit code 0) +print_test_result "true" "$0" "emit-dockerfile" "--emit-dockerfile exits successfully" diff --git a/tests/dryrun/test015--boothfile-flag.sh b/tests/dryrun/test015--boothfile-flag.sh new file mode 100755 index 00000000..36ee4ba6 --- /dev/null +++ b/tests/dryrun/test015--boothfile-flag.sh @@ -0,0 +1,45 @@ +#!/bin/bash +# Copyright 2025-2026 : Nawa Manusitthipol +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. + +set -euo pipefail + +source ../common--source.sh + +# Test --boothfile flag to explicitly specify a Boothfile + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +TEST_DIR="$SCRIPT_DIR/boothfile-test" +BOOTHFILE_PATH="$TEST_DIR/.booth/Boothfile" + +# Run with explicit --boothfile +ACTUAL=$(run_coding_booth emit-dockerfile --code "$TEST_DIR" --boothfile "$BOOTHFILE_PATH" 2>/dev/null) + +# Should produce the same output as auto-detected Boothfile +EXPECT_CONTAINS=( + "RUN python--setup.sh 3.12" + "RUN pip--install.sh django" + "ENV APP_ENV=production" +) + +ALL_PASSED=true + +for expected in "${EXPECT_CONTAINS[@]}"; do + if echo "$ACTUAL" | grep -qF "$expected"; then + print_test_result "true" "$0" "boothfile-flag" "Output contains: $expected" + else + print_test_result "false" "$0" "boothfile-flag" "Output contains: $expected" + ALL_PASSED=false + fi +done + +if [[ "$ALL_PASSED" != "true" ]]; then + echo "-------------------------------------------------------------------------------" + echo "Actual output:" + echo "$ACTUAL" + echo "-------------------------------------------------------------------------------" + exit 1 +fi + +print_test_result "true" "$0" "boothfile-flag" "--boothfile flag works correctly" diff --git a/tests/run-automate-tests.sh b/tests/run-automate-tests.sh index b200e7e7..b22952e1 100755 --- a/tests/run-automate-tests.sh +++ b/tests/run-automate-tests.sh @@ -53,6 +53,17 @@ if ! (cd "$SCRIPT_DIR/dryrun" && ./run-dryrun-tests.sh); then fi echo "" +# Run boothfile tests +echo "----------------------------------------" +echo "Running Boothfile Tests" +echo "----------------------------------------" +total_suites=$((total_suites + 1)) +if ! (cd "$SCRIPT_DIR/boothfile" && ./run-boothfile-tests.sh); then + failed=1 + failed_suites+=("boothfile") +fi +echo "" + # Run complex tests echo "----------------------------------------" echo "Running Complex Tests" diff --git a/version.txt b/version.txt index d183d4ac..d40e9e27 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -0.16.0 \ No newline at end of file +0.17.0--rc2 \ No newline at end of file