Replace Shell Scripting with Exec Modules

Replace Shell Scripting with Exec Modules

Shell scripts are great for quick automation, but they get hard to maintain as workflows grow. spaces exec modules (*.exec.star) give you script-style execution with Python-like syntax, typed argument parsing, and a richer standard library.

ℹ️
Exec modules run immediately (top-to-bottom), unlike .spaces.star files which define rules for deferred execution.

See Operating Modes for a deeper comparison.

Why replace shell scripts?

CapabilityShell scriptsExec modules
SyntaxShell-specific (sh/bash/zsh)Starlark (Python-like)
Data modelMostly stringsLists, dicts, booleans, numbers
Error handlingManual (set -e, $?)Structured return values + fail-fast helpers
Argument parsingPositional parsing by handargs module with validation/help
Process controlBasicTimeouts, capture, pipelines, spawn/wait

Quick start

Create an executable module

Create deploy.exec.star:

#!/usr/bin/env spaces

load("//@star/prelude/exec/log.star", "log_info")
load("//@star/prelude/exec/sh.star", "sh_capture")

log_info("Starting deployment script")
branch = sh_capture("git rev-parse --abbrev-ref HEAD")
log_info("Current branch: " + branch)

Make it executable

chmod +x deploy.exec.star

Run it

./deploy.exec.star
spaces deploy.exec.star
⚠️

If you run an exec module directly (outside spaces run), set SPACES_WORKSPACE so load("//...") paths resolve:

export SPACES_WORKSPACE=/path/to/workspace
./deploy.exec.star

When invoked from rules (for example through run_add_exec()), SPACES_WORKSPACE is set automatically.

Build a proper CLI with args

Use the args module instead of manual $1, $2 parsing.

#!/usr/bin/env spaces

load("//@star/prelude/exec/args.star", "args_parser", "args_flag", "args_opt", "args_parse")
load("//@star/prelude/exec/log.star", "log_info")

spec = args_parser(
    name = "deploy",
    description = "Deploy application",
    options = [
        args_flag("--verbose", "-v", "Verbose logs"),
        args_flag("--dry-run", "-n", "Print actions without executing"),
        args_opt("--env", "-e", "Target environment", default="staging", choices=["dev", "staging", "prod"]),
    ],
)

opts = args_parse(spec)

if opts.get("verbose", False):
    log_info("Verbose mode enabled")

log_info("Deploying to: " + opts.get("env", "staging"))
./deploy.exec.star --help
More argument patterns

Typed options

args_opt("--jobs", "-j", "Parallel jobs", type="int", default=4)
args_opt("--optimize", "-O", "Enable optimizations", type="bool", default=True)

Repeatable options

load("//@star/prelude/exec/args.star", "args_list")
args_list("--tag", "-t", "Can be repeated", type="str")

Positional and variadic arguments

load("//@star/prelude/exec/args.star", "args_pos")

positional = [
    args_pos("source", required=True),
    args_pos("destination", required=True),
]

# Variadic
args_pos("files", required=True, variadic=True)

Process execution: sh vs process

Use sh for quick shell-style commands; use process when you need precise control.

load("//@star/prelude/exec/sh.star", "sh_capture", "sh_exit_code", "sh_run")

branch = sh_capture("git rev-parse --abbrev-ref HEAD")
dirty = sh_exit_code("git diff --quiet") != 0
result = sh_run("cargo build 2>&1", check=False)
load("//@star/prelude/exec/process.star", "process_options", "process_run", "process_stdout_capture")

opts = process_options(
    command = "cargo",
    args = ["build", "--release"],
    timeout_ms = 120000,
    stdout = process_stdout_capture(),
    check = False,
)

result = process_run(opts)
If command input includes untrusted values, prefer process argument lists over shell strings to reduce quoting/injection risk.

Migration example

Before (shell)

#!/bin/bash
set -euo pipefail

ENV=${1:-staging}
VERSION=${2:-$(git rev-parse --short HEAD)}

echo "Deploying $VERSION to $ENV"
cargo build --release
cargo test
rsync -avz target/release/app server:/var/www/app

After (exec module)

#!/usr/bin/env spaces

load("//@star/prelude/exec/args.star", "args_parser", "args_opt", "args_parse")
load("//@star/prelude/exec/log.star", "log_info", "log_fatal")
load("//@star/prelude/exec/sh.star", "sh_capture", "sh_run")

spec = args_parser(
    name = "deploy",
    description = "Deploy application",
    options = [
        args_opt("--env", "-e", "Environment", default="staging"),
        args_opt("--version", "-v", "Version to deploy"),
    ],
)
opts = args_parse(spec)

env = opts.get("env", "staging")
version = opts.get("version") or sh_capture("git rev-parse --short HEAD")

if env == "prod":
    branch = sh_capture("git rev-parse --abbrev-ref HEAD")
    if branch != "main":
        log_fatal("Production deploys must be from main")

log_info("Deploying " + version + " to " + env)
sh_run("cargo build --release", check=True)
sh_run("cargo test", check=True)
sh_run("rsync -avz target/release/app server:/var/www/app", check=True)
log_info("Done")

Best practices

  • Use args_parse() for every CLI script.
  • Prefer process for timeouts, redirection, and safer command composition.
  • Keep script state in structured data (dict/list), not ad-hoc string parsing.
  • Use log_info/log_error/log_fatal so failures are obvious.
  • Split reusable logic into normal .star modules and load() them.

Next steps