shell

Shell runtime rules for mvdan/sh virtual shell in internal/runtime/virtual.go. Covers positional arguments gotcha (prepend "--"), bash strict mode (set -euo pipefail), arithmetic increment pitfalls.

SKILL.md

GitHub

Invowk Shell Runtime Rules

This skill covers how Invowk handles shell interpreters and script execution internally. NOT general bash scripting guidance.

Use this skill when working on:

  • internal/runtime/sh.go - virtual-sh runtime
  • cmd/invowk/internal_exec_sh.go - virtual-sh execution command
  • internal/app/commandadapters/sh_interactive.go and shared virtual interactive helpers
  • Repository bash script execution logic

Virtual Shell (mvdan/sh)

The virtual-sh runtime uses mvdan/sh, a pure Go POSIX shell interpreter. This provides cross-platform bash-like script execution without requiring an external shell binary.

Positional Arguments Gotcha

CRITICAL: Always prepend "--" when passing positional arguments to interp.Params().

The interp.Params() function configures shell parameters, but it follows POSIX shell conventions where arguments starting with - are interpreted as shell options (like -e, -u, -x).

The Problem

Without "--", positional arguments like -v or --env=staging are interpreted as shell options:

// WRONG: Will fail with "invalid option: -v"
if len(args) > 0 {
    opts = append(opts, interp.Params(args...))
}

Error messages you might see:

  • failed to create interpreter: invalid option: "-v"
  • failed to create interpreter: invalid option: "--"
  • failed to create interpreter: invalid option: "--env=staging"

The Solution

Prepend "--" to signal the end of options:

// CORRECT: "--" signals end of options, remaining args become $1, $2, etc.
if len(args) > 0 {
    params := append([]string{"--"}, args...)
    opts = append(opts, interp.Params(params...))
}

Why This Works

In POSIX shells, "--" is the standard delimiter that terminates option parsing. Everything after "--" is treated as a positional parameter, regardless of whether it starts with -:

# Shell equivalent:
set -- -v --env=staging  # Sets $1="-v", $2="--env=staging"

Affected Locations

When working with mvdan/sh in this codebase, ensure "--" is prepended in:

  1. internal/runtime/sh.go - prepareShExec() for normal virtual-sh execution
  2. internal/runtime/sh.go - RunShScript() for the internal exec-sh path

internal/app/commandadapters/sh_interactive.go transports arguments through --args; verify that transport stays aligned with the runtime-side interp.Params delimiter, but do not copy the delimiter pattern there unless it is directly calling mvdan/sh.

Testing

This issue manifests on Windows CI because virtual-sh is the bash-compatible embedded shell option there. When adding new mvdan/sh integration points:

  1. Test with arguments starting with - (e.g., -v, --flag=value)
  2. Run go test ./internal/runtime -run TestShRuntime_PositionalArgs_DashPrefix -count=1
  3. Run relevant interactive adapter tests when internal/app/commandadapters/*sh* changes
  4. Run make test-cli when CLI behavior or txtar fixtures changed

Bash Script Execution

Strict Mode (set -euo pipefail)

Project-level bash entrypoints should use strict mode for safety. Short sourced helpers that only emit shell fragments, such as Bencher helper scripts, may omit it deliberately. Note: This applies to repository bash scripts, not to CUE command scripts executed via container runtimes (/bin/sh -c). For container script behavior, see the testing skill's "Shell Script Behavior in Containers" section.

set -euo pipefail

This exits on command failures, undefined variables, and failed pipeline segments. Verify changed repo bash scripts with bash -n <script> and make lint-scripts when applicable.

Arithmetic Increment Gotcha

CRITICAL: Never use ((var++)) with set -e when var might be 0.

In bash, arithmetic expressions return exit status based on the expression's value:

  • ((0)) returns exit status 1 (false)
  • ((1)) returns exit status 0 (true)

The post-increment ((x++)) evaluates to the original value of x:

# DANGEROUS with set -e:
COUNTER=0
((COUNTER++))  # Evaluates to 0 (the original value), exits with status 1!
               # Script terminates here due to set -e

Safe Arithmetic Patterns

Use assignment syntax instead of increment operators:

# CORRECT: Assignment always succeeds
COUNTER=$((COUNTER + 1))

Anti-Patterns to Avoid

# WRONG: Will fail if COUNTER is 0
((COUNTER++))

# WRONG: Will fail if FAILED is 0
((FAILED++))

# WRONG: Will fail if SKIPPED is 0
((SKIPPED++))

Real-World Example

Shell validation scripts commonly use counters for PASSED, FAILED, and SKIPPED checks:

# WRONG - Script exits on first skip when SKIPPED is 0:
SKIPPED=0
if [[ ! -f "$golden_file" ]]; then
    ((SKIPPED++))  # Exit status 1, script terminates!
    continue
fi

# CORRECT - Assignment syntax always works:
SKIPPED=0
if [[ ! -f "$golden_file" ]]; then
    SKIPPED=$((SKIPPED + 1))  # Always succeeds
    continue
fi

Common Pitfalls

  • Missing "--" delimiter - Always use append([]string{"--"}, args...) when calling interp.Params() with user-provided positional arguments.
  • Arithmetic with set -e - Always use VAR=$((VAR + 1)) instead of ((VAR++)) when VAR might be 0.