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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/workflows/release-binary-and-wrapper.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,13 @@ jobs:
- name: Move example-list.txt to dist
run: mv example-list.txt dist/

# ---------------------------------------------------------
# Build template artifacts
# ---------------------------------------------------------
- name: Package templates
run: |
zip -r dist/templates.zip templates/

# ---------------------------------------------------------
# Publish versioned release (binary + examples)
# ---------------------------------------------------------
Expand Down
Binary file removed cli/coding-booth
Binary file not shown.
3 changes: 3 additions & 0 deletions cli/src/cmd/codingbooth/help.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,9 @@ RUNTIME OPTIONS:

CONTAINER MODE:
--daemon Run the booth container in the background
--public Bind to all interfaces with password authentication.
Password read from .booth/.booth.password (chmod 600, gitignored),
or prompted interactively if not found.
--dind Enable a Docker-in-Docker sidecar and set DOCKER_HOST
--sandboxed Enable egress sandbox defaults (proxy + enforcement setup)
--keep-alive Do not remove the container when stopped
Expand Down
154 changes: 135 additions & 19 deletions cli/src/cmd/codingbooth/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,40 +11,84 @@ import (
"os"
"strings"

"github.com/nawaman/codingbooth/src/pkg/boothinit/cache"
"github.com/nawaman/codingbooth/src/pkg/boothinit/compiler"
"github.com/nawaman/codingbooth/src/pkg/boothinit/output"
"github.com/nawaman/codingbooth/src/pkg/boothinit/selection"
tmpl "github.com/nawaman/codingbooth/src/pkg/boothinit/template"
)

