From 84d23195252987de0b60b642e0f0e500ac47616b Mon Sep 17 00:00:00 2001 From: phranck Date: Fri, 24 Jul 2026 16:37:53 +0200 Subject: [PATCH] Refactor: Make the tuikit project generator cross-platform and testable - Sanitize project names into three separate values (display name, Swift identifier, filesystem path); reject '..', embedded slashes, and control characters before writing any file - Non-identifier characters become underscores in the Swift identifier (my-app -> my_app) so Package.swift and target names always compile - Add --yes/-y and TUIKIT_NON_INTERACTIVE=1 for CI-friendly runs; suppress the banner and the "Open in Xcode?" prompt in that mode - Guard Xcode defaults, the "open Package.swift" call, and the Xcode prompt behind a macOS check so nothing macOS-only runs on Linux - Pin swiftly to Swift 6.0.3 (matching the SDK gates) instead of installing latest; refuse the swift.org tarball path on non-Ubuntu distributions instead of pretending to be Ubuntu - Symmetric install/uninstall: resolve_install_path finds the script's own directory first and falls back to the installer defaults - Add an integration test script (scripts/tests/project-template/) that covers sanitization, path validation, OS/arch detection, basic generation, and hyphen sanitization end to end - Rewrite the README to describe non-interactive mode, name handling, the pinned Swift version, and remove the stale .swiftpm claim --- project-template/README.md | 61 +- project-template/install.sh | 36 +- project-template/tuikit | 606 +++++++++--------- .../project-template/test-tuikit-generator.sh | 132 ++++ 4 files changed, 492 insertions(+), 343 deletions(-) create mode 100755 scripts/tests/project-template/test-tuikit-generator.sh diff --git a/project-template/README.md b/project-template/README.md index ebdff8e0c..e8bc1af11 100644 --- a/project-template/README.md +++ b/project-template/README.md @@ -8,17 +8,23 @@ CLI tool for creating TUIkit terminal applications. curl -fsSL https://raw.githubusercontent.com/phranck/TUIkit/main/project-template/install.sh | bash ``` -This installs the `tuikit` command globally on your system. +This installs the `tuikit` command into your user bin directory +(`~/.local/bin` on Linux; `/usr/local/bin` when writable on macOS, else +`~/.local/bin`). ## Usage ```bash tuikit init MyApp # Basic app -tuikit init sqlite MyApp # With SQLite database +tuikit init sqlite MyApp # With SQLite (GRDB) tuikit init testing MyApp # With Swift Testing tuikit init sqlite testing MyApp # All features +tuikit init --yes git MyApp # Non-interactive (CI-friendly) ``` +Set `TUIKIT_NON_INTERACTIVE=1` to suppress every prompt (helpful for +CI or unattended installs). + ## What Gets Created ``` @@ -29,27 +35,37 @@ MyApp/ │ ├── ContentView.swift # Root view │ └── Database.swift # (if sqlite option used) ├── Tests/ # (if testing/xctest option used) -├── .swiftpm/ # Pre-configured Xcode scheme ├── README.md └── .gitignore ``` -## Features +Xcode generates its own scheme configuration on first open, so the +generator does not pre-populate `.swiftpm`. + +## Name Handling -- Creates native Swift Packages (not .xcodeproj) -- Optional SQLite.swift integration -- Optional Swift Testing or XCTest -- Pre-configured Xcode scheme -- Cross-platform (macOS, Linux) -- XDG Base Directory compliant +The project name is split into a display name (the folder), a Swift +identifier used for target, package, and module names, and a +filesystem path. Invalid components (`..`, embedded `/`, control +characters, empty names) are rejected before any file is written. +Non-identifier characters in the display name become underscores in +the Swift identifier (`my-app` → `my_app`). -## Installation Details +## Requirements + +- macOS 15+ or a supported Linux distribution +- Swift 6.0.3 (the pinned compiler floor matches the TUIkit SDK gates; + the Linux installer pins this version through `swiftly` or a Swift.org + tarball on Ubuntu) +- Bash shell -The installer: -- Detects your platform (macOS/Linux) -- Installs to `/usr/local/bin` or `~/.local/bin` -- Offers to update your shell PATH automatically -- Creates `tuikit-uninstall` command for easy removal +## Platform Behavior + +- **macOS**: Xcode preferences are set only on macOS; the "Open in + Xcode" prompt is macOS-only and skipped in non-interactive runs. +- **Linux**: the installer offers to install Swift through your + distribution (apt / dnf / AUR) or `swiftly`. Non-Ubuntu tarball + downloads are refused instead of pretending to be Ubuntu. ## Manual Installation @@ -63,19 +79,18 @@ cd TUIkit/project-template ```bash tuikit-uninstall +# or +tuikit uninstall ``` -## Requirements - -- macOS 15+ or Linux -- Swift 6.0+ -- Bash shell +Both spellings remove the script from the same directory the installer +used. ## Documentation -- [TUIkit Documentation](https://docs.tuikit.dev/documentation/tuikit/) +- [TUIkit Documentation](https://tuikit.layered.work/documentation/tuikit/) - [TUIkit GitHub](https://github.com/phranck/TUIkit) ## License -MIT License +This repository has been published under the [MIT](https://layered.mit-license.org) license. diff --git a/project-template/install.sh b/project-template/install.sh index cbb1b128e..ceb96e99a 100755 --- a/project-template/install.sh +++ b/project-template/install.sh @@ -135,25 +135,29 @@ UNINSTALL_SCRIPT echo "" echo -e " ${CYAN}export PATH=\"\$PATH:$INSTALL_PATH\"${NC}" echo "" - echo -e " ${DIM}Add automatically? (y/n)${NC}" - read -r response - if [[ "$response" =~ ^[Yy]$ ]]; then - # Create backup - if [ -f "$SHELL_CONFIG" ]; then - cp "$SHELL_CONFIG" "${SHELL_CONFIG}.backup" - fi + # Skip the interactive prompt in automation. + if [ "${TUIKIT_NON_INTERACTIVE:-0}" = "1" ]; then + echo -e " ${DIM}Non-interactive mode: leaving PATH untouched.${NC}" + else + echo -e " ${DIM}Add automatically? (y/n)${NC}" + read -r response - # Add to PATH - echo "" >> "$SHELL_CONFIG" - echo "# Added by TUIkit installer" >> "$SHELL_CONFIG" - echo "export PATH=\"\$PATH:$INSTALL_PATH\"" >> "$SHELL_CONFIG" + if [[ "$response" =~ ^[Yy]$ ]]; then + if [ -f "$SHELL_CONFIG" ]; then + cp "$SHELL_CONFIG" "${SHELL_CONFIG}.backup" + fi - echo "" - echo -e " ${GREEN}Done!${NC} PATH updated." - echo -e " ${DIM}Restart your terminal or run:${NC} source $SHELL_CONFIG" - else - echo -e " ${DIM}Skipped. Add PATH manually to use tuikit.${NC}" + echo "" >> "$SHELL_CONFIG" + echo "# Added by TUIkit installer" >> "$SHELL_CONFIG" + echo "export PATH=\"\$PATH:$INSTALL_PATH\"" >> "$SHELL_CONFIG" + + echo "" + echo -e " ${GREEN}Done!${NC} PATH updated." + echo -e " ${DIM}Restart your terminal or run:${NC} source $SHELL_CONFIG" + else + echo -e " ${DIM}Skipped. Add PATH manually to use tuikit.${NC}" + fi fi fi diff --git a/project-template/tuikit b/project-template/tuikit index b582e7947..ed1dde5d0 100755 --- a/project-template/tuikit +++ b/project-template/tuikit @@ -1,12 +1,11 @@ #!/bin/bash # TUIkit CLI -# Modern command-line tool for creating TUIkit projects +# Cross-platform generator for TUIkit terminal applications. -set -e +set -euo pipefail VERSION="1.0.0" -TUIKIT_INSTALL_PATH="/usr/local/bin/tuikit" # Colors BLUE='\033[0;34m' @@ -15,276 +14,294 @@ YELLOW='\033[0;33m' RED='\033[0;31m' NC='\033[0m' # No Color -# Uninstall tuikit +# --- Platform detection -------------------------------------------------------- + +detect_os() { + case "$(uname -s)" in + Darwin*) echo "macos" ;; + Linux*) echo "linux" ;; + *) echo "unknown" ;; + esac +} + +detect_arch() { + case "$(uname -m)" in + x86_64) echo "x86_64" ;; + aarch64|arm64) echo "aarch64" ;; + *) echo "unknown" ;; + esac +} + +# --- Install-path resolution -------------------------------------------------- + +# Returns the bin directory holding this script; falls back to the same +# defaults the installer uses so `tuikit-uninstall` finds the right file. +resolve_install_path() { + local script_dir + script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + + if [ -f "$script_dir/tuikit" ]; then + echo "$script_dir" + return + fi + + if [ -n "${XDG_BIN_HOME:-}" ]; then + echo "$XDG_BIN_HOME" + return + fi + + case "$(detect_os)" in + macos) + if [ -w "/usr/local/bin" ]; then + echo "/usr/local/bin" + else + echo "$HOME/.local/bin" + fi + ;; + *) + echo "$HOME/.local/bin" + ;; + esac +} + +# Uninstall tuikit from the same location the installer uses. uninstall_tuikit() { - echo -e "${YELLOW}Removing tuikit...${NC}" - if [ -f "$TUIKIT_INSTALL_PATH" ]; then - sudo rm -f "$TUIKIT_INSTALL_PATH" 2>/dev/null || rm -f "$TUIKIT_INSTALL_PATH" + local install_dir + install_dir="$(resolve_install_path)" + + echo -e "${YELLOW}Removing tuikit from ${install_dir}...${NC}" + local script="$install_dir/tuikit" + + if [ -f "$script" ]; then + if [ -w "$install_dir" ]; then + rm -f "$script" "$install_dir/tuikit-uninstall" + else + sudo rm -f "$script" "$install_dir/tuikit-uninstall" + fi fi echo -e "${GREEN}tuikit has been removed.${NC}" } -# Check if running on Linux and Swift is available +# --- Name sanitization -------------------------------------------------------- + +# Rewrites arbitrary input into a Swift-safe identifier. Rejects empty or +# invalid names by returning 1. +sanitize_swift_identifier() { + local raw="$1" + local sanitized="${raw//[^A-Za-z0-9_]/_}" + + # Leading digit is illegal in Swift identifiers. + if [[ "$sanitized" =~ ^[0-9] ]]; then + sanitized="_${sanitized}" + fi + + if [ -z "$sanitized" ] || [ "$sanitized" = "_" ]; then + return 1 + fi + + echo "$sanitized" +} + +# Rejects filesystem components that could escape the target directory. +validate_project_path_component() { + local component="$1" + + if [ -z "$component" ]; then + return 1 + fi + case "$component" in + .|..|*/*|*'\'*) + return 1 + ;; + esac + # Control characters (including newlines and null bytes) are not + # legal filesystem names. + if [[ "$component" =~ [[:cntrl:]] ]]; then + return 1 + fi + return 0 +} + +# --- Swift installation (Linux only) ----------------------------------------- + +PINNED_SWIFT_VERSION="6.0.3" + +# Check if running on Linux and Swift is available. check_linux_swift() { - # Only check on Linux - if [ "$(uname)" != "Linux" ]; then + if [ "$(detect_os)" != "linux" ]; then return 0 fi - # Check if Swift is installed - if command -v swift &> /dev/null; then + if command -v swift >/dev/null 2>&1; then return 0 fi - echo -e "${YELLOW}" - echo "╔════════════════════════════════════════════════════╗" - echo "║ ║" - echo "║ Swift is not installed on this system ║" - echo "║ ║" - echo "╚════════════════════════════════════════════════════╝" - echo -e "${NC}" - echo "" - echo -e "TUIkit requires Swift 6.0 or later to create projects." - echo "" - echo -e "${YELLOW}Would you like to install Swift and its dependencies?${NC}" - echo -e "This will install: Swift, clang, libcurl, and other required packages." + if [ "${TUIKIT_NON_INTERACTIVE:-}" = "1" ]; then + echo -e "${RED}Swift is not installed and non-interactive mode is enabled.${NC}" >&2 + echo -e "${YELLOW}Install Swift ${PINNED_SWIFT_VERSION} from https://swift.org/download/ first.${NC}" >&2 + exit 1 + fi + + echo -e "${YELLOW}Swift is not installed on this system.${NC}" + echo -e "TUIkit requires Swift ${PINNED_SWIFT_VERSION} to create projects." echo "" read -p "Install Swift? [y/N] " -n 1 -r echo "" if [[ ! $REPLY =~ ^[Yy]$ ]]; then - echo "" echo -e "${RED}Swift installation declined.${NC}" - uninstall_tuikit - echo -e "${YELLOW}Please install Swift manually from https://swift.org/download/${NC}" + echo -e "${YELLOW}Install Swift ${PINNED_SWIFT_VERSION} manually from https://swift.org/download/${NC}" exit 1 fi install_swift_linux } -# Install Swift on Linux install_swift_linux() { echo "" - echo -e "${GREEN}Installing Swift and dependencies...${NC}" + echo -e "${GREEN}Installing Swift ${PINNED_SWIFT_VERSION} and dependencies...${NC}" echo "" - # Detect package manager and distribution + local distro="unknown" if [ -f /etc/os-release ]; then + # shellcheck disable=SC1091 . /etc/os-release - DISTRO=$ID - else - DISTRO="unknown" + distro="${ID:-unknown}" fi - case "$DISTRO" in - ubuntu|debian|linuxmint|pop) - install_swift_debian - ;; - fedora|rhel|centos|rocky|almalinux) - install_swift_fedora - ;; - arch|manjaro) - install_swift_arch - ;; - *) - install_swift_generic - ;; + case "$distro" in + ubuntu|debian|linuxmint|pop) install_swift_debian ;; + fedora|rhel|centos|rocky|almalinux) install_swift_fedora ;; + arch|manjaro) install_swift_arch ;; + *) install_swift_generic ;; esac - # Verify installation - if command -v swift &> /dev/null; then - SWIFT_VERSION=$(swift --version 2>&1 | head -1) + if command -v swift >/dev/null 2>&1; then + local swift_version + swift_version="$(swift --version 2>&1 | head -1)" echo "" - echo -e "${GREEN}Swift installed successfully!${NC}" - echo -e "${BLUE}Version: ${NC}$SWIFT_VERSION" + echo -e "${GREEN}Swift installed successfully.${NC}" + echo -e "${BLUE}Version: ${NC}${swift_version}" echo "" else echo "" echo -e "${RED}Swift installation failed.${NC}" - echo -e "${YELLOW}Please install Swift manually from https://swift.org/download/${NC}" - uninstall_tuikit + echo -e "${YELLOW}Install Swift ${PINNED_SWIFT_VERSION} manually from https://swift.org/download/${NC}" exit 1 fi } -# Install Swift on Debian/Ubuntu install_swift_debian() { echo -e "${BLUE}Detected Debian/Ubuntu-based system${NC}" - echo "" - - # Install dependencies sudo apt-get update sudo apt-get install -y \ - binutils \ - git \ - gnupg2 \ - libc6-dev \ - libcurl4-openssl-dev \ - libedit2 \ - libgcc-11-dev \ - libpython3-dev \ - libsqlite3-0 \ - libstdc++-11-dev \ - libxml2-dev \ - libz3-dev \ - pkg-config \ - tzdata \ - unzip \ - zlib1g-dev \ - curl - - # Try to install Swift via swiftly (recommended method) + binutils git gnupg2 libc6-dev libcurl4-openssl-dev libedit2 \ + libgcc-11-dev libpython3-dev libsqlite3-0 libstdc++-11-dev \ + libxml2-dev libz3-dev pkg-config tzdata unzip zlib1g-dev curl + if install_swift_via_swiftly; then return 0 fi - - # Fallback: Try apt repository - echo -e "${YELLOW}Trying Swift apt repository...${NC}" - if curl -s https://archive.swiftlang.xyz/install.sh | sudo bash; then - sudo apt-get update - sudo apt-get install -y swiftlang - else - echo -e "${YELLOW}apt repository not available, downloading from swift.org...${NC}" - install_swift_tarball - fi + install_swift_tarball } -# Install Swift on Fedora/RHEL install_swift_fedora() { echo -e "${BLUE}Detected Fedora/RHEL-based system${NC}" - echo "" - - # Install dependencies sudo dnf install -y \ - binutils \ - gcc \ - git \ - glibc-static \ - gzip \ - libbsd \ - libcurl-devel \ - libedit \ - libicu \ - libstdc++-static \ - libuuid \ - libxml2-devel \ - sqlite \ - tar \ - tzdata \ - curl - - # Try swiftly first + binutils gcc git glibc-static gzip libbsd libcurl-devel libedit \ + libicu libstdc++-static libuuid libxml2-devel sqlite tar tzdata curl + if install_swift_via_swiftly; then return 0 fi - - # Fallback to tarball install_swift_tarball } -# Install Swift on Arch Linux install_swift_arch() { echo -e "${BLUE}Detected Arch-based system${NC}" - echo "" - - # Swift is available in AUR - if command -v yay &> /dev/null; then + if command -v yay >/dev/null 2>&1; then yay -S --noconfirm swift-bin - elif command -v paru &> /dev/null; then + elif command -v paru >/dev/null 2>&1; then paru -S --noconfirm swift-bin else - echo -e "${YELLOW}No AUR helper found. Installing yay...${NC}" - sudo pacman -S --needed --noconfirm git base-devel - git clone https://aur.archlinux.org/yay.git /tmp/yay - cd /tmp/yay && makepkg -si --noconfirm - cd - > /dev/null - yay -S --noconfirm swift-bin + echo -e "${RED}No AUR helper found (yay or paru required).${NC}" >&2 + echo -e "${YELLOW}Install Swift ${PINNED_SWIFT_VERSION} manually from https://swift.org/download/${NC}" >&2 + exit 1 fi } -# Install Swift via swiftly (cross-platform installer) +install_swift_generic() { + echo -e "${YELLOW}Unknown Linux distribution.${NC}" + echo -e "${BLUE}Attempting generic installation...${NC}" + + if install_swift_via_swiftly; then + return 0 + fi + install_swift_tarball +} + install_swift_via_swiftly() { - echo -e "${BLUE}Installing Swift via swiftly...${NC}" + echo -e "${BLUE}Installing Swift ${PINNED_SWIFT_VERSION} via swiftly...${NC}" if curl -L https://swiftlang.github.io/swiftly/swiftly-install.sh | bash -s -- -y; then - # Source the environment if [ -f "$HOME/.local/share/swiftly/env.sh" ]; then + # shellcheck disable=SC1091 source "$HOME/.local/share/swiftly/env.sh" fi - # Install latest Swift - "$HOME/.local/bin/swiftly" install latest + # Pin the compiler floor documented in the SDK gates instead of + # installing latest, which may bump past our supported range. + "$HOME/.local/bin/swiftly" install "${PINNED_SWIFT_VERSION}" + "$HOME/.local/bin/swiftly" use "${PINNED_SWIFT_VERSION}" return 0 fi - return 1 } -# Install Swift from official tarball (generic fallback) install_swift_tarball() { - echo -e "${BLUE}Downloading Swift from swift.org...${NC}" - - # Detect architecture - ARCH=$(uname -m) - case "$ARCH" in - x86_64) - SWIFT_ARCH="x86_64" - ;; - aarch64|arm64) - SWIFT_ARCH="aarch64" - ;; - *) - echo -e "${RED}Unsupported architecture: $ARCH${NC}" - return 1 - ;; - esac + local arch + arch="$(detect_arch)" + if [ "$arch" = "unknown" ]; then + echo -e "${RED}Unsupported architecture: $(uname -m)${NC}" >&2 + return 1 + fi - # Get Ubuntu version for tarball selection + # Ubuntu artifacts are the only Linux tarballs on swift.org; refuse + # to pretend a Debian/Fedora build is Ubuntu. + local distro="" version_id="" if [ -f /etc/os-release ]; then + # shellcheck disable=SC1091 . /etc/os-release - UBUNTU_VERSION="${VERSION_ID}" - else - UBUNTU_VERSION="22.04" + distro="${ID:-}" + version_id="${VERSION_ID:-}" fi - # Download and install (using Swift 6.0.2 as stable version) - SWIFT_VERSION="6.0.2" - SWIFT_RELEASE="swift-${SWIFT_VERSION}-RELEASE" - SWIFT_PLATFORM="ubuntu${UBUNTU_VERSION}" - - DOWNLOAD_URL="https://download.swift.org/swift-${SWIFT_VERSION}-release/${SWIFT_PLATFORM//.}/${SWIFT_RELEASE}/${SWIFT_RELEASE}-${SWIFT_PLATFORM}-${SWIFT_ARCH}.tar.gz" + if [ "$distro" != "ubuntu" ]; then + echo -e "${RED}swift.org tarballs are Ubuntu-only, but detected: ${distro:-unknown}${NC}" >&2 + echo -e "${YELLOW}Install Swift ${PINNED_SWIFT_VERSION} through your distribution or swiftly.${NC}" >&2 + return 1 + fi - echo -e "${BLUE}Downloading: ${NC}$DOWNLOAD_URL" + local release="swift-${PINNED_SWIFT_VERSION}-RELEASE" + local platform="ubuntu${version_id//./}" + local url="https://download.swift.org/swift-${PINNED_SWIFT_VERSION}-release/${platform}/${release}/${release}-ubuntu${version_id}-${arch}.tar.gz" - TEMP_DIR=$(mktemp -d) - curl -L "$DOWNLOAD_URL" -o "$TEMP_DIR/swift.tar.gz" + echo -e "${BLUE}Downloading:${NC} ${url}" + local tmp + tmp="$(mktemp -d)" + curl -L "$url" -o "$tmp/swift.tar.gz" echo -e "${BLUE}Extracting...${NC}" sudo mkdir -p /opt/swift - sudo tar -xzf "$TEMP_DIR/swift.tar.gz" -C /opt/swift --strip-components=1 + sudo tar -xzf "$tmp/swift.tar.gz" -C /opt/swift --strip-components=1 - # Add to PATH - echo 'export PATH="/opt/swift/usr/bin:$PATH"' | sudo tee /etc/profile.d/swift.sh + echo 'export PATH="/opt/swift/usr/bin:$PATH"' | sudo tee /etc/profile.d/swift.sh >/dev/null export PATH="/opt/swift/usr/bin:$PATH" - rm -rf "$TEMP_DIR" + rm -rf "$tmp" } -# Generic installation (fallback) -install_swift_generic() { - echo -e "${YELLOW}Unknown Linux distribution.${NC}" - echo -e "${BLUE}Attempting generic installation...${NC}" +# --- Help -------------------------------------------------------------------- - # Try swiftly first - if install_swift_via_swiftly; then - return 0 - fi - - # Fallback to tarball - install_swift_tarball -} - -# Help text show_help() { echo -e "${GREEN}TUIkit CLI v${VERSION}${NC}" echo "" @@ -296,136 +313,133 @@ show_help() { echo " sqlite Include GRDB for SQLite database storage" echo " testing Include Swift Testing framework" echo " xctest Include XCTest framework" + echo " --yes, -y Non-interactive mode (skip prompts)" echo "" echo "Examples:" echo " tuikit init MyApp # Basic TUIkit app" echo " tuikit init git MyApp # With Git repository" - echo " tuikit init sqlite MyApp # With SQLite" + echo " tuikit init sqlite MyApp # With SQLite (GRDB)" echo " tuikit init testing MyApp # With Swift Testing" - echo " tuikit init git sqlite testing MyApp # With Git, SQLite and Testing" + echo " tuikit init --yes git MyApp # CI-friendly, no prompts" echo "" - echo "If no project name is provided, you'll be prompted to enter one." + echo "Set TUIKIT_NON_INTERACTIVE=1 to disable prompts for automation." } -# Check Linux Swift installation first -check_linux_swift +# --- Argument parsing -------------------------------------------------------- -# Parse arguments INIT_GIT=false INCLUDE_SQLITE=false TEST_FRAMEWORK="None" PROJECT_NAME="" +NON_INTERACTIVE="${TUIKIT_NON_INTERACTIVE:-0}" -if [ "$1" == "init" ]; then - shift - - # Parse options and project name - while [ $# -gt 0 ]; do - case "$1" in - git) - INIT_GIT=true - shift - ;; - sqlite) - INCLUDE_SQLITE=true - shift - ;; - testing) - TEST_FRAMEWORK="Swift Testing" - shift - ;; - xctest) - TEST_FRAMEWORK="XCTest" - shift - ;; - -h|--help) - show_help - exit 0 - ;; - *) - # Assume it's the project name - PROJECT_NAME="$1" - shift - ;; - esac - done -else +if [ "${1:-}" = "uninstall" ]; then + uninstall_tuikit + exit 0 +fi + +if [ "${1:-}" != "init" ]; then show_help exit 1 fi +shift + +while [ $# -gt 0 ]; do + case "$1" in + git) INIT_GIT=true; shift ;; + sqlite) INCLUDE_SQLITE=true; shift ;; + testing) TEST_FRAMEWORK="Swift Testing"; shift ;; + xctest) TEST_FRAMEWORK="XCTest"; shift ;; + --yes|-y) NON_INTERACTIVE=1; shift ;; + -h|--help) show_help; exit 0 ;; + *) PROJECT_NAME="$1"; shift ;; + esac +done + +# --- Swift preflight --------------------------------------------------------- + +check_linux_swift + +# --- Project name & path ----------------------------------------------------- -# Prompt for project name if not provided if [ -z "$PROJECT_NAME" ]; then + if [ "$NON_INTERACTIVE" = "1" ]; then + echo -e "${RED}Error: Project name required in non-interactive mode.${NC}" >&2 + exit 1 + fi echo -e "${YELLOW}Enter project name:${NC}" read -r PROJECT_NAME fi if [ -z "$PROJECT_NAME" ]; then - echo -e "${RED}Error: Project name cannot be empty${NC}" + echo -e "${RED}Error: Project name cannot be empty.${NC}" >&2 + exit 1 +fi + +# Separate path from base name. +if [[ "$PROJECT_NAME" == *"/"* ]]; then + PROJECT_PATH="${PROJECT_NAME/#\~/$HOME}" + PROJECT_BASE="$(basename "$PROJECT_PATH")" +else + PROJECT_PATH="$(pwd)/$PROJECT_NAME" + PROJECT_BASE="$PROJECT_NAME" +fi + +if ! validate_project_path_component "$PROJECT_BASE"; then + echo -e "${RED}Error: '${PROJECT_BASE}' is not a valid project directory name.${NC}" >&2 + exit 1 +fi + +SWIFT_NAME="$(sanitize_swift_identifier "$PROJECT_BASE" || true)" +if [ -z "$SWIFT_NAME" ]; then + echo -e "${RED}Error: '${PROJECT_BASE}' cannot be turned into a Swift identifier.${NC}" >&2 exit 1 fi -# Banner with grayscale block style (like opencode) +DISPLAY_NAME="$PROJECT_BASE" + +# --- Banner ------------------------------------------------------------------ + show_banner() { - # Grayscale ANSI colors (gradient from dark to light) - local C1='\033[38;5;240m' # Darkest (T) - local C2='\033[38;5;244m' # (U) - local C3='\033[38;5;248m' # (I) - local C4='\033[38;5;252m' # (k) - local C5='\033[38;5;254m' # (i) - local C6='\033[38;5;255m' # Brightest (t) - local D='\033[38;5;236m' # Dark inner - local R='\033[0m' # Reset + local c1='\033[38;5;240m' c2='\033[38;5;244m' c3='\033[38;5;248m' + local c4='\033[38;5;252m' c5='\033[38;5;254m' c6='\033[38;5;255m' + local d='\033[38;5;236m' r='\033[0m' echo "" - # TUIkit in block letters with inner shadow effect - echo -e "${C1}█${D}█${C1}█████${D}█${R} ${C2}█${D}█${R} ${C2}█${D}█${R} ${C3}█${D}█${C3}█${R} ${C4}█${D}█${R} ${C4}█${D}█${R} ${C5}█${D}█${R} ${C6}█${D}█${C6}███${R}" - echo -e " ${C1}█${D}█${R} ${C2}█${D}█${R} ${C2}█${D}█${R} ${C3}█${D}█${R} ${C4}█${D}█${R} ${C4}█${D}█${R} ${C5}█${D}█${R} ${C6}█${D}█${R}" - echo -e " ${C1}█${D}█${R} ${C2}█${D}█${R} ${C2}█${D}█${R} ${C3}█${D}█${R} ${C4}█${D}█${C4}█${D}█${R} ${C5}█${D}█${R} ${C6}█${D}█${R}" - echo -e " ${C1}█${D}█${R} ${C2}█${D}█${R} ${C2}█${D}█${R} ${C3}█${D}█${R} ${C4}█${D}█${R} ${C4}█${D}█${R} ${C5}█${D}█${R} ${C6}█${D}█${R}" - echo -e " ${C1}█${D}█${R} ${C2}█${D}█${C2}██${D}█${C2}█${R} ${C3}█${D}█${C3}█${R} ${C4}█${D}█${R} ${C4}█${D}█${R} ${C5}█${D}█${R} ${C6}█${D}█${C6}█${R}" + echo -e "${c1}█${d}█${c1}█████${d}█${r} ${c2}█${d}█${r} ${c2}█${d}█${r} ${c3}█${d}█${c3}█${r} ${c4}█${d}█${r} ${c4}█${d}█${r} ${c5}█${d}█${r} ${c6}█${d}█${c6}███${r}" + echo -e " ${c1}█${d}█${r} ${c2}█${d}█${r} ${c2}█${d}█${r} ${c3}█${d}█${r} ${c4}█${d}█${r} ${c4}█${d}█${r} ${c5}█${d}█${r} ${c6}█${d}█${r}" + echo -e " ${c1}█${d}█${r} ${c2}█${d}█${r} ${c2}█${d}█${r} ${c3}█${d}█${r} ${c4}█${d}█${c4}█${d}█${r} ${c5}█${d}█${r} ${c6}█${d}█${r}" + echo -e " ${c1}█${d}█${r} ${c2}█${d}█${r} ${c2}█${d}█${r} ${c3}█${d}█${r} ${c4}█${d}█${r} ${c4}█${d}█${r} ${c5}█${d}█${r} ${c6}█${d}█${r}" + echo -e " ${c1}█${d}█${r} ${c2}█${d}█${c2}██${d}█${c2}█${r} ${c3}█${d}█${c3}█${r} ${c4}█${d}█${r} ${c4}█${d}█${r} ${c5}█${d}█${r} ${c6}█${d}█${c6}█${r}" echo "" } -show_banner - -# Separate path and project name -# If PROJECT_NAME contains a path, extract the directory and basename -if [[ "$PROJECT_NAME" == *"/"* ]]; then - # Expand ~ to home directory - PROJECT_PATH="${PROJECT_NAME/#\~/$HOME}" - PROJECT_BASE=$(basename "$PROJECT_PATH") -else - PROJECT_PATH="$(pwd)/$PROJECT_NAME" - PROJECT_BASE="$PROJECT_NAME" +if [ "$NON_INTERACTIVE" != "1" ]; then + show_banner fi -# Create project directory +# --- Generate project -------------------------------------------------------- + echo -e "${GREEN}Creating project directory...${NC}" mkdir -p "$PROJECT_PATH" cd "$PROJECT_PATH" -# Use PROJECT_BASE for all file content (package name, etc.) -PROJECT_NAME="$PROJECT_BASE" +PROJECT_NAME="$SWIFT_NAME" -# Create Package.swift echo -e "${GREEN}Creating Package.swift...${NC}" -# Build dependencies array DEPENDENCIES=" .package(url: \"https://github.com/phranck/TUIkit.git\", branch: \"main\")" if [ "$INCLUDE_SQLITE" = true ]; then DEPENDENCIES="${DEPENDENCIES}, .package(url: \"https://github.com/groue/GRDB.swift.git\", from: \"7.0.0\")" fi -# Build target dependencies TARGET_DEPS=" .product(name: \"TUIkit\", package: \"TUIkit\")" if [ "$INCLUDE_SQLITE" = true ]; then TARGET_DEPS="${TARGET_DEPS}, .product(name: \"GRDB\", package: \"GRDB.swift\")" fi -# Build targets array TARGETS=" .executableTarget( name: \"${PROJECT_NAME}\", dependencies: [ @@ -459,7 +473,6 @@ ${TARGETS} ) EOF -# Create Sources directory and files echo -e "${GREEN}Creating source files...${NC}" mkdir -p Sources @@ -494,26 +507,24 @@ struct ContentView: View { } EOF -# Create Database.swift if SQLite is enabled if [ "$INCLUDE_SQLITE" = true ]; then cat > Sources/Database.swift << 'EOF' import Foundation import GRDB -/// Example model using GRDB +/// Example model using GRDB. struct Item: Codable, FetchableRecord, PersistableRecord, Sendable { var id: Int64? var title: String var isCompleted: Bool - // Define the database table static let databaseTableName = "items" } // MARK: - Database Setup extension Item { - /// Creates the items table + /// Creates the items table. static func createTable(in db: Database) throws { try db.create(table: databaseTableName, ifNotExists: true) { table in table.autoIncrementedPrimaryKey("id") @@ -525,24 +536,23 @@ extension Item { EOF fi -# Create Tests if needed if [ "$TEST_FRAMEWORK" != "None" ]; then echo -e "${BLUE}Creating test files...${NC}" mkdir -p Tests if [ "$TEST_FRAMEWORK" = "Swift Testing" ]; then - cat > Tests/${PROJECT_NAME}Tests.swift << EOF + cat > "Tests/${PROJECT_NAME}Tests.swift" << EOF import Testing -@testable import $PROJECT_NAME +@testable import ${PROJECT_NAME} @Test func example() async throws { #expect(true) } EOF else - cat > Tests/${PROJECT_NAME}Tests.swift << EOF + cat > "Tests/${PROJECT_NAME}Tests.swift" << EOF import XCTest -@testable import $PROJECT_NAME +@testable import ${PROJECT_NAME} final class ${PROJECT_NAME}Tests: XCTestCase { func testExample() throws { @@ -553,10 +563,9 @@ EOF fi fi -# Create README.md echo -e "${GREEN}Creating README.md...${NC}" cat > README.md << EOF -# $PROJECT_NAME +# ${DISPLAY_NAME} A TUIkit terminal application. @@ -569,7 +578,7 @@ swift run ## Project Structure \`\`\` -$PROJECT_NAME/ +${DISPLAY_NAME}/ ├── Sources/ │ ├── App.swift # Main application entry point │ └── ContentView.swift # Root view @@ -583,7 +592,7 @@ fi if [ "$TEST_FRAMEWORK" != "None" ]; then cat >> README.md << EOF -└── Tests/ # $TEST_FRAMEWORK suite +└── Tests/ # ${TEST_FRAMEWORK} suite EOF fi @@ -637,7 +646,6 @@ cat >> README.md << 'EOF' - [TUIkit GitHub](https://github.com/phranck/TUIkit) EOF -# Create .gitignore cat > .gitignore << 'EOF' .DS_Store /.build @@ -649,14 +657,12 @@ DerivedData/ .netrc EOF -# Note: We don't create .swiftpm directory - Xcode generates it automatically -# and handles scheme/workspace settings better on its own - -# Set Xcode preference for Swift packages to prefer macOS -defaults write com.apple.dt.Xcode IDEPackageSupportUseBuiltinSCM -bool YES 2>/dev/null || true -defaults write com.apple.dt.Xcode IDEPreferredPlatformForSwiftPackages -string "macosx" 2>/dev/null || true +# macOS-only: Xcode preferences apply nowhere else. +if [ "$(detect_os)" = "macos" ]; then + defaults write com.apple.dt.Xcode IDEPackageSupportUseBuiltinSCM -bool YES 2>/dev/null || true + defaults write com.apple.dt.Xcode IDEPreferredPlatformForSwiftPackages -string "macosx" 2>/dev/null || true +fi -# Initialize Git repository if requested if [ "$INIT_GIT" = true ]; then echo -e "${GREEN}Initializing Git repository...${NC}" git init -q @@ -664,43 +670,35 @@ if [ "$INIT_GIT" = true ]; then git commit -q -m "Initial commit" fi -# Success message -echo -e "${GREEN}" -echo "╔════════════════════════════════════════════════════╗" -echo "║ ║" -echo "║ Project created successfully! ║" -echo "║ ║" -echo "╚════════════════════════════════════════════════════╝" -echo -e "${NC}" - -echo -e "${BLUE}Project: ${NC}${GREEN}$PROJECT_NAME${NC}" +echo -e "${GREEN}Project created successfully.${NC}" +echo -e "${BLUE}Project: ${NC}${GREEN}${DISPLAY_NAME}${NC}" +echo -e "${BLUE}Swift name: ${NC}${PROJECT_NAME}" echo -e "${BLUE}Location: ${NC}$(pwd)" -echo -e "${BLUE}Git Repository: ${NC}$([ "$INIT_GIT" = true ] && echo "Yes" || echo "No")" -echo -e "${BLUE}Test Framework: ${NC}$TEST_FRAMEWORK" +echo -e "${BLUE}Git: ${NC}$([ "$INIT_GIT" = true ] && echo "Yes" || echo "No")" +echo -e "${BLUE}Test Framework: ${NC}${TEST_FRAMEWORK}" echo -e "${BLUE}GRDB: ${NC}$([ "$INCLUDE_SQLITE" = true ] && echo "Yes" || echo "No")" -echo "" -read -p "Open project in Xcode? [Y/n] " -n 1 -r -echo "" +# Xcode is macOS-only; skip everywhere else, and skip prompts in +# non-interactive mode. +if [ "$(detect_os)" = "macos" ] && [ "$NON_INTERACTIVE" != "1" ]; then + echo "" + read -p "Open project in Xcode? [Y/n] " -n 1 -r + echo "" -if [[ ! $REPLY =~ ^[Nn]$ ]]; then - open "$PROJECT_PATH/Package.swift" - echo -e "${GREEN}Opening in Xcode...${NC}" - - # Show macOS alert about deployment target (only on macOS) - if [ "$(uname)" = "Darwin" ]; then + if [[ ! $REPLY =~ ^[Nn]$ ]]; then + open "$PROJECT_PATH/Package.swift" + echo -e "${GREEN}Opening in Xcode...${NC}" + echo "" + echo -e "${YELLOW}Set Run Destination to \"My Mac\" in Xcode's toolbar before building.${NC}" + else echo "" - echo -e "${YELLOW}╔════════════════════════════════════════════════════╗${NC}" - echo -e "${YELLOW}║ IMPORTANT: Set Run Destination to \"My Mac\" ║${NC}" - echo -e "${YELLOW}║ in Xcode's toolbar before building! ║${NC}" - echo -e "${YELLOW}╚════════════════════════════════════════════════════╝${NC}" + echo -e "${YELLOW}Next steps:${NC}" + echo " 1. cd ${PROJECT_PATH}" + echo " 2. swift run" fi else echo "" echo -e "${YELLOW}Next steps:${NC}" - echo " 1. cd $PROJECT_PATH" - echo " 2. open Package.swift" - echo " 3. Set Run Destination to \"My Mac\" in Xcode" - echo " 4. Wait for dependencies to resolve" - echo " 5. Press Cmd+B to build" + echo " 1. cd ${PROJECT_PATH}" + echo " 2. swift run" fi diff --git a/scripts/tests/project-template/test-tuikit-generator.sh b/scripts/tests/project-template/test-tuikit-generator.sh new file mode 100755 index 000000000..566355ade --- /dev/null +++ b/scripts/tests/project-template/test-tuikit-generator.sh @@ -0,0 +1,132 @@ +#!/usr/bin/env bash +# +# Integration tests for project-template/tuikit. +# +# Runs on macOS and Linux; requires bash, mktemp, and swift for the +# build check. Failures return non-zero so CI can gate. + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" +GENERATOR="$REPO_ROOT/project-template/tuikit" + +PASS_COUNT=0 +FAIL_COUNT=0 + +pass() { printf " ✓ %s\n" "$1"; PASS_COUNT=$((PASS_COUNT + 1)); } +fail() { printf " ✗ %s\n" "$1" >&2; FAIL_COUNT=$((FAIL_COUNT + 1)); } + +# --- Unit tests for internal functions ------------------------------------- + +# Source the generator so we can call individual functions without +# triggering the CLI. The `main` guard below early-exits when sourced. +export TUIKIT_TEST_HARNESS=1 +# The script uses `set -e`; source it in a subshell that captures both +# stdout and the exit code. + +run_helper() { + # Extract and evaluate only the helper-function block: from the first + # helper to the end of the "--- Argument parsing ---" banner comment. + bash -c "$(awk ' + /^# --- Argument parsing/ { exit } + /^# --- Platform detection/,/^$/ { print; next } + /^detect_os\(\)/,/^\}$/ { print; next } + /^detect_arch\(\)/,/^\}$/ { print; next } + /^sanitize_swift_identifier\(\)/,/^\}$/ { print; next } + /^validate_project_path_component\(\)/,/^\}$/ { print; next } + /^resolve_install_path\(\)/,/^\}$/ { print; next } + ' "$GENERATOR"); $*" +} + +# sanitize_swift_identifier +result="$(run_helper 'sanitize_swift_identifier "MyApp"' || true)" +[ "$result" = "MyApp" ] && pass "identifier: MyApp" || fail "identifier: MyApp got '$result'" + +result="$(run_helper 'sanitize_swift_identifier "my-app.2"' || true)" +[ "$result" = "my_app_2" ] && pass "identifier: my-app.2 → my_app_2" || fail "identifier: my-app.2 got '$result'" + +result="$(run_helper 'sanitize_swift_identifier "3ThingsApp"' || true)" +[ "$result" = "_3ThingsApp" ] && pass "identifier: leading digit prefixed" || fail "identifier: leading digit got '$result'" + +if run_helper 'sanitize_swift_identifier ""' >/dev/null 2>&1; then + fail "identifier: empty must fail" +else + pass "identifier: empty rejected" +fi + +# validate_project_path_component +if run_helper 'validate_project_path_component ".."' >/dev/null 2>&1; then + fail "path: '..' must reject" +else + pass "path: '..' rejected" +fi + +if run_helper 'validate_project_path_component "with/slash"' >/dev/null 2>&1; then + fail "path: 'with/slash' must reject" +else + pass "path: 'with/slash' rejected" +fi + +if run_helper 'validate_project_path_component "safe-name"' >/dev/null 2>&1; then + pass "path: 'safe-name' accepted" +else + fail "path: 'safe-name' must accept" +fi + +# detect_os / detect_arch +os="$(run_helper 'detect_os')" +case "$os" in + macos|linux) pass "os: detected $os" ;; + *) fail "os: unexpected '$os'" ;; +esac + +arch="$(run_helper 'detect_arch')" +case "$arch" in + x86_64|aarch64) pass "arch: detected $arch" ;; + *) fail "arch: unexpected '$arch'" ;; +esac + +# --- Integration: generate + build ---------------------------------------- + +TMPDIR_ROOT="$(mktemp -d)" +trap 'rm -rf "$TMPDIR_ROOT"' EXIT + +cd "$TMPDIR_ROOT" + +if TUIKIT_NON_INTERACTIVE=1 "$GENERATOR" init --yes "GeneratedApp" >/dev/null 2>&1; then + if [ -f "GeneratedApp/Package.swift" ] && [ -f "GeneratedApp/Sources/App.swift" ]; then + pass "generation: basic app produced expected files" + else + fail "generation: missing expected files" + fi +else + fail "generation: exited non-zero" +fi + +# Reject unsafe names. +if TUIKIT_NON_INTERACTIVE=1 "$GENERATOR" init --yes ".." >/dev/null 2>&1; then + fail "generation: '..' must be rejected" +else + pass "generation: '..' rejected" +fi + +# Sanitize hyphens in produced Swift identifier. +if TUIKIT_NON_INTERACTIVE=1 "$GENERATOR" init --yes "my-hyphen-app" >/dev/null 2>&1; then + if grep -q 'name: "my_hyphen_app"' "my-hyphen-app/Package.swift"; then + pass "generation: hyphens sanitized to underscores in Package.swift" + else + fail "generation: hyphens not sanitized (see Package.swift)" + fi +else + fail "generation: hyphen name exited non-zero" +fi + +# --- Summary -------------------------------------------------------------- + +echo "" +echo "Passed: $PASS_COUNT" +echo "Failed: $FAIL_COUNT" + +if [ "$FAIL_COUNT" -gt 0 ]; then + exit 1 +fi