diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..8dfd10f --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "pkgbox/tap/darkn3rd/homebrew-tools"] + path = pkgbox/tap/darkn3rd/homebrew-tools + url = git@github.com:darkn3rd/homebrew-tools.git diff --git a/configbox/ansible/provision/roles/lessons/tasks/install_step.yml b/configbox/ansible/provision/roles/lessons/tasks/install_step.yml index b7fd3b4..50a0ccd 100644 --- a/configbox/ansible/provision/roles/lessons/tasks/install_step.yml +++ b/configbox/ansible/provision/roles/lessons/tasks/install_step.yml @@ -68,8 +68,19 @@ owner: "{{ lessons_user }}" mode: '0644' +# interpolate: true (see generate_install_script.rb's own append_lines +# comment) has no meaning here - lineinfile writes `line` as plain +# Python data, no shell involved, so there's nothing to evaluate a +# manifest's own `$(...)`/`$VAR` against. Fail loudly rather than +# silently write that text out literally - a step needing real +# interpolation has to become a 'script' step instead. +- name: "append: reject interpolate" + when: step.type == 'append' and step.interpolate | default(false) + ansible.builtin.fail: + msg: "lessons: append '{{ step.name }}' sets interpolate: true, which this role's 'append' task can't honor (lineinfile has no shell to interpolate through) - use a 'script' step instead" + - name: "append" - when: step.type == 'append' + when: step.type == 'append' and not (step.interpolate | default(false)) become: true vars: lessons_append_dests: "{{ ([step.dest] if step.dest is string else step.dest) | map('replace', '$HOME', lessons_home) | list }}" diff --git a/configbox/chef/cookbooks/lessons/libraries/helpers.rb b/configbox/chef/cookbooks/lessons/libraries/helpers.rb index 1b2d4fd..fadaf21 100644 --- a/configbox/chef/cookbooks/lessons/libraries/helpers.rb +++ b/configbox/chef/cookbooks/lessons/libraries/helpers.rb @@ -145,6 +145,17 @@ def lessons_install(pkg) # directory gap as 'file' above - append_if_no_line creates the # destination file itself if missing, which needs the directory # to already exist. + # + # interpolate: true (see generate_install_script.rb's own + # append_lines comment) has no meaning here - append_if_no_line + # writes `line` as plain Ruby content, no shell involved, so + # there's nothing to evaluate a manifest's own `$(...)`/`$VAR` + # against. Fail loudly rather than silently write that text out + # literally - a step needing real interpolation has to become a + # 'script' step instead, the same way it was before append: + # existed at all. + raise "lessons: append '#{pkg['name']}' sets interpolate: true, which the Chef 'append' case can't honor (append_if_no_line has no shell to interpolate through) - use a 'script' step instead" if pkg['interpolate'] + home = Etc.getpwnam(node['lessons']['user']).dir Array(pkg['dest']).each do |dest| real_dest = dest.sub('$HOME', home) diff --git a/configbox/puppet/shared_modules/lessons/manifests/install_step.pp b/configbox/puppet/shared_modules/lessons/manifests/install_step.pp index c4d4e70..c8ebc3c 100644 --- a/configbox/puppet/shared_modules/lessons/manifests/install_step.pp +++ b/configbox/puppet/shared_modules/lessons/manifests/install_step.pp @@ -125,6 +125,16 @@ } 'append': { + # interpolate: true (see generate_install_script.rb's own + # append_lines comment) has no meaning here - file_line writes + # `line` as plain Puppet data, no shell involved, so there's + # nothing to evaluate a manifest's own `$(...)`/`$VAR` against. + # Fail loudly rather than silently write that text out literally - + # a step needing real interpolation has to become a 'script' step + # instead. + if 'interpolate' in $step and $step['interpolate'] { + fail("lessons::install_step '${title}': append '${step_name}' sets interpolate: true, which this case can't honor (file_line has no shell to interpolate through) - use a 'script' step instead") + } $raw_dests = $step['dest'] =~ String ? { true => [$step['dest']], default => $step['dest'] } $dests = $raw_dests.map |$d| { regsubst($d, '\$HOME', $home) } $dests.each |$d| { diff --git a/pkgbox/tap/darkn3rd/homebrew-tools b/pkgbox/tap/darkn3rd/homebrew-tools new file mode 160000 index 0000000..472890b --- /dev/null +++ b/pkgbox/tap/darkn3rd/homebrew-tools @@ -0,0 +1 @@ +Subproject commit 472890b67216957925e693e47be166e8b6b44e82 diff --git a/scriptbox/config/helpers/common.yml b/scriptbox/config/helpers/common.yml index beeb86c..054fb3e 100644 --- a/scriptbox/config/helpers/common.yml +++ b/scriptbox/config/helpers/common.yml @@ -3,13 +3,37 @@ common: helpers: append_line: cmd: | + # $3 (SUDO_NEEDED, "true"/empty) - a manifest's own appends: entry + # says so explicitly (sudo: true), decided once at generation + # time - see generate_install_script.rb's own append_lines. + # `sudo append_line ...` from the call site can't work instead: + # sudo execs a fresh subprocess and looks up its argument as an + # external program on PATH - it has no visibility into this + # shell's own function table, so it would just fail outright + # with "command not found". The elevation has to happen on + # append_line's own internal touch/tee calls, from inside. append_line() { local DEST=$1 local LINE=$2 + local SUDO_NEEDED=$3 + local DIR + DIR="$(dirname "$DEST")" + local SUDO="" + [ "$SUDO_NEEDED" = "true" ] && SUDO="sudo" - mkdir -p "$(dirname "$DEST")" - [ -f "$DEST" ] || touch "$DEST" - grep -qxF "$LINE" "$DEST" 2>/dev/null || echo "$LINE" >> "$DEST" + $SUDO mkdir -p "$DIR" + [ -f "$DEST" ] || $SUDO touch "$DEST" + if ! grep -qxF "$LINE" "$DEST" 2>/dev/null; then + # sudo echo ... >> "$DEST" would NOT actually gain root for + # the redirection - >> is set up by *this* unprivileged + # shell before sudo ever runs. sudo tee -a is the real fix, + # piped rather than prefixed, only when actually needed. + if [ -n "$SUDO" ]; then + echo "$LINE" | sudo tee -a "$DEST" >/dev/null + else + echo "$LINE" >> "$DEST" + fi + fi } cmd_powershell: | function append_line { diff --git a/scriptbox/config/macos.yml b/scriptbox/config/macos.yml index f6e4cfe..42109cb 100644 --- a/scriptbox/config/macos.yml +++ b/scriptbox/config/macos.yml @@ -1,13 +1,29 @@ macos: + variables: + brew_prefix: "<%= RbConfig::CONFIG['host_cpu'] == 'x86_64' ? '/usr/local' : '/opt/homebrew' %>" + python3_ver: 3.14 + ruby4_ver: 4.0 + php_ver: 8.5 + tcl_ver: 9 + go_ver: 1.27 global: packages: + - script: macos_rosetta - script: macos_xcode_cli_tools - script: macos_homebrew + - append: macos_homebrew_zsh + - append: macos_homebrew_bash + - brew: grep + append: macos_brew_gnu_grep + script: macos_brew_gnu_grep + meets: gnu_grep lessons: gen_scripts: awk: packages: - brew: gawk + script: macos_brew_gawk + append: macos_brew_gawk groovy: packages: - brew: groovy @@ -18,34 +34,63 @@ macos: - brew: perl meets: perl script: macos_cpan_local_setup + append: macos_cpan_local_setup - cpan: App::cpanminus - cpanm: Switch php: packages: - - brew: php + # Dependencies + # - apr + # - apr-util + # - argon2 + # - autoconf + # - curl + # - freetds + # - gd + # - gmp + # - icu4c@78 + # - libpq + # - libsodium + # - libzip + # - net-snmp + # - oniguruma + # - openldap + # - openssl@3 + # - pcre2 + # - sqlite + # - tidy-html5 + # - unixodbc + # - gettext + - brew: php@<%= $php_ver %> python3: packages: - - brew: python3 + - brew: python@<%= $python3_ver %> ruby: packages: - - brew: ruby + - brew: ruby@<%= $ruby4_ver %> + - append: macos_brew_ruby4 + meets: ruby4 tcl: packages: - - brew: tcl-tk + - brew: tcl-tk@<%= $tcl_ver %> shell_scripts: packages: - brew: bc - - brew: getopt + script: macos_brew_bc + append: macos_brew_bc + - brew: gnu-getopt + script: macos_brew_gnu-getopt + append: macos_brew_gnu-getopt bash: packages: - brew: bash - script: macos_setup_homebrew_bash + append: macos_setup_homebrew_bash csh: packages: - brew: tcsh ksh: packages: - - brew: ksh + - brew: ksh93 posix: packages: - brew: dash-shell @@ -53,81 +98,93 @@ macos: zsh: packages: - brew: zsh - script: macos_setup_homebrew_zsh + append: macos_setup_homebrew_zsh compiled_lang: cs: packages: - cask: dotnet-sdk go: packages: - - brew: go + - brew: go@<%= $go_ver %> + append: macos_go java: packages: - tap: homebrew/cask-versions - cask: corretto@17 meets: java script: macos_java_17_home + append: macos_java_17_home rust: packages: + # Dependencies + # - libgit2 + # - libssh2 + # - llvm@22 + # - openssl@3 + # - pkgconf + # - sqlite + # Zsh Completions: + # /opt/homebrew/share/zsh/site-functions + # Link this toolchain with `rustup` under the name `system` with: + # rustup toolchain link system "$(brew --prefix rust)" - brew: rust win_scripts: packages: - cask: wine-stable script: macos_wine_fix + tag: wine - brew: winetricks script: macos_winetricks_wsh57 + tag: wine - script: macos_wine_coreutils + tag: wine powershell: packages: - - brew: powershell + # * Bottle requires building from source (downloads dotnet 10.x in source form) + # * Cask has the official, self-contained Microsoft version of PowerShell that has the embedded .NET runtime + - cask: darkn3rd/tools/powershell-pkg cibox: packages: - brew: act - brew: colima - brew: docker + scriptbox: + packages: + - script: macos_gem_update + needs: ruby4, gnu_grep + - gem: ratatui_ruby + needs: ruby >= 3.2 testbox: packages: - brew: powershell - brew: ruby + scripts: + macos_rosetta: + type: bash + cmd: | + [[ "$(uname -m)" == "arm64" ]] && sudo softwareupdate --install-rosetta --agree-to-license + macos_homebrew: type: bash cmd: | - script_url="https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh" - /bin/bash -c "$(curl -fsSL "$script_url")" + SCRIPT_URL="https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh" + /bin/bash -c "$(curl -fsSL $SCRIPT_URL)" - # Applied before the PATH check below, not after - Homebrew's own - # installer does not add itself to the *current* process's PATH, and - # on Apple Silicon (/opt/homebrew) unlike Intel (/usr/local) that - # directory isn't on the default PATH either way, so `command -v - # brew` would otherwise fail here even on a successful install. - eval "$(brew --prefix)/bin/brew shellenv" + # Bootstrap Homebrew into existing environment + eval "$(<%= $brew_prefix %>/bin/brew shellenv bash)" if ! command -v brew >/dev/null 2>&1; then echo "macos_homebrew: brew not found on PATH after install - stopping" >&2 exit 1 fi - - grep -qxF 'eval "$(brew --prefix)/bin/brew shellenv"' ~/.bashrc 2>/dev/null || echo 'eval "$(brew --prefix)/bin/brew shellenv"' >> ~/.bashrc - grep -qxF 'eval "$(brew --prefix)/bin/brew shellenv"' ~/.zshrc 2>/dev/null || echo 'eval "$(brew --prefix)/bin/brew shellenv"' >> ~/.zshrc macos_cpan_local_setup: type: bash cmd: | # Force ExtUtils::MakeMaker's own prompt() to silently accept # every default answer instead of blocking on stdin. PERL_MM_USE_DEFAULT=1 PERL_MM_OPT="INSTALL_BASE=$HOME/perl5" cpan local::lib - grep -qxF 'eval "$(perl -I$HOME/perl5/lib/perl5 -Mlocal::lib=$HOME/perl5)"' ~/.bashrc 2>/dev/null || echo 'eval "$(perl -I$HOME/perl5/lib/perl5 -Mlocal::lib=$HOME/perl5)"' >> ~/.bashrc - grep -qxF 'export PATH=$HOME/perl5/bin:$PATH' ~/.bashrc 2>/dev/null || echo 'export PATH=$HOME/perl5/bin:$PATH' >> ~/.bashrc - grep -qxF 'eval "$(perl -I$HOME/perl5/lib/perl5 -Mlocal::lib=$HOME/perl5)"' ~/.zshrc 2>/dev/null || echo 'eval "$(perl -I$HOME/perl5/lib/perl5 -Mlocal::lib=$HOME/perl5)"' >> ~/.zshrc - grep -qxF 'export PATH=$HOME/perl5/bin:$PATH' ~/.zshrc 2>/dev/null || echo 'export PATH=$HOME/perl5/bin:$PATH' >> ~/.zshrc - - # Direct, not `source ~/.bashrc` - a script run non-interactively - # (any provisioner, or even a human running this file with `bash - # script.sh`) hits nearly every default .bashrc's own "return if - # not interactive" guard before ever reaching the lines just - # appended above, so they'd never take effect in this process - - # and the very next steps in this same script (cpan: - # App::cpanminus, cpanm: Switch) need cpanm on PATH already. + eval "$(perl -I$HOME/perl5/lib/perl5 -Mlocal::lib=$HOME/perl5)" export PATH=$HOME/perl5/bin:$PATH macos_wine_fix: @@ -135,48 +192,43 @@ scripts: cmd: | ln -s "/Applications/Wine Stable.app/Contents/Resources/wine/share/wine" /usr/local/share/wine ln -s "/Applications/Wine Stable.app/Contents/Resources/wine/lib/wine" /usr/local/lib/wine - grep -qxF 'export MVK_CONFIG_LOG_LEVEL=0' ~/.bashrc 2>/dev/null || echo 'export MVK_CONFIG_LOG_LEVEL=0' >> ~/.bashrc - grep -qxF 'export WINEDEBUG=-all' ~/.bashrc 2>/dev/null || echo 'export WINEDEBUG=-all' >> ~/.bashrc - grep -qxF 'export MVK_CONFIG_LOG_LEVEL=0' ~/.zshrc 2>/dev/null || echo 'export MVK_CONFIG_LOG_LEVEL=0' >> ~/.zshrc - grep -qxF 'export WINEDEBUG=-all' ~/.zshrc 2>/dev/null || echo 'export WINEDEBUG=-all' >> ~/.zshrc - # Applied immediately too - macos_winetricks_wsh57 runs wine-based - # commands later in this same script and benefits from the + # macos_winetricks_wsh57 runs wine-basedvcommands later in this same script and benefits from the # quieter debug output just as much as a future session would. export MVK_CONFIG_LOG_LEVEL=0 export WINEDEBUG=-all - macos_setup_homebrew_bash: - type: bash - cmd: | - grep -qxF "$(brew --prefix)/bin/bash" /etc/shells 2>/dev/null || echo "$(brew --prefix)/bin/bash" | sudo tee -a /etc/shells - macos_setup_homebrew_zsh: - type: bash - cmd: | - grep -qxF "$(brew --prefix)/bin/zsh" /etc/shells 2>/dev/null || echo "$(brew --prefix)/bin/zsh" | sudo tee -a /etc/shells macos_xcode_cli_tools: type: bash cmd: | - touch /tmp/.com.apple.dt.CommandLineTools.installondemand.in-progress - label=$(softwareupdate -l 2>&1 \ - | grep -E '^[[:space:]]*\* Command Line Tools' \ - | sed 's/^[[:space:]]*\* //' \ - | head -1) - if softwareupdate --help 2>&1 | grep -q -- '--agree-to-license'; then - sudo softwareupdate -i "$label" --agree-to-license --verbose - else - sudo softwareupdate -i "$label" --verbose - fi - rm -f /tmp/.com.apple.dt.CommandLineTools.installondemand.in-progress + # Run interactive GUI installer + # CLT packages now appear in list with sfotwareupdate -l + xcode-select --install + + # new packages available + CLT_LABEL=$( + softwareupdate -l 2>&1 | + awk -F': ' ' + /Label: Command Line Tools/ { + label = $2 + version = label + sub(/^Command Line Tools for Xcode /, "", version) + sub(/-.*/, "", version) + + if (version + 0 > max) { + max = version + 0 + newest = label + } + } + END { + print newest + }' + ) + + sudo softwareupdate --install "$CLT_LABEL" + pkill -9 -f 'Install Command Line Developer Tools' macos_java_17_home: type: bash cmd: | - grep -qxF 'export JAVA_HOME=$(/usr/libexec/java_home -v 17)' ~/.bashrc 2>/dev/null || echo 'export JAVA_HOME=$(/usr/libexec/java_home -v 17)' >> ~/.bashrc - grep -qxF 'export PATH=$JAVA_HOME/bin:$PATH' ~/.bashrc 2>/dev/null || echo 'export PATH=$JAVA_HOME/bin:$PATH' >> ~/.bashrc - grep -qxF 'export JAVA_HOME=$(/usr/libexec/java_home -v 17)' ~/.zshrc 2>/dev/null || echo 'export JAVA_HOME=$(/usr/libexec/java_home -v 17)' >> ~/.zshrc - grep -qxF 'export PATH=$JAVA_HOME/bin:$PATH' ~/.zshrc 2>/dev/null || echo 'export PATH=$JAVA_HOME/bin:$PATH' >> ~/.zshrc - - # Applied immediately too - groovy (needs: java) installs right - # after this in the same script and needs java on PATH already. export JAVA_HOME=$(/usr/libexec/java_home -v 17) export PATH=$JAVA_HOME/bin:$PATH macos_winetricks_wsh57: @@ -193,26 +245,107 @@ scripts: macos_wine_coreutils: type: bash cmd: | - pushd $HOME/Downloads - - # Download coreutils - curl -LO https://github.com/microsoft/coreutils/releases/download/v2026.6.16/coreutils-2026.6.16-x64.exe - - # Verify checksum - ACTUAL_SHA=$(shasum -a 256 coreutils-2026.6.16-x64.exe | cut -f1 -d' ') - EXPECTED_SHA="f862b1aa433310420ae20f9b1384f3f974a26ba98ae37ac548061116a3ef6c62" - - if [ "$ACTUAL_SHA" != "$EXPECTED_SHA" ]; then - echo "ERROR: CHECKSUM MISMATCH - DO NOT USE THIS FILE" >&2 - exit 1 - fi - echo "CHECKSUM OK" - - # install coreutils - wine coreutils-2026.6.16-x64.exe /VERYSILENT /SUPPRESSMSGBOXES /NORESTART /SP- - - popd - - - + pushd $HOME/Downloads + # Download coreutils + curl -LO https://github.com/microsoft/coreutils/releases/download/v2026.6.16/coreutils-2026.6.16-x64.exe + + # Verify checksum + ACTUAL_SHA=$(shasum -a 256 coreutils-2026.6.16-x64.exe | cut -f1 -d' ') + EXPECTED_SHA="f862b1aa433310420ae20f9b1384f3f974a26ba98ae37ac548061116a3ef6c62" + + if [ "$ACTUAL_SHA" != "$EXPECTED_SHA" ]; then + echo "ERROR: CHECKSUM MISMATCH - DO NOT USE THIS FILE" >&2 + exit 1 + fi + echo "CHECKSUM OK" + + # install coreutils + wine coreutils-2026.6.16-x64.exe /VERYSILENT /SUPPRESSMSGBOXES /NORESTART /SP- + + popd + macos_gem_update: + type: bash + cmd: | + chmod -R u+w <%= $brew_prefix %>/lib/ruby/gems/4.0.0/plugins/ + + URL="https://rubygems.org/api/v1/versions/rubygems-update/latest.json" + if [[ "$(curl -s $URL | grep -oP '"version"[: ]*"\K[^"]+')" != "$(gem --version)" ]]; then + gem update --system + fi + macos_brew_gnu_grep: + cmd: | + PATH="<%= $brew_prefix %>/opt/grep/libexec/gnubin:$PATH" + macos_brew_gawk: + cmd: | + PATH="<%= $brew_prefix %>/opt/gawk/libexec/gnubin:$PATH" + macos_brew_bc: + cmd: | + PATH="<%= $brew_prefix %>/opt/bc/bin:$PATH" + macos_brew_gnu-getopt: + cmd: | + PATH="<%= $brew_prefix %>/opt/gnu-getopt/bin:$PATH" + + + +appends: + # Configure Brew CLI for default zsh + macos_homebrew_zsh: + dest: $HOME/.zprofile + lines: + - eval "$(<%= $brew_prefix %>/bin/brew shellenv zsh)" + # Configure Brew CLI for default bash + macos_homebrew_bash: + dest: $HOME/.bash_profile + lines: + - eval "$(<%= $brew_prefix %>/bin/brew shellenv bash)" + macos_setup_homebrew_bash: + dest: /etc/shells + sudo: true + lines: + - <%= $brew_prefix %>/bin/bash + macos_setup_homebrew_zsh: + dest: /etc/shells + sudo: true + lines: + - <%= $brew_prefix %>/bin/zsh + macos_java_17_home: + dest: ["$HOME/.bashrc", "$HOME/.zshrc"] + lines: + - export JAVA_HOME=$(/usr/libexec/java_home -v 17) + - export PATH=$JAVA_HOME/bin:$PATH + macos_cpan_local_setup: + dest: ["$HOME/.bashrc", "$HOME/.zshrc"] + lines: + - eval "$(perl -I$HOME/perl5/lib/perl5 -Mlocal::lib=$HOME/perl5)" + - export PATH=$HOME/perl5/bin:$PATH + macos_wine_fix: + dest: ["$HOME/.bashrc", "$HOME/.zshrc"] + lines: + - export MVK_CONFIG_LOG_LEVEL=0 + - export WINEDEBUG=-all + macos_brew_gawk: + dest: ["$HOME/.bashrc", "$HOME/.zshrc"] + lines: + - PATH="<%= $brew_prefix %>/opt/gawk/libexec/gnubin:$PATH" + macos_brew_bc: + dest: ["$HOME/.bashrc", "$HOME/.zshrc"] + lines: + - PATH="<%= $brew_prefix %>/opt/bc/bin:$PATH" + macos_brew_gnu-getopt: + dest: ["$HOME/.bashrc", "$HOME/.zshrc"] + lines: + - PATH="<%= $brew_prefix %>/opt/gnu-getopt/bin:$PATH" + macos_brew_ruby4: + dest: ["$HOME/.bashrc", "$HOME/.zshrc"] + lines: + - PATH="<%= $brew_prefix %>/lib/ruby/gems/4.0.0/bin:$PATH" + macos_brew_gnu_grep: + dest: ["$HOME/.bashrc", "$HOME/.zshrc"] + lines: + - PATH="<%= $brew_prefix %>/opt/grep/libexec/gnubin:$PATH" + macos_go: + dest: ["$HOME/.bashrc", "$HOME/.zshrc"] + lines: + - export GOPATH=$(go env GOPATH) + - export PATH=$GOPATH/bin:$PATH diff --git a/scriptbox/scripts/generate_chef_databag.rb b/scriptbox/scripts/generate_chef_databag.rb index ec69926..3a339b5 100644 --- a/scriptbox/scripts/generate_chef_databag.rb +++ b/scriptbox/scripts/generate_chef_databag.rb @@ -97,6 +97,17 @@ def step_to_entry(step, tree) append = (tree['appends'] || {})[step[:name]] entry[:dest] = append['dest'] entry[:lines] = append['lines'] + # Passed through, not acted on - Chef's own append_if_no_line, + # Ansible's lineinfile, and Puppet's file_line all write `line` as + # inert Ruby/Python/Puppet data with no shell involved at all (see + # generate_install_script.rb's own append_lines comment on why this + # flag exists in the first place - it's meaningless without a real + # shell downstream to do the evaluating). Carried into the data bag + # anyway so a consumer that reaches an interpolate: true step it + # can't honor has something to check and reject loudly against, + # rather than silently writing the manifest's own literal `$(...)` + # text into the target file. + entry[:interpolate] = true if append['interpolate'] end entry diff --git a/scriptbox/scripts/generate_install_script.rb b/scriptbox/scripts/generate_install_script.rb index 34bd0a0..f490a37 100755 --- a/scriptbox/scripts/generate_install_script.rb +++ b/scriptbox/scripts/generate_install_script.rb @@ -297,21 +297,45 @@ def file_write(step, tree) # grep-guard/`|| true` logic itself lives once in append_line() now, # not re-emitted per line here - see common.yml's own comment for why # each of those pieces matters. +# +# interpolate: true (default false, per entry - not per line) switches +# from single- to double-quoting, so a line's own `$(...)`/`$VAR` gets +# evaluated by the shell running append_line - a live homebrew prefix +# (`$(brew --prefix)/bin/bash`, /etc/shells needs the real resolved +# path, not the literal text) has no other way to reach the target +# machine's own actual value. Off by default because most appends - +# msys2/cygwin_purge_windows_path's own tr/grep pipelines, anything +# with embedded quotes - need the opposite: written exactly as typed, +# none of it evaluated as shell syntax. +# +# sudo: true (default false, per entry) - a destination like /etc/shells +# is root-owned; append_line's own internal touch/tee calls need it +# passed through as a third argument (see common.yml's own comment on +# why this can't just be `sudo append_line ...` from out here instead). def append_lines(step, tree) entry = tree['appends'][step[:name]] + sudo_arg = entry['sudo'] ? ' true' : '' Array(entry['dest']).flat_map do |dest| Array(entry['lines']).map do |line| - # Lines like msys2/cygwin_purge_windows_path embed their own single - # quotes (tr ':' '\n', grep -vE '^/[a-zA-Z]/', ...). Naively - # interpolating `line` inside a '...' wrapper lets those embedded - # quotes toggle bash's own quote-parsing mid-string, silently - # corrupting what gets written (confirmed directly: produced - # `tr : n` instead of `tr ':' '\n'` in a real generated .bashrc). - # Standard bash single-quote escaping - close, escaped literal - # quote, reopen - keeps the whole line literal regardless of - # what it contains. - quoted = "'" + line.gsub("'") { "'\\''" } + "'" - %(append_line "#{dest}" #{quoted}) + quoted = if entry['interpolate'] + # Double-quote escaping - only \ and " need it here; + # $ and ` are deliberately left alone, that's the + # entire point of this branch existing. + '"' + line.gsub('\\') { '\\\\' }.gsub('"') { '\\"' } + '"' + else + # Lines like msys2/cygwin_purge_windows_path embed + # their own single quotes (tr ':' '\n', grep -vE + # '^/[a-zA-Z]/', ...). Naively interpolating `line` + # inside a '...' wrapper lets those embedded quotes + # toggle bash's own quote-parsing mid-string, silently + # corrupting what gets written (confirmed directly: + # produced `tr : n` instead of `tr ':' '\n'` in a real + # generated .bashrc). Standard bash single-quote + # escaping - close, escaped literal quote, reopen - + # keeps the whole line literal regardless of content. + "'" + line.gsub("'") { "'\\''" } + "'" + end + %(append_line "#{dest}" #{quoted}#{sudo_arg}) end end.join("\n") end @@ -326,13 +350,24 @@ def append_lines(step, tree) # variables the same way bash's do, so append_line's own $Dest # parameter receives the already-expanded path, not the literal text # "$PROFILE". Line values are escaped for a PowerShell single-quoted -# string (a literal quote there is just doubled, not the close/escape/ -# reopen dance bash needs). +# string by default (a literal quote there is just doubled, not the +# close/escape/reopen dance bash needs) - unless interpolate: true +# (see append_lines' own comment on why this exists at all), which +# switches to a double-quoted line the same way, so $variable/$(...) +# in the manifest's own text gets evaluated by the shell running +# append_line instead of written out literally. def powershell_append_lines(step, tree) entry = tree['appends'][step[:name]] Array(entry['dest']).flat_map do |dest| Array(entry['lines']).map do |line| - quoted = "'" + line.gsub("'") { "''" } + "'" + quoted = if entry['interpolate'] + # ` is PowerShell's own escape character - has to be + # escaped first, before ", or a line containing both + # would double-escape the quote's own backtick. + '"' + line.gsub('`') { '``' }.gsub('"') { '`"' } + '"' + else + "'" + line.gsub("'") { "''" } + "'" + end %(append_line "#{dest}" #{quoted}) end end.join("\n") diff --git a/scriptbox/scripts/resolve_order.rb b/scriptbox/scripts/resolve_order.rb index 50ec570..02fa3fa 100644 --- a/scriptbox/scripts/resolve_order.rb +++ b/scriptbox/scripts/resolve_order.rb @@ -1,4 +1,5 @@ require 'yaml' +require 'erb' PACKAGE_TYPES = %w[brew cask tap cpan cpanm system apt pyenv rbenv rvm sdkman asdf asdf_plugin choco choco_cyg choco_local feature gem pacman path cyg cmd sysctl salt_formula apt_pin pipx powershell_package_provider powershell_module powershell_cmd noop].freeze @@ -568,6 +569,27 @@ def parse_condition(condition) # since it has more than one dot; but `retries: 3` would parse as an # Integer) - always coerced to a String on substitution since it's # being spliced into one. +# +# Two unrelated things both spelled `<%= ... %>`, on purpose: a +# *reference* (`<%= $name %>`, VARIABLE_REF's own syntax, matched by +# the gsub below) is never templated - just a literal name lookup, so +# `$PATH` sitting right next to one in the same string (e.g. `PATH="<%= +# $brew_prefix %>/opt/gawk/libexec/gnubin:$PATH"`) is never touched, +# whatever it contains. A variable's own *definition* in the +# variables: block, on the other hand, is run through a real +# ERB.new(...).result here, every time it's looked up - genuine Ruby, +# evaluated on whatever machine runs this generator (RbConfig::CONFIG, +# ENV, anything else in scope), not the eventual target - e.g. +# `brew_prefix: "<%= RbConfig::CONFIG['host_cpu'] == 'x86_64' ? '/usr/ +# local/' : '/opt/homebrew/' %>"` resolves once per reference to a +# plain, already-final string before VARIABLE_REF ever splices it in. +# A value with no ERB tags at all (`ruby_ver: 4.0.6`) passes through +# ERB unchanged - it's a no-op for plain text, not just for expressions. +# Re-evaluated on every reference rather than cached once - harmless +# (a pure function of the string plus this process's own stable state, +# same result every time within one generator run) and far simpler +# than threading a resolved-vars cache through every one of this +# function's own recursive calls for no real benefit. def substitute_variables(value, vars) case value when String @@ -575,7 +597,7 @@ def substitute_variables(value, vars) name = Regexp.last_match(1) raise "unknown variable '#{name}' referenced as '<%= $#{name} %>' - not defined in this manifest's own variables: block" unless vars.key?(name) - vars[name].to_s + ERB.new(vars[name].to_s).result end when Hash value.transform_values { |v| substitute_variables(v, vars) }