diff --git a/justfile b/justfile new file mode 100644 index 0000000..a739158 --- /dev/null +++ b/justfile @@ -0,0 +1,38 @@ +# Human-facing entry points for resolved provisioning plans. The generated +# files remain disposable compiler output under scriptbox/generated/. + +[unix] +set shell := ["bash", "-uc"] + +[windows] +set shell := ["powershell.exe", "-NoLogo", "-Command"] + +generate-ubuntu *args: + ruby scriptbox/scripts/generate_justfile.rb scriptbox/config/ubuntu2204.yml --output scriptbox/generated/ubuntu2204.just {{args}} + +install-ubuntu: + just --justfile scriptbox/generated/ubuntu2204.just install + +generate-macos *args: + ruby scriptbox/scripts/generate_justfile.rb scriptbox/config/macos.yml --output scriptbox/generated/macos.just {{args}} + +install-macos: + just --justfile scriptbox/generated/macos.just install + +generate-windows *args: + ruby scriptbox/scripts/generate_justfile.rb scriptbox/config/windows.yml --output scriptbox/generated/windows.just {{args}} + +install-windows: + just --justfile scriptbox/generated/windows.just install + +generate-msys2 *args: + ruby scriptbox/scripts/generate_justfile.rb scriptbox/config/msys2.yml --output scriptbox/generated/msys2.just {{args}} + +install-msys2: + just --justfile scriptbox/generated/msys2.just install + +generate-cygwin *args: + ruby scriptbox/scripts/generate_justfile.rb scriptbox/config/cygwin.yml --output scriptbox/generated/cygwin.just {{args}} + +install-cygwin: + just --justfile scriptbox/generated/cygwin.just install diff --git a/scriptbox/README.md b/scriptbox/README.md index 224495a..bd790bb 100644 --- a/scriptbox/README.md +++ b/scriptbox/README.md @@ -7,6 +7,13 @@ This area has information about installation and useful scripts. * Ruby for any `.rb` scripts * Powershell for any `.ps1` scripts +## Justfile Support + +You will need to install just to be able to use Justfiles. + +* https://github.com/casey/just +* https://just.systems/man/en/ + ## RatatuiRuby Library Some scripts will use the [RatatuiRuby](https://www.ratatui-ruby.dev/) TUI library. diff --git a/scriptbox/generated/README.md b/scriptbox/generated/README.md index 9b24ffa..1f892bf 100644 --- a/scriptbox/generated/README.md +++ b/scriptbox/generated/README.md @@ -1,8 +1,9 @@ # generated/ -Output of `scripts/gen_installer.rb` (and `scripts/generate_install_sh.rb` in -single-file mode) lands here - one `_install.sh` per platform, built -from `config/*.yml`. +Output of `scripts/gen_installer.rb`, `scripts/generate_install_script.rb`, and +`scripts/generate_justfile.rb` lands here. Outputs include standalone install +scripts and resolved `.just` execution plans built from +`config/*.yml`. Everything in this folder except this README is gitignored and gets regenerated on demand: diff --git a/scriptbox/scripts/README.md b/scriptbox/scripts/README.md index 0c9bf5f..f1f0e25 100644 --- a/scriptbox/scripts/README.md +++ b/scriptbox/scripts/README.md @@ -87,6 +87,26 @@ every change - a deliberately-triggered suite, one letter at a time. This script generates an install script: GNU Bash (`.sh`) for POSIX environments and powershell (`.ps1`) for Windows. The scripts will vary depending on the options selected. +## generate_justfile.rb + +Generates a resolved Justfile from the same manifest, selection, dependency, +and command-rendering pipeline as the standalone installer. The generated +`install` recipe is deliberately one script recipe so exports, sourced version +manager initialization, shell functions, and working-directory changes remain +in one interpreter process. + +```bash +./generate_justfile.rb ../config/ubuntu2204.yml \ + --select rbenv,pyenv,sdkman_groovy \ + --output ../generated/ubuntu2204.just + +just --justfile ../generated/ubuntu2204.just install +``` + +Generation checks `config/env.yml` and rejects a manifest that does not match +the current environment. Native Windows, MSYS2, and Cygwin are separate +platforms. Use `--allow-host-mismatch` for deliberate cross-generation. + ### Ubuntu 22.04 These are some combinations you would try below: diff --git a/scriptbox/scripts/generate_justfile.rb b/scriptbox/scripts/generate_justfile.rb new file mode 100644 index 0000000..c7d17e3 --- /dev/null +++ b/scriptbox/scripts/generate_justfile.rb @@ -0,0 +1,131 @@ +#!/usr/bin/env ruby +require 'fileutils' +require 'optparse' +require 'tempfile' +require 'yaml' +require_relative 'generate_install_script' +require_relative 'verify_commands' + +# A Justfile is an execution backend, not a second resolver. Generate the +# ordinary, fully-resolved installer first, then place that exact program in a +# single script recipe. Keeping the plan in one interpreter process is +# important: invoking `just` from a terminal does not make each recipe shell +# interactive, and separate recipes would lose exports/functions established +# by earlier installation steps. + +def just_platform_supported?(config_path, platform, detected) + env_path = File.join(File.dirname(config_path), 'env.yml') + return nil unless File.file?(env_path) + + environments = YAML.load_file(env_path)['environments'] || [] + entry = environments.find { |candidate| candidate['platform'].to_s.casecmp?(platform) } + entry && Array(entry['supports']).include?(detected) +end + +def indent_just_recipe(body) + body.lines.map { |line| line.strip.empty? ? "\n" : " #{line}" }.join +end + +def write_resolved_justfile(config_path, out_path, select_tags: [], exclude_tags: [], + selectors: [], check_host: true) + tree = YAML.load_file(config_path) + platform = root_key(tree) + raise "no platform manifest found in #{config_path}" unless platform + + detected = uname_string + supported = just_platform_supported?(config_path, platform, detected) + if check_host && supported == false + raise "detected environment '#{detected}' is not supported for platform '#{platform}' " \ + "(use --allow-host-mismatch only when intentionally cross-generating)" + end + + tree = substitute_variables(tree, tree[platform]['variables'] || {}) + natural_steps = flatten(tree[platform]) + steps, omitted = resolve_included( + natural_steps.dup, select_tags, exclude_tags, + selectors: expand_selectors(selectors) + ) + topological_order(steps) + dedup!(steps) + + header_lines = [ + "Generated by generate_justfile.rb from #{File.basename(config_path)} - do not edit by hand.", + "Resolved platform: #{platform}", + "Detected generation environment: #{detected}" + ] + omitted.each do |step, missing| + header_lines << "Omitted: [#{step[:path]}] #{step[:type]}: #{step[:name]} - " \ + "needs '#{missing}', no eligible provider" + end + check_version_needs!(steps).each { |step| header_lines << "Omitted: #{step[:omitted_reason]}" } + + dialect = dialect_for(tree, platform) + Tempfile.create(["#{platform}_resolved", dialect == 'powershell' ? '.ps1' : '.sh']) do |installer| + installer.close + write_install_script( + platform, steps, tree, dialect, File.dirname(installer.path), header_lines, + natural_steps, apt_mirror_for(tree, platform), out_path: installer.path + ) + program = File.binread(installer.path).sub(/\A#![^\n]*\n/, '') + if dialect == 'powershell' + # just writes a script recipe to a temporary file. Keep the parent + # invocation alive until the elevated child finishes, or just may remove + # that temporary file before the child has opened $PSCommandPath. + program = program.sub('Start-Process powershell.exe -Verb RunAs -ArgumentList ', + 'Start-Process powershell.exe -Verb RunAs -Wait -ArgumentList ') + end + + FileUtils.mkdir_p(File.dirname(File.expand_path(out_path))) + File.open(out_path, 'wb') do |file| + file.puts '# Generated file. Change the platform manifest or generator instead.' + file.puts "# Source: #{File.basename(config_path)}" + file.puts "# Platform: #{platform}; generated on: #{detected}" + file.puts + if dialect == 'powershell' + file.puts '[script("powershell.exe", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File")]' + else + file.puts '[script("bash")]' + end + file.puts '# Install the fully resolved provisioning plan.' + file.puts 'install:' + file.write(indent_just_recipe(program)) + end + end + + out_path +end + +if __FILE__ == $PROGRAM_NAME + options = { select: [], exclude: [], selectors: [], check_host: true } + parser = OptionParser.new do |opts| + opts.banner = 'usage: generate_justfile.rb [SECTION ...] --output PATH [options]' + opts.on('--select TAGS', 'comma-separated provider tags to enable') do |value| + options[:select] = value.split(',').map(&:strip) + end + opts.on('--exclude TAGS', 'comma-separated tags to veto') do |value| + options[:exclude] = value.split(',').map(&:strip) + end + opts.on('--output PATH', 'resolved Justfile path (required)') { |value| options[:output] = value } + opts.on('--allow-host-mismatch', 'permit intentional cross-generation') { options[:check_host] = false } + end + parser.parse! + + config_path = ARGV.shift + options[:selectors] = ARGV + if config_path.nil? || options[:output].nil? + warn parser + exit 1 + end + + begin + path = write_resolved_justfile( + config_path, options[:output], select_tags: options[:select], + exclude_tags: options[:exclude], selectors: options[:selectors], + check_host: options[:check_host] + ) + puts "wrote #{path}" + rescue StandardError => error + warn "generate_justfile.rb: #{error.message}" + exit 1 + end +end diff --git a/scriptbox/scripts/test/test_generate_justfile.rb b/scriptbox/scripts/test/test_generate_justfile.rb new file mode 100644 index 0000000..1400e85 --- /dev/null +++ b/scriptbox/scripts/test/test_generate_justfile.rb @@ -0,0 +1,58 @@ +require_relative 'test_helper' +require_relative '../generate_justfile' +require 'tmpdir' + +class TestGenerateJustfile < Minitest::Test + CONFIG_PATH = File.expand_path('../../config/ubuntu2204.yml', __dir__) + + def generate(select = []) + Dir.mktmpdir do |dir| + path = File.join(dir, 'ubuntu2204.just') + write_resolved_justfile( + CONFIG_PATH, path, select_tags: select, check_host: false, + selectors: ['lessons.gen_scripts.{ruby,python3}'] + ) + return File.binread(path) + end + end + + def test_emits_one_bash_script_recipe + output = generate + + assert_includes output, '[script("bash")]' + assert_includes output, "install:\n" + assert_includes output, ' set -e' + refute_includes output, '#!/bin/bash' + end + + def test_uses_the_existing_resolved_provider_selection + output = generate(%w[asdf asdf_ruby asdf_python]) + + assert_includes output, 'asdf install ruby 4.0.6' + assert_includes output, 'asdf install python 3.14.7' + refute_includes output, 'rbenv install' + refute_includes output, 'pyenv install' + end + + def test_platform_matching_distinguishes_windows_shell_environments + env_path = File.expand_path('../../config/env.yml', __dir__) + dir = File.dirname(env_path) + + assert_equal true, just_platform_supported?(File.join(dir, 'windows.yml'), 'windows', 'windows_nt.10_0_26200') + assert_equal false, just_platform_supported?(File.join(dir, 'windows.yml'), 'windows', 'mingw64_nt.10_0_26200') + assert_equal true, just_platform_supported?(File.join(dir, 'msys2.yml'), 'msys2', 'mingw64_nt.10_0_26200') + assert_equal true, just_platform_supported?(File.join(dir, 'cygwin.yml'), 'cygwin', 'cygwin_nt.10_0_26200') + end + + def test_windows_elevation_waits_while_justs_temporary_script_exists + config = File.expand_path('../../config/windows.yml', __dir__) + Dir.mktmpdir do |dir| + path = File.join(dir, 'windows.just') + write_resolved_justfile(config, path, check_host: false) + output = File.binread(path) + + assert_includes output, '[script("powershell.exe", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File")]' + assert_includes output, 'Start-Process powershell.exe -Verb RunAs -Wait -ArgumentList' + end + end +end