// runInit handles the "init" command and its subcommands.
func runInit() {
func runInit(version string) {
args := os.Args[2:] // skip "codingbooth" and "init"

if len(args) == 0 {
fmt.Fprintln(os.Stderr, "Error: missing subcommand")
fmt.Fprintln(os.Stderr, "Usage: codingbooth init <new <path>|dryrun> --select <dsl> --templates-path <dir>")
os.Exit(1)
if len(args) == 0 || args[0] == "help" || args[0] == "--help" || args[0] == "-h" {
printInitHelp()
if len(args) == 0 {
os.Exit(1)
}
return
}

subCmd := args[0]
switch subCmd {
case "list":
runInitList(version, args[1:])
case "search":
runInitSearch(version, args[1:])
case "new":
runInitNew(args[1:])
runInitNew(version, args[1:])
case "dryrun":
runInitDryrun(args[1:])
runInitDryrun(version, args[1:])
default:
fmt.Fprintf(os.Stderr, "Error: unknown init subcommand: %s\n", subCmd)
fmt.Fprintln(os.Stderr, "Usage: codingbooth init <new <path>|dryrun> --select <dsl> --templates-path <dir>")
fmt.Fprintf(os.Stderr, "Error: unknown init subcommand: %s\n\n", subCmd)
printInitHelp()
os.Exit(1)
}
}

func printInitHelp() {
fmt.Println(`Usage: codingbooth init <command> [flags]

Commands:
list List available templates
search <term> Search templates by name or tag
new <path> Create a new booth at the given path
dryrun Preview what would be generated

Selection:
Templates are selected with a DSL passed via --select.

Format: name[:param1,param2][+extension]/name2[:params][+ext]

/ separates templates
: sets parameters (positional, comma-separated)
+ adds an extension to the preceding template

The selection can also be read from a file (@file) or URL (@@url).

Flags:
--templates-path <dir> Use local templates directory (or set CB_TEMPLATES_PATH)
--select <dsl> Template selection DSL (required for new/dryrun)
--full Show all templates including secondary (for list)
--debug Print debug output (for new/dryrun)
--start Start the booth after creation (for new)

Examples:
codingbooth init list
codingbooth init search python
codingbooth init new ./myproject --select "go/python"
codingbooth init new ./myproject --select "java:21+maven/postgresql"
codingbooth init dryrun --select "go:1.23.0+linter+vscode-ext"
codingbooth init new ./myproject --select @selections.txt`)
}

type initFlags struct {
selectDSL string
templatesPath string
debug bool
start bool
full bool
}

func parseInitFlags(args []string) initFlags {
Expand All @@ -69,6 +113,8 @@ func parseInitFlags(args []string) initFlags {
flags.debug = true
case "--start":
flags.start = true
case "--full":
flags.full = true
default:
fmt.Fprintf(os.Stderr, "Error: unknown flag: %s\n", args[i])
os.Exit(1)
Expand All @@ -81,8 +127,78 @@ func parseInitFlags(args []string) initFlags {
return flags
}

// resolveTemplatesPath returns the templates directory path and a cleanup function.
// If --templates-path or CB_TEMPLATES_PATH is set, it uses that directly.
// Otherwise, it downloads and extracts templates from the GitHub release cache.
func resolveTemplatesPath(flags initFlags, version string) (string, func()) {
if flags.templatesPath != "" {
return flags.templatesPath, func() {}
}

dir, cleanup, err := cache.ResolveTemplatesDir(version)
if err != nil {
fmt.Fprintf(os.Stderr, "Error resolving templates: %v\n", err)
fmt.Fprintln(os.Stderr, "Hint: use --templates-path or set CB_TEMPLATES_PATH for local templates")
os.Exit(1)
}
return dir, cleanup
}

// runInitList handles: codingbooth init list [--templates-path <dir>] [--full]
func runInitList(version string, args []string) {
flags := parseInitFlags(args)
templatesPath, cleanup := resolveTemplatesPath(flags, version)
defer cleanup()

registry, err := tmpl.LoadRegistry(templatesPath)
if err != nil {
fmt.Fprintf(os.Stderr, "Error loading templates: %v\n", err)
os.Exit(1)
}

if !flags.full {
registry = registry.FilterPrimary()
}

tmpl.FormatRegistry(os.Stdout, registry)

if !flags.full {
fmt.Println("\nUse --full to see all available templates.")
}
fmt.Println("Use 'init search <term>' to find templates by name or tag.")
}

// runInitSearch handles: codingbooth init search <term> [--templates-path <dir>] [--full]
func runInitSearch(version string, args []string) {
if len(args) == 0 {
fmt.Fprintln(os.Stderr, "Error: 'init search' requires a search term")
fmt.Fprintln(os.Stderr, "Usage: codingbooth init search <term> --templates-path <dir>")
os.Exit(1)
}

searchTerm := args[0]
flags := parseInitFlags(args[1:])
templatesPath, cleanup := resolveTemplatesPath(flags, version)
defer cleanup()

registry, err := tmpl.LoadRegistry(templatesPath)
if err != nil {
fmt.Fprintf(os.Stderr, "Error loading templates: %v\n", err)
os.Exit(1)
}

filtered := registry.Search(searchTerm)

if len(filtered.Categories) == 0 {
fmt.Println("No templates found matching:", searchTerm)
return
}

tmpl.FormatRegistry(os.Stdout, filtered)
}

// runInitNew handles: codingbooth init new <path> --select <dsl> [--templates-path <dir>] [--debug]
func runInitNew(args []string) {
func runInitNew(version string, args []string) {
if len(args) == 0 {
fmt.Fprintln(os.Stderr, "Error: 'init new' requires a target path")
fmt.Fprintln(os.Stderr, "Usage: codingbooth init new <path> --select <dsl> --templates-path <dir>")
Expand All @@ -96,10 +212,10 @@ func runInitNew(args []string) {
fmt.Fprintln(os.Stderr, "Error: --select is required")
os.Exit(1)
}
if flags.templatesPath == "" {
fmt.Fprintln(os.Stderr, "Error: --templates-path is required (template download not yet implemented)")
os.Exit(1)
}

templatesPath, cleanup := resolveTemplatesPath(flags, version)
defer cleanup()
flags.templatesPath = templatesPath

out, resolved := compileSelection(flags)

Expand All @@ -125,17 +241,17 @@ func runInitNew(args []string) {
}

// runInitDryrun handles: codingbooth init dryrun --select <dsl> [--templates-path <dir>] [--debug]
func runInitDryrun(args []string) {
func runInitDryrun(version string, args []string) {
flags := parseInitFlags(args)

if flags.selectDSL == "" {
fmt.Fprintln(os.Stderr, "Error: --select is required")
os.Exit(1)
}
if flags.templatesPath == "" {
fmt.Fprintln(os.Stderr, "Error: --templates-path is required (template download not yet implemented)")
os.Exit(1)
}

templatesPath, cleanup := resolveTemplatesPath(flags, version)
defer cleanup()
flags.templatesPath = templatesPath

out, resolved := compileSelection(flags)

Expand Down
2 changes: 1 addition & 1 deletion cli/src/cmd/codingbooth/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ func main() {
runExample(version)
return
case "init":
runInit()
runInit(version)
return
case "emit-dockerfile":
emitDockerfile()
Expand Down
Binary file renamed cli/codingbooth → cli/src/codingbooth
Binary file not shown.
29 changes: 22 additions & 7 deletions cli/src/pkg/appctx/app_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,15 +28,21 @@ type AppConfig struct {
// --------------------
// Flags
// --------------------
KeepAlive bool `toml:"keep-alive,omitempty" envconfig:"CB_KEEP_ALIVE" default:"false"`
SilenceBuild bool `toml:"silence-build,omitempty" envconfig:"CB_SILENCE_BUILD" default:"false"`
Daemon bool `toml:"daemon,omitempty" envconfig:"CB_DAEMON" default:"false"`
Pull bool `toml:"pull,omitempty" envconfig:"CB_PULL" default:"false"`
Dind bool `toml:"dind,omitempty" envconfig:"CB_DIND" default:"false"`
KeepAlive bool `toml:"keep-alive,omitempty" envconfig:"CB_KEEP_ALIVE" default:"false"`
SilenceBuild bool `toml:"silence-build,omitempty" envconfig:"CB_SILENCE_BUILD" default:"false"`
Daemon bool `toml:"daemon,omitempty" envconfig:"CB_DAEMON" default:"false"`
Pull bool `toml:"pull,omitempty" envconfig:"CB_PULL" default:"false"`
Dind bool `toml:"dind,omitempty" envconfig:"CB_DIND" default:"false"`
Sandbox bool `toml:"sandboxed,omitempty" envconfig:"CB_SANDBOX" default:"false"`
WritableBooth bool `toml:"writable-booth,omitempty" envconfig:"CB_WRITABLE_BOOTH" default:"false"`
SandboxAllowlistFile string `toml:"sandbox-allowlist-file,omitempty" envconfig:"CB_SANDBOX_ALLOWLIST_FILE"`
SandboxPolicyFile string `toml:"sandbox-policy-file,omitempty" envconfig:"CB_SANDBOX_POLICY_FILE"`

// Public exposes the booth on all interfaces (0.0.0.0) with password auth.
// Password is resolved at startup from .booth/.booth.password or interactive stdin.
// These are never read from TOML or environment variables.
Public bool `toml:"-" ignored:"true"`
Password string `toml:"-" ignored:"true"`
SandboxAllowlistFile string `toml:"sandbox-allowlist-file,omitempty" envconfig:"CB_SANDBOX_ALLOWLIST_FILE"`
SandboxPolicyFile string `toml:"sandbox-policy-file,omitempty" envconfig:"CB_SANDBOX_POLICY_FILE"`
SandboxAllowlist []string `toml:"sandbox-allowlist,omitempty" envconfig:"CB_SANDBOX_ALLOWLIST"`

// --------------------
Expand Down Expand Up @@ -125,6 +131,8 @@ func (config AppConfig) String() string {
fmt.Fprintf(&str, " Dind: %t\n", config.Dind)
fmt.Fprintf(&str, " Sandbox: %t\n", config.Sandbox)
fmt.Fprintf(&str, " WritableBooth: %t\n", config.WritableBooth)
fmt.Fprintf(&str, " Public: %t\n", config.Public)
fmt.Fprintf(&str, " Password: %s\n", maskStr(config.Password))
fmt.Fprintf(&str, " SandboxAllowlist: %q\n", config.SandboxAllowlistFile)
fmt.Fprintf(&str, " SandboxPolicy: %q\n", config.SandboxPolicyFile)
fmt.Fprintf(&str, " SandboxAllowlist+: %v\n", config.SandboxAllowlist)
Expand Down Expand Up @@ -166,3 +174,10 @@ func (config AppConfig) String() string {

return str.String()
}

func maskStr(s string) string {
if s == "" {
return "(not set)"
}
return "(set)"
}
4 changes: 4 additions & 0 deletions cli/src/pkg/appctx/app_context.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,8 @@ func (ctx AppContext) Pull() bool { return ctx.values.Config.Pull }
func (ctx AppContext) Dind() bool { return ctx.values.Config.Dind }
func (ctx AppContext) Sandbox() bool { return ctx.values.Config.Sandbox }
func (ctx AppContext) WritableBooth() bool { return ctx.values.Config.WritableBooth }
func (ctx AppContext) Public() bool { return ctx.values.Config.Public }
func (ctx AppContext) Password() string { return ctx.values.Config.Password }
func (ctx AppContext) SandboxAllowlistFile() string {
return ctx.values.Config.SandboxAllowlistFile
}
Expand Down Expand Up @@ -184,6 +186,8 @@ func (ctx AppContext) String() string {
fmt.Fprintf(&str, " Dind: %t\n", ctx.Dind())
fmt.Fprintf(&str, " Sandbox: %t\n", ctx.Sandbox())
fmt.Fprintf(&str, " WritableBooth: %t\n", ctx.WritableBooth())
fmt.Fprintf(&str, " Public: %t\n", ctx.Public())
fmt.Fprintf(&str, " Password: %s\n", maskStr(ctx.Password()))

fmt.Fprintf(&str, "# Image Configuration -----------\n")
fmt.Fprintf(&str, " Dockerfile: %q\n", ctx.Dockerfile())
Expand Down
18 changes: 17 additions & 1 deletion cli/src/pkg/booth/booth.go
Original file line number Diff line number Diff line change
Expand Up @@ -295,7 +295,13 @@ func PrepareCommonArgs(ctx appctx.AppContext) appctx.AppContext {

// Skip port mapping when using shared network namespace sidecars.
if !ctx.Dind() && !ctx.Sandbox() {
builder.CommonArgs.Append(ilist.NewList[string]("-p", fmt.Sprintf("%d:10000", ctx.PortNumber())))
portMapping := formatPortMapping(ctx.Public(), ctx.PortNumber(), 10000)
builder.CommonArgs.Append(ilist.NewList[string]("-p", portMapping))
}

// Inject PASSWORD env var into the container when set
if ctx.Password() != "" {
builder.CommonArgs.Append(ilist.NewList[string]("-e", "PASSWORD="+ctx.Password()))
}

// Metadata
Expand Down Expand Up @@ -365,6 +371,16 @@ func addReadOnlyBoothDir(builder *appctx.AppContextBuilder, codePath string) {
builder.CommonArgs.Append(ilist.NewList[string]("-v", hostPath+":/home/coder/code/.booth:ro"))
}

// formatPortMapping returns a Docker port mapping string.
// When public is false, binds to 127.0.0.1 (localhost only).
// When public is true, binds to all interfaces (0.0.0.0).
func formatPortMapping(public bool, hostPort, containerPort int) string {
if !public {
return fmt.Sprintf("127.0.0.1:%d:%d", hostPort, containerPort)
}
return fmt.Sprintf("%d:%d", hostPort, containerPort)
}

func flattenArgs(argsList ilist.List[ilist.List[string]]) []string {
var flattened []string
argsList.Range(func(_ int, group ilist.List[string]) bool {
Expand Down
2 changes: 1 addition & 1 deletion cli/src/pkg/booth/dind_setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ func startDindSidecar(ctx appctx.AppContext, dindName, dindNet string, hostPort
isDockerDesktop := isDockerDesktop(ctx)

// Port mapping for the booth container (since booth shares DinD's network)
portMapping := fmt.Sprintf("%d:10000", hostPort)
portMapping := formatPortMapping(ctx.Public(), hostPort, 10000)

var args []string
if isDockerDesktop {
Expand Down
Loading
Loading