diff --git a/.github/workflows/release-binary-and-wrapper.yaml b/.github/workflows/release-binary-and-wrapper.yaml index 0cb2807d..62e13176 100644 --- a/.github/workflows/release-binary-and-wrapper.yaml +++ b/.github/workflows/release-binary-and-wrapper.yaml @@ -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) # --------------------------------------------------------- diff --git a/cli/coding-booth b/cli/coding-booth deleted file mode 100755 index f4e8a09c..00000000 Binary files a/cli/coding-booth and /dev/null differ diff --git a/cli/src/cmd/codingbooth/help.go b/cli/src/cmd/codingbooth/help.go index 16f9183a..2885074c 100644 --- a/cli/src/cmd/codingbooth/help.go +++ b/cli/src/cmd/codingbooth/help.go @@ -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 diff --git a/cli/src/cmd/codingbooth/init.go b/cli/src/cmd/codingbooth/init.go index 496bbeaf..f4b70254 100644 --- a/cli/src/cmd/codingbooth/init.go +++ b/cli/src/cmd/codingbooth/init.go @@ -11,6 +11,7 @@ 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" @@ -18,33 +19,76 @@ import ( ) // 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 |dryrun> --select --templates-path ") - 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 |dryrun> --select --templates-path ") + fmt.Fprintf(os.Stderr, "Error: unknown init subcommand: %s\n\n", subCmd) + printInitHelp() os.Exit(1) } } +func printInitHelp() { + fmt.Println(`Usage: codingbooth init [flags] + +Commands: + list List available templates + search Search templates by name or tag + new 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 Use local templates directory (or set CB_TEMPLATES_PATH) + --select 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 { @@ -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) @@ -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 ] [--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 ' to find templates by name or tag.") +} + +// runInitSearch handles: codingbooth init search [--templates-path ] [--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 --templates-path ") + 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 --select [--templates-path ] [--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 --select --templates-path ") @@ -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) @@ -125,17 +241,17 @@ func runInitNew(args []string) { } // runInitDryrun handles: codingbooth init dryrun --select [--templates-path ] [--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) diff --git a/cli/src/cmd/codingbooth/main.go b/cli/src/cmd/codingbooth/main.go index 410f5c18..1c6b2638 100644 --- a/cli/src/cmd/codingbooth/main.go +++ b/cli/src/cmd/codingbooth/main.go @@ -50,7 +50,7 @@ func main() { runExample(version) return case "init": - runInit() + runInit(version) return case "emit-dockerfile": emitDockerfile() diff --git a/cli/codingbooth b/cli/src/codingbooth similarity index 63% rename from cli/codingbooth rename to cli/src/codingbooth index 8fd7a935..0860db1c 100755 Binary files a/cli/codingbooth and b/cli/src/codingbooth differ diff --git a/cli/src/pkg/appctx/app_config.go b/cli/src/pkg/appctx/app_config.go index 7f6994ab..7c0caba1 100644 --- a/cli/src/pkg/appctx/app_config.go +++ b/cli/src/pkg/appctx/app_config.go @@ -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"` // -------------------- @@ -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) @@ -166,3 +174,10 @@ func (config AppConfig) String() string { return str.String() } + +func maskStr(s string) string { + if s == "" { + return "(not set)" + } + return "(set)" +} diff --git a/cli/src/pkg/appctx/app_context.go b/cli/src/pkg/appctx/app_context.go index 3cdd8558..08e9dee7 100644 --- a/cli/src/pkg/appctx/app_context.go +++ b/cli/src/pkg/appctx/app_context.go @@ -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 } @@ -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()) diff --git a/cli/src/pkg/booth/booth.go b/cli/src/pkg/booth/booth.go index 044bceb3..7bec9bac 100644 --- a/cli/src/pkg/booth/booth.go +++ b/cli/src/pkg/booth/booth.go @@ -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 @@ -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 { diff --git a/cli/src/pkg/booth/dind_setup.go b/cli/src/pkg/booth/dind_setup.go index 2c4bbbed..7857f0a7 100644 --- a/cli/src/pkg/booth/dind_setup.go +++ b/cli/src/pkg/booth/dind_setup.go @@ -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 { diff --git a/cli/src/pkg/booth/init/initialize_app_context.go b/cli/src/pkg/booth/init/initialize_app_context.go index da1951bc..a30cbc97 100644 --- a/cli/src/pkg/booth/init/initialize_app_context.go +++ b/cli/src/pkg/booth/init/initialize_app_context.go @@ -5,11 +5,16 @@ package init import ( + "bufio" "fmt" "os" + "os/exec" "path/filepath" + "runtime" "strings" + "golang.org/x/term" + "github.com/nawaman/codingbooth/src/pkg/appctx" "github.com/nawaman/codingbooth/src/pkg/defaults" "github.com/nawaman/codingbooth/src/pkg/ilist" @@ -58,6 +63,7 @@ func InitializeAppContext(version string, boundary InitializeAppContextBoundary) readFromToml(boundary, &context, configExplicitlySet) readFromArgs(boundary, &context, ilist.NewListFromSlice(args.Slice()[1:])) validateConfig(&context.Config) + resolvePassword(&context.Config) if context.Config.ProjectName == "" { context.Config.ProjectName = getProjectName(context.Config.Code.ValueOr(".")) @@ -378,6 +384,10 @@ func parseArgs(args ilist.List[string], cfg *appctx.AppConfig) error { cfg.WritableBooth = true i++ + case "--public": + cfg.Public = true + i++ + // Image selection case "--image": v, err := needValue(args, i, arg) @@ -598,3 +608,127 @@ func fileExists(path string) bool { } return !info.IsDir() } + +// resolvePassword populates config.Password when --public is set. +// Priority: .booth/.booth.password file β†’ interactive stdin prompt β†’ error. +func resolvePassword(config *appctx.AppConfig) { + if !config.Public { + return + } + + // Priority 1: .booth/.booth.password file + codeDir := config.Code.ValueOr("") + if codeDir != "" { + passwordFile := filepath.Join(codeDir, ".booth", ".booth.password") + if fileExists(passwordFile) { + if err := validatePasswordFile(passwordFile, codeDir); err != nil { + panic(err) + } + content, err := os.ReadFile(passwordFile) + if err != nil { + panic(fmt.Errorf("failed to read password file %q: %w", passwordFile, err)) + } + password := strings.TrimSpace(string(content)) + if password == "" { + panic(fmt.Errorf("password file %q is empty", passwordFile)) + } + config.Password = password + return + } + } + + // Priority 2: interactive stdin prompt + password, err := readPasswordFromStdin() + if err != nil { + panic(fmt.Errorf("--public requires a password but none could be read: %w", err)) + } + password = strings.TrimSpace(password) + if password == "" { + panic(fmt.Errorf("--public requires a password but received empty input")) + } + config.Password = password +} + +// readPasswordFromStdin reads a password from stdin. +// If stdin is a terminal, it prompts interactively (no echo). +// If stdin is a pipe, it reads the first line. +func readPasswordFromStdin() (string, error) { + if term.IsTerminal(int(os.Stdin.Fd())) { + fmt.Fprint(os.Stderr, "Password: ") + password, err := term.ReadPassword(int(os.Stdin.Fd())) + fmt.Fprintln(os.Stderr) // newline after hidden input + if err != nil { + return "", fmt.Errorf("failed to read password: %w", err) + } + return string(password), nil + } + + // Piped input: read first line + scanner := bufio.NewScanner(os.Stdin) + if scanner.Scan() { + return scanner.Text(), nil + } + if err := scanner.Err(); err != nil { + return "", err + } + return "", fmt.Errorf("no input received on stdin") +} + +// validatePasswordFile checks that the password file has safe permissions and is gitignored. +func validatePasswordFile(passwordFile, codeDir string) error { + // Check file permissions (skip on Windows) + if runtime.GOOS != "windows" { + info, err := os.Stat(passwordFile) + if err != nil { + return fmt.Errorf("cannot stat password file %q: %w", passwordFile, err) + } + if info.Mode().Perm()&0077 != 0 { + return fmt.Errorf( + "password file %q has too-permissive permissions %04o; "+ + "group and others must have no access. Fix with: chmod 600 %s", + passwordFile, info.Mode().Perm(), passwordFile, + ) + } + } + + // Check gitignore + if err := checkPasswordFileGitignored(passwordFile, codeDir); err != nil { + return err + } + + return nil +} + +// checkPasswordFileGitignored verifies that .booth/.booth.password is gitignored. +// Skips the check if git is not available or the project is not a git repo. +func checkPasswordFileGitignored(passwordFile, codeDir string) error { + gitPath, err := exec.LookPath("git") + if err != nil { + // git not installed, skip check + return nil + } + + // Check if we are in a git repo + cmd := exec.Command(gitPath, "-C", codeDir, "rev-parse", "--git-dir") + cmd.Stdout = nil + cmd.Stderr = nil + if err := cmd.Run(); err != nil { + // Not a git repo, skip check + return nil + } + + // git check-ignore returns exit 0 if ignored, exit 1 if NOT ignored + cmd = exec.Command(gitPath, "-C", codeDir, "check-ignore", "-q", ".booth/.booth.password") + cmd.Stdout = nil + cmd.Stderr = nil + if err := cmd.Run(); err != nil { + return fmt.Errorf( + "password file %q is NOT gitignored. "+ + "Add '.booth.password' to .booth/.gitignore before using this feature. "+ + "Refusing to run to prevent accidental credential exposure", + passwordFile, + ) + } + + return nil +} diff --git a/cli/src/pkg/booth/port_determination.go b/cli/src/pkg/booth/port_determination.go index 88b78c27..ebb513ca 100644 --- a/cli/src/pkg/booth/port_determination.go +++ b/cli/src/pkg/booth/port_determination.go @@ -70,7 +70,7 @@ func PortDetermination(ctx appctx.AppContext) appctx.AppContext { builder.PortGenerated = portGenerated if (portGenerated || ctx.Verbose()) && ctx.Cmds().Length() == 0 { - printPortBanner(portNumber) + printPortBanner(portNumber, ctx.Public()) } return builder.Build() @@ -115,13 +115,18 @@ func isPortFree(port int) bool { } // printPortBanner prints the port selection banner. -func printPortBanner(portNumber int) { +func printPortBanner(portNumber int, public bool) { fmt.Println() fmt.Println("============================================================") fmt.Println("πŸš€ BOOTH PORT SELECTED") fmt.Println("============================================================") fmt.Printf("πŸ”Œ Using host port: \033[1;32m%d\033[0m -> container: \033[1;34m10000\033[0m\n", portNumber) - fmt.Printf("🌐 Open: http://localhost:%d\n", portNumber) + if public { + fmt.Printf("🌐 Open: http://localhost:%d\n", portNumber) + fmt.Println("πŸ”“ PUBLIC: PORT IS OPEN ON ALL INTERFACES (PASSWORD PROTECTED)") + } else { + fmt.Printf("🌐 Open: http://localhost:%d\n", portNumber) + } fmt.Println("============================================================") fmt.Println() } diff --git a/cli/src/pkg/booth/sandbox_setup.go b/cli/src/pkg/booth/sandbox_setup.go index 34d0ebdb..ceaf3b61 100644 --- a/cli/src/pkg/booth/sandbox_setup.go +++ b/cli/src/pkg/booth/sandbox_setup.go @@ -130,7 +130,7 @@ func startSandboxNetnsOwner(ctx appctx.AppContext, ownerName, netName string, ho return nil } - portMapping := fmt.Sprintf("%d:10000", hostPort) + portMapping := formatPortMapping(ctx.Public(), hostPort, 10000) args := []string{ "run", "-d", "--rm", "--cap-add=NET_ADMIN", diff --git a/cli/src/pkg/boothinit/cache/cache.go b/cli/src/pkg/boothinit/cache/cache.go new file mode 100644 index 00000000..a4b168c7 --- /dev/null +++ b/cli/src/pkg/boothinit/cache/cache.go @@ -0,0 +1,189 @@ +// 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 cache handles downloading, caching, and extracting template zip +// files from GitHub releases. +package cache + +import ( + "archive/zip" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" +) + +const ( + githubRepo = "NawaMan/CodingBooth" + templateFile = "templates.zip" +) + +// ResolveTemplatesDir returns a path to an extracted templates directory. +// It checks the local cache for a previously downloaded templates.zip, +// downloading from GitHub releases if not found. The zip is extracted +// to a temporary directory. The caller must invoke the returned cleanup +// function when done. +func ResolveTemplatesDir(version string) (string, func(), error) { + cacheDir, err := getCacheDir() + if err != nil { + return "", nil, fmt.Errorf("determining cache directory: %w", err) + } + + zipPath := filepath.Join(cacheDir, version, templateFile) + + // Download if not cached + if _, err := os.Stat(zipPath); os.IsNotExist(err) { + fmt.Printf("Downloading templates for version %s...\n", version) + if err := downloadTemplatesZip(version, zipPath); err != nil { + return "", nil, fmt.Errorf("downloading templates: %w", err) + } + } + + // Extract to temp dir + tmpDir, err := os.MkdirTemp("", "cb-init-") + if err != nil { + return "", nil, fmt.Errorf("creating temp directory: %w", err) + } + + cleanup := func() { os.RemoveAll(tmpDir) } + + if err := ExtractTemplatesZip(zipPath, tmpDir); err != nil { + cleanup() + return "", nil, fmt.Errorf("extracting templates: %w", err) + } + + // The zip contains a top-level "templates/" directory + templatesDir := filepath.Join(tmpDir, "templates") + if info, err := os.Stat(templatesDir); err != nil || !info.IsDir() { + cleanup() + return "", nil, fmt.Errorf("extracted zip does not contain a templates/ directory") + } + + return templatesDir, cleanup, nil +} + +// downloadTemplatesZip downloads templates.zip from GitHub releases for the +// given version and saves it to destPath. +func downloadTemplatesZip(version, destPath string) error { + url := fmt.Sprintf("https://github.com/%s/releases/download/%s/%s", githubRepo, version, templateFile) + + resp, err := http.Get(url) + if err != nil { + return fmt.Errorf("fetching %s: %w", url, err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("fetching %s: HTTP %d %s", url, resp.StatusCode, resp.Status) + } + + // Ensure parent directory exists + if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil { + return fmt.Errorf("creating cache directory: %w", err) + } + + tmpFile, err := os.CreateTemp(filepath.Dir(destPath), "templates-*.zip.tmp") + if err != nil { + return fmt.Errorf("creating temp file: %w", err) + } + tmpPath := tmpFile.Name() + + if _, err := io.Copy(tmpFile, resp.Body); err != nil { + tmpFile.Close() + os.Remove(tmpPath) + return fmt.Errorf("writing download: %w", err) + } + tmpFile.Close() + + // Atomic rename to final path + if err := os.Rename(tmpPath, destPath); err != nil { + os.Remove(tmpPath) + return fmt.Errorf("saving to cache: %w", err) + } + + return nil +} + +// ExtractTemplatesZip extracts a zip file to destDir with security checks. +// It rejects entries with path traversal ("../") and symbolic links. +func ExtractTemplatesZip(zipPath, destDir string) error { + r, err := zip.OpenReader(zipPath) + if err != nil { + return fmt.Errorf("opening zip %s: %w", zipPath, err) + } + defer r.Close() + + destDir, err = filepath.Abs(destDir) + if err != nil { + return fmt.Errorf("resolving destination path: %w", err) + } + + for _, f := range r.File { + // Security: reject path traversal + if strings.Contains(f.Name, "..") { + return fmt.Errorf("zip contains path traversal entry: %s", f.Name) + } + + // Security: reject symbolic links + if f.FileInfo().Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("zip contains symbolic link: %s", f.Name) + } + + fpath := filepath.Join(destDir, f.Name) + + // Security: verify resolved path is within destination + if !strings.HasPrefix(fpath, destDir+string(os.PathSeparator)) && fpath != destDir { + return fmt.Errorf("zip entry escapes destination: %s", f.Name) + } + + if f.FileInfo().IsDir() { + if err := os.MkdirAll(fpath, 0755); err != nil { + return fmt.Errorf("creating directory %s: %w", f.Name, err) + } + continue + } + + // Create parent directories + if err := os.MkdirAll(filepath.Dir(fpath), 0755); err != nil { + return fmt.Errorf("creating parent directory for %s: %w", f.Name, err) + } + + outFile, err := os.OpenFile(fpath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode()) + if err != nil { + return fmt.Errorf("creating file %s: %w", f.Name, err) + } + + rc, err := f.Open() + if err != nil { + outFile.Close() + return fmt.Errorf("reading zip entry %s: %w", f.Name, err) + } + + _, err = io.Copy(outFile, rc) + outFile.Close() + rc.Close() + + if err != nil { + return fmt.Errorf("extracting %s: %w", f.Name, err) + } + } + + return nil +} + +// getCacheDir returns the cache directory for codingbooth versions. +// Uses XDG_CACHE_HOME if set, otherwise ~/.cache. +func getCacheDir() (string, error) { + cacheHome := os.Getenv("XDG_CACHE_HOME") + if cacheHome == "" { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + cacheHome = filepath.Join(home, ".cache") + } + return filepath.Join(cacheHome, "codingbooth", "versions"), nil +} diff --git a/cli/src/pkg/boothinit/cache/cache_test.go b/cli/src/pkg/boothinit/cache/cache_test.go new file mode 100644 index 00000000..48b499f5 --- /dev/null +++ b/cli/src/pkg/boothinit/cache/cache_test.go @@ -0,0 +1,168 @@ +// 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 cache + +import ( + "archive/zip" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// createTestZip creates a zip file at zipPath with the given entries. +// Each entry is a pathβ†’content pair. Directories should end with "/". +func createTestZip(t *testing.T, zipPath string, entries map[string]string) { + t.Helper() + f, err := os.Create(zipPath) + require.NoError(t, err) + defer f.Close() + + w := zip.NewWriter(f) + for name, content := range entries { + fw, err := w.Create(name) + require.NoError(t, err) + _, err = fw.Write([]byte(content)) + require.NoError(t, err) + } + require.NoError(t, w.Close()) +} + +func TestExtractTemplatesZip_ValidZip(t *testing.T) { + tmpDir := t.TempDir() + zipPath := filepath.Join(tmpDir, "test.zip") + + createTestZip(t, zipPath, map[string]string{ + "templates/languages/meta.toml": "display-name = \"Languages\"\norder = 1\n", + "templates/languages/go/template.toml": "display-name = \"Go\"\n", + "templates/tools/meta.toml": "display-name = \"Tools\"\norder = 3\n", + "templates/tools/neovim/template.toml": "display-name = \"Neovim\"\n", + }) + + destDir := filepath.Join(tmpDir, "extracted") + require.NoError(t, os.MkdirAll(destDir, 0755)) + + err := ExtractTemplatesZip(zipPath, destDir) + require.NoError(t, err) + + // Verify files exist + content, err := os.ReadFile(filepath.Join(destDir, "templates", "languages", "meta.toml")) + require.NoError(t, err) + assert.Contains(t, string(content), "Languages") + + content, err = os.ReadFile(filepath.Join(destDir, "templates", "languages", "go", "template.toml")) + require.NoError(t, err) + assert.Contains(t, string(content), "Go") + + content, err = os.ReadFile(filepath.Join(destDir, "templates", "tools", "neovim", "template.toml")) + require.NoError(t, err) + assert.Contains(t, string(content), "Neovim") +} + +func TestExtractTemplatesZip_RejectsPathTraversal(t *testing.T) { + tmpDir := t.TempDir() + zipPath := filepath.Join(tmpDir, "evil.zip") + + createTestZip(t, zipPath, map[string]string{ + "../etc/passwd": "root:x:0:0", + }) + + destDir := filepath.Join(tmpDir, "extracted") + require.NoError(t, os.MkdirAll(destDir, 0755)) + + err := ExtractTemplatesZip(zipPath, destDir) + require.Error(t, err) + assert.Contains(t, err.Error(), "path traversal") +} + +func TestExtractTemplatesZip_RejectsNestedPathTraversal(t *testing.T) { + tmpDir := t.TempDir() + zipPath := filepath.Join(tmpDir, "evil.zip") + + createTestZip(t, zipPath, map[string]string{ + "templates/../../etc/passwd": "root:x:0:0", + }) + + destDir := filepath.Join(tmpDir, "extracted") + require.NoError(t, os.MkdirAll(destDir, 0755)) + + err := ExtractTemplatesZip(zipPath, destDir) + require.Error(t, err) + assert.Contains(t, err.Error(), "path traversal") +} + +func TestExtractTemplatesZip_MissingZipFile(t *testing.T) { + tmpDir := t.TempDir() + destDir := filepath.Join(tmpDir, "extracted") + require.NoError(t, os.MkdirAll(destDir, 0755)) + + err := ExtractTemplatesZip(filepath.Join(tmpDir, "nonexistent.zip"), destDir) + require.Error(t, err) + assert.Contains(t, err.Error(), "opening zip") +} + +func TestExtractTemplatesZip_EmptyZip(t *testing.T) { + tmpDir := t.TempDir() + zipPath := filepath.Join(tmpDir, "empty.zip") + + createTestZip(t, zipPath, map[string]string{}) + + destDir := filepath.Join(tmpDir, "extracted") + require.NoError(t, os.MkdirAll(destDir, 0755)) + + err := ExtractTemplatesZip(zipPath, destDir) + require.NoError(t, err) +} + +func TestExtractTemplatesZip_PreservesFileContent(t *testing.T) { + tmpDir := t.TempDir() + zipPath := filepath.Join(tmpDir, "test.zip") + + expectedContent := `display-name = "Go" +display-disc = "Go language toolchain" +display-order = 10 +tags = ["go", "golang", "backend"] + +[segments] +Boothfile = """ +setup go ${GO_VERSION} +""" +` + + createTestZip(t, zipPath, map[string]string{ + "templates/languages/go/template.toml": expectedContent, + }) + + destDir := filepath.Join(tmpDir, "extracted") + require.NoError(t, os.MkdirAll(destDir, 0755)) + + err := ExtractTemplatesZip(zipPath, destDir) + require.NoError(t, err) + + actual, err := os.ReadFile(filepath.Join(destDir, "templates", "languages", "go", "template.toml")) + require.NoError(t, err) + assert.Equal(t, expectedContent, string(actual)) +} + +func TestExtractTemplatesZip_CreatesDirectories(t *testing.T) { + tmpDir := t.TempDir() + zipPath := filepath.Join(tmpDir, "test.zip") + + createTestZip(t, zipPath, map[string]string{ + "templates/deep/nested/dir/file.txt": "content", + }) + + destDir := filepath.Join(tmpDir, "extracted") + require.NoError(t, os.MkdirAll(destDir, 0755)) + + err := ExtractTemplatesZip(zipPath, destDir) + require.NoError(t, err) + + info, err := os.Stat(filepath.Join(destDir, "templates", "deep", "nested", "dir")) + require.NoError(t, err) + assert.True(t, info.IsDir()) +} diff --git a/cli/src/pkg/boothinit/output/writer.go b/cli/src/pkg/boothinit/output/writer.go index 9a339dbb..674da18b 100644 --- a/cli/src/pkg/boothinit/output/writer.go +++ b/cli/src/pkg/boothinit/output/writer.go @@ -23,6 +23,12 @@ func WriteOutput(out *BoothOutput, targetPath string) error { return fmt.Errorf("creating .booth/: %w", err) } + // Ensure .booth.password is always gitignored + gitignoreContent := "# Secrets - never commit\n.booth.password\n\n# Lock file is version-controlled\n# Binaries are in ~/.cache/codingbooth/ (not here)\n" + if err := writeFile(filepath.Join(boothDir, ".gitignore"), gitignoreContent, 0644); err != nil { + return fmt.Errorf("writing .gitignore: %w", err) + } + if out.Config != nil { content := SerializeConfigToml(out.Config) if content != "" { diff --git a/cli/src/pkg/boothinit/template/display.go b/cli/src/pkg/boothinit/template/display.go new file mode 100644 index 00000000..b7104e6e --- /dev/null +++ b/cli/src/pkg/boothinit/template/display.go @@ -0,0 +1,76 @@ +// 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 template + +import ( + "fmt" + "io" + "strings" +) + +// FormatRegistry writes a formatted listing of templates grouped by category. +// Templates are indented with 2 spaces; extensions with 4 spaces and a "+ " prefix. +// Columns are dynamically aligned based on content widths. +func FormatRegistry(w io.Writer, registry *TemplateRegistry) { + if len(registry.Categories) == 0 { + return + } + + nameWidth, displayWidth := computeColumnWidths(registry) + + for i, cat := range registry.Categories { + if i > 0 { + fmt.Fprintln(w) + } + fmt.Fprintln(w, cat.DisplayName) + for _, t := range cat.Templates { + fmt.Fprintf(w, " %-*s %-*s %s\n", + nameWidth, t.Name, + displayWidth, t.DisplayName, + formatTags(t.Tags)) + for _, ext := range t.Extensions { + fmt.Fprintf(w, " + %-*s %-*s %s\n", + nameWidth-4, ext.Name, + displayWidth, ext.DisplayName, + formatTags(ext.Tags)) + } + } + } +} + +// formatTags formats a slice of tags as "[tag1, tag2]" or "[]" if empty. +func formatTags(tags []string) string { + if len(tags) == 0 { + return "[]" + } + return "[" + strings.Join(tags, ", ") + "]" +} + +// computeColumnWidths computes the max name width and max display-name width +// across all templates and extensions in the registry. Extension names account +// for the 4-character indent difference (" + " vs " "). +func computeColumnWidths(registry *TemplateRegistry) (nameWidth, displayWidth int) { + for _, cat := range registry.Categories { + for _, t := range cat.Templates { + if len(t.Name) > nameWidth { + nameWidth = len(t.Name) + } + if len(t.DisplayName) > displayWidth { + displayWidth = len(t.DisplayName) + } + for _, ext := range t.Extensions { + // Extensions use " + " (6 chars) vs templates " " (2 chars), + // so the name needs 4 extra chars to align the display-name column. + if len(ext.Name)+4 > nameWidth { + nameWidth = len(ext.Name) + 4 + } + if len(ext.DisplayName) > displayWidth { + displayWidth = len(ext.DisplayName) + } + } + } + } + return nameWidth, displayWidth +} diff --git a/cli/src/pkg/boothinit/template/display_test.go b/cli/src/pkg/boothinit/template/display_test.go new file mode 100644 index 00000000..d820f342 --- /dev/null +++ b/cli/src/pkg/boothinit/template/display_test.go @@ -0,0 +1,248 @@ +// 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 template + +import ( + "bytes" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --- formatTags --- + +func TestFormatTags_Multiple(t *testing.T) { + assert.Equal(t, "[golang, backend]", formatTags([]string{"golang", "backend"})) +} + +func TestFormatTags_Single(t *testing.T) { + assert.Equal(t, "[go]", formatTags([]string{"go"})) +} + +func TestFormatTags_Empty(t *testing.T) { + assert.Equal(t, "[]", formatTags([]string{})) +} + +func TestFormatTags_Nil(t *testing.T) { + assert.Equal(t, "[]", formatTags(nil)) +} + +// --- computeColumnWidths --- + +func TestComputeColumnWidths_BasicTemplates(t *testing.T) { + registry := &TemplateRegistry{ + Categories: []*Category{ + { + Templates: []*Template{ + {Name: "go", DisplayName: "Go"}, + {Name: "python", DisplayName: "Python"}, + }, + }, + }, + } + nameW, displayW := computeColumnWidths(registry) + assert.Equal(t, 6, nameW) // "python" = 6 chars + assert.Equal(t, 6, displayW) // "Python" = 6 chars +} + +func TestComputeColumnWidths_WithExtensions(t *testing.T) { + registry := &TemplateRegistry{ + Categories: []*Category{ + { + Templates: []*Template{ + { + Name: "go", + DisplayName: "Go", + Extensions: []*Template{ + {Name: "linter", DisplayName: "Go Linter"}, + }, + }, + }, + }, + }, + } + nameW, displayW := computeColumnWidths(registry) + // Extension "linter" (6 chars) + 4 indent = 10, vs "go" (2 chars). Max = 10. + assert.Equal(t, 10, nameW) + assert.Equal(t, 9, displayW) // "Go Linter" = 9 chars +} + +func TestComputeColumnWidths_EmptyRegistry(t *testing.T) { + registry := &TemplateRegistry{} + nameW, displayW := computeColumnWidths(registry) + assert.Equal(t, 0, nameW) + assert.Equal(t, 0, displayW) +} + +// --- FormatRegistry --- + +func TestFormatRegistry_EmptyRegistry(t *testing.T) { + var buf bytes.Buffer + FormatRegistry(&buf, &TemplateRegistry{}) + assert.Empty(t, buf.String()) +} + +func TestFormatRegistry_CategoryHeaders(t *testing.T) { + registry, err := LoadRegistry(testdataDir()) + require.NoError(t, err) + + var buf bytes.Buffer + FormatRegistry(&buf, registry) + output := buf.String() + + assert.Contains(t, output, "Languages\n") + assert.Contains(t, output, "Frameworks\n") + assert.Contains(t, output, "Tools\n") +} + +func TestFormatRegistry_TemplateNames(t *testing.T) { + registry, err := LoadRegistry(testdataDir()) + require.NoError(t, err) + + var buf bytes.Buffer + FormatRegistry(&buf, registry) + output := buf.String() + + // Template names should appear indented with 2 spaces + assert.Contains(t, output, " go") + assert.Contains(t, output, " python") + assert.Contains(t, output, " django") + assert.Contains(t, output, " neovim") + assert.Contains(t, output, " claude-code") +} + +func TestFormatRegistry_ExtensionIndent(t *testing.T) { + registry, err := LoadRegistry(testdataDir()) + require.NoError(t, err) + + var buf bytes.Buffer + FormatRegistry(&buf, registry) + output := buf.String() + + // Extensions should appear with " + " prefix + assert.Contains(t, output, " + linter") + assert.Contains(t, output, " + pip") +} + +func TestFormatRegistry_DisplayNames(t *testing.T) { + registry, err := LoadRegistry(testdataDir()) + require.NoError(t, err) + + var buf bytes.Buffer + FormatRegistry(&buf, registry) + output := buf.String() + + assert.Contains(t, output, "Go") + assert.Contains(t, output, "Python") + assert.Contains(t, output, "Django") + assert.Contains(t, output, "Neovim") + assert.Contains(t, output, "Claude Code") + assert.Contains(t, output, "Go Linter") + assert.Contains(t, output, "pip requirements") +} + +func TestFormatRegistry_Tags(t *testing.T) { + registry, err := LoadRegistry(testdataDir()) + require.NoError(t, err) + + var buf bytes.Buffer + FormatRegistry(&buf, registry) + output := buf.String() + + assert.Contains(t, output, "[golang, backend]") + assert.Contains(t, output, "[python, scripting]") + assert.Contains(t, output, "[python, web]") + assert.Contains(t, output, "[editor, vim]") + assert.Contains(t, output, "[ai, assistant]") +} + +func TestFormatRegistry_CategoryOrder(t *testing.T) { + registry, err := LoadRegistry(testdataDir()) + require.NoError(t, err) + + var buf bytes.Buffer + FormatRegistry(&buf, registry) + output := buf.String() + + // Languages (order 1) before Frameworks (order 2) before Tools (order 3) + langIdx := strings.Index(output, "Languages") + fwIdx := strings.Index(output, "Frameworks") + toolIdx := strings.Index(output, "Tools") + + assert.True(t, langIdx < fwIdx, "Languages should appear before Frameworks") + assert.True(t, fwIdx < toolIdx, "Frameworks should appear before Tools") +} + +func TestFormatRegistry_TemplateOrderWithinCategory(t *testing.T) { + registry, err := LoadRegistry(testdataDir()) + require.NoError(t, err) + + var buf bytes.Buffer + FormatRegistry(&buf, registry) + output := buf.String() + + // In Languages: go (order 10) before python (order 20) + goIdx := strings.Index(output, " go") + pyIdx := strings.Index(output, " python") + assert.True(t, goIdx < pyIdx, "go should appear before python") +} + +func TestFormatRegistry_BlankLineBetweenCategories(t *testing.T) { + registry, err := LoadRegistry(testdataDir()) + require.NoError(t, err) + + var buf bytes.Buffer + FormatRegistry(&buf, registry) + output := buf.String() + + // There should be a blank line between categories + assert.Contains(t, output, "\n\nFrameworks") + assert.Contains(t, output, "\n\nTools") +} + +func TestFormatRegistry_SingleCategory(t *testing.T) { + registry := &TemplateRegistry{ + Categories: []*Category{ + { + DisplayName: "TestCat", + Templates: []*Template{ + {Name: "item1", DisplayName: "Item One", Tags: []string{"a"}}, + }, + }, + }, + } + + var buf bytes.Buffer + FormatRegistry(&buf, registry) + output := buf.String() + + assert.Contains(t, output, "TestCat\n") + assert.Contains(t, output, " item1") + assert.Contains(t, output, "Item One") + assert.Contains(t, output, "[a]") + // No leading blank line for first category + assert.True(t, strings.HasPrefix(output, "TestCat\n")) +} + +func TestFormatRegistry_EmptyTags(t *testing.T) { + registry := &TemplateRegistry{ + Categories: []*Category{ + { + DisplayName: "Cat", + Templates: []*Template{ + {Name: "notags", DisplayName: "No Tags"}, + }, + }, + }, + } + + var buf bytes.Buffer + FormatRegistry(&buf, registry) + output := buf.String() + + assert.Contains(t, output, "[]") +} diff --git a/cli/src/pkg/boothinit/template/loader.go b/cli/src/pkg/boothinit/template/loader.go index bb9669ef..fbad6ff3 100644 --- a/cli/src/pkg/boothinit/template/loader.go +++ b/cli/src/pkg/boothinit/template/loader.go @@ -30,6 +30,7 @@ type specToml struct { DisplayDesc string `toml:"display-disc"` DisplayOrder int `toml:"display-order"` Tags []string `toml:"tags"` + Primary bool `toml:"primary"` AutoSelect *bool `toml:"auto-select"` Variant string `toml:"variant"` Port string `toml:"port"` @@ -162,6 +163,7 @@ func loadTemplateDir(dir, name, categoryName string, allowExtensions bool) (*Tem DisplayDesc: spec.DisplayDesc, DisplayOrder: spec.DisplayOrder, Tags: spec.Tags, + Primary: spec.Primary, AutoSelect: spec.AutoSelect, Variant: spec.Variant, Port: spec.Port, @@ -298,6 +300,7 @@ func loadExtensionFile(filePath, name, categoryName string) (*Template, error) { DisplayDesc: spec.DisplayDesc, DisplayOrder: spec.DisplayOrder, Tags: spec.Tags, + Primary: spec.Primary, AutoSelect: spec.AutoSelect, Variant: spec.Variant, Port: spec.Port, diff --git a/cli/src/pkg/boothinit/template/model.go b/cli/src/pkg/boothinit/template/model.go index 15d6e7f5..435461d4 100644 --- a/cli/src/pkg/boothinit/template/model.go +++ b/cli/src/pkg/boothinit/template/model.go @@ -28,6 +28,7 @@ type Template struct { DisplayDesc string // from template.toml display-disc DisplayOrder int // from template.toml display-order Tags []string + Primary bool // shown by default in list/search; non-primary only shown with --full AutoSelect *bool // extension only: auto-select when parent is selected // Config scalar values (match-or-error merge strategy) diff --git a/cli/src/pkg/boothinit/template/search.go b/cli/src/pkg/boothinit/template/search.go new file mode 100644 index 00000000..cfcf624b --- /dev/null +++ b/cli/src/pkg/boothinit/template/search.go @@ -0,0 +1,100 @@ +// 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 template + +import "strings" + +// Search returns a filtered copy of the registry containing only templates +// (and extensions) that match the given term via case-insensitive prefix match +// on name, display-name, or any tag. Categories with no matches are omitted. +// If a parent template matches, all its extensions are included. +// If only extension(s) match, the parent is included with only matching extensions. +func (r *TemplateRegistry) Search(term string) *TemplateRegistry { + lowerTerm := strings.ToLower(term) + result := &TemplateRegistry{ + ByName: make(map[string]*Template), + } + + for _, cat := range r.Categories { + var matched []*Template + for _, t := range cat.Templates { + if matchesPrefix(t, lowerTerm) { + // Parent matches: include with all extensions + matched = append(matched, t) + result.ByName[t.Name] = t + continue + } + // Check extensions + var matchedExts []*Template + for _, ext := range t.Extensions { + if matchesPrefix(ext, lowerTerm) { + matchedExts = append(matchedExts, ext) + } + } + if len(matchedExts) > 0 { + // Shallow copy of the template with only matching extensions + filtered := *t + filtered.Extensions = matchedExts + matched = append(matched, &filtered) + result.ByName[t.Name] = &filtered + } + } + if len(matched) > 0 { + result.Categories = append(result.Categories, &Category{ + Name: cat.Name, + DisplayName: cat.DisplayName, + Order: cat.Order, + Templates: matched, + }) + } + } + + return result +} + +// FilterPrimary returns a filtered copy of the registry containing only +// templates with Primary=true. Categories with no primary templates are omitted. +func (r *TemplateRegistry) FilterPrimary() *TemplateRegistry { + result := &TemplateRegistry{ + ByName: make(map[string]*Template), + } + + for _, cat := range r.Categories { + var matched []*Template + for _, t := range cat.Templates { + if t.Primary { + matched = append(matched, t) + result.ByName[t.Name] = t + } + } + if len(matched) > 0 { + result.Categories = append(result.Categories, &Category{ + Name: cat.Name, + DisplayName: cat.DisplayName, + Order: cat.Order, + Templates: matched, + }) + } + } + + return result +} + +// matchesPrefix returns true if the template's name, display-name, +// or any tag starts with the given lowercase term (case-insensitive). +func matchesPrefix(t *Template, lowerTerm string) bool { + if strings.HasPrefix(strings.ToLower(t.Name), lowerTerm) { + return true + } + if strings.HasPrefix(strings.ToLower(t.DisplayName), lowerTerm) { + return true + } + for _, tag := range t.Tags { + if strings.HasPrefix(strings.ToLower(tag), lowerTerm) { + return true + } + } + return false +} diff --git a/cli/src/pkg/boothinit/template/search_test.go b/cli/src/pkg/boothinit/template/search_test.go new file mode 100644 index 00000000..289ceb3b --- /dev/null +++ b/cli/src/pkg/boothinit/template/search_test.go @@ -0,0 +1,227 @@ +// 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 template + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func loadSearchRegistry(t *testing.T) *TemplateRegistry { + t.Helper() + registry, err := LoadRegistry(testdataDir()) + require.NoError(t, err) + return registry +} + +// --- matchesPrefix --- + +func TestMatchesPrefix_ByName(t *testing.T) { + tmpl := &Template{Name: "go", DisplayName: "Go", Tags: []string{"golang"}} + assert.True(t, matchesPrefix(tmpl, "go")) +} + +func TestMatchesPrefix_ByDisplayName(t *testing.T) { + tmpl := &Template{Name: "claude-code", DisplayName: "Claude Code", Tags: []string{"ai"}} + assert.True(t, matchesPrefix(tmpl, "cl")) +} + +func TestMatchesPrefix_ByTag(t *testing.T) { + tmpl := &Template{Name: "go", DisplayName: "Go", Tags: []string{"golang", "backend"}} + assert.True(t, matchesPrefix(tmpl, "back")) +} + +func TestMatchesPrefix_CaseInsensitive_DisplayName(t *testing.T) { + // "Claude Code" display name should match lowercase "cl" term + tmpl := &Template{Name: "claude-code", DisplayName: "Claude Code", Tags: []string{"ai"}} + assert.True(t, matchesPrefix(tmpl, "claude")) +} + +func TestMatchesPrefix_NoMatch(t *testing.T) { + tmpl := &Template{Name: "go", DisplayName: "Go", Tags: []string{"golang"}} + assert.False(t, matchesPrefix(tmpl, "py")) +} + +func TestMatchesPrefix_EmptyTerm(t *testing.T) { + tmpl := &Template{Name: "go", DisplayName: "Go", Tags: []string{"golang"}} + assert.True(t, matchesPrefix(tmpl, "")) +} + +func TestMatchesPrefix_NoTags(t *testing.T) { + tmpl := &Template{Name: "go", DisplayName: "Go"} + assert.True(t, matchesPrefix(tmpl, "go")) + assert.False(t, matchesPrefix(tmpl, "xyz")) +} + +// --- Search --- + +func TestSearch_ByTemplateName(t *testing.T) { + registry := loadSearchRegistry(t) + result := registry.Search("go") + + // "go" matches the go template by name + require.Len(t, result.Categories, 1) + assert.Equal(t, "Languages", result.Categories[0].DisplayName) + require.Len(t, result.Categories[0].Templates, 1) + assert.Equal(t, "go", result.Categories[0].Templates[0].Name) +} + +func TestSearch_ByTemplateName_IncludesAllExtensions(t *testing.T) { + registry := loadSearchRegistry(t) + result := registry.Search("go") + + // When parent matches, all extensions are included + goTmpl := result.Categories[0].Templates[0] + require.Len(t, goTmpl.Extensions, 1) + assert.Equal(t, "linter", goTmpl.Extensions[0].Name) +} + +func TestSearch_ByDisplayName(t *testing.T) { + registry := loadSearchRegistry(t) + result := registry.Search("Cl") + + // "Cl" matches "Claude Code" display-name + require.Len(t, result.Categories, 1) + assert.Equal(t, "Tools", result.Categories[0].DisplayName) + require.Len(t, result.Categories[0].Templates, 1) + assert.Equal(t, "claude-code", result.Categories[0].Templates[0].Name) +} + +func TestSearch_ByTag(t *testing.T) { + registry := loadSearchRegistry(t) + result := registry.Search("back") + + // "back" matches go's "backend" tag + require.Len(t, result.Categories, 1) + assert.Equal(t, "Languages", result.Categories[0].DisplayName) + require.Len(t, result.Categories[0].Templates, 1) + assert.Equal(t, "go", result.Categories[0].Templates[0].Name) +} + +func TestSearch_CaseInsensitive(t *testing.T) { + registry := loadSearchRegistry(t) + result := registry.Search("GO") + + require.Len(t, result.Categories, 1) + require.Len(t, result.Categories[0].Templates, 1) + assert.Equal(t, "go", result.Categories[0].Templates[0].Name) +} + +func TestSearch_MultipleMatches(t *testing.T) { + registry := loadSearchRegistry(t) + result := registry.Search("python") + + // "python" matches: + // - python template (name and tag) + // - django template (tag "python") + // - pip extension (tag "python") + // Since python parent matches, it includes all extensions. + // Since django matches directly by tag, it's also included. + require.Len(t, result.Categories, 2) + + assert.Equal(t, "Languages", result.Categories[0].DisplayName) + require.Len(t, result.Categories[0].Templates, 1) + assert.Equal(t, "python", result.Categories[0].Templates[0].Name) + + assert.Equal(t, "Frameworks", result.Categories[1].DisplayName) + require.Len(t, result.Categories[1].Templates, 1) + assert.Equal(t, "django", result.Categories[1].Templates[0].Name) +} + +func TestSearch_NoMatch(t *testing.T) { + registry := loadSearchRegistry(t) + result := registry.Search("xyz") + + assert.Empty(t, result.Categories) + assert.Empty(t, result.ByName) +} + +func TestSearch_EmptyTerm_ReturnsAll(t *testing.T) { + registry := loadSearchRegistry(t) + result := registry.Search("") + + // Empty term matches everything (every string has prefix "") + assert.Equal(t, len(registry.Categories), len(result.Categories)) +} + +func TestSearch_ExtensionMatch_OnlyMatchingExtensions(t *testing.T) { + registry := loadSearchRegistry(t) + result := registry.Search("lint") + + // "lint" matches the "linter" extension name under go + // Parent go doesn't match "lint", so only the linter extension is included + require.Len(t, result.Categories, 1) + assert.Equal(t, "Languages", result.Categories[0].DisplayName) + require.Len(t, result.Categories[0].Templates, 1) + + goTmpl := result.Categories[0].Templates[0] + assert.Equal(t, "go", goTmpl.Name) + require.Len(t, goTmpl.Extensions, 1) + assert.Equal(t, "linter", goTmpl.Extensions[0].Name) +} + +func TestSearch_ExtensionMatchByTag(t *testing.T) { + registry := loadSearchRegistry(t) + result := registry.Search("pip") + + // "pip" matches the pip extension's tag "pip" + // Python parent also matches? Let's check: python name starts with "py" not "pip". + // Python tags: ["python", "scripting"]. Neither starts with "pip". + // So only the pip extension matches. + found := false + for _, cat := range result.Categories { + for _, tmpl := range cat.Templates { + if tmpl.Name == "python" { + found = true + // Only pip extension should be included + require.Len(t, tmpl.Extensions, 1) + assert.Equal(t, "pip", tmpl.Extensions[0].Name) + } + } + } + assert.True(t, found, "python template should appear due to pip extension match") +} + +func TestSearch_CategoriesPreserveOrder(t *testing.T) { + registry := loadSearchRegistry(t) + result := registry.Search("python") + + // Languages (order 1) should come before Frameworks (order 2) + require.Len(t, result.Categories, 2) + assert.Equal(t, "Languages", result.Categories[0].DisplayName) + assert.Equal(t, "Frameworks", result.Categories[1].DisplayName) +} + +func TestSearch_ByNameInByNameMap(t *testing.T) { + registry := loadSearchRegistry(t) + result := registry.Search("go") + + _, exists := result.ByName["go"] + assert.True(t, exists) +} + +func TestSearch_ByTag_Assistant(t *testing.T) { + registry := loadSearchRegistry(t) + result := registry.Search("assistant") + + // "assistant" tag on claude-code + require.Len(t, result.Categories, 1) + assert.Equal(t, "Tools", result.Categories[0].DisplayName) + require.Len(t, result.Categories[0].Templates, 1) + assert.Equal(t, "claude-code", result.Categories[0].Templates[0].Name) +} + +func TestSearch_ByTag_Editor(t *testing.T) { + registry := loadSearchRegistry(t) + result := registry.Search("editor") + + // "editor" tag on neovim + require.Len(t, result.Categories, 1) + assert.Equal(t, "Tools", result.Categories[0].DisplayName) + require.Len(t, result.Categories[0].Templates, 1) + assert.Equal(t, "neovim", result.Categories[0].Templates[0].Name) +} diff --git a/examples/playground2/pj-java/.booth/Boothfile b/examples/playground2/pj-java/.booth/Boothfile deleted file mode 100644 index 2911c228..00000000 --- a/examples/playground2/pj-java/.booth/Boothfile +++ /dev/null @@ -1,9 +0,0 @@ -# syntax=codingbooth/boothfile:1 -# Generated by booth init - -arg JDK_VENDOR=temurin -arg JDK_VERSION=25 - -setup clojure -setup jdk ${JDK_VERSION} ${JDK_VENDOR} -setup java-code-extension diff --git a/examples/playground2/pj-java/.booth/config.toml b/examples/playground2/pj-java/.booth/config.toml deleted file mode 100644 index a33820a0..00000000 --- a/examples/playground2/pj-java/.booth/config.toml +++ /dev/null @@ -1,2 +0,0 @@ -# Generated by booth init - diff --git a/examples/test-jetbrains/.booth/Boothfile b/examples/test-jetbrains/.booth/Boothfile deleted file mode 100644 index 00fe87f3..00000000 --- a/examples/test-jetbrains/.booth/Boothfile +++ /dev/null @@ -1,18 +0,0 @@ -# syntax=codingbooth/boothfile:1 -# Generated by booth init - -arg JDK_VENDOR=temurin -arg JDK_VERSION=21 - -setup xfce -setup jdk ${JDK_VERSION} ${JDK_VENDOR} -setup java-code-extension -setup jetbrains clion -setup jetbrains datagrip -setup jetbrains goland -setup idea -setup jetbrains phpstorm -setup pycharm -setup jetbrains rider -setup jetbrains rubymine -setup jetbrains webstorm diff --git a/examples/test-jetbrains/.booth/config.toml b/examples/test-jetbrains/.booth/config.toml deleted file mode 100644 index ad8ecc8c..00000000 --- a/examples/test-jetbrains/.booth/config.toml +++ /dev/null @@ -1,3 +0,0 @@ -# Generated by booth init - -variant = "xfce" \ No newline at end of file diff --git a/examples/workspaces/zig-snake-example/.booth/Boothfile b/examples/workspaces/zig-snake-example/.booth/Boothfile new file mode 100644 index 00000000..781f0dcb --- /dev/null +++ b/examples/workspaces/zig-snake-example/.booth/Boothfile @@ -0,0 +1,7 @@ +# syntax=codingbooth/boothfile:1 +# Generated by booth init + +arg ZIG_VERSION=0.15.1 + +setup claude-code +setup zig --zig-version ${ZIG_VERSION} diff --git a/examples/workspaces/zig-snake-example/.booth/config.toml b/examples/workspaces/zig-snake-example/.booth/config.toml new file mode 100644 index 00000000..02143344 --- /dev/null +++ b/examples/workspaces/zig-snake-example/.booth/config.toml @@ -0,0 +1,9 @@ +# Generated by booth init + + +run-args = [ + "-v", + "~/.claude.json:/etc/cb-home-seed/.claude.json:ro", + "-v", + "~/.claude:/etc/cb-home-seed/.claude:ro" +] diff --git a/examples/workspaces/zig-snake-example/README.md b/examples/workspaces/zig-snake-example/README.md new file mode 100644 index 00000000..e81d4f0c --- /dev/null +++ b/examples/workspaces/zig-snake-example/README.md @@ -0,0 +1,62 @@ +# Snake β€” A Terminal Game in Zig + +A classic terminal snake game built inside [CodingBooth](https://github.com/NawaMan/CodingBooth), no Zig installation required. + +## Prerequisites + +- Bash +- Docker + +## Quick Start + +```bash +git clone +cd zig-snake +./booth # Start the CodingBooth container +zig build run # Build and play! +``` + +## Controls + +| Key | Action | +|---------------|------------------------| +| Arrow keys | Move | +| W / A / S / D | Move | +| Q | Quit | +| R | Restart (on game over) | + +## Try Modifying + +Open `src/main.zig` and tweak the constants at the top: + +```zig +const BOARD_WIDTH: u16 = 30; // try 40 for a wider board +const BOARD_HEIGHT: u16 = 20; // try 15 for a shorter one +const INITIAL_SPEED_MS: u64 = 150; // lower = faster (try 80!) +const SNAKE_COLOR: []const u8 = "\x1b[32m"; // change to "\x1b[33m" for yellow +``` + +Then rebuild and run: + +```bash +zig build run +``` + +## Cross-Compile + +Build binaries for 6 platforms from inside the booth: + +```bash +./build-all.sh +ls dist/ +``` + +Exit the booth and run the native binary directly on your host machine. + +## Project Structure + +``` +src/main.zig β€” The game (single file, ~360 lines) +build.zig β€” Zig build configuration +build-all.sh β€” Cross-compilation script +``` diff --git a/examples/workspaces/zig-snake-example/build-all.sh b/examples/workspaces/zig-snake-example/build-all.sh new file mode 100755 index 00000000..b402578f --- /dev/null +++ b/examples/workspaces/zig-snake-example/build-all.sh @@ -0,0 +1,29 @@ +#!/bin/bash +set -e + +# Cross-compile the snake game to multiple platforms. +# The game uses POSIX terminal APIs (termios, poll), so only Unix targets are supported. + +TARGETS=( + "x86_64-linux-gnu" + "aarch64-linux-gnu" + "x86_64-linux-musl" + "aarch64-linux-musl" + "x86_64-macos" + "aarch64-macos" +) + +mkdir -p dist + +for target in "${TARGETS[@]}"; do + echo "Building for $target..." + zig build -Dtarget="$target" -Doptimize=ReleaseSafe + + # Derive a short name for the output binary + name="snake-${target}" + cp "zig-out/bin/snake" "dist/${name}" +done + +echo "" +echo "Done! Binaries in dist/" +ls -lh dist/ diff --git a/examples/workspaces/zig-snake-example/build.zig b/examples/workspaces/zig-snake-example/build.zig new file mode 100644 index 00000000..8f525f66 --- /dev/null +++ b/examples/workspaces/zig-snake-example/build.zig @@ -0,0 +1,27 @@ +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const exe = b.addExecutable(.{ + .name = "snake", + .root_module = b.createModule(.{ + .root_source_file = b.path("src/main.zig"), + .target = target, + .optimize = optimize, + }), + }); + + b.installArtifact(exe); + + const run_cmd = b.addRunArtifact(exe); + run_cmd.step.dependOn(b.getInstallStep()); + + if (b.args) |args| { + run_cmd.addArgs(args); + } + + const run_step = b.step("run", "Run the snake game"); + run_step.dependOn(&run_cmd.step); +} diff --git a/examples/workspaces/zig-snake-example/run-snake.sh b/examples/workspaces/zig-snake-example/run-snake.sh new file mode 100755 index 00000000..e9a61eb0 --- /dev/null +++ b/examples/workspaces/zig-snake-example/run-snake.sh @@ -0,0 +1,11 @@ +#!/bin/bash +set -e + +BIN="zig-out/bin/snake" + +if [ ! -f "$BIN" ]; then + echo "Building snake..." + zig build +fi + +exec "$BIN" "$@" diff --git a/examples/workspaces/zig-snake-example/src/main.zig b/examples/workspaces/zig-snake-example/src/main.zig new file mode 100644 index 00000000..a02c3fda --- /dev/null +++ b/examples/workspaces/zig-snake-example/src/main.zig @@ -0,0 +1,370 @@ +const std = @import("std"); +const posix = std.posix; + +// ============================================ +// TRY CHANGING THESE! +// ============================================ +const BOARD_WIDTH: u16 = 30; +const BOARD_HEIGHT: u16 = 20; +const INITIAL_SPEED_MS: u64 = 150; // lower = faster +const SNAKE_CHAR: []const u8 = "β–ˆ"; +const FOOD_CHAR: []const u8 = "●"; +const SNAKE_COLOR: []const u8 = "\x1b[34m"; // blue +const FOOD_COLOR: []const u8 = "\x1b[31m"; // red +const BORDER_COLOR: []const u8 = "\x1b[36m"; // cyan +// ============================================ + +const RESET: []const u8 = "\x1b[0m"; +const BOLD: []const u8 = "\x1b[1m"; +const MAX_SNAKE_LEN = @as(usize, BOARD_WIDTH) * @as(usize, BOARD_HEIGHT); + +// Each cell is 2 chars wide. Frame buffer needs room for the full board + borders + escape codes. +// Generous allocation: (BOARD_WIDTH*2 + 20) * (BOARD_HEIGHT + 4) * 4 bytes for escape codes +const FRAME_BUF_SIZE = (@as(usize, BOARD_WIDTH) * 2 + 40) * (@as(usize, BOARD_HEIGHT) + 6) * 4; + +const Direction = enum { up, down, left, right }; + +const Point = struct { + x: i16, + y: i16, + + fn eql(a: Point, b: Point) bool { + return a.x == b.x and a.y == b.y; + } +}; + +// --- Game state --- +var snake: [MAX_SNAKE_LEN]Point = undefined; +var snake_len: usize = 0; +var direction: Direction = .right; +var next_direction: Direction = .right; +var food: Point = .{ .x = 0, .y = 0 }; +var score: u32 = 0; +var game_over: bool = false; +var wants_quit: bool = false; +var prng: std.Random.DefaultPrng = undefined; +var orig_termios: posix.termios = undefined; + +// --- Entry point --- + +pub fn main() !void { + const stdout = std.fs.File.stdout(); + const stdin_fd = std.fs.File.stdin().handle; + + // The game needs an interactive terminal for keyboard input + if (!posix.isatty(stdin_fd)) { + try stdout.writeAll("This game needs an interactive terminal.\nRun it directly: zig build run\n"); + return; + } + + // Seed the random number generator from the system clock + prng = std.Random.DefaultPrng.init(@as(u64, @bitCast(std.time.milliTimestamp()))); + + // Switch terminal to raw mode so we can read keypresses directly + orig_termios = try posix.tcgetattr(stdin_fd); + var raw = orig_termios; + raw.lflag.ECHO = false; + raw.lflag.ICANON = false; + raw.lflag.ISIG = false; + raw.lflag.IEXTEN = false; + raw.iflag.IXON = false; + raw.iflag.ICRNL = false; + raw.iflag.BRKINT = false; + raw.iflag.INPCK = false; + raw.iflag.ISTRIP = false; + raw.oflag.OPOST = false; + raw.cflag.CSIZE = .CS8; + raw.cc[@intFromEnum(posix.V.MIN)] = 0; + raw.cc[@intFromEnum(posix.V.TIME)] = 0; + try posix.tcsetattr(stdin_fd, .FLUSH, raw); + defer posix.tcsetattr(stdin_fd, .FLUSH, orig_termios) catch {}; + + // Hide cursor and clear screen + try stdout.writeAll("\x1b[?25l\x1b[2J"); + defer stdout.writeAll("\x1b[?25h\x1b[0m") catch {}; + + // Outer loop supports restarting the game + while (!wants_quit) { + try stdout.writeAll("\x1b[2J"); + initGame(); + try render(stdout); + + // --- Game tick loop --- + while (!game_over and !wants_quit) { + const frame_start = std.time.milliTimestamp(); + + // Collect input for the duration of one tick + while (true) { + const now = std.time.milliTimestamp(); + const elapsed = now - frame_start; + if (elapsed >= @as(i64, @intCast(INITIAL_SPEED_MS))) break; + + const remaining: i32 = @intCast(@as(i64, @intCast(INITIAL_SPEED_MS)) - elapsed); + if (pollInput(stdin_fd, remaining)) { + readAndProcessInput(stdin_fd); + } else { + break; + } + } + + // Apply the buffered direction change and advance the game + direction = next_direction; + update(); + if (!game_over) try render(stdout); + } + + if (wants_quit) return; + + // Show final frame with the game-over overlay + try render(stdout); + try renderGameOver(stdout); + + // Wait for R (restart) or Q (quit) + while (true) { + _ = pollInput(stdin_fd, -1); + var buf: [16]u8 = undefined; + const n = std.fs.File.stdin().read(&buf) catch continue; + if (n > 0) { + if (buf[0] == 'r' or buf[0] == 'R') break; + if (buf[0] == 'q' or buf[0] == 'Q') return; + } + } + } +} + +// --- Game logic --- + +fn initGame() void { + const cx: i16 = @intCast(BOARD_WIDTH / 2); + const cy: i16 = @intCast(BOARD_HEIGHT / 2); + + // Start with a 3-segment snake in the middle, heading right + snake_len = 3; + snake[0] = .{ .x = cx, .y = cy }; + snake[1] = .{ .x = cx - 1, .y = cy }; + snake[2] = .{ .x = cx - 2, .y = cy }; + + direction = .right; + next_direction = .right; + score = 0; + game_over = false; + + spawnFood(); +} + +fn spawnFood() void { + const rand = prng.random(); + // Keep trying random positions until we find one not on the snake + while (true) { + const fx = rand.intRangeLessThan(i16, 0, @as(i16, BOARD_WIDTH)); + const fy = rand.intRangeLessThan(i16, 0, @as(i16, BOARD_HEIGHT)); + const candidate = Point{ .x = fx, .y = fy }; + + var on_snake = false; + for (snake[0..snake_len]) |seg| { + if (seg.eql(candidate)) { + on_snake = true; + break; + } + } + if (!on_snake) { + food = candidate; + return; + } + } +} + +fn update() void { + var new_head = snake[0]; + switch (direction) { + .up => new_head.y -= 1, + .down => new_head.y += 1, + .left => new_head.x -= 1, + .right => new_head.x += 1, + } + + // Wall collision + if (new_head.x < 0 or new_head.x >= BOARD_WIDTH or + new_head.y < 0 or new_head.y >= BOARD_HEIGHT) + { + game_over = true; + return; + } + + // Self collision (skip the tail β€” it will move away) + for (snake[0 .. snake_len - 1]) |seg| { + if (seg.eql(new_head)) { + game_over = true; + return; + } + } + + const ate_food = new_head.eql(food); + + if (ate_food) { + // Grow: shift all segments back, then place the new head + var i: usize = snake_len; + while (i > 0) : (i -= 1) { + snake[i] = snake[i - 1]; + } + snake_len += 1; + score += 10; + spawnFood(); + } else { + // Move: shift body, the tail disappears + var i: usize = snake_len - 1; + while (i > 0) : (i -= 1) { + snake[i] = snake[i - 1]; + } + } + + snake[0] = new_head; +} + +// --- Input handling --- + +fn readAndProcessInput(fd: posix.fd_t) void { + var buf: [16]u8 = undefined; + const n = posix.read(fd, &buf) catch return; + if (n == 0) return; + + // Arrow keys send: ESC [ A/B/C/D + if (n >= 3 and buf[0] == 0x1b and buf[1] == '[') { + switch (buf[2]) { + 'A' => { + if (direction != .down) next_direction = .up; + }, + 'B' => { + if (direction != .up) next_direction = .down; + }, + 'C' => { + if (direction != .left) next_direction = .right; + }, + 'D' => { + if (direction != .right) next_direction = .left; + }, + else => {}, + } + return; + } + + switch (buf[0]) { + 'q', 'Q' => wants_quit = true, + 'w', 'W' => { + if (direction != .down) next_direction = .up; + }, + 'a', 'A' => { + if (direction != .right) next_direction = .left; + }, + 's', 'S' => { + if (direction != .up) next_direction = .down; + }, + 'd', 'D' => { + if (direction != .left) next_direction = .right; + }, + else => {}, + } +} + +fn pollInput(fd: posix.fd_t, timeout_ms: i32) bool { + var fds = [_]posix.pollfd{ + .{ .fd = fd, .events = posix.POLL.IN, .revents = 0 }, + }; + const n = posix.poll(&fds, timeout_ms) catch return false; + return n > 0; +} + +// --- Rendering --- + +fn render(stdout: std.fs.File) !void { + var frame_buf: [FRAME_BUF_SIZE]u8 = undefined; + var fbs = std.io.fixedBufferStream(&frame_buf); + const w = fbs.writer(); + + // Move cursor to top-left (overwrite the previous frame) + try w.writeAll("\x1b[H"); + + // Score line + try w.writeAll(RESET); + try w.print(" Score: {d} \n\r", .{score}); + + // Top border: β”Œβ”€β”€...──┐ + try w.writeAll(BORDER_COLOR); + try w.writeAll(" β”Œ"); + for (0..BOARD_WIDTH) |_| try w.writeAll("──"); + try w.writeAll("┐\n\r"); + + // Board rows + var y: i16 = 0; + while (y < BOARD_HEIGHT) : (y += 1) { + try w.writeAll(BORDER_COLOR); + try w.writeAll(" β”‚"); + + var x: i16 = 0; + while (x < BOARD_WIDTH) : (x += 1) { + const p = Point{ .x = x, .y = y }; + + if (snakeIndex(p)) |idx| { + // Bright head, normal body + if (idx == 0) try w.writeAll(BOLD); + try w.writeAll(SNAKE_COLOR); + try w.writeAll(SNAKE_CHAR); + try w.writeAll(" "); + if (idx == 0) try w.writeAll(RESET); + } else if (p.eql(food)) { + try w.writeAll(FOOD_COLOR); + try w.writeAll(FOOD_CHAR); + try w.writeAll(" "); + } else { + try w.writeAll(" "); + } + } + + try w.writeAll(BORDER_COLOR); + try w.writeAll("β”‚\n\r"); + } + + // Bottom border: └──...β”€β”€β”˜ + try w.writeAll(BORDER_COLOR); + try w.writeAll(" β””"); + for (0..BOARD_WIDTH) |_| try w.writeAll("──"); + try w.writeAll("β”˜\n\r"); + + // Controls hint + try w.writeAll(RESET); + try w.writeAll(" Arrow keys / WASD to move | Q to quit\n\r"); + + // Write the entire frame at once (prevents flicker) + try stdout.writeAll(fbs.getWritten()); +} + +fn renderGameOver(stdout: std.fs.File) !void { + var buf: [512]u8 = undefined; + var fbs = std.io.fixedBufferStream(&buf); + const w = fbs.writer(); + + // Position the overlay in the center of the board + const cy: u16 = BOARD_HEIGHT / 2 + 2; + const cx: u16 = BOARD_WIDTH + 2; + + try w.print("\x1b[{d};{d}H", .{ cy, cx - 6 }); + try w.writeAll("\x1b[41;37;1m GAME OVER! " ++ RESET); + + try w.print("\x1b[{d};{d}H", .{ cy + 2, cx - 8 }); + try w.print(BOLD ++ " Final Score: {d} " ++ RESET, .{score}); + + try w.print("\x1b[{d};{d}H", .{ cy + 4, cx - 14 }); + try w.writeAll(" Press " ++ BOLD ++ "R" ++ RESET ++ " to restart | " ++ BOLD ++ "Q" ++ RESET ++ " to quit "); + + // Park cursor below the board + try w.print("\x1b[{d};1H", .{@as(u16, BOARD_HEIGHT) + 5}); + + try stdout.writeAll(fbs.getWritten()); +} + +fn snakeIndex(p: Point) ?usize { + for (snake[0..snake_len], 0..) |seg, i| { + if (seg.eql(p)) return i; + } + return null; +} diff --git a/templates/ai-tools/antigravity/template.toml b/templates/ai-tools/antigravity/template.toml new file mode 100644 index 00000000..626a7305 --- /dev/null +++ b/templates/ai-tools/antigravity/template.toml @@ -0,0 +1,13 @@ +display-name = "Antigravity" +display-disc = "Antigravity AI assistant (formerly Windsurf)" +display-order = 14 +tags = [ + "ai", + "antigravity", + "google" +] + +[segments] +"Boothfile--60" = """ +setup antigravity +""" diff --git a/templates/tools/claude-code/template.toml b/templates/ai-tools/claude-code/template.toml similarity index 96% rename from templates/tools/claude-code/template.toml rename to templates/ai-tools/claude-code/template.toml index 8bc784f7..a9679cc8 100644 --- a/templates/tools/claude-code/template.toml +++ b/templates/ai-tools/claude-code/template.toml @@ -1,4 +1,5 @@ display-name = "Claude Code" +primary = true display-disc = "Anthropic Claude Code AI assistant" display-order = 10 tags = [ diff --git a/templates/tools/codex/template.toml b/templates/ai-tools/codex/template.toml similarity index 91% rename from templates/tools/codex/template.toml rename to templates/ai-tools/codex/template.toml index 9c89342f..3f557b68 100644 --- a/templates/tools/codex/template.toml +++ b/templates/ai-tools/codex/template.toml @@ -1,4 +1,5 @@ display-name = "Codex" +primary = true display-disc = "OpenAI Codex CLI" display-order = 12 tags = [ diff --git a/templates/ai-tools/cursor/template.toml b/templates/ai-tools/cursor/template.toml new file mode 100644 index 00000000..29957f10 --- /dev/null +++ b/templates/ai-tools/cursor/template.toml @@ -0,0 +1,14 @@ +display-name = "Cursor" +primary = true +display-disc = "Cursor AI code editor" +display-order = 16 +tags = [ + "ai", + "cursor", + "editor" +] + +[segments] +"Boothfile--60" = """ +setup cursor +""" diff --git a/templates/ai-tools/meta.toml b/templates/ai-tools/meta.toml new file mode 100644 index 00000000..97011a3e --- /dev/null +++ b/templates/ai-tools/meta.toml @@ -0,0 +1,2 @@ +display-name = "AI Tools" +order = 7 diff --git a/templates/tools/warp/template.toml b/templates/ai-tools/warp/template.toml similarity index 100% rename from templates/tools/warp/template.toml rename to templates/ai-tools/warp/template.toml diff --git a/templates/ides/vscode/template.toml b/templates/ides/vscode/template.toml deleted file mode 100644 index 37a34a09..00000000 --- a/templates/ides/vscode/template.toml +++ /dev/null @@ -1,13 +0,0 @@ -display-name = "VS Code" -display-disc = "Visual Studio Code desktop IDE" -display-order = 10 -tags = [ - "vscode", - "ide", - "editor" -] - -[segments] -"Boothfile--60" = """ -setup vscode -""" diff --git a/templates/languages/bun/vscode-ext--extension.toml b/templates/languages/bun/vscode-ext--extension.toml new file mode 100644 index 00000000..10f39778 --- /dev/null +++ b/templates/languages/bun/vscode-ext--extension.toml @@ -0,0 +1,15 @@ +# NOTE: This template has not been tested -- no time (sorry). Please report success or failure. :-p +display-name = "Bun VS Code Extension" +display-disc = "Bun runtime support for VS Code" +display-order = 1 +auto-select = true +tags = [ + "bun", + "ide", + "vscode", +] + +[segments] +Boothfile = """ +setup bun-code-extension +""" diff --git a/templates/languages/clojure/vscode-ext--extension.toml b/templates/languages/clojure/vscode-ext--extension.toml new file mode 100644 index 00000000..6a35147d --- /dev/null +++ b/templates/languages/clojure/vscode-ext--extension.toml @@ -0,0 +1,15 @@ +# NOTE: This template has not been tested -- no time (sorry). Please report success or failure. :-p +display-name = "Clojure VS Code Extension" +display-disc = "Clojure language support for VS Code" +display-order = 1 +auto-select = true +tags = [ + "clojure", + "ide", + "vscode", +] + +[segments] +Boothfile = """ +setup clojure-code-extension +""" diff --git a/templates/languages/deno/vscode-ext--extension.toml b/templates/languages/deno/vscode-ext--extension.toml new file mode 100644 index 00000000..f0d0d914 --- /dev/null +++ b/templates/languages/deno/vscode-ext--extension.toml @@ -0,0 +1,15 @@ +# NOTE: This template has not been tested -- no time (sorry). Please report success or failure. :-p +display-name = "Deno VS Code Extension" +display-disc = "Deno runtime support for VS Code" +display-order = 1 +auto-select = true +tags = [ + "deno", + "ide", + "vscode", +] + +[segments] +Boothfile = """ +setup deno-code-extension +""" diff --git a/templates/languages/elixir/vscode-ext--extension.toml b/templates/languages/elixir/vscode-ext--extension.toml new file mode 100644 index 00000000..c5759c57 --- /dev/null +++ b/templates/languages/elixir/vscode-ext--extension.toml @@ -0,0 +1,15 @@ +# NOTE: This template has not been tested -- no time (sorry). Please report success or failure. :-p +display-name = "Elixir VS Code Extension" +display-disc = "Elixir language support for VS Code" +display-order = 1 +auto-select = true +tags = [ + "elixir", + "ide", + "vscode", +] + +[segments] +Boothfile = """ +setup elixir-code-extension +""" diff --git a/templates/languages/erlang/vscode-ext--extension.toml b/templates/languages/erlang/vscode-ext--extension.toml new file mode 100644 index 00000000..e70f6c76 --- /dev/null +++ b/templates/languages/erlang/vscode-ext--extension.toml @@ -0,0 +1,15 @@ +# NOTE: This template has not been tested -- no time (sorry). Please report success or failure. :-p +display-name = "Erlang VS Code Extension" +display-disc = "Erlang language support for VS Code" +display-order = 1 +auto-select = true +tags = [ + "erlang", + "ide", + "vscode", +] + +[segments] +Boothfile = """ +setup erlang-code-extension +""" diff --git a/templates/languages/fpc/vscode-ext--extension.toml b/templates/languages/fpc/vscode-ext--extension.toml new file mode 100644 index 00000000..787220be --- /dev/null +++ b/templates/languages/fpc/vscode-ext--extension.toml @@ -0,0 +1,16 @@ +# NOTE: This template has not been tested -- no time (sorry). Please report success or failure. :-p +display-name = "Free Pascal VS Code Extension" +display-disc = "Pascal language support for VS Code" +display-order = 1 +auto-select = true +tags = [ + "fpc", + "pascal", + "ide", + "vscode", +] + +[segments] +Boothfile = """ +setup fpc-code-extension +""" diff --git a/templates/languages/go/kernel--extension.toml b/templates/languages/go/kernel--extension.toml new file mode 100644 index 00000000..b17337a8 --- /dev/null +++ b/templates/languages/go/kernel--extension.toml @@ -0,0 +1,15 @@ +display-name = "Go Notebook Kernel" +display-disc = "GoNB Jupyter kernel for Go" +display-order = 5 +auto-select = false +requires = ["notebook"] +tags = [ + "go", + "kernel", + "notebook" +] + +[segments] +"Boothfile--60" = """ +setup go-kernel +""" diff --git a/templates/languages/go/template.toml b/templates/languages/go/template.toml index e676a078..68b1290b 100644 --- a/templates/languages/go/template.toml +++ b/templates/languages/go/template.toml @@ -1,4 +1,5 @@ display-name = "Go" +primary = true display-disc = "Go language toolchain" display-order = 10 tags = [ diff --git a/templates/languages/haskell/vscode-ext--extension.toml b/templates/languages/haskell/vscode-ext--extension.toml new file mode 100644 index 00000000..980ae440 --- /dev/null +++ b/templates/languages/haskell/vscode-ext--extension.toml @@ -0,0 +1,15 @@ +# NOTE: This template has not been tested -- no time (sorry). Please report success or failure. :-p +display-name = "Haskell VS Code Extension" +display-disc = "Haskell language support for VS Code" +display-order = 1 +auto-select = true +tags = [ + "haskell", + "ide", + "vscode", +] + +[segments] +Boothfile = """ +setup haskell-code-extension +""" diff --git a/templates/languages/java/kernel--extension.toml b/templates/languages/java/kernel--extension.toml new file mode 100644 index 00000000..46fe5fac --- /dev/null +++ b/templates/languages/java/kernel--extension.toml @@ -0,0 +1,15 @@ +display-name = "Java Notebook Kernel" +display-disc = "IJava Jupyter kernel for Java" +display-order = 5 +auto-select = false +requires = ["notebook"] +tags = [ + "java", + "kernel", + "notebook" +] + +[segments] +"Boothfile--60" = """ +setup java-kernel +""" diff --git a/templates/languages/java/template.toml b/templates/languages/java/template.toml index db6b8ab1..99688495 100644 --- a/templates/languages/java/template.toml +++ b/templates/languages/java/template.toml @@ -1,4 +1,5 @@ display-name = "Java" +primary = true display-disc = "JDK with build tools" display-order = 30 tags = [ diff --git a/templates/languages/kotlin/kernel--extension.toml b/templates/languages/kotlin/kernel--extension.toml new file mode 100644 index 00000000..25b34131 --- /dev/null +++ b/templates/languages/kotlin/kernel--extension.toml @@ -0,0 +1,15 @@ +display-name = "Kotlin Notebook Kernel" +display-disc = "Kotlin Jupyter kernel" +display-order = 5 +auto-select = false +requires = ["notebook"] +tags = [ + "kotlin", + "kernel", + "notebook" +] + +[segments] +"Boothfile--60" = """ +setup kotlin-kernel +""" diff --git a/templates/languages/kotlin/vscode-ext--extension.toml b/templates/languages/kotlin/vscode-ext--extension.toml new file mode 100644 index 00000000..3c2664de --- /dev/null +++ b/templates/languages/kotlin/vscode-ext--extension.toml @@ -0,0 +1,15 @@ +# NOTE: This template has not been tested -- no time (sorry). Please report success or failure. :-p +display-name = "Kotlin VS Code Extension" +display-disc = "Kotlin language support for VS Code" +display-order = 1 +auto-select = true +tags = [ + "kotlin", + "ide", + "vscode", +] + +[segments] +Boothfile = """ +setup kotlin-code-extension +""" diff --git a/templates/languages/lua/vscode-ext--extension.toml b/templates/languages/lua/vscode-ext--extension.toml new file mode 100644 index 00000000..330c7d5c --- /dev/null +++ b/templates/languages/lua/vscode-ext--extension.toml @@ -0,0 +1,15 @@ +# NOTE: This template has not been tested -- no time (sorry). Please report success or failure. :-p +display-name = "Lua VS Code Extension" +display-disc = "Lua language support for VS Code" +display-order = 1 +auto-select = true +tags = [ + "lua", + "ide", + "vscode", +] + +[segments] +Boothfile = """ +setup lua-code-extension +""" diff --git a/templates/languages/nodejs/kernel--extension.toml b/templates/languages/nodejs/kernel--extension.toml new file mode 100644 index 00000000..f12a0e48 --- /dev/null +++ b/templates/languages/nodejs/kernel--extension.toml @@ -0,0 +1,15 @@ +display-name = "Node.js Notebook Kernel" +display-disc = "tslab Jupyter kernel for JavaScript/TypeScript" +display-order = 5 +auto-select = false +requires = ["notebook"] +tags = [ + "nodejs", + "kernel", + "notebook" +] + +[segments] +"Boothfile--60" = """ +setup nodejs-kernel +""" diff --git a/templates/languages/nodejs/template.toml b/templates/languages/nodejs/template.toml index 8d8e4c89..7fbd8ab5 100644 --- a/templates/languages/nodejs/template.toml +++ b/templates/languages/nodejs/template.toml @@ -1,4 +1,5 @@ display-name = "Node.js" +primary = true display-disc = "Node.js JavaScript runtime" display-order = 50 tags = [ diff --git a/templates/languages/nodejs/vscode-ext--extension.toml b/templates/languages/nodejs/vscode-ext--extension.toml new file mode 100644 index 00000000..f1eba14b --- /dev/null +++ b/templates/languages/nodejs/vscode-ext--extension.toml @@ -0,0 +1,16 @@ +# NOTE: This template has not been tested -- no time (sorry). Please report success or failure. :-p +display-name = "Node.js VS Code Extension" +display-disc = "Node.js/JavaScript support for VS Code" +display-order = 1 +auto-select = true +tags = [ + "nodejs", + "javascript", + "ide", + "vscode", +] + +[segments] +Boothfile = """ +setup nodejs-code-extension +""" diff --git a/templates/languages/php/vscode-ext--extension.toml b/templates/languages/php/vscode-ext--extension.toml new file mode 100644 index 00000000..78eeb7b8 --- /dev/null +++ b/templates/languages/php/vscode-ext--extension.toml @@ -0,0 +1,15 @@ +# NOTE: This template has not been tested -- no time (sorry). Please report success or failure. :-p +display-name = "PHP VS Code Extension" +display-disc = "PHP language support for VS Code" +display-order = 1 +auto-select = true +tags = [ + "php", + "ide", + "vscode", +] + +[segments] +Boothfile = """ +setup php-code-extension +""" diff --git a/templates/languages/python/kernel--extension.toml b/templates/languages/python/kernel--extension.toml new file mode 100644 index 00000000..cb6ed885 --- /dev/null +++ b/templates/languages/python/kernel--extension.toml @@ -0,0 +1,15 @@ +display-name = "Python Notebook Kernel" +display-disc = "ipykernel for Python" +display-order = 5 +auto-select = false +requires = ["notebook"] +tags = [ + "python", + "kernel", + "notebook" +] + +[segments] +"Boothfile--60" = """ +setup python-kernel +""" diff --git a/templates/languages/python/template.toml b/templates/languages/python/template.toml index ed0fb98c..273cf783 100644 --- a/templates/languages/python/template.toml +++ b/templates/languages/python/template.toml @@ -1,4 +1,5 @@ display-name = "Python" +primary = true display-disc = "Python language with pip" display-order = 20 tags = [ diff --git a/templates/languages/r/kernel--extension.toml b/templates/languages/r/kernel--extension.toml new file mode 100644 index 00000000..b3fbc848 --- /dev/null +++ b/templates/languages/r/kernel--extension.toml @@ -0,0 +1,15 @@ +display-name = "R Notebook Kernel" +display-disc = "IRkernel Jupyter kernel for R" +display-order = 5 +auto-select = false +requires = ["notebook"] +tags = [ + "r", + "kernel", + "notebook" +] + +[segments] +"Boothfile--60" = """ +setup r-kernel +""" diff --git a/templates/languages/r/vscode-ext--extension.toml b/templates/languages/r/vscode-ext--extension.toml new file mode 100644 index 00000000..d70eddb9 --- /dev/null +++ b/templates/languages/r/vscode-ext--extension.toml @@ -0,0 +1,15 @@ +# NOTE: This template has not been tested -- no time (sorry). Please report success or failure. :-p +display-name = "R VS Code Extension" +display-disc = "R language support for VS Code" +display-order = 1 +auto-select = true +tags = [ + "r", + "ide", + "vscode", +] + +[segments] +Boothfile = """ +setup r-code-extension +""" diff --git a/templates/languages/ruby/kernel--extension.toml b/templates/languages/ruby/kernel--extension.toml new file mode 100644 index 00000000..ee745867 --- /dev/null +++ b/templates/languages/ruby/kernel--extension.toml @@ -0,0 +1,15 @@ +display-name = "Ruby Notebook Kernel" +display-disc = "IRuby Jupyter kernel for Ruby" +display-order = 5 +auto-select = false +requires = ["notebook"] +tags = [ + "ruby", + "kernel", + "notebook" +] + +[segments] +"Boothfile--60" = """ +setup ruby-kernel +""" diff --git a/templates/languages/ruby/vscode-ext--extension.toml b/templates/languages/ruby/vscode-ext--extension.toml new file mode 100644 index 00000000..5c0ab96b --- /dev/null +++ b/templates/languages/ruby/vscode-ext--extension.toml @@ -0,0 +1,15 @@ +# NOTE: This template has not been tested -- no time (sorry). Please report success or failure. :-p +display-name = "Ruby VS Code Extension" +display-disc = "Ruby language support for VS Code" +display-order = 1 +auto-select = true +tags = [ + "ruby", + "ide", + "vscode", +] + +[segments] +Boothfile = """ +setup ruby-code-extension +""" diff --git a/templates/languages/rust/kernel--extension.toml b/templates/languages/rust/kernel--extension.toml new file mode 100644 index 00000000..a76d701a --- /dev/null +++ b/templates/languages/rust/kernel--extension.toml @@ -0,0 +1,15 @@ +display-name = "Rust Notebook Kernel" +display-disc = "evcxr Jupyter kernel for Rust" +display-order = 5 +auto-select = false +requires = ["notebook"] +tags = [ + "rust", + "kernel", + "notebook" +] + +[segments] +"Boothfile--60" = """ +setup rust-kernel +""" diff --git a/templates/languages/rust/template.toml b/templates/languages/rust/template.toml index ba31d449..1a05ce92 100644 --- a/templates/languages/rust/template.toml +++ b/templates/languages/rust/template.toml @@ -1,4 +1,5 @@ display-name = "Rust" +primary = true display-disc = "Rust language toolchain" display-order = 40 tags = [ diff --git a/templates/languages/scala/vscode-ext--extension.toml b/templates/languages/scala/vscode-ext--extension.toml new file mode 100644 index 00000000..931cc4ee --- /dev/null +++ b/templates/languages/scala/vscode-ext--extension.toml @@ -0,0 +1,15 @@ +# NOTE: This template has not been tested -- no time (sorry). Please report success or failure. :-p +display-name = "Scala VS Code Extension" +display-disc = "Scala language support for VS Code" +display-order = 1 +auto-select = true +tags = [ + "scala", + "ide", + "vscode", +] + +[segments] +Boothfile = """ +setup scala-code-extension +""" diff --git a/templates/languages/zig/vscode-ext--extension.toml b/templates/languages/zig/vscode-ext--extension.toml new file mode 100644 index 00000000..921772eb --- /dev/null +++ b/templates/languages/zig/vscode-ext--extension.toml @@ -0,0 +1,15 @@ +# NOTE: This template has not been tested -- no time (sorry). Please report success or failure. :-p +display-name = "Zig VS Code Extension" +display-disc = "Zig language support for VS Code" +display-order = 1 +auto-select = true +tags = [ + "zig", + "ide", + "vscode", +] + +[segments] +Boothfile = """ +setup zig-code-extension +""" diff --git a/variants/base/Dockerfile b/variants/base/Dockerfile index 2f17a10c..31f412e7 100644 --- a/variants/base/Dockerfile +++ b/variants/base/Dockerfile @@ -152,5 +152,8 @@ RUN rm -f /etc/.pwd.lock /etc/passwd.lock /etc/shadow.lock /etc/group.lock # so we need tini to handle signals properly. ENTRYPOINT ["tini","-g","--","/usr/local/bin/booth-entry"] +# start-ttyd wrapper: supports PASSWORD env var for basic auth +COPY --chmod=0755 start-ttyd /usr/local/bin/start-ttyd + # By default, start ttyd on port 10000 in the home directory (the online terminal) -CMD ["bash","-lc","exec ttyd -W -i 0.0.0.0 -p 10000 --writable bash -l"] \ No newline at end of file +CMD ["bash","-lc","exec start-ttyd"] \ No newline at end of file diff --git a/variants/base/setups/bun-code-extension--setup.sh b/variants/base/setups/bun-code-extension--setup.sh new file mode 100755 index 00000000..51defc74 --- /dev/null +++ b/variants/base/setups/bun-code-extension--setup.sh @@ -0,0 +1,31 @@ +#!/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. + +# bun-code-extension--setup.sh +# NOTE: This script has not been tested -- no time (sorry). Please report success or failure. :-p +set -Eeuo pipefail + +if [[ ${EUID} -ne 0 ]]; then + echo "This installer must be run as root." >&2 + exit 1 +fi + +# This script will always be installed by root. +HOME=/root + +SCRIPT_NAME="$(basename "$0")" +SCRIPT_DIR="$(dirname "$0")" +source "$SCRIPT_DIR/libs/skip-setup.sh" +if ! "$SCRIPT_DIR/cb-has-vscode.sh"; then + skip_setup "$SCRIPT_NAME" "code-server/VSCode not installed" +fi + +SETUP_LIBS_DIR=${SETUP_LIBS_DIR:-/opt/codingbooth/setups/libs} +CODE_EXTENSION_LIB=${CODE_EXTENSION_LIB:-code-extension-source.sh} +source "${SETUP_LIBS_DIR}/${CODE_EXTENSION_LIB}" + +install_extensions oven.bun-vscode + +echo "βœ… Extension installation completed." diff --git a/variants/base/setups/clojure-code-extension--setup.sh b/variants/base/setups/clojure-code-extension--setup.sh new file mode 100755 index 00000000..b278d6df --- /dev/null +++ b/variants/base/setups/clojure-code-extension--setup.sh @@ -0,0 +1,31 @@ +#!/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. + +# clojure-code-extension--setup.sh +# NOTE: This script has not been tested -- no time (sorry). Please report success or failure. :-p +set -Eeuo pipefail + +if [[ ${EUID} -ne 0 ]]; then + echo "This installer must be run as root." >&2 + exit 1 +fi + +# This script will always be installed by root. +HOME=/root + +SCRIPT_NAME="$(basename "$0")" +SCRIPT_DIR="$(dirname "$0")" +source "$SCRIPT_DIR/libs/skip-setup.sh" +if ! "$SCRIPT_DIR/cb-has-vscode.sh"; then + skip_setup "$SCRIPT_NAME" "code-server/VSCode not installed" +fi + +SETUP_LIBS_DIR=${SETUP_LIBS_DIR:-/opt/codingbooth/setups/libs} +CODE_EXTENSION_LIB=${CODE_EXTENSION_LIB:-code-extension-source.sh} +source "${SETUP_LIBS_DIR}/${CODE_EXTENSION_LIB}" + +install_extensions betterthantomorrow.calva + +echo "βœ… Extension installation completed." diff --git a/variants/base/setups/codeserver--setup.sh b/variants/base/setups/codeserver--setup.sh index 089c7943..e2000787 100755 --- a/variants/base/setups/codeserver--setup.sh +++ b/variants/base/setups/codeserver--setup.sh @@ -193,12 +193,13 @@ code-server --extensions-dir "$CODESERVER_EXTENSION_DIR" --list-extensions || tr echo "[4/9] Create launcher: /usr/local/bin/codeserver" export CODESERVER_EXTENSION_DIR -envsubst '$PASSWORD $CODESERVER_EXTENSION_DIR' > ${STARTER_FILE} <<'LAUNCH' +envsubst '$CODESERVER_EXTENSION_DIR' > ${STARTER_FILE} <<'LAUNCH' #!/usr/bin/env bash set -Eeuo pipefail trap 'echo "❌ Error on line $LINENO"; exit 1' ERR PORT=${1:-10000} +PASSWORD="${PASSWORD:-}" # Ensure PATH and /opt/python are active in non-login shells source /etc/profile.d/53-cb-python--profile.sh 2>/dev/null || true diff --git a/variants/base/setups/deno-code-extension--setup.sh b/variants/base/setups/deno-code-extension--setup.sh new file mode 100755 index 00000000..297b9d28 --- /dev/null +++ b/variants/base/setups/deno-code-extension--setup.sh @@ -0,0 +1,31 @@ +#!/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. + +# deno-code-extension--setup.sh +# NOTE: This script has not been tested -- no time (sorry). Please report success or failure. :-p +set -Eeuo pipefail + +if [[ ${EUID} -ne 0 ]]; then + echo "This installer must be run as root." >&2 + exit 1 +fi + +# This script will always be installed by root. +HOME=/root + +SCRIPT_NAME="$(basename "$0")" +SCRIPT_DIR="$(dirname "$0")" +source "$SCRIPT_DIR/libs/skip-setup.sh" +if ! "$SCRIPT_DIR/cb-has-vscode.sh"; then + skip_setup "$SCRIPT_NAME" "code-server/VSCode not installed" +fi + +SETUP_LIBS_DIR=${SETUP_LIBS_DIR:-/opt/codingbooth/setups/libs} +CODE_EXTENSION_LIB=${CODE_EXTENSION_LIB:-code-extension-source.sh} +source "${SETUP_LIBS_DIR}/${CODE_EXTENSION_LIB}" + +install_extensions denoland.vscode-deno + +echo "βœ… Extension installation completed." diff --git a/variants/base/setups/deno-nb-kernel--setup.sh b/variants/base/setups/deno-nb-kernel--setup.sh new file mode 100755 index 00000000..036064fa --- /dev/null +++ b/variants/base/setups/deno-nb-kernel--setup.sh @@ -0,0 +1,99 @@ +#!/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. + +# deno-nb-kernel--setup.sh +# NOTE: This script has not been tested -- no time (sorry). Please report success or failure. :-p +# +# Installs the built-in Deno Jupyter kernel. +# Deno has native Jupyter support via `deno jupyter --install`. +# +# Prereqs: +# - deno--setup.sh already ran (deno on PATH). +# - python--setup.sh and notebook--setup.sh already ran. +# - /etc/profile.d/53-cb-python--profile.sh should be sourced. + +set -Eeuo pipefail +trap 'echo "❌ Error on line $LINENO"; exit 1' ERR + +# ---------------- Root & early checks ---------------- +if [ "$EUID" -ne 0 ]; then + echo "❌ This script must be run as root (use sudo)." >&2 + exit 1 +fi + +# This script will always be installed by root. +HOME=/root + +# ---------------- Load environment from profile.d ---------------- +source /etc/profile.d/53-cb-python--profile.sh 2>/dev/null || true + +# ---------------- Defaults / Tunables ---------------- +JUPYTER_KERNEL_PREFIX="${JUPYTER_KERNEL_PREFIX:-/usr/local}" +KERNEL_NAME="${KERNEL_NAME:-deno}" +KERNEL_DISPLAY_NAME="${KERNEL_DISPLAY_NAME:-Deno}" + +# ---------------- Sanity checks ---------------- +if ! command -v python >/dev/null 2>&1; then + echo "❌ Could not find any Python interpreter." >&2 + exit 1 +fi + +if ! command -v deno >/dev/null 2>&1; then + echo "❌ Deno is not installed or not on PATH." >&2 + exit 1 +fi + +# Ensure python has jupyter_client and jupyter_core +if ! python - <<'PY' >/dev/null 2>&1 +import importlib.util as u +raise SystemExit(0 if all(u.find_spec(m) for m in ("jupyter_client","jupyter_core")) else 1) +PY +then + echo "❌ python lacks required Jupyter packages ('jupyter_client' and/or 'jupyter_core')." >&2 + exit 2 +fi + +# ---------------- Create kernelspec manually ---------------- +# `deno jupyter --install` writes to user data dir. We create for our prefix. +DENO_BIN="$(command -v deno)" +TMPKDIR="$(mktemp -d)/deno" +mkdir -p "${TMPKDIR}" + +cat > "${TMPKDIR}/kernel.json" </dev/null || true + +# ---------------- Verification ---------------- +echo +echo "πŸ”Ž Kernels:" +python -m jupyter kernelspec list || true + +# ---------------- Friendly summary ---------------- +echo +echo "βœ… Deno kernel installed." +echo " Kernel name: ${KERNEL_NAME}" +echo " Display name: ${KERNEL_DISPLAY_NAME}" +echo " Kernelspec dir: ${KDIR}" +echo " Deno binary: ${DENO_BIN}" +echo " Deno version: $(deno --version 2>/dev/null | head -1 || echo 'unknown')" diff --git a/variants/base/setups/elixir-code-extension--setup.sh b/variants/base/setups/elixir-code-extension--setup.sh new file mode 100755 index 00000000..84c2faa0 --- /dev/null +++ b/variants/base/setups/elixir-code-extension--setup.sh @@ -0,0 +1,31 @@ +#!/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. + +# elixir-code-extension--setup.sh +# NOTE: This script has not been tested -- no time (sorry). Please report success or failure. :-p +set -Eeuo pipefail + +if [[ ${EUID} -ne 0 ]]; then + echo "This installer must be run as root." >&2 + exit 1 +fi + +# This script will always be installed by root. +HOME=/root + +SCRIPT_NAME="$(basename "$0")" +SCRIPT_DIR="$(dirname "$0")" +source "$SCRIPT_DIR/libs/skip-setup.sh" +if ! "$SCRIPT_DIR/cb-has-vscode.sh"; then + skip_setup "$SCRIPT_NAME" "code-server/VSCode not installed" +fi + +SETUP_LIBS_DIR=${SETUP_LIBS_DIR:-/opt/codingbooth/setups/libs} +CODE_EXTENSION_LIB=${CODE_EXTENSION_LIB:-code-extension-source.sh} +source "${SETUP_LIBS_DIR}/${CODE_EXTENSION_LIB}" + +install_extensions JakeBecker.elixir-ls + +echo "βœ… Extension installation completed." diff --git a/variants/base/setups/erlang-code-extension--setup.sh b/variants/base/setups/erlang-code-extension--setup.sh new file mode 100755 index 00000000..fec4e3ef --- /dev/null +++ b/variants/base/setups/erlang-code-extension--setup.sh @@ -0,0 +1,31 @@ +#!/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. + +# erlang-code-extension--setup.sh +# NOTE: This script has not been tested -- no time (sorry). Please report success or failure. :-p +set -Eeuo pipefail + +if [[ ${EUID} -ne 0 ]]; then + echo "This installer must be run as root." >&2 + exit 1 +fi + +# This script will always be installed by root. +HOME=/root + +SCRIPT_NAME="$(basename "$0")" +SCRIPT_DIR="$(dirname "$0")" +source "$SCRIPT_DIR/libs/skip-setup.sh" +if ! "$SCRIPT_DIR/cb-has-vscode.sh"; then + skip_setup "$SCRIPT_NAME" "code-server/VSCode not installed" +fi + +SETUP_LIBS_DIR=${SETUP_LIBS_DIR:-/opt/codingbooth/setups/libs} +CODE_EXTENSION_LIB=${CODE_EXTENSION_LIB:-code-extension-source.sh} +source "${SETUP_LIBS_DIR}/${CODE_EXTENSION_LIB}" + +install_extensions erlang-ls.erlang-ls + +echo "βœ… Extension installation completed." diff --git a/variants/base/setups/fpc-code-extension--setup.sh b/variants/base/setups/fpc-code-extension--setup.sh new file mode 100755 index 00000000..ebc350a2 --- /dev/null +++ b/variants/base/setups/fpc-code-extension--setup.sh @@ -0,0 +1,31 @@ +#!/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. + +# fpc-code-extension--setup.sh +# NOTE: This script has not been tested -- no time (sorry). Please report success or failure. :-p +set -Eeuo pipefail + +if [[ ${EUID} -ne 0 ]]; then + echo "This installer must be run as root." >&2 + exit 1 +fi + +# This script will always be installed by root. +HOME=/root + +SCRIPT_NAME="$(basename "$0")" +SCRIPT_DIR="$(dirname "$0")" +source "$SCRIPT_DIR/libs/skip-setup.sh" +if ! "$SCRIPT_DIR/cb-has-vscode.sh"; then + skip_setup "$SCRIPT_NAME" "code-server/VSCode not installed" +fi + +SETUP_LIBS_DIR=${SETUP_LIBS_DIR:-/opt/codingbooth/setups/libs} +CODE_EXTENSION_LIB=${CODE_EXTENSION_LIB:-code-extension-source.sh} +source "${SETUP_LIBS_DIR}/${CODE_EXTENSION_LIB}" + +install_extensions alefragnani.pascal + +echo "βœ… Extension installation completed." diff --git a/variants/base/setups/go-nb-kernel--setup.sh b/variants/base/setups/go-nb-kernel--setup.sh new file mode 100755 index 00000000..7d231cee --- /dev/null +++ b/variants/base/setups/go-nb-kernel--setup.sh @@ -0,0 +1,109 @@ +#!/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. + +# go-nb-kernel--setup.sh +# NOTE: This script has not been tested -- no time (sorry). Please report success or failure. :-p +# +# Installs the GoNB (Go Notebook) Jupyter kernel. +# GoNB provides a full Go REPL experience in Jupyter. +# +# Prereqs: +# - go--setup.sh already ran successfully (Go on PATH, GOPATH set). +# - python--setup.sh and notebook--setup.sh already ran. +# - /etc/profile.d/53-cb-python--profile.sh should be sourced. + +set -Eeuo pipefail +trap 'echo "❌ Error on line $LINENO"; exit 1' ERR + +# ---------------- Root & early checks ---------------- +if [ "$EUID" -ne 0 ]; then + echo "❌ This script must be run as root (use sudo)." >&2 + exit 1 +fi + +# This script will always be installed by root. +HOME=/root + +# ---------------- Load environment from profile.d ---------------- +source /etc/profile.d/53-cb-python--profile.sh 2>/dev/null || true + +# ---------------- Defaults / Tunables ---------------- +JUPYTER_KERNEL_PREFIX="${JUPYTER_KERNEL_PREFIX:-/usr/local}" +KERNEL_NAME="${KERNEL_NAME:-go}" +KERNEL_DISPLAY_NAME="${KERNEL_DISPLAY_NAME:-Go (GoNB)}" + +# ---------------- Sanity checks ---------------- +if ! command -v python >/dev/null 2>&1; then + echo "❌ Could not find any Python interpreter." >&2 + exit 1 +fi + +if ! command -v go >/dev/null 2>&1; then + echo "❌ Go is not installed or not on PATH." >&2 + exit 1 +fi + +# Ensure python has jupyter_client and jupyter_core +if ! python - <<'PY' >/dev/null 2>&1 +import importlib.util as u +raise SystemExit(0 if all(u.find_spec(m) for m in ("jupyter_client","jupyter_core")) else 1) +PY +then + echo "❌ python lacks required Jupyter packages ('jupyter_client' and/or 'jupyter_core')." >&2 + exit 2 +fi + +# ---------------- Install GoNB ---------------- +echo "πŸ“¦ Installing GoNB kernel binary..." +go install github.com/janpfeifer/gonb@latest +go install golang.org/x/tools/cmd/goimports@latest + +GONB_BIN="$(go env GOPATH)/bin/gonb" +if [ ! -x "${GONB_BIN}" ]; then + echo "❌ gonb binary not found at ${GONB_BIN}" >&2 + exit 1 +fi + +# ---------------- Create kernelspec manually ---------------- +# We create the kernelspec ourselves rather than relying on `gonb --install` +# which writes to the user's jupyter data dir, not our system prefix. +TMPKDIR="$(mktemp -d)/gonb" +mkdir -p "${TMPKDIR}" + +cat > "${TMPKDIR}/kernel.json" </dev/null || true + +# ---------------- Verification ---------------- +echo +echo "πŸ”Ž Kernels:" +python -m jupyter kernelspec list || true + +# ---------------- Friendly summary ---------------- +echo +echo "βœ… Go (GoNB) kernel installed." +echo " Kernel name: ${KERNEL_NAME}" +echo " Display name: ${KERNEL_DISPLAY_NAME}" +echo " Kernelspec dir: ${KDIR}" +echo " GoNB binary: ${GONB_BIN}" diff --git a/variants/base/setups/haskell-code-extension--setup.sh b/variants/base/setups/haskell-code-extension--setup.sh new file mode 100755 index 00000000..60c76ab5 --- /dev/null +++ b/variants/base/setups/haskell-code-extension--setup.sh @@ -0,0 +1,31 @@ +#!/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. + +# haskell-code-extension--setup.sh +# NOTE: This script has not been tested -- no time (sorry). Please report success or failure. :-p +set -Eeuo pipefail + +if [[ ${EUID} -ne 0 ]]; then + echo "This installer must be run as root." >&2 + exit 1 +fi + +# This script will always be installed by root. +HOME=/root + +SCRIPT_NAME="$(basename "$0")" +SCRIPT_DIR="$(dirname "$0")" +source "$SCRIPT_DIR/libs/skip-setup.sh" +if ! "$SCRIPT_DIR/cb-has-vscode.sh"; then + skip_setup "$SCRIPT_NAME" "code-server/VSCode not installed" +fi + +SETUP_LIBS_DIR=${SETUP_LIBS_DIR:-/opt/codingbooth/setups/libs} +CODE_EXTENSION_LIB=${CODE_EXTENSION_LIB:-code-extension-source.sh} +source "${SETUP_LIBS_DIR}/${CODE_EXTENSION_LIB}" + +install_extensions haskell.haskell + +echo "βœ… Extension installation completed." diff --git a/variants/base/setups/kde--setup.sh b/variants/base/setups/kde--setup.sh index 671376e2..6c2ae3b3 100755 --- a/variants/base/setups/kde--setup.sh +++ b/variants/base/setups/kde--setup.sh @@ -269,6 +269,10 @@ trap 'echo "❌ Error on line $LINENO" >&2; exit 1' ERR : "${GEOMETRY:=1280x800}" : "${NOVNC_PORT:=10000}" : "${VNC_PASSWORD:=}" +# Map unified PASSWORD to VNC_PASSWORD if VNC_PASSWORD is not explicitly set +if [[ -z "${VNC_PASSWORD}" && -n "${PASSWORD:-}" ]]; then + VNC_PASSWORD="${PASSWORD}" +fi : "${KEYRING_MODE:=basic}" # basic | disable | keep # infer VNC port diff --git a/variants/base/setups/kotlin-code-extension--setup.sh b/variants/base/setups/kotlin-code-extension--setup.sh new file mode 100755 index 00000000..b1c44116 --- /dev/null +++ b/variants/base/setups/kotlin-code-extension--setup.sh @@ -0,0 +1,31 @@ +#!/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. + +# kotlin-code-extension--setup.sh +# NOTE: This script has not been tested -- no time (sorry). Please report success or failure. :-p +set -Eeuo pipefail + +if [[ ${EUID} -ne 0 ]]; then + echo "This installer must be run as root." >&2 + exit 1 +fi + +# This script will always be installed by root. +HOME=/root + +SCRIPT_NAME="$(basename "$0")" +SCRIPT_DIR="$(dirname "$0")" +source "$SCRIPT_DIR/libs/skip-setup.sh" +if ! "$SCRIPT_DIR/cb-has-vscode.sh"; then + skip_setup "$SCRIPT_NAME" "code-server/VSCode not installed" +fi + +SETUP_LIBS_DIR=${SETUP_LIBS_DIR:-/opt/codingbooth/setups/libs} +CODE_EXTENSION_LIB=${CODE_EXTENSION_LIB:-code-extension-source.sh} +source "${SETUP_LIBS_DIR}/${CODE_EXTENSION_LIB}" + +install_extensions fwcd.kotlin + +echo "βœ… Extension installation completed." diff --git a/variants/base/setups/kotlin-nb-kernel--setup.sh b/variants/base/setups/kotlin-nb-kernel--setup.sh new file mode 100755 index 00000000..f0493f8f --- /dev/null +++ b/variants/base/setups/kotlin-nb-kernel--setup.sh @@ -0,0 +1,112 @@ +#!/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. + +# kotlin-nb-kernel--setup.sh +# NOTE: This script has not been tested -- no time (sorry). Please report success or failure. :-p +# +# Installs the Kotlin Jupyter kernel (JetBrains official, pip-based). +# This is a pip package that wraps a JVM-based kernel. +# +# Prereqs: +# - kotlin--setup.sh already ran (JDK on PATH for the kernel runtime). +# - python--setup.sh and notebook--setup.sh already ran. +# - /etc/profile.d/53-cb-python--profile.sh should be sourced. + +set -Eeuo pipefail +trap 'echo "❌ Error on line $LINENO"; exit 1' ERR + +# ---------------- Root & early checks ---------------- +if [ "$EUID" -ne 0 ]; then + echo "❌ This script must be run as root (use sudo)." >&2 + exit 1 +fi + +# This script will always be installed by root. +HOME=/root + +# ---------------- Load environment from profile.d ---------------- +source /etc/profile.d/53-cb-python--profile.sh 2>/dev/null || true + +# ---------------- Defaults / Tunables ---------------- +JUPYTER_KERNEL_PREFIX="${JUPYTER_KERNEL_PREFIX:-/usr/local}" +KERNEL_NAME="${KERNEL_NAME:-kotlin}" +KERNEL_DISPLAY_NAME="${KERNEL_DISPLAY_NAME:-Kotlin}" + +# ---------------- Sanity checks ---------------- +if ! command -v python >/dev/null 2>&1; then + echo "❌ Could not find any Python interpreter." >&2 + exit 1 +fi + +if ! command -v java >/dev/null 2>&1; then + echo "❌ Java is not installed or not on PATH (required by Kotlin kernel)." >&2 + exit 1 +fi + +# Ensure python has jupyter_client and jupyter_core +if ! python - <<'PY' >/dev/null 2>&1 +import importlib.util as u +raise SystemExit(0 if all(u.find_spec(m) for m in ("jupyter_client","jupyter_core")) else 1) +PY +then + echo "❌ python lacks required Jupyter packages ('jupyter_client' and/or 'jupyter_core')." >&2 + exit 2 +fi + +# ---------------- Install Kotlin kernel (pip-based) ---------------- +echo "πŸ“¦ Installing kotlin-jupyter-kernel into VENV..." +env PIP_CACHE_DIR="${PIP_CACHE_DIR}" PIP_DISABLE_PIP_VERSION_CHECK=1 \ + python -m pip install -U kotlin-jupyter-kernel >/dev/null + +# ---------------- Register kernelspec ---------------- +# The kotlin kernel package provides `python -m kotlin_kernel` for management. +# We use jupyter kernelspec install for reliable prefix-based install. +echo "🧩 Registering Kotlin kernel under ${JUPYTER_KERNEL_PREFIX} (system-wide)..." + +# Find where the kernel resources are installed in the VENV +KOTLIN_RESOURCES="$(python -c "import kotlin_kernel; import os; print(os.path.join(os.path.dirname(kotlin_kernel.__file__), 'resources'))" 2>/dev/null || true)" + +if [ -n "${KOTLIN_RESOURCES}" ] && [ -d "${KOTLIN_RESOURCES}" ]; then + python -m jupyter kernelspec install "${KOTLIN_RESOURCES}" \ + --prefix="${JUPYTER_KERNEL_PREFIX}" \ + --replace \ + --name="${KERNEL_NAME}" +else + # Fallback: let the kernel module install itself, then copy + echo "ℹ️ Falling back to kotlin_kernel install..." + python -m kotlin_kernel install --prefix="${JUPYTER_KERNEL_PREFIX}" || { + echo "❌ Failed to register Kotlin kernel." >&2 + exit 1 + } +fi + +KDIR="${JUPYTER_KERNEL_PREFIX}/share/jupyter/kernels/${KERNEL_NAME}" + +# Stamp display name if needed +if [ -f "${KDIR}/kernel.json" ]; then + python - "${KDIR}/kernel.json" "${KERNEL_DISPLAY_NAME}" <<'PY' +import json, sys +path, display = sys.argv[1], sys.argv[2] +with open(path) as f: + data = json.load(f) +data["display_name"] = display +with open(path, "w") as f: + json.dump(data, f, indent=2) +PY +fi + +chmod -R a+rX "${KDIR}" 2>/dev/null || true + +# ---------------- Verification ---------------- +echo +echo "πŸ”Ž Kernels:" +python -m jupyter kernelspec list || true + +# ---------------- Friendly summary ---------------- +echo +echo "βœ… Kotlin kernel installed." +echo " Kernel name: ${KERNEL_NAME}" +echo " Display name: ${KERNEL_DISPLAY_NAME}" +echo " Kernelspec dir: ${KDIR}" diff --git a/variants/base/setups/lua-code-extension--setup.sh b/variants/base/setups/lua-code-extension--setup.sh new file mode 100755 index 00000000..777c9890 --- /dev/null +++ b/variants/base/setups/lua-code-extension--setup.sh @@ -0,0 +1,31 @@ +#!/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. + +# lua-code-extension--setup.sh +# NOTE: This script has not been tested -- no time (sorry). Please report success or failure. :-p +set -Eeuo pipefail + +if [[ ${EUID} -ne 0 ]]; then + echo "This installer must be run as root." >&2 + exit 1 +fi + +# This script will always be installed by root. +HOME=/root + +SCRIPT_NAME="$(basename "$0")" +SCRIPT_DIR="$(dirname "$0")" +source "$SCRIPT_DIR/libs/skip-setup.sh" +if ! "$SCRIPT_DIR/cb-has-vscode.sh"; then + skip_setup "$SCRIPT_NAME" "code-server/VSCode not installed" +fi + +SETUP_LIBS_DIR=${SETUP_LIBS_DIR:-/opt/codingbooth/setups/libs} +CODE_EXTENSION_LIB=${CODE_EXTENSION_LIB:-code-extension-source.sh} +source "${SETUP_LIBS_DIR}/${CODE_EXTENSION_LIB}" + +install_extensions sumneko.lua + +echo "βœ… Extension installation completed." diff --git a/variants/base/setups/lua-nb-kernel--setup.sh b/variants/base/setups/lua-nb-kernel--setup.sh new file mode 100755 index 00000000..1c638db4 --- /dev/null +++ b/variants/base/setups/lua-nb-kernel--setup.sh @@ -0,0 +1,113 @@ +#!/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. + +# lua-nb-kernel--setup.sh +# NOTE: This script has not been tested -- no time (sorry). Please report success or failure. :-p +# +# Installs the ILua Jupyter kernel for Lua. +# ILua is a pip-based Lua kernel that delegates to the lua interpreter. +# +# Prereqs: +# - lua--setup.sh already ran (lua on PATH). +# - python--setup.sh and notebook--setup.sh already ran. +# - /etc/profile.d/53-cb-python--profile.sh should be sourced. + +set -Eeuo pipefail +trap 'echo "❌ Error on line $LINENO"; exit 1' ERR + +# ---------------- Root & early checks ---------------- +if [ "$EUID" -ne 0 ]; then + echo "❌ This script must be run as root (use sudo)." >&2 + exit 1 +fi + +# This script will always be installed by root. +HOME=/root + +# ---------------- Load environment from profile.d ---------------- +source /etc/profile.d/53-cb-python--profile.sh 2>/dev/null || true + +# ---------------- Defaults / Tunables ---------------- +JUPYTER_KERNEL_PREFIX="${JUPYTER_KERNEL_PREFIX:-/usr/local}" +KERNEL_NAME="${KERNEL_NAME:-lua}" +KERNEL_DISPLAY_NAME="${KERNEL_DISPLAY_NAME:-Lua}" + +# ---------------- Sanity checks ---------------- +if ! command -v python >/dev/null 2>&1; then + echo "❌ Could not find any Python interpreter." >&2 + exit 1 +fi + +if ! command -v lua >/dev/null 2>&1; then + echo "❌ Lua is not installed or not on PATH." >&2 + exit 1 +fi + +# Ensure python has jupyter_client and jupyter_core +if ! python - <<'PY' >/dev/null 2>&1 +import importlib.util as u +raise SystemExit(0 if all(u.find_spec(m) for m in ("jupyter_client","jupyter_core")) else 1) +PY +then + echo "❌ python lacks required Jupyter packages ('jupyter_client' and/or 'jupyter_core')." >&2 + exit 2 +fi + +# ---------------- Install ILua (pip-based) ---------------- +echo "πŸ“¦ Installing ILua into VENV..." +env PIP_CACHE_DIR="${PIP_CACHE_DIR}" PIP_DISABLE_PIP_VERSION_CHECK=1 \ + python -m pip install -U ilua >/dev/null + +# ---------------- Create kernelspec manually ---------------- +# ILua doesn't have a clean --prefix install; create kernelspec for our prefix. +ILUA_BIN="$(command -v ilua 2>/dev/null || true)" +if [ -z "${ILUA_BIN}" ]; then + # Try finding it in the VENV bin + ILUA_BIN="${CB_VENV_DIR}/bin/ilua" +fi + +if [ ! -x "${ILUA_BIN}" ]; then + echo "❌ ilua binary not found after pip install." >&2 + exit 1 +fi + +TMPKDIR="$(mktemp -d)/lua" +mkdir -p "${TMPKDIR}" + +cat > "${TMPKDIR}/kernel.json" </dev/null || true + +# ---------------- Verification ---------------- +echo +echo "πŸ”Ž Kernels:" +python -m jupyter kernelspec list || true + +# ---------------- Friendly summary ---------------- +echo +echo "βœ… Lua (ILua) kernel installed." +echo " Kernel name: ${KERNEL_NAME}" +echo " Display name: ${KERNEL_DISPLAY_NAME}" +echo " Kernelspec dir: ${KDIR}" +echo " ILua binary: ${ILUA_BIN}" diff --git a/variants/base/setups/nodejs-code-extension--setup.sh b/variants/base/setups/nodejs-code-extension--setup.sh new file mode 100755 index 00000000..4ff5df16 --- /dev/null +++ b/variants/base/setups/nodejs-code-extension--setup.sh @@ -0,0 +1,31 @@ +#!/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. + +# nodejs-code-extension--setup.sh +# NOTE: This script has not been tested -- no time (sorry). Please report success or failure. :-p +set -Eeuo pipefail + +if [[ ${EUID} -ne 0 ]]; then + echo "This installer must be run as root." >&2 + exit 1 +fi + +# This script will always be installed by root. +HOME=/root + +SCRIPT_NAME="$(basename "$0")" +SCRIPT_DIR="$(dirname "$0")" +source "$SCRIPT_DIR/libs/skip-setup.sh" +if ! "$SCRIPT_DIR/cb-has-vscode.sh"; then + skip_setup "$SCRIPT_NAME" "code-server/VSCode not installed" +fi + +SETUP_LIBS_DIR=${SETUP_LIBS_DIR:-/opt/codingbooth/setups/libs} +CODE_EXTENSION_LIB=${CODE_EXTENSION_LIB:-code-extension-source.sh} +source "${SETUP_LIBS_DIR}/${CODE_EXTENSION_LIB}" + +install_extensions dbaeumer.vscode-eslint + +echo "βœ… Extension installation completed." diff --git a/variants/base/setups/nodejs-nb-kernel--setup.sh b/variants/base/setups/nodejs-nb-kernel--setup.sh new file mode 100755 index 00000000..b54cdf52 --- /dev/null +++ b/variants/base/setups/nodejs-nb-kernel--setup.sh @@ -0,0 +1,139 @@ +#!/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. + +# nodejs-nb-kernel--setup.sh +# NOTE: This script has not been tested -- no time (sorry). Please report success or failure. :-p +# +# Installs the tslab Jupyter kernel for Node.js / TypeScript. +# tslab provides JavaScript and TypeScript REPLs in Jupyter. +# +# Prereqs: +# - nodejs--setup.sh already ran (node/npm on PATH). +# - python--setup.sh and notebook--setup.sh already ran. +# - /etc/profile.d/53-cb-python--profile.sh should be sourced. + +set -Eeuo pipefail +trap 'echo "❌ Error on line $LINENO"; exit 1' ERR + +# ---------------- Root & early checks ---------------- +if [ "$EUID" -ne 0 ]; then + echo "❌ This script must be run as root (use sudo)." >&2 + exit 1 +fi + +# This script will always be installed by root. +HOME=/root + +# ---------------- Load environment from profile.d ---------------- +source /etc/profile.d/53-cb-python--profile.sh 2>/dev/null || true + +# ---------------- Defaults / Tunables ---------------- +JUPYTER_KERNEL_PREFIX="${JUPYTER_KERNEL_PREFIX:-/usr/local}" +KERNEL_NAME="${KERNEL_NAME:-javascript}" +KERNEL_DISPLAY_NAME="${KERNEL_DISPLAY_NAME:-JavaScript (Node.js)}" + +# ---------------- Sanity checks ---------------- +if ! command -v python >/dev/null 2>&1; then + echo "❌ Could not find any Python interpreter." >&2 + exit 1 +fi + +if ! command -v node >/dev/null 2>&1; then + echo "❌ Node.js is not installed or not on PATH." >&2 + exit 1 +fi + +if ! command -v npm >/dev/null 2>&1; then + echo "❌ npm is not installed or not on PATH." >&2 + exit 1 +fi + +# Ensure python has jupyter_client and jupyter_core +if ! python - <<'PY' >/dev/null 2>&1 +import importlib.util as u +raise SystemExit(0 if all(u.find_spec(m) for m in ("jupyter_client","jupyter_core")) else 1) +PY +then + echo "❌ python lacks required Jupyter packages ('jupyter_client' and/or 'jupyter_core')." >&2 + exit 2 +fi + +# ---------------- Install tslab ---------------- +echo "πŸ“¦ Installing tslab globally..." +npm install -g tslab + +TSLAB_BIN="$(command -v tslab 2>/dev/null || true)" +if [ -z "${TSLAB_BIN}" ]; then + echo "❌ tslab binary not found after npm install." >&2 + exit 1 +fi + +# ---------------- Create kernelspec manually ---------------- +# tslab install writes to user/sys data dir. We create kernelspec for our prefix. +TMPKDIR_JS="$(mktemp -d)/javascript" +mkdir -p "${TMPKDIR_JS}" + +NODE_BIN="$(command -v node)" + +cat > "${TMPKDIR_JS}/kernel.json" < "${TMPKDIR_TS}/kernel.json" </dev/null || true +chmod -R a+rX "${JUPYTER_KERNEL_PREFIX}/share/jupyter/kernels/typescript" 2>/dev/null || true + +# ---------------- Verification ---------------- +echo +echo "πŸ”Ž Kernels:" +python -m jupyter kernelspec list || true + +# ---------------- Friendly summary ---------------- +echo +echo "βœ… JavaScript/TypeScript (tslab) kernels installed." +echo " JS kernel name: ${KERNEL_NAME}" +echo " TS kernel name: typescript" +echo " Display name: ${KERNEL_DISPLAY_NAME}" +echo " tslab binary: ${TSLAB_BIN}" +echo " Node.js: $(node --version 2>/dev/null || echo 'unknown')" diff --git a/variants/base/setups/notebook--setup.sh b/variants/base/setups/notebook--setup.sh index 21d0b2af..025295a1 100755 --- a/variants/base/setups/notebook--setup.sh +++ b/variants/base/setups/notebook--setup.sh @@ -32,7 +32,7 @@ source /etc/profile.d/53-cb-python--profile.sh 2>/dev/null || true # ---- Jupyter kernel registration tunables (match code-server) ---- JUPYTER_KERNEL_NAME="${JUPYTER_KERNEL_NAME:-python}" JUPYTER_KERNEL_PREFIX="${JUPYTER_KERNEL_PREFIX:-/usr/local}" -NOTEBOOK_DEFAULT_PORT="${NOTEBOOK_DEFAULT_PORT:-18888}" +NOTEBOOK_DEFAULT_PORT="${1:-${NOTEBOOK_DEFAULT_PORT:-18888}}" # ---- helper: install + verify Jupyter in venv ---- @@ -127,6 +127,7 @@ cat > ${STARTER_FILE} <<'EOF' set -euo pipefail PORT=${1:-__NOTEBOOK_DEFAULT_PORT__} +TOKEN="${PASSWORD:-}" # Make sure non-Python kernels in the venv are visible if present export JUPYTER_PATH="__CB_NOTEBOOK_VENV_DIR__/share/jupyter:/usr/local/share/jupyter:/usr/share/jupyter${JUPYTER_PATH:+:$JUPYTER_PATH}" @@ -135,7 +136,7 @@ exec "__CB_NOTEBOOK_VENV_DIR__/bin/jupyter-lab" \ --no-browser \ --ip=0.0.0.0 \ --port=$PORT \ - --ServerApp.token='' \ + --ServerApp.token="$TOKEN" \ --ServerApp.custom_display_url="http://localhost:$PORT/lab" \ --ServerApp.terminado_settings='{"shell_command":["/bin/bash"]}' EOF diff --git a/variants/base/setups/php-code-extension--setup.sh b/variants/base/setups/php-code-extension--setup.sh new file mode 100755 index 00000000..4b8f835f --- /dev/null +++ b/variants/base/setups/php-code-extension--setup.sh @@ -0,0 +1,31 @@ +#!/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. + +# php-code-extension--setup.sh +# NOTE: This script has not been tested -- no time (sorry). Please report success or failure. :-p +set -Eeuo pipefail + +if [[ ${EUID} -ne 0 ]]; then + echo "This installer must be run as root." >&2 + exit 1 +fi + +# This script will always be installed by root. +HOME=/root + +SCRIPT_NAME="$(basename "$0")" +SCRIPT_DIR="$(dirname "$0")" +source "$SCRIPT_DIR/libs/skip-setup.sh" +if ! "$SCRIPT_DIR/cb-has-vscode.sh"; then + skip_setup "$SCRIPT_NAME" "code-server/VSCode not installed" +fi + +SETUP_LIBS_DIR=${SETUP_LIBS_DIR:-/opt/codingbooth/setups/libs} +CODE_EXTENSION_LIB=${CODE_EXTENSION_LIB:-code-extension-source.sh} +source "${SETUP_LIBS_DIR}/${CODE_EXTENSION_LIB}" + +install_extensions bmewburn.vscode-intelephense-client + +echo "βœ… Extension installation completed." diff --git a/variants/base/setups/r-code-extension--setup.sh b/variants/base/setups/r-code-extension--setup.sh new file mode 100755 index 00000000..1b753d1a --- /dev/null +++ b/variants/base/setups/r-code-extension--setup.sh @@ -0,0 +1,31 @@ +#!/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. + +# r-code-extension--setup.sh +# NOTE: This script has not been tested -- no time (sorry). Please report success or failure. :-p +set -Eeuo pipefail + +if [[ ${EUID} -ne 0 ]]; then + echo "This installer must be run as root." >&2 + exit 1 +fi + +# This script will always be installed by root. +HOME=/root + +SCRIPT_NAME="$(basename "$0")" +SCRIPT_DIR="$(dirname "$0")" +source "$SCRIPT_DIR/libs/skip-setup.sh" +if ! "$SCRIPT_DIR/cb-has-vscode.sh"; then + skip_setup "$SCRIPT_NAME" "code-server/VSCode not installed" +fi + +SETUP_LIBS_DIR=${SETUP_LIBS_DIR:-/opt/codingbooth/setups/libs} +CODE_EXTENSION_LIB=${CODE_EXTENSION_LIB:-code-extension-source.sh} +source "${SETUP_LIBS_DIR}/${CODE_EXTENSION_LIB}" + +install_extensions REditorSupport.r + +echo "βœ… Extension installation completed." diff --git a/variants/base/setups/r-nb-kernel--setup.sh b/variants/base/setups/r-nb-kernel--setup.sh new file mode 100755 index 00000000..7fc97652 --- /dev/null +++ b/variants/base/setups/r-nb-kernel--setup.sh @@ -0,0 +1,82 @@ +#!/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. + +# r-nb-kernel--setup.sh +# NOTE: This script has not been tested -- no time (sorry). Please report success or failure. :-p +# +# Installs the IRkernel Jupyter kernel for R. +# IRkernel is the official R kernel for Jupyter. +# +# Prereqs: +# - r-rscript--setup.sh already ran successfully (R/Rscript on PATH). +# - python--setup.sh and notebook--setup.sh already ran. +# - /etc/profile.d/53-cb-python--profile.sh should be sourced. + +set -Eeuo pipefail +trap 'echo "❌ Error on line $LINENO"; exit 1' ERR + +# ---------------- Root & early checks ---------------- +if [ "$EUID" -ne 0 ]; then + echo "❌ This script must be run as root (use sudo)." >&2 + exit 1 +fi + +# This script will always be installed by root. +HOME=/root + +# ---------------- Load environment from profile.d ---------------- +source /etc/profile.d/53-cb-python--profile.sh 2>/dev/null || true + +# ---------------- Defaults / Tunables ---------------- +JUPYTER_KERNEL_PREFIX="${JUPYTER_KERNEL_PREFIX:-/usr/local}" +KERNEL_NAME="${KERNEL_NAME:-ir}" +KERNEL_DISPLAY_NAME="${KERNEL_DISPLAY_NAME:-R}" + +# ---------------- Sanity checks ---------------- +if ! command -v python >/dev/null 2>&1; then + echo "❌ Could not find any Python interpreter." >&2 + exit 1 +fi + +if ! command -v R >/dev/null 2>&1; then + echo "❌ R is not installed or not on PATH." >&2 + exit 1 +fi + +# Ensure python has jupyter_client and jupyter_core +if ! python - <<'PY' >/dev/null 2>&1 +import importlib.util as u +raise SystemExit(0 if all(u.find_spec(m) for m in ("jupyter_client","jupyter_core")) else 1) +PY +then + echo "❌ python lacks required Jupyter packages ('jupyter_client' and/or 'jupyter_core')." >&2 + exit 2 +fi + +# ---------------- Install IRkernel R package ---------------- +echo "πŸ“¦ Installing IRkernel R package..." +R --slave -e "install.packages('IRkernel', repos='https://cloud.r-project.org', quiet=TRUE)" + +# ---------------- Register kernelspec ---------------- +# IRkernel::installspec() natively supports prefix β€” very clean! +echo "🧩 Registering R kernel under ${JUPYTER_KERNEL_PREFIX} (system-wide)..." +R --slave -e "IRkernel::installspec(name='${KERNEL_NAME}', displayname='${KERNEL_DISPLAY_NAME}', prefix='${JUPYTER_KERNEL_PREFIX}')" + +KDIR="${JUPYTER_KERNEL_PREFIX}/share/jupyter/kernels/${KERNEL_NAME}" +chmod -R a+rX "${KDIR}" 2>/dev/null || true + +# ---------------- Verification ---------------- +echo +echo "πŸ”Ž Kernels:" +python -m jupyter kernelspec list || true + +# ---------------- Friendly summary ---------------- +echo +echo "βœ… R (IRkernel) kernel installed." +echo " Kernel name: ${KERNEL_NAME}" +echo " Display name: ${KERNEL_DISPLAY_NAME}" +echo " Kernelspec dir: ${KDIR}" +R_VER="$(R --version 2>/dev/null | head -1 || echo 'unknown')" +echo " R version: ${R_VER}" diff --git a/variants/base/setups/ruby-code-extension--setup.sh b/variants/base/setups/ruby-code-extension--setup.sh new file mode 100755 index 00000000..a9f9a854 --- /dev/null +++ b/variants/base/setups/ruby-code-extension--setup.sh @@ -0,0 +1,31 @@ +#!/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. + +# ruby-code-extension--setup.sh +# NOTE: This script has not been tested -- no time (sorry). Please report success or failure. :-p +set -Eeuo pipefail + +if [[ ${EUID} -ne 0 ]]; then + echo "This installer must be run as root." >&2 + exit 1 +fi + +# This script will always be installed by root. +HOME=/root + +SCRIPT_NAME="$(basename "$0")" +SCRIPT_DIR="$(dirname "$0")" +source "$SCRIPT_DIR/libs/skip-setup.sh" +if ! "$SCRIPT_DIR/cb-has-vscode.sh"; then + skip_setup "$SCRIPT_NAME" "code-server/VSCode not installed" +fi + +SETUP_LIBS_DIR=${SETUP_LIBS_DIR:-/opt/codingbooth/setups/libs} +CODE_EXTENSION_LIB=${CODE_EXTENSION_LIB:-code-extension-source.sh} +source "${SETUP_LIBS_DIR}/${CODE_EXTENSION_LIB}" + +install_extensions Shopify.ruby-lsp + +echo "βœ… Extension installation completed." diff --git a/variants/base/setups/ruby-nb-kernel--setup.sh b/variants/base/setups/ruby-nb-kernel--setup.sh new file mode 100755 index 00000000..858b10a0 --- /dev/null +++ b/variants/base/setups/ruby-nb-kernel--setup.sh @@ -0,0 +1,111 @@ +#!/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. + +# ruby-nb-kernel--setup.sh +# NOTE: This script has not been tested -- no time (sorry). Please report success or failure. :-p +# +# Installs the IRuby Jupyter kernel for Ruby. +# +# Prereqs: +# - ruby--setup.sh already ran successfully (ruby/gem on PATH). +# - python--setup.sh and notebook--setup.sh already ran. +# - /etc/profile.d/53-cb-python--profile.sh should be sourced. + +set -Eeuo pipefail +trap 'echo "❌ Error on line $LINENO"; exit 1' ERR + +# ---------------- Root & early checks ---------------- +if [ "$EUID" -ne 0 ]; then + echo "❌ This script must be run as root (use sudo)." >&2 + exit 1 +fi + +# This script will always be installed by root. +HOME=/root + +# ---------------- Load environment from profile.d ---------------- +source /etc/profile.d/53-cb-python--profile.sh 2>/dev/null || true + +# ---------------- Defaults / Tunables ---------------- +JUPYTER_KERNEL_PREFIX="${JUPYTER_KERNEL_PREFIX:-/usr/local}" +KERNEL_NAME="${KERNEL_NAME:-ruby}" +KERNEL_DISPLAY_NAME="${KERNEL_DISPLAY_NAME:-Ruby}" + +# ---------------- Sanity checks ---------------- +if ! command -v python >/dev/null 2>&1; then + echo "❌ Could not find any Python interpreter." >&2 + exit 1 +fi + +if ! command -v ruby >/dev/null 2>&1; then + echo "❌ Ruby is not installed or not on PATH." >&2 + exit 1 +fi + +if ! command -v gem >/dev/null 2>&1; then + echo "❌ gem is not installed or not on PATH." >&2 + exit 1 +fi + +# Ensure python has jupyter_client and jupyter_core +if ! python - <<'PY' >/dev/null 2>&1 +import importlib.util as u +raise SystemExit(0 if all(u.find_spec(m) for m in ("jupyter_client","jupyter_core")) else 1) +PY +then + echo "❌ python lacks required Jupyter packages ('jupyter_client' and/or 'jupyter_core')." >&2 + exit 2 +fi + +# ---------------- Install IRuby ---------------- +echo "πŸ“¦ Installing IRuby gem..." +gem install iruby + +IRUBY_BIN="$(command -v iruby 2>/dev/null || true)" +if [ -z "${IRUBY_BIN}" ]; then + echo "❌ iruby binary not found after gem install." >&2 + exit 1 +fi + +# ---------------- Create kernelspec manually ---------------- +# iruby register writes to user's data dir; we create kernelspec for our prefix. +TMPKDIR="$(mktemp -d)/ruby" +mkdir -p "${TMPKDIR}" + +cat > "${TMPKDIR}/kernel.json" </dev/null || true + +# ---------------- Verification ---------------- +echo +echo "πŸ”Ž Kernels:" +python -m jupyter kernelspec list || true + +# ---------------- Friendly summary ---------------- +echo +echo "βœ… Ruby (IRuby) kernel installed." +echo " Kernel name: ${KERNEL_NAME}" +echo " Display name: ${KERNEL_DISPLAY_NAME}" +echo " Kernelspec dir: ${KDIR}" +echo " IRuby binary: ${IRUBY_BIN}" diff --git a/variants/base/setups/rust-nb-kernel--setup.sh b/variants/base/setups/rust-nb-kernel--setup.sh new file mode 100755 index 00000000..7b96a7b5 --- /dev/null +++ b/variants/base/setups/rust-nb-kernel--setup.sh @@ -0,0 +1,114 @@ +#!/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. + +# rust-nb-kernel--setup.sh +# NOTE: This script has not been tested -- no time (sorry). Please report success or failure. :-p +# +# Installs the Evcxr Jupyter kernel for Rust. +# Evcxr provides an interactive Rust REPL in Jupyter notebooks. +# +# Prereqs: +# - rust--setup.sh already ran successfully (cargo/rustc on PATH). +# - python--setup.sh and notebook--setup.sh already ran. +# - /etc/profile.d/53-cb-python--profile.sh should be sourced. + +set -Eeuo pipefail +trap 'echo "❌ Error on line $LINENO"; exit 1' ERR + +# ---------------- Root & early checks ---------------- +if [ "$EUID" -ne 0 ]; then + echo "❌ This script must be run as root (use sudo)." >&2 + exit 1 +fi + +# This script will always be installed by root. +HOME=/root + +# ---------------- Load environment from profile.d ---------------- +source /etc/profile.d/53-cb-python--profile.sh 2>/dev/null || true + +# Load Rust/Cargo environment +[ -f /root/.cargo/env ] && source /root/.cargo/env + +# ---------------- Defaults / Tunables ---------------- +JUPYTER_KERNEL_PREFIX="${JUPYTER_KERNEL_PREFIX:-/usr/local}" +KERNEL_NAME="${KERNEL_NAME:-rust}" +KERNEL_DISPLAY_NAME="${KERNEL_DISPLAY_NAME:-Rust (Evcxr)}" + +# ---------------- Sanity checks ---------------- +if ! command -v python >/dev/null 2>&1; then + echo "❌ Could not find any Python interpreter." >&2 + exit 1 +fi + +if ! command -v cargo >/dev/null 2>&1; then + echo "❌ Cargo is not installed or not on PATH." >&2 + exit 1 +fi + +# Ensure python has jupyter_client and jupyter_core +if ! python - <<'PY' >/dev/null 2>&1 +import importlib.util as u +raise SystemExit(0 if all(u.find_spec(m) for m in ("jupyter_client","jupyter_core")) else 1) +PY +then + echo "❌ python lacks required Jupyter packages ('jupyter_client' and/or 'jupyter_core')." >&2 + exit 2 +fi + +# ---------------- Install Evcxr ---------------- +echo "πŸ“¦ Installing evcxr_jupyter (this may take a while β€” it compiles from source)..." +cargo install --locked evcxr_jupyter + +EVCXR_BIN="${CARGO_HOME:-$HOME/.cargo}/bin/evcxr_jupyter" +if [ ! -x "${EVCXR_BIN}" ]; then + # Try finding it on PATH + EVCXR_BIN="$(command -v evcxr_jupyter 2>/dev/null || true)" + if [ -z "${EVCXR_BIN}" ]; then + echo "❌ evcxr_jupyter binary not found after cargo install." >&2 + exit 1 + fi +fi + +# ---------------- Create kernelspec manually ---------------- +# We create the kernelspec ourselves for reliable prefix-based install. +TMPKDIR="$(mktemp -d)/rust" +mkdir -p "${TMPKDIR}" + +cat > "${TMPKDIR}/kernel.json" </dev/null || true + +# ---------------- Verification ---------------- +echo +echo "πŸ”Ž Kernels:" +python -m jupyter kernelspec list || true + +# ---------------- Friendly summary ---------------- +echo +echo "βœ… Rust (Evcxr) kernel installed." +echo " Kernel name: ${KERNEL_NAME}" +echo " Display name: ${KERNEL_DISPLAY_NAME}" +echo " Kernelspec dir: ${KDIR}" +echo " Evcxr binary: ${EVCXR_BIN}" diff --git a/variants/base/setups/scala-code-extension--setup.sh b/variants/base/setups/scala-code-extension--setup.sh new file mode 100755 index 00000000..70230e77 --- /dev/null +++ b/variants/base/setups/scala-code-extension--setup.sh @@ -0,0 +1,31 @@ +#!/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. + +# scala-code-extension--setup.sh +# NOTE: This script has not been tested -- no time (sorry). Please report success or failure. :-p +set -Eeuo pipefail + +if [[ ${EUID} -ne 0 ]]; then + echo "This installer must be run as root." >&2 + exit 1 +fi + +# This script will always be installed by root. +HOME=/root + +SCRIPT_NAME="$(basename "$0")" +SCRIPT_DIR="$(dirname "$0")" +source "$SCRIPT_DIR/libs/skip-setup.sh" +if ! "$SCRIPT_DIR/cb-has-vscode.sh"; then + skip_setup "$SCRIPT_NAME" "code-server/VSCode not installed" +fi + +SETUP_LIBS_DIR=${SETUP_LIBS_DIR:-/opt/codingbooth/setups/libs} +CODE_EXTENSION_LIB=${CODE_EXTENSION_LIB:-code-extension-source.sh} +source "${SETUP_LIBS_DIR}/${CODE_EXTENSION_LIB}" + +install_extensions scalameta.metals + +echo "βœ… Extension installation completed." diff --git a/variants/base/setups/scala-nb-kernel--setup.sh b/variants/base/setups/scala-nb-kernel--setup.sh new file mode 100755 index 00000000..5b5e0a6b --- /dev/null +++ b/variants/base/setups/scala-nb-kernel--setup.sh @@ -0,0 +1,109 @@ +#!/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. + +# scala-nb-kernel--setup.sh +# NOTE: This script has not been tested -- no time (sorry). Please report success or failure. :-p +# +# Installs the Almond Jupyter kernel for Scala. +# Almond uses Coursier to fetch the kernel launcher. +# +# Prereqs: +# - scala--setup.sh already ran (JDK on PATH; JAVA_HOME set). +# - python--setup.sh and notebook--setup.sh already ran. +# - /etc/profile.d/53-cb-python--profile.sh should be sourced. +# - curl available. + +set -Eeuo pipefail +trap 'echo "❌ Error on line $LINENO"; exit 1' ERR + +# ---------------- Root & early checks ---------------- +if [ "$EUID" -ne 0 ]; then + echo "❌ This script must be run as root (use sudo)." >&2 + exit 1 +fi + +# This script will always be installed by root. +HOME=/root + +# ---------------- Load environment from profile.d ---------------- +source /etc/profile.d/53-cb-python--profile.sh 2>/dev/null || true + +# ---------------- Defaults / Tunables ---------------- +JUPYTER_KERNEL_PREFIX="${JUPYTER_KERNEL_PREFIX:-/usr/local}" +ALMOND_VERSION="${ALMOND_VERSION:-0.14.0-RC15}" +SCALA_VERSION="${SCALA_VERSION:-3.3.4}" +KERNEL_NAME="${KERNEL_NAME:-scala}" +KERNEL_DISPLAY_NAME="${KERNEL_DISPLAY_NAME:-Scala ${SCALA_VERSION}}" + +# ---------------- Sanity checks ---------------- +if ! command -v python >/dev/null 2>&1; then + echo "❌ Could not find any Python interpreter." >&2 + exit 1 +fi + +if ! command -v java >/dev/null 2>&1; then + echo "❌ Java is not installed or not on PATH (required by Scala kernel)." >&2 + exit 1 +fi + +# Ensure python has jupyter_client and jupyter_core +if ! python - <<'PY' >/dev/null 2>&1 +import importlib.util as u +raise SystemExit(0 if all(u.find_spec(m) for m in ("jupyter_client","jupyter_core")) else 1) +PY +then + echo "❌ python lacks required Jupyter packages ('jupyter_client' and/or 'jupyter_core')." >&2 + exit 2 +fi + +# ---------------- Install Coursier (if not present) ---------------- +if ! command -v cs >/dev/null 2>&1; then + echo "πŸ“¦ Installing Coursier..." + curl -fL "https://github.com/coursier/launchers/raw/master/cs-$(uname -m)-pc-linux.gz" \ + | gzip -d > /usr/local/bin/cs + chmod +x /usr/local/bin/cs +fi + +# ---------------- Install Almond ---------------- +echo "πŸ“¦ Installing Almond ${ALMOND_VERSION} for Scala ${SCALA_VERSION}..." +ALMOND_LAUNCHER="$(mktemp)" + +cs bootstrap \ + "sh.almond:scala-kernel_${SCALA_VERSION}:${ALMOND_VERSION}" \ + --default=true \ + -o "${ALMOND_LAUNCHER}" \ + --standalone + +chmod +x "${ALMOND_LAUNCHER}" + +# Move to a stable location +ALMOND_BIN="/usr/local/bin/almond" +mv "${ALMOND_LAUNCHER}" "${ALMOND_BIN}" + +# ---------------- Register kernelspec ---------------- +echo "🧩 Registering Scala kernel under ${JUPYTER_KERNEL_PREFIX} (system-wide)..." +"${ALMOND_BIN}" --install \ + --jupyter-path "${JUPYTER_KERNEL_PREFIX}/share/jupyter" \ + --id "${KERNEL_NAME}" \ + --display-name "${KERNEL_DISPLAY_NAME}" \ + --force + +KDIR="${JUPYTER_KERNEL_PREFIX}/share/jupyter/kernels/${KERNEL_NAME}" +chmod -R a+rX "${KDIR}" 2>/dev/null || true + +# ---------------- Verification ---------------- +echo +echo "πŸ”Ž Kernels:" +python -m jupyter kernelspec list || true + +# ---------------- Friendly summary ---------------- +echo +echo "βœ… Scala (Almond) kernel installed." +echo " Kernel name: ${KERNEL_NAME}" +echo " Display name: ${KERNEL_DISPLAY_NAME}" +echo " Kernelspec dir: ${KDIR}" +echo " Almond binary: ${ALMOND_BIN}" +echo " Scala version: ${SCALA_VERSION}" +echo " Almond version: ${ALMOND_VERSION}" diff --git a/variants/base/setups/xfce--setup.sh b/variants/base/setups/xfce--setup.sh index baa9e43d..a60b5076 100755 --- a/variants/base/setups/xfce--setup.sh +++ b/variants/base/setups/xfce--setup.sh @@ -343,6 +343,10 @@ trap 'echo "❌ Error on line $LINENO" >&2; exit 1' ERR : "${GEOMETRY:=1280x800}" : "${NOVNC_PORT:=10000}" : "${VNC_PASSWORD:=}" +# Map unified PASSWORD to VNC_PASSWORD if VNC_PASSWORD is not explicitly set +if [[ -z "${VNC_PASSWORD}" && -n "${PASSWORD:-}" ]]; then + VNC_PASSWORD="${PASSWORD}" +fi : "${KEYRING_MODE:=basic}" # basic | disable | keep : "${HOME:?HOME must be set and writable}" diff --git a/variants/base/setups/zig-code-extension--setup.sh b/variants/base/setups/zig-code-extension--setup.sh new file mode 100755 index 00000000..73bbfc26 --- /dev/null +++ b/variants/base/setups/zig-code-extension--setup.sh @@ -0,0 +1,31 @@ +#!/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. + +# zig-code-extension--setup.sh +# NOTE: This script has not been tested -- no time (sorry). Please report success or failure. :-p +set -Eeuo pipefail + +if [[ ${EUID} -ne 0 ]]; then + echo "This installer must be run as root." >&2 + exit 1 +fi + +# This script will always be installed by root. +HOME=/root + +SCRIPT_NAME="$(basename "$0")" +SCRIPT_DIR="$(dirname "$0")" +source "$SCRIPT_DIR/libs/skip-setup.sh" +if ! "$SCRIPT_DIR/cb-has-vscode.sh"; then + skip_setup "$SCRIPT_NAME" "code-server/VSCode not installed" +fi + +SETUP_LIBS_DIR=${SETUP_LIBS_DIR:-/opt/codingbooth/setups/libs} +CODE_EXTENSION_LIB=${CODE_EXTENSION_LIB:-code-extension-source.sh} +source "${SETUP_LIBS_DIR}/${CODE_EXTENSION_LIB}" + +install_extensions ziglang.vscode-zig + +echo "βœ… Extension installation completed." diff --git a/variants/base/start-ttyd b/variants/base/start-ttyd new file mode 100755 index 00000000..3dbaf547 --- /dev/null +++ b/variants/base/start-ttyd @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +set -euo pipefail + +PORT=${1:-10000} + +TTYD_ARGS=(-W -i 0.0.0.0 -p "$PORT" --writable) + +if [[ -n "${PASSWORD:-}" ]]; then + TTYD_ARGS+=(-c "coder:${PASSWORD}") +fi + +exec ttyd "${TTYD_ARGS[@]}" bash -l diff --git a/version.txt b/version.txt index a881cf79..a088db38 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -0.20.0 \ No newline at end of file +0.21.0--rc1 \ No newline at end of file