From 3cb192453a07fdb3c2a23637a278211e1b67654f Mon Sep 17 00:00:00 2001 From: Nicholas Hart Date: Fri, 17 Oct 2025 20:35:00 -0700 Subject: [PATCH 1/2] Refactor: Simplify template architecture with in-place configuration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Major architectural simplification that eliminates template processing complexity and unifies CI workflows for both template and generated projects. ## Changes ### Eliminated Complexity - Removed templates/ directory entirely - Deleted template-validation.yml (redundant) - Replaced complex template processing with simple in-place sed replacement - setup.sh: 1,244 lines โ†’ 368 lines (70% reduction) - CI workflow: 217 lines โ†’ 95 lines (56% reduction) ### New Architecture - Files now live in their final locations with {{PLACEHOLDERS}} - MyProject/ is placeholder directory (gets renamed during setup) - Single unified ci.yml that auto-configures when needed - Same workflow works for template repo and generated projects ### Improvements - Simpler mental model: files are where they live - No template copying, just in-place replacement - Can run setup.sh on template repo immediately - CI detects unconfigured state and auto-runs setup - Local and CI use identical scripts (no duplication) ### Fixed - Pre-commit hook now works correctly from .git/hooks/ - Uses git rev-parse to find repo root reliably - Sources _helpers.sh from correct location ## Benefits 1. **Easier to understand**: See actual project structure immediately 2. **Easier to maintain**: One CI workflow instead of two 3. **Faster setup**: Simple sed replacement vs template processing 4. **Same CI everywhere**: Template and generated repos use identical workflow 5. **Better DX**: Preview project structure before setup Co-Authored-By: Claude --- .github/workflows/ci.yml | 95 ++ .github/workflows/template-validation.yml | 145 -- .../.swiftformat.template => .swiftformat | 2 +- .../.swiftlint.yml.template => .swiftlint.yml | 2 +- CLAUDE.md | 139 ++ MyProject/AppDelegate.swift | 37 + MyProject/Extensions/.gitkeep | 0 MyProject/Helpers/.gitkeep | 0 MyProject/Info.plist | 23 + MyProject/Models/.gitkeep | 0 MyProject/Resources/.gitkeep | 0 MyProject/SceneDelegate.swift | 50 + MyProject/Services/.gitkeep | 0 MyProject/ViewModels/.gitkeep | 0 MyProject/Views/ViewController.swift | 21 + MyProjectTests/MyProjectTests.swift | 9 + MyProjectUITests/MyProjectUITests.swift | 15 + templates/project.yml.template => project.yml | 2 +- scripts/pre-commit.sh | 11 +- scripts/setup.sh | 1215 +++-------------- templates/README.md.template | 134 -- templates/ci.yml.template | 217 --- 22 files changed, 574 insertions(+), 1543 deletions(-) create mode 100644 .github/workflows/ci.yml delete mode 100644 .github/workflows/template-validation.yml rename templates/.swiftformat.template => .swiftformat (98%) rename templates/.swiftlint.yml.template => .swiftlint.yml (99%) create mode 100644 CLAUDE.md create mode 100644 MyProject/AppDelegate.swift create mode 100644 MyProject/Extensions/.gitkeep create mode 100644 MyProject/Helpers/.gitkeep create mode 100644 MyProject/Info.plist create mode 100644 MyProject/Models/.gitkeep create mode 100644 MyProject/Resources/.gitkeep create mode 100644 MyProject/SceneDelegate.swift create mode 100644 MyProject/Services/.gitkeep create mode 100644 MyProject/ViewModels/.gitkeep create mode 100644 MyProject/Views/ViewController.swift create mode 100644 MyProjectTests/MyProjectTests.swift create mode 100644 MyProjectUITests/MyProjectUITests.swift rename templates/project.yml.template => project.yml (99%) delete mode 100644 templates/README.md.template delete mode 100644 templates/ci.yml.template diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d067199 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,95 @@ +name: iOS CI + +on: + pull_request: + push: + branches: [main, develop] + +env: + DEVELOPER_DIR: /Applications/Xcode.app/Contents/Developer + +jobs: + ci: + name: Build, Test & Quality Checks + runs-on: macos-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Xcode + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: "16.4" + + - name: Auto-configure if unconfigured + run: | + # Detect if this is an unconfigured template + if grep -q "{{PROJECT_NAME}}" project.yml 2>/dev/null || [ ! -f project.yml ]; then + echo "๐Ÿ”ง Unconfigured template detected - running setup with test configuration" + ./scripts/setup.sh \ + --project-name "CITestApp" \ + --bundle-id-root "com.ci.test" \ + --deployment-target 18.0 \ + --swift-version 6.2 \ + --test-framework "swift-testing" \ + --no-git-hooks \ + --no-commit \ + --skip-brew \ + --force + echo "โœ… Template auto-configured for CI" + else + echo "โœ… Project already configured" + fi + + - name: Cache Homebrew packages + uses: actions/cache@v4 + with: + path: | + ~/Library/Caches/Homebrew + /usr/local/Homebrew + key: ${{ runner.os }}-homebrew-${{ hashFiles('**/Brewfile') }} + restore-keys: | + ${{ runner.os }}-homebrew- + + - name: Install dependencies + run: | + echo "๐Ÿ“ฆ Installing Homebrew dependencies..." + brew bundle install --file=./Brewfile + + echo "โœ… Installed versions:" + swiftlint --version + swiftformat --version + xcodegen --version + yq --version + + - name: Generate Xcode project + run: | + echo "๐Ÿ—๏ธ Generating Xcode project from project.yml..." + xcodegen generate + + PROJECT_NAME=$(yq eval '.name' project.yml) + if [ -d "${PROJECT_NAME}.xcodeproj" ]; then + echo "โœ… Xcode project generated: ${PROJECT_NAME}.xcodeproj" + else + echo "โŒ Failed to generate Xcode project" + exit 1 + fi + + - name: Run preflight checks + run: | + echo "๐Ÿš€ Running preflight checks (format, lint, build, test)..." + ./scripts/preflight.sh + + - name: CI Summary + if: success() + run: | + echo "" + echo "๐ŸŽ‰ CI Complete!" + echo "" + echo "โœ… Code formatting validated" + echo "โœ… Code quality checks passed" + echo "โœ… Build completed successfully" + echo "โœ… Tests executed" + echo "" + echo "Ready for review! ๐Ÿš€" diff --git a/.github/workflows/template-validation.yml b/.github/workflows/template-validation.yml deleted file mode 100644 index af728a4..0000000 --- a/.github/workflows/template-validation.yml +++ /dev/null @@ -1,145 +0,0 @@ -name: Template Validation - -on: - pull_request: - branches: [main, develop] - push: - branches: [main, develop] - -env: - DEVELOPER_DIR: /Applications/Xcode.app/Contents/Developer - -jobs: - validate-template: - name: Template End-to-End Validation - runs-on: macos-latest - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Check if this is the template repository - id: repo_check - run: | - echo "Repository: $GITHUB_REPOSITORY" - if [[ "$GITHUB_REPOSITORY" == *"/SwiftProjectTemplate" ]]; then - echo "โœ… This is the SwiftProjectTemplate repository - proceeding with validation" - echo "is_template_repo=true" >> $GITHUB_OUTPUT - else - echo "โ„น๏ธ This is a generated project repository - skipping template validation" - echo "๐Ÿ’ก Template validation only runs on the SwiftProjectTemplate repository" - echo "is_template_repo=false" >> $GITHUB_OUTPUT - fi - - - name: Set up Xcode - if: steps.repo_check.outputs.is_template_repo == 'true' - uses: maxim-lobanov/setup-xcode@v1 - with: - xcode-version: "16.0" - - - name: Cache Homebrew packages - if: steps.repo_check.outputs.is_template_repo == 'true' - uses: actions/cache@v4 - with: - path: | - ~/Library/Caches/Homebrew - /usr/local/Homebrew - key: ${{ runner.os }}-homebrew-${{ hashFiles('**/Brewfile') }} - restore-keys: | - ${{ runner.os }}-homebrew- - - - name: Install dependencies - if: steps.repo_check.outputs.is_template_repo == 'true' - run: | - # Install Homebrew dependencies - brew bundle install --file=./Brewfile - - # Verify installations - echo "Installed versions:" - swiftlint --version - swiftformat --version - xcodegen --version - yq --version - - - name: Generate test project - if: steps.repo_check.outputs.is_template_repo == 'true' - run: | - echo "๐Ÿ—๏ธ Generating test project for E2E validation..." - echo "Working directory: $(pwd)" - echo "GITHUB_WORKSPACE: $GITHUB_WORKSPACE" - - # Create test directory but run setup from template root - mkdir -p ci-test-project - ./scripts/dev-sync.sh to ci-test-project - - echo "Running setup script from template root directory" - echo "Target directory: $(pwd)/ci-test-project" - - cd ci-test-project - - echo "๐Ÿš€ Running setup script to generate test project..." - ./scripts/setup.sh \ - --project-name "CITestApp" \ - --bundle-id-root "com.ci.test" \ - --deployment-target 18.0 \ - --swift-version 5.10 \ - --test-framework "swift-testing" \ - --source-language en \ - --no-git-hooks \ - --no-commit \ - --public \ - --skip-brew \ - --force || { - echo "โŒ Setup script failed with exit code $?" - echo "๐Ÿ’ก For detailed debugging, enable ACTIONS_STEP_DEBUG in repository settings" - exit 1 - } - - echo "โœ“ Test project generated successfully" - echo "Generated files:" - ls -la - - - name: Run E2E validation on generated project - if: steps.repo_check.outputs.is_template_repo == 'true' - run: | - echo "๐Ÿ” Running end-to-end validation on generated project..." - cd ci-test-project - - # Run the generated project's preflight check - echo "Running preflight check..." - ./scripts/preflight.sh - - echo "โœ“ E2E validation completed successfully" - - - name: Cleanup test project - if: always() && steps.repo_check.outputs.is_template_repo == 'true' - run: | - echo "๐Ÿงน Cleaning up test project..." - rm -rf ci-test-project - echo "โœ“ Cleanup completed" - - - name: Template validation summary - if: steps.repo_check.outputs.is_template_repo == 'true' - run: | - echo "๐ŸŽ‰ Template Validation Complete!" - echo "" - echo "โœ… Template generates project successfully" - echo "โœ… Generated project passes all quality checks" - echo "โœ… E2E workflow validated" - echo "" - echo "Template is ready for use! ๐Ÿš€" - - - name: Generated repository info - if: steps.repo_check.outputs.is_template_repo == 'false' - run: | - echo "๐ŸŽ‰ Welcome to your new iOS project!" - echo "" - echo "This repository was created from SwiftProjectTemplate." - echo "Template validation is skipped for generated projects." - echo "" - echo "๐Ÿ“– Next steps:" - echo " 1. Run './scripts/setup.sh' to configure your project" - echo " 2. Follow the README.md for development guidance" - echo " 3. Start building your iOS app!" - echo "" - echo "โœจ Happy coding! ๐Ÿš€" diff --git a/templates/.swiftformat.template b/.swiftformat similarity index 98% rename from templates/.swiftformat.template rename to .swiftformat index 6ea1acd..089877e 100644 --- a/templates/.swiftformat.template +++ b/.swiftformat @@ -67,4 +67,4 @@ --exclude .build --exclude DerivedData --exclude build ---exclude **/.git \ No newline at end of file +--exclude **/.git diff --git a/templates/.swiftlint.yml.template b/.swiftlint.yml similarity index 99% rename from templates/.swiftlint.yml.template rename to .swiftlint.yml index b40d321..462c212 100644 --- a/templates/.swiftlint.yml.template +++ b/.swiftlint.yml @@ -128,4 +128,4 @@ custom_rules: message: "Prefer @objc on individual members over @objcMembers" severity: warning -reporter: "xcode" \ No newline at end of file +reporter: "xcode" diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..5c45fce --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,139 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Development Commands + +### Essential Commands +- `./scripts/setup.sh` - One-time project setup: installs dependencies, generates project, configures simulators +- `./scripts/build.sh` - Build the app (add `--device` for device builds, `--release` for Release config) +- `./scripts/test.sh` - Run unit tests (add `--ui` for UI tests, `--all` for both, `--release` for Release config) +- `./scripts/lint.sh` - Check code style with SwiftLint (add `--fix` for auto-fix, `--strict` for warnings as errors) +- `./scripts/format.sh` - Check code formatting with SwiftFormat (add `--fix` for auto-fix) +- `./scripts/preflight.sh` - Complete local CI check: fixes formatting, runs linting, builds, and tests +- `xcodegen` - Regenerate Xcode project from project.yml (required after adding/removing files or changing project structure) + +### Simulator Management +- `./scripts/simulator.sh list` - Show available simulators +- `./scripts/simulator.sh config-tests "device-name"` - Configure simulator for unit tests +- `./scripts/simulator.sh config-ui-tests "device-name"` - Configure simulator for UI tests +- `./scripts/simulator.sh show-config` - Display current simulator configuration + +## Architecture Overview + +### Project Structure Pattern +This is a **Swift iOS project template** that uses: +- **XcodeGen**: Project files generated from `project.yml` configuration, not manually managed +- **In-Place Configuration**: Files contain `{{PLACEHOLDERS}}` that get replaced during setup +- **Script Automation**: Comprehensive bash script ecosystem for all development tasks +- **Quality-First**: Built-in SwiftLint, SwiftFormat, and pre-commit hooks +- **Unified CI**: Same workflow runs on template repo and generated projects + +### Core Components +- **`scripts/` Directory**: Contains all development automation scripts with helper functions in `_helpers.sh` +- **`project.yml`**: XcodeGen configuration with placeholders like `{{PROJECT_NAME}}` +- **`MyProject/`**: Placeholder project directory that gets renamed during setup +- **`simulator.yml`**: Auto-generated simulator configuration for tests +- **`.github/workflows/ci.yml`**: Unified CI workflow that auto-configures if needed +- **Brewfile**: Manages all development tool dependencies (yq, jq, xcodegen, swiftlint, etc.) + +### Setup Flow +1. User runs `./scripts/setup.sh --project-name MyApp` +2. Script does in-place replacement of `{{PLACEHOLDERS}}` in all files +3. Renames `MyProject/` โ†’ `MyApp/`, `MyProjectTests/` โ†’ `MyAppTests/`, etc. +4. Runs `xcodegen` to create `.xcodeproj` from configured `project.yml` +5. Configures simulators and installs pre-commit hooks + +## Project Generation and Management + +### Using This Template +This repository is a **template repository** with placeholder files. To create a new project: +1. Use "Use this template" on GitHub or clone and rename +2. Run `./scripts/setup.sh --project-name YourApp` +3. Setup script replaces placeholders and renames directories in-place +4. Result: Configured project with `YourApp/`, `YourAppTests/`, `YourAppUITests/` + +### After Project Generation +- **Always run `xcodegen`** after modifying `project.yml` or adding/removing files +- Use scripts for all development tasks rather than Xcode's built-in build/test +- Generated projects follow MVVM architecture with Models/, Views/, ViewModels/, Services/, Extensions/, Helpers/ + +## Configuration Files + +### Critical Files (Pre-configured with Placeholders) +- **`project.yml`** - XcodeGen project definition with `{{PROJECT_NAME}}` placeholders +- **`simulator.yml`** - Auto-generated during setup with sensible defaults +- **`.swiftlint.yml`** - SwiftLint rules with `{{PROJECT_NAME}}` in paths +- **`.swiftformat`** - SwiftFormat configuration ready to use + +### Development Dependencies +All tools installed via Brewfile: yq, jq, xcodegen, swiftlint, swiftformat, xcbeautify, gh + +## Testing Strategy + +### Test Target Structure +- **Unit Tests**: `{ProjectName}Tests/` - mirrors main app structure +- **UI Tests**: `{ProjectName}UITests/` - user interaction flows +- **Test Configuration**: Managed via `simulator.yml` for consistent device/OS selection +- **Coverage**: Enabled by default, viewable in Xcode Report Navigator + +### Running Tests +- Unit tests use `simulators.tests` configuration from `simulator.yml` +- UI tests use `simulators.ui-tests` configuration from `simulator.yml` +- Scripts auto-detect available simulators and suggest alternatives if configured simulator not found + +## Code Quality and Style + +### Formatting and Linting +- **SwiftFormat**: 2-space indentation, 120 character line width, self insertion required +- **SwiftLint**: iOS development best practices, configurable via `.swiftlint.yml` +- **Pre-commit Hooks**: Automatically installed, run formatting and linting before commits + +### Quality Workflow +1. `./scripts/format.sh --fix` - Fix formatting issues +2. `./scripts/lint.sh --fix` - Fix linting issues +3. `./scripts/build.sh` - Verify build +4. `./scripts/test.sh --all` - Run all tests +5. `./scripts/preflight.sh` - Complete quality check + +## Common Development Tasks + +### Adding New Features (in Generated Project) +1. Create files in appropriate directories (Models/, Views/, ViewModels/, Services/) +2. Add corresponding tests in `{ProjectName}Tests/` +3. Update `project.yml` if new groups/files need explicit configuration +4. Run `xcodegen` to regenerate project file +5. Run `./scripts/preflight.sh` before committing + +### Template Development (this repository) +- Edit placeholder files directly (project.yml, .swiftlint.yml, etc.) +- Test with `./scripts/setup.sh --project-name TestApp --force` +- CI automatically tests template by running setup then preflight +- Same CI workflow works for both template and generated projects + +### Before Every Commit +- Run `./scripts/preflight.sh` (does formatting, linting, building, testing) +- Review git diff for auto-fix changes +- Ensure all tests pass + +### Debugging Issues +- **Build failures**: Clean build folder, run `xcodegen`, check `project.yml` syntax +- **Missing tools**: Run `brew bundle install` to install dependencies +- **Simulator issues**: Use `./scripts/simulator.sh list` to find available devices +- **Setup issues**: Check for `{{PLACEHOLDERS}}` that weren't replaced + +## GitHub Integration + +### CI/CD Strategy +- **Unified Workflow**: `.github/workflows/ci.yml` works for both template and generated projects +- **Auto-Configuration**: CI detects unconfigured state and runs setup automatically +- **Local Parity**: CI runs `./scripts/preflight.sh` - same as local development +- **No Duplication**: CI uses scripts instead of duplicating logic + +### CI Workflow +1. Checkout code +2. Detect if project is configured (check for `{{PROJECT_NAME}}` in project.yml) +3. If unconfigured: run `./scripts/setup.sh` with test parameters +4. Install dependencies via Brewfile +5. Generate Xcode project with xcodegen +6. Run `./scripts/preflight.sh` (format, lint, build, test) \ No newline at end of file diff --git a/MyProject/AppDelegate.swift b/MyProject/AppDelegate.swift new file mode 100644 index 0000000..d56b516 --- /dev/null +++ b/MyProject/AppDelegate.swift @@ -0,0 +1,37 @@ +import UIKit + +@main +class AppDelegate: UIResponder, UIApplicationDelegate { + func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + // Override point for customization after application launch. + return true + } + + // MARK: UISceneSession Lifecycle + + func application( + _ application: UIApplication, + configurationForConnecting connectingSceneSession: UISceneSession, + options: UIScene.ConnectionOptions + ) -> UISceneConfiguration { + // Called when a new scene session is being created. + // Use this method to select a configuration to create the new scene with. + return UISceneConfiguration( + name: "Default Configuration", + sessionRole: connectingSceneSession.role + ) + } + + func application( + _ application: UIApplication, + didDiscardSceneSessions sceneSessions: Set + ) { + // Called when the user discards a scene session. + // If any sessions were discarded while the application was not running, + // this will be called shortly after application:didFinishLaunchingWithOptions. + // Use this method to release any resources that were specific to the discarded scenes. + } +} diff --git a/MyProject/Extensions/.gitkeep b/MyProject/Extensions/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/MyProject/Helpers/.gitkeep b/MyProject/Helpers/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/MyProject/Info.plist b/MyProject/Info.plist new file mode 100644 index 0000000..0eb786d --- /dev/null +++ b/MyProject/Info.plist @@ -0,0 +1,23 @@ + + + + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneConfigurationName + Default Configuration + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + + + + + + diff --git a/MyProject/Models/.gitkeep b/MyProject/Models/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/MyProject/Resources/.gitkeep b/MyProject/Resources/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/MyProject/SceneDelegate.swift b/MyProject/SceneDelegate.swift new file mode 100644 index 0000000..1316b8c --- /dev/null +++ b/MyProject/SceneDelegate.swift @@ -0,0 +1,50 @@ +import UIKit + +class SceneDelegate: UIResponder, UIWindowSceneDelegate { + var window: UIWindow? + + func scene( + _ scene: UIScene, + willConnectTo session: UISceneSession, + options connectionOptions: UIScene.ConnectionOptions + ) { + // Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`. + // If using a storyboard, the `window` property will automatically be initialized and attached to the scene. + // This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead). + guard let windowScene = (scene as? UIWindowScene) else { return } + + let window = UIWindow(windowScene: windowScene) + let viewController = ViewController() + window.rootViewController = viewController + window.makeKeyAndVisible() + self.window = window + } + + func sceneDidDisconnect(_ scene: UIScene) { + // Called as the scene is being released by the system. + // This occurs shortly after the scene enters the background, or when its session is discarded. + // Release any resources associated with this scene that can be re-created the next time the scene connects. + // The scene may re-connect later, as its session was not necessarily discarded (see `application:didDiscardSceneSessions` instead). + } + + func sceneDidBecomeActive(_ scene: UIScene) { + // Called when the scene has moved from an inactive state to an active state. + // Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive. + } + + func sceneWillResignActive(_ scene: UIScene) { + // Called when the scene will move from an active state to an inactive state. + // This may occur due to temporary interruptions (ex. an incoming phone call). + } + + func sceneWillEnterForeground(_ scene: UIScene) { + // Called as the scene transitions from the background to the foreground. + // Use this method to undo the changes made on entering the background. + } + + func sceneDidEnterBackground(_ scene: UIScene) { + // Called as the scene transitions from the foreground to the background. + // Use this method to save data, release shared resources, and store enough scene-specific state information + // to restore the scene back to its current state. + } +} diff --git a/MyProject/Services/.gitkeep b/MyProject/Services/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/MyProject/ViewModels/.gitkeep b/MyProject/ViewModels/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/MyProject/Views/ViewController.swift b/MyProject/Views/ViewController.swift new file mode 100644 index 0000000..d471336 --- /dev/null +++ b/MyProject/Views/ViewController.swift @@ -0,0 +1,21 @@ +import UIKit + +class ViewController: UIViewController { + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .systemBackground + + let label = UILabel() + label.text = "Hello, {{PROJECT_NAME}}!" + label.font = .preferredFont(forTextStyle: .largeTitle) + label.textAlignment = .center + label.translatesAutoresizingMaskIntoConstraints = false + + view.addSubview(label) + + NSLayoutConstraint.activate([ + label.centerXAnchor.constraint(equalTo: view.centerXAnchor), + label.centerYAnchor.constraint(equalTo: view.centerYAnchor), + ]) + } +} diff --git a/MyProjectTests/MyProjectTests.swift b/MyProjectTests/MyProjectTests.swift new file mode 100644 index 0000000..183a1af --- /dev/null +++ b/MyProjectTests/MyProjectTests.swift @@ -0,0 +1,9 @@ +import Testing +@testable import MyProject + +struct MyProjectTests { + @Test func exampleTest() async throws { + // Write your test here + #expect(true) + } +} diff --git a/MyProjectUITests/MyProjectUITests.swift b/MyProjectUITests/MyProjectUITests.swift new file mode 100644 index 0000000..cdef782 --- /dev/null +++ b/MyProjectUITests/MyProjectUITests.swift @@ -0,0 +1,15 @@ +import XCTest + +final class MyProjectUITests: XCTestCase { + override func setUpWithError() throws { + continueAfterFailure = false + } + + func testExample() throws { + let app = XCUIApplication() + app.launch() + + // Verify the app launched successfully + XCTAssertTrue(app.exists) + } +} diff --git a/templates/project.yml.template b/project.yml similarity index 99% rename from templates/project.yml.template rename to project.yml index 2629adb..4a90fe3 100644 --- a/templates/project.yml.template +++ b/project.yml @@ -111,4 +111,4 @@ schemes: analyze: config: Debug archive: - config: Release \ No newline at end of file + config: Release diff --git a/scripts/pre-commit.sh b/scripts/pre-commit.sh index 29d1bc5..ed0b1f3 100755 --- a/scripts/pre-commit.sh +++ b/scripts/pre-commit.sh @@ -4,11 +4,16 @@ set -euo pipefail # Pre-commit hook for SwiftProjectTemplate projects # Runs formatting, linting, and basic validation before allowing commits -ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +# Find the repository root (works from both scripts/ and .git/hooks/) +if [[ -d ".git" ]]; then + ROOT_DIR="$(pwd)" +else + ROOT_DIR="$(git rev-parse --show-toplevel)" +fi cd "$ROOT_DIR" -# Source helper functions -source "$(dirname "${BASH_SOURCE[0]}")/_helpers.sh" +# Source helper functions from scripts directory +source "$ROOT_DIR/scripts/_helpers.sh" # Configuration AUTO_FIX=true diff --git a/scripts/setup.sh b/scripts/setup.sh index d769dd6..f4cec9b 100755 --- a/scripts/setup.sh +++ b/scripts/setup.sh @@ -1,8 +1,8 @@ #!/usr/bin/env bash set -euo pipefail -# Enhanced setup script for SwiftProjectTemplate -# Supports both interactive and CLI modes with intelligent prompting +# Simplified setup script for SwiftProjectTemplate +# Does in-place replacement of placeholders instead of template processing ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$ROOT_DIR" @@ -12,93 +12,46 @@ source "$(dirname "${BASH_SOURCE[0]}")/_helpers.sh" # Default values PROJECT_NAME="" -DEPLOYMENT_TARGET="26.0" +DEPLOYMENT_TARGET="18.0" SWIFT_VERSION="6.2" -PROJECT_TYPE="private" # private or public +PROJECT_TYPE="private" BUNDLE_ID_ROOT="com.yourcompany" -TEST_FRAMEWORK="swift-testing" # swift-testing or xctest +TEST_FRAMEWORK="swift-testing" SOURCE_LANGUAGE="en" USE_GIT_HOOKS=true CREATE_INITIAL_COMMIT=true -GIT_REPOSITORY_AVAILABLE=false FORCE_OVERWRITE=false SKIP_BREW=false -# Track which values were set via CLI -CLI_PROJECT_NAME_SET=false -CLI_DEPLOYMENT_TARGET_SET=false -CLI_SWIFT_VERSION_SET=false -CLI_VISIBILITY_SET=false -CLI_BUNDLE_ID_ROOT_SET=false -CLI_TEST_FRAMEWORK_SET=false -CLI_SOURCE_LANGUAGE_SET=false -CLI_GIT_HOOKS_SET=false -CLI_COMMIT_SET=false -CLI_GIT_INIT_SET=false - show_help() { cat < Project name (e.g., "FooApp") - Must be a valid Swift identifier + --project-name Project name (required) --bundle-id-root Bundle identifier root (default: $BUNDLE_ID_ROOT) - Format: com.yourname or com.company --deployment-target iOS deployment target (default: $DEPLOYMENT_TARGET) - Format: X.Y (e.g., 17.0, 18.0, 26.0) --swift-version Swift version (default: $SWIFT_VERSION) - Format: X.Y (e.g., 5.9, 5.10, 6.0) - --test-framework Test framework (default: $TEST_FRAMEWORK) - Options: swift-testing, xctest - --source-language Source language for localization (default: $SOURCE_LANGUAGE) - Format: ISO 639-1 code (e.g., en, es, fr, de) - --git-hooks Enable git pre-commit hooks (default) - --no-git-hooks Disable git pre-commit hooks - --commit Create initial git commit after setup (default) - --no-commit Skip initial git commit after setup - --public Make this a public project (includes LICENSE in README) - --private Make this a private project (default) - --force Overwrite existing files without prompting - --skip-brew Skip Homebrew dependency installation - --help Show this help message + --test-framework Test framework: swift-testing or xctest (default: $TEST_FRAMEWORK) + --source-language Source language (default: $SOURCE_LANGUAGE) + --git-hooks Enable git pre-commit hooks (default) + --no-git-hooks Disable git pre-commit hooks + --commit Create initial git commit (default) + --no-commit Skip initial git commit + --public Make this a public project + --private Make this a private project (default) + --force Overwrite existing files without prompting + --skip-brew Skip Homebrew dependency installation + --help Show this help message EXAMPLES: - $0 # Interactive mode - $0 --project-name "MyApp" # CLI + interactive for missing info - $0 --project-name "MyApp" --public # Mostly CLI, minimal prompts - $0 --project-name "MyApp" \\ - --deployment-target "26.0" \\ - --swift-version "6.2" \\ - --source-language "en" \\ - --public --force # Full CLI mode - $0 --project-name "MyApp" --no-commit # Skip initial git commit - -VALIDATION: - - Project name must be a valid Swift identifier (alphanumeric, starts with letter) - - Deployment target and Swift version must be in X.Y format - - Conflicting options (--public and --private) will cause an error - -WORKFLOW: - 1. Parse CLI arguments and validate for conflicts - 2. Prompt interactively for missing required information - 3. Install Homebrew dependencies (unless --skip-brew) - 4. Generate all configuration files from templates - 5. Create MVVM folder structure - 6. Create Resources with localization and asset catalogs - 7. Generate Xcode project with XcodeGen - 8. Configure simulators with intelligent defaults - 9. Verify or create git repository (offers to run 'git init' if not in a repository) - 10. Set up git pre-commit hooks (if git repository available) - 11. Create initial git commit with project details (unless --no-commit) - 12. Display next steps + $0 --project-name "MyApp" + $0 --project-name "MyApp" --public --deployment-target 17.0 EOF } @@ -107,100 +60,51 @@ parse_arguments() { while [[ $# -gt 0 ]]; do case $1 in --project-name) - if [[ -z "${2:-}" ]]; then - log_error "Option --project-name requires a value" - exit 1 - fi PROJECT_NAME="$2" - CLI_PROJECT_NAME_SET=true shift 2 ;; --bundle-id-root) - if [[ -z "${2:-}" ]]; then - log_error "Option --bundle-id-root requires a value" - exit 1 - fi BUNDLE_ID_ROOT="$2" - CLI_BUNDLE_ID_ROOT_SET=true shift 2 ;; --deployment-target) - if [[ -z "${2:-}" ]]; then - log_error "Option --deployment-target requires a value" - exit 1 - fi DEPLOYMENT_TARGET="$2" - CLI_DEPLOYMENT_TARGET_SET=true shift 2 ;; --swift-version) - if [[ -z "${2:-}" ]]; then - log_error "Option --swift-version requires a value" - exit 1 - fi SWIFT_VERSION="$2" - CLI_SWIFT_VERSION_SET=true shift 2 ;; --test-framework) - if [[ -z "${2:-}" ]]; then - log_error "Option --test-framework requires a value" - exit 1 - fi - if [[ "$2" != "swift-testing" && "$2" != "xctest" ]]; then - log_error "Invalid test framework: $2. Must be 'swift-testing' or 'xctest'" - exit 1 - fi TEST_FRAMEWORK="$2" - CLI_TEST_FRAMEWORK_SET=true shift 2 ;; --source-language) - if [[ -z "${2:-}" ]]; then - log_error "Option --source-language requires a value" - exit 1 - fi SOURCE_LANGUAGE="$2" - CLI_SOURCE_LANGUAGE_SET=true shift 2 ;; --git-hooks) USE_GIT_HOOKS=true - CLI_GIT_HOOKS_SET=true shift ;; --no-git-hooks) USE_GIT_HOOKS=false - CLI_GIT_HOOKS_SET=true shift ;; --commit) CREATE_INITIAL_COMMIT=true - CLI_COMMIT_SET=true shift ;; --no-commit) CREATE_INITIAL_COMMIT=false - CLI_COMMIT_SET=true shift ;; --public) - if [[ "$PROJECT_TYPE" == "private" ]]; then - PROJECT_TYPE="public" - else - log_error "Conflicting options: --public specified but PROJECT_TYPE is already '$PROJECT_TYPE'" - exit 1 - fi - CLI_VISIBILITY_SET=true + PROJECT_TYPE="public" shift ;; --private) - if [[ "$PROJECT_TYPE" == "public" ]]; then - log_error "Conflicting options: --private specified but --public was already set" - exit 1 - fi PROJECT_TYPE="private" - CLI_VISIBILITY_SET=true shift ;; --force) @@ -215,13 +119,8 @@ parse_arguments() { show_help exit 0 ;; - -*) - log_error "Unknown option: $1" - echo "Use '$0 --help' for usage information" - exit 1 - ;; *) - log_error "Unexpected argument: $1" + log_error "Unknown option: $1" echo "Use '$0 --help' for usage information" exit 1 ;; @@ -229,1017 +128,251 @@ parse_arguments() { done } -validate_inputs() { - local has_errors=false - - # Validate project name - if [[ -n "$PROJECT_NAME" ]]; then - if ! validate_project_name "$PROJECT_NAME"; then - log_error "Invalid project name: '$PROJECT_NAME'" - log_info "Project name must:" - log_info " - Start with a letter (A-Z, a-z)" - log_info " - Contain only alphanumeric characters" - log_info " - Be a valid Swift identifier" - log_info "Examples: MyApp, FooApp, TimeTracker" - has_errors=true - fi - fi - - # Validate bundle ID root - if [[ -n "$BUNDLE_ID_ROOT" ]]; then - if ! validate_bundle_id_root "$BUNDLE_ID_ROOT"; then - log_error "Invalid bundle ID root: '$BUNDLE_ID_ROOT'" - log_info "Bundle ID root must be in reverse domain format" - log_info "Examples: com.yourname, com.company, io.github.username" - has_errors=true - fi - fi - - # Validate deployment target - if ! validate_ios_version "$DEPLOYMENT_TARGET"; then - log_error "Invalid deployment target: '$DEPLOYMENT_TARGET'" - log_info "Deployment target must be in format X.Y (e.g., 17.0, 18.0, 26.0)" - has_errors=true - fi - - # Validate Swift version - if ! validate_swift_version "$SWIFT_VERSION"; then - log_error "Invalid Swift version: '$SWIFT_VERSION'" - log_info "Swift version must be in format X.Y (e.g., 5.9, 5.10, 6.0)" - has_errors=true - fi - - if $has_errors; then +validate_project_name() { + if [[ -z "$PROJECT_NAME" ]]; then + log_error "Project name is required" + echo "Use: $0 --project-name YourProjectName" exit 1 fi -} - -prompt_for_missing_info() { - # Only prompt for project name if not provided via CLI - if [[ "$CLI_PROJECT_NAME_SET" == false ]]; then - while true; do - echo - read -p "Enter project name (e.g., MyApp): " PROJECT_NAME - if validate_project_name "$PROJECT_NAME"; then - break - else - log_error "Invalid project name. Must start with a letter and contain only alphanumeric characters." - fi - done - fi - # Only prompt for bundle ID root if not provided via CLI - if [[ "$CLI_BUNDLE_ID_ROOT_SET" == false ]]; then - while true; do - echo - read -p "Bundle ID root (default: $BUNDLE_ID_ROOT): " input_bundle_id_root - if [[ -n "$input_bundle_id_root" ]]; then - if validate_bundle_id_root "$input_bundle_id_root"; then - BUNDLE_ID_ROOT="$input_bundle_id_root" - break - else - log_error "Invalid bundle ID root. Use reverse domain format (e.g., com.yourname)" - fi - else - break # Use default - fi - done - fi - - # Only prompt for deployment target if not provided via CLI - if [[ "$CLI_DEPLOYMENT_TARGET_SET" == false ]]; then - echo - read -p "iOS deployment target (default: $DEPLOYMENT_TARGET): " input_deployment_target - if [[ -n "$input_deployment_target" ]]; then - DEPLOYMENT_TARGET="$input_deployment_target" - if ! validate_ios_version "$DEPLOYMENT_TARGET"; then - log_error "Invalid deployment target format. Using default: $DEPLOYMENT_TARGET" - DEPLOYMENT_TARGET="26.0" - fi - fi - fi - - # Only prompt for Swift version if not provided via CLI - if [[ "$CLI_SWIFT_VERSION_SET" == false ]]; then - echo - read -p "Swift version (default: $SWIFT_VERSION): " input_swift_version - if [[ -n "$input_swift_version" ]]; then - SWIFT_VERSION="$input_swift_version" - if ! validate_swift_version "$SWIFT_VERSION"; then - log_error "Invalid Swift version format. Using default: $SWIFT_VERSION" - SWIFT_VERSION="6.2" - fi - fi - fi - - # Only prompt for test framework if not provided via CLI - if [[ "$CLI_TEST_FRAMEWORK_SET" == false ]]; then - echo - while true; do - read -p "Test framework (swift-testing/xctest, default: $TEST_FRAMEWORK): " input_test_framework - if [[ -z "$input_test_framework" ]]; then - break # Use default - elif [[ "$input_test_framework" == "swift-testing" || "$input_test_framework" == "xctest" ]]; then - TEST_FRAMEWORK="$input_test_framework" - break - else - log_error "Invalid test framework. Choose 'swift-testing' or 'xctest'" - fi - done - fi - - # Only prompt for source language if not provided via CLI - if [[ "$CLI_SOURCE_LANGUAGE_SET" == false ]]; then - echo - read -p "Source language for localization (default: $SOURCE_LANGUAGE): " input_source_language - if [[ -n "$input_source_language" ]]; then - SOURCE_LANGUAGE="$input_source_language" - fi - fi - - # Only prompt for git hooks if not provided via CLI - if [[ "$CLI_GIT_HOOKS_SET" == false ]]; then - echo - while true; do - read -p "Enable git pre-commit hooks? (Y/n): " yn - case $yn in - [Yy]*|"") USE_GIT_HOOKS=true; break;; - [Nn]*) USE_GIT_HOOKS=false; break;; - *) log_error "Please answer y or n";; - esac - done - fi - - # Ask about project visibility if not specified via CLI - if [[ "$CLI_VISIBILITY_SET" == false ]]; then - echo - while true; do - read -p "Is this a public project? (y/N): " yn - case $yn in - [Yy]* ) PROJECT_TYPE="public"; break;; - [Nn]* | "" ) PROJECT_TYPE="private"; break;; - * ) echo "Please answer yes or no.";; - esac - done - fi - - # Handle git repository initialization if not already in one and git features are needed - if ! is_git_repo && ($USE_GIT_HOOKS || $CREATE_INITIAL_COMMIT) && [[ "$CLI_GIT_INIT_SET" == false ]]; then - echo - log_warning "Not in a git repository" - while true; do - read -p "Would you like to initialize a git repository? (Y/n): " yn - case $yn in - [Yy]*|"") - log_info "Will initialize git repository during setup" - GIT_REPOSITORY_AVAILABLE=true - break - ;; - [Nn]*) - log_info "Git repository will not be initialized" - log_info "Git hooks and initial commit will be skipped" - GIT_REPOSITORY_AVAILABLE=false - break - ;; - *) echo "Please answer yes or no.";; - esac - done - elif is_git_repo; then - GIT_REPOSITORY_AVAILABLE=true - else - GIT_REPOSITORY_AVAILABLE=false + # Validate it's a valid Swift identifier + if [[ ! "$PROJECT_NAME" =~ ^[A-Za-z][A-Za-z0-9]*$ ]]; then + log_error "Invalid project name. Must start with a letter and contain only alphanumerics." + exit 1 fi } -detect_architecture() { - # Detect Mac architecture for simulator settings - case "$(uname -m)" in - arm64) echo "arm64" ;; - x86_64) echo "x86_64" ;; - *) echo "arm64" ;; # Default to arm64 for Apple Silicon - esac -} - install_dependencies() { if $SKIP_BREW; then - log_info "Skipping Homebrew dependency installation (--skip-brew specified)" + log_info "Skipping Homebrew dependency installation" return fi log_info "Installing Homebrew dependencies..." if ! command_exists brew; then - log_error "Homebrew is not installed" - log_info "Please install Homebrew first: https://brew.sh" - log_info "Or run this script with --skip-brew to skip dependency installation" - exit 1 - fi - - if ! brew bundle install --file="$ROOT_DIR/Brewfile"; then - log_error "Failed to install Homebrew dependencies" - log_info "You can retry with: brew bundle install --file=./Brewfile" + log_error "Homebrew is not installed. Install from https://brew.sh" exit 1 fi - log_success "Dependencies installed successfully" + brew bundle install --file="$ROOT_DIR/Brewfile" + log_success "Dependencies installed" } -generate_template_files() { - log_info "Generating project files from templates..." +replace_placeholders() { + log_info "Replacing placeholders in project files..." - local templates_dir="$ROOT_DIR/templates" local project_name_lower project_name_lower=$(echo "$PROJECT_NAME" | tr '[:upper:]' '[:lower:]') - local simulator_arch - simulator_arch=$(detect_architecture) - # Prepare template variables - local template_vars=( - "PROJECT_NAME=$PROJECT_NAME" - "PROJECT_NAME_LOWER=$project_name_lower" - "BUNDLE_ID_ROOT=$BUNDLE_ID_ROOT" - "DEPLOYMENT_TARGET=$DEPLOYMENT_TARGET" - "SWIFT_VERSION=$SWIFT_VERSION" - "SIMULATOR_ARCH=$simulator_arch" - "PROJECT_DESCRIPTION=A new iOS application built with SwiftUI" - "ARCHITECTURE=SwiftUI + MVVM" - "ARCHITECTURE_DESCRIPTION=Modern SwiftUI app with Model-View-ViewModel architecture" - "PATTERN_DESCRIPTION=ViewModels handle business logic, Views handle UI" - "KEY_COMPONENTS=- App entry point\\n- Core Data stack\\n- Main ViewModels\\n- SwiftUI Views" - "DATA_FLOW_DESCRIPTION=1. Views observe ViewModels\\n2. ViewModels update Models\\n3. Models notify ViewModels of changes\\n4. ViewModels update Views" - "PROJECT_SPECIFIC_FEATURES=- SwiftUI interface\\n- Core Data persistence\\n- MVVM architecture" - ) + # Prepare replacement values + local license_badge="" + local license_section="" + local contributing_section="" - # Add project type specific variables if [[ "$PROJECT_TYPE" == "public" ]]; then - template_vars+=( - "LICENSE_BADGE=![License](https://img.shields.io/badge/license-MIT-green)" - "LICENSE_SECTION=## License\\n\\nThis project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details." - "CONTRIBUTING_SECTION=## Contributing\\n\\nContributions are welcome! Please feel free to submit a Pull Request." - "REPOSITORY_URL=https://github.com/yourusername/$project_name_lower" - ) - else - template_vars+=( - "LICENSE_BADGE=" - "LICENSE_SECTION=" - "CONTRIBUTING_SECTION=" - "REPOSITORY_URL=https://github.com/yourusername/$project_name_lower" - ) - fi - - # Generate each template file - local files_to_generate=( - "project.yml.template:project.yml" - ".swiftlint.yml.template:.swiftlint.yml" - ".swiftformat.template:.swiftformat" - "README.md.template:README.md" - "ci.yml.template:.github/workflows/ci.yml" - ) - - for file_mapping in "${files_to_generate[@]}"; do - local template_file="${file_mapping%:*}" - local output_file="${file_mapping#*:}" - local template_path="$templates_dir/$template_file" - local output_path="$ROOT_DIR/$output_file" - - # Check if output file exists and handle overwrite - # Force overwrite for template-specific files that should always be regenerated - local force_overwrite_files=("README.md") - local should_force_overwrite=false - - for force_file in "${force_overwrite_files[@]}"; do - if [[ "$output_file" == "$force_file" ]]; then - should_force_overwrite=true - break - fi - done - - if [[ -f "$output_path" && "$FORCE_OVERWRITE" != true && "$should_force_overwrite" != true ]]; then - log_warning "File $output_file already exists" - while true; do - read -p "Overwrite $output_file? (y/n): " yn - case $yn in - [Yy]* ) break;; - [Nn]* ) - log_info "Skipping $output_file" - continue 2;; - * ) echo "Please answer yes or no.";; - esac - done - elif [[ -f "$output_path" && "$should_force_overwrite" == true ]]; then - log_info "Force overwriting $output_file (template-specific file)" - fi - - # Generate file from template - if [[ -f "$template_path" ]]; then - # Create output directory if needed - local output_dir - output_dir=$(dirname "$output_path") - if [[ ! -d "$output_dir" ]]; then - mkdir -p "$output_dir" - log_info "Created directory: $output_dir" + license_badge="![License](https://img.shields.io/badge/license-MIT-green)" + license_section="## License\\n\\nThis project is licensed under the MIT License." + contributing_section="## Contributing\\n\\nContributions are welcome! Please submit a Pull Request." + fi + + # Files to process (excluding .git, scripts, etc.) + local files_to_process=$(find . -type f \ + -not -path "./.git/*" \ + -not -path "./scripts/*" \ + -not -path "./.claude/*" \ + -not -path "./Brewfile" \ + -not -path "./README.md" \ + -not -name "*.xcodeproj" \ + -not -name ".DS_Store") + + # Perform replacements on each file + for file in $files_to_process; do + if grep -q "{{" "$file" 2>/dev/null; then + if [[ "$OSTYPE" == "darwin"* ]]; then + # macOS sed + sed -i '' \ + -e "s|{{PROJECT_NAME}}|$PROJECT_NAME|g" \ + -e "s|{{PROJECT_NAME_LOWER}}|$project_name_lower|g" \ + -e "s|{{BUNDLE_ID_ROOT}}|$BUNDLE_ID_ROOT|g" \ + -e "s|{{DEPLOYMENT_TARGET}}|$DEPLOYMENT_TARGET|g" \ + -e "s|{{SWIFT_VERSION}}|$SWIFT_VERSION|g" \ + -e "s|{{SOURCE_LANGUAGE}}|$SOURCE_LANGUAGE|g" \ + -e "s|{{PROJECT_DESCRIPTION}}|An iOS application built with Swift|g" \ + -e "s|{{REPOSITORY_URL}}|https://github.com/yourusername/$project_name_lower|g" \ + -e "s|{{LICENSE_BADGE}}|$license_badge|g" \ + -e "s|{{LICENSE_SECTION}}|$license_section|g" \ + -e "s|{{CONTRIBUTING_SECTION}}|$contributing_section|g" \ + "$file" + else + # GNU sed + sed -i \ + -e "s|{{PROJECT_NAME}}|$PROJECT_NAME|g" \ + -e "s|{{PROJECT_NAME_LOWER}}|$project_name_lower|g" \ + -e "s|{{BUNDLE_ID_ROOT}}|$BUNDLE_ID_ROOT|g" \ + -e "s|{{DEPLOYMENT_TARGET}}|$DEPLOYMENT_TARGET|g" \ + -e "s|{{SWIFT_VERSION}}|$SWIFT_VERSION|g" \ + -e "s|{{SOURCE_LANGUAGE}}|$SOURCE_LANGUAGE|g" \ + -e "s|{{PROJECT_DESCRIPTION}}|An iOS application built with Swift|g" \ + -e "s|{{REPOSITORY_URL}}|https://github.com/yourusername/$project_name_lower|g" \ + -e "s|{{LICENSE_BADGE}}|$license_badge|g" \ + -e "s|{{LICENSE_SECTION}}|$license_section|g" \ + -e "s|{{CONTRIBUTING_SECTION}}|$contributing_section|g" \ + "$file" fi - - replace_template_vars "$template_path" "$output_path" "${template_vars[@]}" - log_success "Generated $output_file" - else - log_warning "Template $template_file not found, skipping" + log_success "Processed: $file" fi done + log_success "Placeholder replacement complete" } -create_project_structure() { - log_info "Creating MVVM project structure..." - - # Create main app directories - local app_dirs=( - "$PROJECT_NAME/Models" - "$PROJECT_NAME/Views" - "$PROJECT_NAME/ViewModels" - "$PROJECT_NAME/Services" - "$PROJECT_NAME/Extensions" - "$PROJECT_NAME/Helpers" - ) - - # Create test directories - local test_dirs=( - "${PROJECT_NAME}Tests/Models" - "${PROJECT_NAME}Tests/Views" - "${PROJECT_NAME}Tests/ViewModels" - "${PROJECT_NAME}Tests/Services" - "${PROJECT_NAME}UITests" - ) - - # Create all directories - for dir in "${app_dirs[@]}" "${test_dirs[@]}"; do - mkdir -p "$dir" - # Add .gitkeep to empty directories - touch "$dir/.gitkeep" - log_success "Created $dir/" - done - - # Create basic app entry point - local app_file="$PROJECT_NAME/${PROJECT_NAME}App.swift" - if [[ ! -f "$app_file" || "$FORCE_OVERWRITE" == true ]]; then - cat > "$app_file" < "$content_view_file" < "$main_viewmodel_file" < "$viewmodel_test_file" < "$viewmodel_test_file" <! - - override func setUp() { - super.setUp() - viewModel = MainViewModel() - cancellables = Set() - } +rename_directories() { + log_info "Renaming project directories..." - override func tearDown() { - viewModel = nil - cancellables = nil - super.tearDown() - } - - func testInitialState() throws { - XCTAssertEqual(viewModel.message, "Hello from $PROJECT_NAME!") - XCTAssertFalse(viewModel.isLoading) - } - - func testRefreshData() throws { - let expectation = XCTestExpectation(description: "Data refresh completes") - - viewModel.\$message - .dropFirst() // Skip initial value - .sink { message in - XCTAssertTrue(message.contains("Data refreshed")) - expectation.fulfill() - } - .store(in: &cancellables) - - viewModel.refreshData() - XCTAssertTrue(viewModel.isLoading) - - wait(for: [expectation], timeout: 2.0) - XCTAssertFalse(viewModel.isLoading) - } -} -EOF - fi - log_success "Created MainViewModelTests.swift ($TEST_FRAMEWORK)" + # Rename directories from MyProject to actual project name + if [[ -d "MyProject" ]]; then + mv "MyProject" "$PROJECT_NAME" + log_success "Renamed MyProject โ†’ $PROJECT_NAME" fi - # Create basic UI test - local ui_test_file="${PROJECT_NAME}UITests/${PROJECT_NAME}UITests.swift" - if [[ ! -f "$ui_test_file" || "$FORCE_OVERWRITE" == true ]]; then - cat > "$ui_test_file" < "$info_plist_file" < - - - - CFBundleDevelopmentRegion - \$(DEVELOPMENT_LANGUAGE) - CFBundleExecutable - \$(EXECUTABLE_NAME) - CFBundleIdentifier - \$(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - \$(PRODUCT_NAME) - CFBundlePackageType - \$(PRODUCT_BUNDLE_PACKAGE_TYPE) - CFBundleShortVersionString - 1.0 - CFBundleVersion - 1 - LSRequiresIPhoneOS - - UIApplicationSceneManifest - - UIApplicationSupportsMultipleScenes - - UISceneConfigurations - - UIWindowSceneSessionRoleApplication - - - UISceneConfigurationName - Default Configuration - UISceneDelegateClassName - \$(PRODUCT_MODULE_NAME).SceneDelegate - - - - - UIApplicationSupportsIndirectInputEvents - - UILaunchStoryboardName - LaunchScreen - UISupportedInterfaceOrientations - - UIInterfaceOrientationPortrait - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UISupportedInterfaceOrientations~ipad - - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - - -EOF - log_success "Created Info.plist" + if [[ -d "MyProjectUITests" ]]; then + mv "MyProjectUITests" "${PROJECT_NAME}UITests" + log_success "Renamed MyProjectUITests โ†’ ${PROJECT_NAME}UITests" fi } -create_resources_structure() { - log_info "Creating Resources with localization and asset catalogs..." - - # Create Resources directory if it doesn't exist - mkdir -p "$PROJECT_NAME/Resources" - - # Create Localizable.xcstrings - local localizable_file="$PROJECT_NAME/Resources/Localizable.xcstrings" - if [[ ! -f "$localizable_file" || "$FORCE_OVERWRITE" == true ]]; then - cat > "$localizable_file" < "$assets_dir/Contents.json" < "$appicon_dir/Contents.json" < "$accentcolor_dir/Contents.json" </dev/null 2>&1; then - default_iphone="$iphone" - break - fi - done - - # Find first available iPad - for ipad in "${preferred_ipads[@]}"; do - if "$ROOT_DIR/scripts/simulator.sh" optimal-os "$ipad" >/dev/null 2>&1; then - default_ipad="$ipad" - break - fi - done - - # Configure unit tests simulator - if [[ -n "$default_iphone" ]]; then - log_info "Configuring unit tests with: $default_iphone" - if "$ROOT_DIR/scripts/simulator.sh" config-tests "$default_iphone" --yes; then - log_success "Unit tests simulator configured" - else - log_warning "Failed to configure unit tests simulator" - fi - else - log_warning "No suitable iPhone simulator found for unit tests" - log_info "You can configure manually with: ./scripts/simulator.sh config-tests \"\"" - fi - - # Configure UI tests simulator (prefer iPad if available, fallback to iPhone) - local ui_device="$default_ipad" - if [[ -z "$ui_device" ]]; then - ui_device="$default_iphone" - fi - - if [[ -n "$ui_device" ]]; then - log_info "Configuring UI tests with: $ui_device" - if "$ROOT_DIR/scripts/simulator.sh" config-ui-tests "$ui_device" --yes; then - log_success "UI tests simulator configured" - else - log_warning "Failed to configure UI tests simulator" - fi - else - log_warning "No suitable simulator found for UI tests" - log_info "You can configure manually with: ./scripts/simulator.sh config-ui-tests \"\"" - fi - - echo - log_info "Simulator configuration complete. To view current settings:" - echo " ./scripts/simulator.sh show-config" + log_success "Xcode project generated: ${PROJECT_NAME}.xcodeproj" } -verify_or_create_git_repository() { - # If already in a git repository, nothing to do - if is_git_repo; then - return - fi - - # If git repository should be created (determined during prompting) - if $GIT_REPOSITORY_AVAILABLE; then - log_info "Initializing git repository with 'main' as default branch..." - - # Try modern git init with --initial-branch, fallback for older git versions - if git init --initial-branch=main 2>/dev/null; then - log_success "Git repository initialized with 'main' branch" - elif git init 2>/dev/null; then - # Older git version - rename default branch to main - git checkout -b main 2>/dev/null || true - log_success "Git repository initialized and switched to 'main' branch" - else - log_error "Failed to initialize git repository" - GIT_REPOSITORY_AVAILABLE=false - fi +configure_simulators() { + log_info "Configuring simulators..." + + # Create simulator.yml if it doesn't exist + if [[ ! -f "simulator.yml" ]]; then + cat > simulator.yml < "$pre_commit_hook" </dev/null - - # Check if there are files to commit - if git diff --cached --quiet; then - log_warning "No changes to commit" - return + if [[ ! -d ".git" ]]; then + log_info "Initializing git repository..." + git init + log_success "Git repository initialized" fi - # Create initial commit with descriptive message - local commit_message="Initial project setup - -Generated iOS project using SwiftProjectTemplate with: -- Project: $PROJECT_NAME -- Bundle ID: ${BUNDLE_ID_ROOT}.${PROJECT_NAME} -- iOS Deployment Target: $DEPLOYMENT_TARGET -- Swift Version: $SWIFT_VERSION -- Test Framework: $TEST_FRAMEWORK -- Source Language: $SOURCE_LANGUAGE" + log_info "Creating initial commit..." - if git commit -m "$commit_message" 2>/dev/null; then - log_success "Initial commit created successfully" - log_info "You can view the commit with: git log --oneline -1" - else - log_error "Failed to create initial commit" - log_info "You may need to configure git user settings:" - echo " git config --global user.name \"Your Name\"" - echo " git config --global user.email \"your.email@example.com\"" - fi -} + # Add all files + git add . -cleanup_template_files() { - log_info "Cleaning up template-specific files..." + # Create commit + git commit -m "Initial commit: $PROJECT_NAME - # Remove template-validation.yml workflow since this is now a generated project - local template_validation_workflow=".github/workflows/template-validation.yml" - if [[ -f "$template_validation_workflow" ]]; then - rm "$template_validation_workflow" - log_success "Removed template-validation.yml (not needed for generated projects)" - fi +Generated from SwiftProjectTemplate +- iOS $DEPLOYMENT_TARGET+ +- Swift $SWIFT_VERSION +- Test framework: $TEST_FRAMEWORK - # Remove empty .github/workflows directory if it has no other workflows - local workflows_dir=".github/workflows" - if [[ -d "$workflows_dir" && -z "$(ls -A "$workflows_dir" 2>/dev/null)" ]]; then - rmdir "$workflows_dir" - log_info "Removed empty workflows directory" - fi +๐Ÿค– Generated with SwiftProjectTemplate" || log_warning "Commit may have failed or no changes to commit" - # Remove empty .github directory if it has no other content - local github_dir=".github" - if [[ -d "$github_dir" && -z "$(ls -A "$github_dir" 2>/dev/null)" ]]; then - rmdir "$github_dir" - log_info "Removed empty .github directory" - fi + log_success "Initial commit created" } -display_next_steps() { - log_success "๐ŸŽ‰ Project setup complete!" +show_next_steps() { + echo + echo "================================================" + log_success "๐ŸŽ‰ Project Setup Complete!" echo - echo "Project: $PROJECT_NAME" - echo "iOS Deployment Target: $DEPLOYMENT_TARGET" - echo "Swift Version: $SWIFT_VERSION" - echo "Source Language: $SOURCE_LANGUAGE" - echo "Project Type: $PROJECT_TYPE" + log_info "Project: $PROJECT_NAME" + log_info "Bundle ID: ${BUNDLE_ID_ROOT}.${PROJECT_NAME}" + log_info "Deployment Target: iOS $DEPLOYMENT_TARGET+" + log_info "Swift Version: $SWIFT_VERSION" echo log_info "Next steps:" - echo " 1. [optional] Reconfigure simulators for building and testing:" - echo " ./scripts/simulator.sh list" - echo " ./scripts/simulator.sh config-tests \"iPhone 16 Pro\"" - echo " 2. [optional] run the preflight script to verify everything builds and tests with 0 errors!" - echo " ./scripts/preflight.sh" - echo " 3. Open $PROJECT_NAME.xcodeproj in Xcode" - echo " 4. Start building your app!" + echo " 1. Open ${PROJECT_NAME}.xcodeproj in Xcode" + echo " 2. Build and run: ./scripts/build.sh" + echo " 3. Run tests: ./scripts/test.sh" + echo " 4. Before committing: ./scripts/preflight.sh" echo - log_info "Available commands:" - echo " ./scripts/build.sh # Build for simulator" - echo " ./scripts/test.sh # Run tests" - echo " ./scripts/lint.sh --fix # Fix linting issues" - echo " ./scripts/format.sh --fix # Fix formatting" - echo " ./scripts/preflight.sh # Full CI check" - echo " ./scripts/simulator.sh list # List available simulators" + log_info "Development scripts available in scripts/ directory" + echo " Run any script with --help for options" echo - log_info "Need help? Check README.md for guidance." } # Main execution main() { - log_info "SwiftProjectTemplate Setup" + echo "๐Ÿš€ SwiftProjectTemplate Setup" + echo "==============================" echo parse_arguments "$@" - validate_inputs - prompt_for_missing_info - validate_inputs # Validate again after interactive input - - log_info "Setting up project with:" - log_info " Project Name: $PROJECT_NAME" - log_info " Bundle Identifier: ${BUNDLE_ID_ROOT}.$(echo "$PROJECT_NAME" | tr '[:upper:]' '[:lower:]')" - log_info " Deployment Target: iOS $DEPLOYMENT_TARGET" - log_info " Swift Version: $SWIFT_VERSION" - log_info " Test Framework: $TEST_FRAMEWORK" - log_info " Source Language: $SOURCE_LANGUAGE" - log_info " Git Hooks: $(if $USE_GIT_HOOKS; then echo "enabled"; else echo "disabled"; fi)" - log_info " Project Type: $PROJECT_TYPE" - echo + validate_project_name install_dependencies - generate_template_files - create_project_structure - create_resources_structure + replace_placeholders + rename_directories + configure_simulators generate_xcode_project - setup_default_simulators - verify_or_create_git_repository setup_git_hooks create_initial_commit - cleanup_template_files - - display_next_steps + show_next_steps } -# Run main function with all arguments -main "$@" \ No newline at end of file +main "$@" diff --git a/templates/README.md.template b/templates/README.md.template deleted file mode 100644 index fa40442..0000000 --- a/templates/README.md.template +++ /dev/null @@ -1,134 +0,0 @@ -# {{PROJECT_NAME}} - -{{PROJECT_DESCRIPTION}} - -## Badges - -![iOS](https://img.shields.io/badge/iOS-{{DEPLOYMENT_TARGET}}%2B-blue) -![Swift](https://img.shields.io/badge/Swift-{{SWIFT_VERSION}}-orange) -![Xcode](https://img.shields.io/badge/Xcode-15.0%2B-blue) -{{LICENSE_BADGE}} - -## Requirements - -- iOS {{DEPLOYMENT_TARGET}}+ -- Xcode 15.0+ -- Swift {{SWIFT_VERSION}} - -## Quick Start - -### Prerequisites - -Make sure you have [Homebrew](https://brew.sh) installed, then run: - -```bash -./scripts/setup.sh -``` - -This will: -- Install all required dependencies (SwiftLint, SwiftFormat, XcodeGen, etc.) -- Generate the Xcode project from `project.yml` -- Set up git pre-commit hooks - -### Building - -```bash -# Build for simulator -./scripts/build.sh - -# Build for device -./scripts/build.sh --device -``` - -### Testing - -```bash -# Run unit tests -./scripts/test.sh - -# Run UI tests -./scripts/test.sh --ui - -# Run all tests -./scripts/test.sh --all -``` - -### Code Quality - -```bash -# Check and fix formatting -./scripts/format.sh --fix - -# Check and fix linting issues -./scripts/lint.sh --fix - -# Run full preflight check (format, lint, test) -./scripts/preflight.sh -``` - -## Project Structure - -``` -{{PROJECT_NAME}}/ -โ”œโ”€โ”€ Models/ # Data models and Core Data entities -โ”œโ”€โ”€ Views/ # SwiftUI views and UIKit view controllers -โ”œโ”€โ”€ ViewModels/ # Business logic and view state management -โ”œโ”€โ”€ Services/ # Network, persistence, and business services -โ”œโ”€โ”€ Extensions/ # Swift extensions and utilities -โ””โ”€โ”€ Helpers/ # Helper functions and utilities -``` - -## Development Scripts - -This project includes several helper scripts in the `scripts/` directory: - -| Script | Purpose | -|--------|---------| -| `setup.sh` | One-time project setup and dependency installation | -| `build.sh` | Build the app for simulator or device | -| `test.sh` | Run unit tests, UI tests, or both | -| `lint.sh` | Run SwiftLint and optionally fix issues | -| `format.sh` | Run SwiftFormat and optionally fix issues | -| `simulator.sh` | Manage iOS simulators for testing | -| `preflight.sh` | Complete local CI check before committing | -| `ci.sh` | CI-specific build and test commands | - -Run any script with `--help` to see available options. - -## Configuration - -### XcodeGen - -This project uses [XcodeGen](https://github.com/yonaskolb/XcodeGen) to generate the Xcode project file from `project.yml`. - -To regenerate the project file: -```bash -xcodegen -``` - -### Simulators - -Simulator configuration is managed in `simulator.yml`. To update simulator settings: - -```bash -# Configure test simulators -./scripts/simulator.sh --config-tests "iPhone 16 Pro Max" - -# Configure UI test simulators -./scripts/simulator.sh --config-ui-tests "iPad Air 11-inch" -``` - -### Code Style - -- **SwiftLint**: Configuration in `.swiftlint.yml` -- **SwiftFormat**: Configuration in `.swiftformat` -- **Line Length**: 120 characters (warnings), 150 characters (errors) -- **Indentation**: 2 spaces - -{{CONTRIBUTING_SECTION}} - -{{LICENSE_SECTION}} - -## Support - -If you encounter any issues or have questions, please [open an issue]({{REPOSITORY_URL}}/issues) on GitHub. \ No newline at end of file diff --git a/templates/ci.yml.template b/templates/ci.yml.template deleted file mode 100644 index 56a2480..0000000 --- a/templates/ci.yml.template +++ /dev/null @@ -1,217 +0,0 @@ -name: iOS CI - -on: - pull_request: - branches: [main, develop] - push: - branches: [main, develop] - -env: - DEVELOPER_DIR: /Applications/Xcode.app/Contents/Developer - -jobs: - ios-ci: - name: Build, Test & Quality Checks - runs-on: macos-latest - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up Xcode - uses: maxim-lobanov/setup-xcode@v1 - with: - xcode-version: "16.4" - - - name: Cache Homebrew packages - uses: actions/cache@v4 - with: - path: | - ~/Library/Caches/Homebrew - /usr/local/Homebrew - key: ${{ runner.os }}-homebrew-${{ hashFiles('**/Brewfile') }} - restore-keys: | - ${{ runner.os }}-homebrew- - - - name: Install dependencies - run: | - # Install Homebrew dependencies - brew bundle install --file=./Brewfile - - # Verify installations - echo "Installed versions:" - swiftlint --version - swiftformat --version - xcodegen --version - yq --version - - - name: Generate Xcode project - run: | - echo "Generating Xcode project..." - xcodegen generate - - PROJECT_NAME=$(yq eval '.name' project.yml) - if [ -d "${PROJECT_NAME}.xcodeproj" ]; then - echo "โœ“ Xcode project generated successfully" - else - echo "โœ— Failed to generate Xcode project" - exit 1 - fi - - - name: SwiftFormat check - run: | - echo "Checking code formatting..." - PROJECT_NAME=$(yq eval '.name' project.yml) - - if [ -d "$PROJECT_NAME" ]; then - swiftformat --lint $PROJECT_NAME - echo "โœ“ SwiftFormat check passed" - else - echo "โš  Source directory $PROJECT_NAME not found, skipping SwiftFormat" - fi - - - name: SwiftLint - run: | - echo "Running SwiftLint..." - PROJECT_NAME=$(yq eval '.name' project.yml) - - if [ -d "$PROJECT_NAME" ]; then - swiftlint lint --strict - echo "โœ“ SwiftLint passed" - else - echo "โš  Source directory $PROJECT_NAME not found, skipping SwiftLint" - fi - - - name: Build project - run: | - echo "Building project..." - PROJECT_NAME=$(yq eval '.name' project.yml) - DEPLOYMENT_TARGET=$(yq eval '.options.deploymentTarget.iOS' project.yml) - - # Determine simulator based on deployment target - if [ "$DEPLOYMENT_TARGET" != "null" ] && [ -n "$DEPLOYMENT_TARGET" ]; then - MAJOR_VERSION=$(echo $DEPLOYMENT_TARGET | cut -d'.' -f1) - if [ "$MAJOR_VERSION" -ge "17" ]; then - SIMULATOR_NAME="iPhone 16 Pro" - else - SIMULATOR_NAME="iPhone 15 Pro" - fi - else - SIMULATOR_NAME="iPhone 16 Pro" - fi - - echo "Building for $SIMULATOR_NAME..." - - xcodebuild build \ - -project "${PROJECT_NAME}.xcodeproj" \ - -scheme "$PROJECT_NAME" \ - -destination "platform=iOS Simulator,name=$SIMULATOR_NAME" \ - -configuration Debug \ - ONLY_ACTIVE_ARCH=YES \ - CODE_SIGNING_REQUIRED=NO \ - CODE_SIGNING_ALLOWED=NO - - echo "โœ“ Build completed successfully" - - - name: Run unit tests - run: | - PROJECT_NAME=$(yq eval '.name' project.yml) - - # Check if test target exists - HAS_TESTS=$(yq eval ".targets | has(\"${PROJECT_NAME}Tests\")" project.yml) - - if [ "$HAS_TESTS" = "true" ]; then - echo "Running unit tests..." - - DEPLOYMENT_TARGET=$(yq eval '.options.deploymentTarget.iOS' project.yml) - if [ "$DEPLOYMENT_TARGET" != "null" ] && [ -n "$DEPLOYMENT_TARGET" ]; then - MAJOR_VERSION=$(echo $DEPLOYMENT_TARGET | cut -d'.' -f1) - if [ "$MAJOR_VERSION" -ge "17" ]; then - SIMULATOR_NAME="iPhone 16 Pro" - else - SIMULATOR_NAME="iPhone 15 Pro" - fi - else - SIMULATOR_NAME="iPhone 16 Pro" - fi - - xcodebuild test \ - -project "${PROJECT_NAME}.xcodeproj" \ - -scheme "$PROJECT_NAME" \ - -destination "platform=iOS Simulator,name=$SIMULATOR_NAME" \ - -configuration Debug \ - -enableCodeCoverage YES \ - ONLY_ACTIVE_ARCH=YES \ - CODE_SIGNING_REQUIRED=NO \ - CODE_SIGNING_ALLOWED=NO - - echo "โœ“ Unit tests completed" - else - echo "โš  No unit test target found, skipping tests" - fi - - - name: Validate scripts - run: | - echo "Validating helper scripts..." - - # Check that all scripts are executable - find scripts -name "*.sh" -type f | while read script; do - if [ -x "$script" ]; then - echo "โœ“ $script is executable" - else - echo "โœ— $script is not executable" - exit 1 - fi - done - - # Check script syntax - find scripts -name "*.sh" -type f | while read script; do - if bash -n "$script"; then - echo "โœ“ $script syntax is valid" - else - echo "โœ— $script has syntax errors" - exit 1 - fi - done - - echo "โœ“ All scripts validated" - - - name: Check configuration files - run: | - echo "Validating configuration files..." - - # Check .swiftlint.yml if it exists - if [ -f ".swiftlint.yml" ]; then - yq eval '.' .swiftlint.yml > /dev/null - echo "โœ“ .swiftlint.yml is valid" - fi - - # Check .swiftformat if it exists - if [ -f ".swiftformat" ]; then - echo "โœ“ .swiftformat found" - fi - - # Check simulator.yml if it exists - if [ -f "simulator.yml" ]; then - yq eval '.' simulator.yml > /dev/null - echo "โœ“ simulator.yml is valid" - fi - - # Check Brewfile - if [ -f "Brewfile" ]; then - echo "โœ“ Brewfile found" - fi - - echo "โœ“ Configuration files validated" - - - name: CI Summary - run: | - echo "๐ŸŽ‰ CI Complete!" - echo "" - echo "โœ… Code formatting validated" - echo "โœ… Code quality checks passed" - echo "โœ… Build completed successfully" - echo "โœ… Tests executed" - echo "โœ… Configuration validated" - echo "" - echo "Ready for review! ๐Ÿš€" \ No newline at end of file From cea9a0d2750a739af950a475845c2c59e3433ad4 Mon Sep 17 00:00:00 2001 From: Nicholas Hart Date: Fri, 17 Oct 2025 21:18:45 -0700 Subject: [PATCH 2/2] feat: Add dual-mode setup (adopt existing vs generate minimal) Implements two setup modes to support different workflows: 1. ADOPT mode (primary): Integrate with existing Xcode projects 2. GENERATE mode (secondary): Create minimal project for quick starts ## Key Changes ### Removed - MyProject/, MyProjectTests/, MyProjectUITests/ placeholder directories - No more checked-in placeholder code - Removed rename_directories() function (obsolete) ### Added Setup Modes **ADOPT Mode (Primary Workflow)** - Detects existing .xcodeproj automatically - Configures tooling around existing project structure - Respects Xcode-generated code and templates - Optional directory structure creation with --structure flag **GENERATE Mode (Quick Start)** - Creates minimal UIKit project on-demand - Generates AppDelegate, SceneDelegate, ViewController - Creates test targets with swift-testing - Configurable structure: mvvm, clean, or none ### New setup.sh Features - `--generate-minimal`: Force generation mode - `--structure `: Choose directory structure (mvvm/clean/none) - Auto-detection: Checks for .xcodeproj and chooses mode intelligently - Helpful error messages when mode is ambiguous ### Functions Added - `detect_project_mode()`: Auto-detect adopt vs generate - `generate_minimal_project()`: Create minimal Swift files - `create_directory_structure()`: Flexible structure creation - `adopt_existing_project()`: Verify and configure existing project ### CI Updates - Uses `--generate-minimal` flag for template testing - Explicitly specifies `--structure mvvm` for clarity - Better detection message (distinguishes template vs derived repos) ### Documentation Updates - CLAUDE.md: Comprehensive dual-mode workflow documentation - setup.sh: Enhanced help with examples for both modes - Clear primary vs secondary workflow guidance ## Benefits 1. **Xcode-first workflow**: Use any Xcode template, add automation 2. **Flexibility**: Quick start still available with --generate-minimal 3. **No confusion**: No placeholder MyProject/ sitting in repo 4. **Testable template**: CI generates and validates automatically 5. **Clean repository**: Only config files, no placeholder code ## Migration Users upgrading should: - Old workflow still works with --generate-minimal flag - New recommended workflow: Create in Xcode first, then adopt Co-Authored-By: Claude --- .github/workflows/ci.yml | 10 +- CLAUDE.md | 54 +++-- MyProject/AppDelegate.swift | 37 --- MyProject/Extensions/.gitkeep | 0 MyProject/Helpers/.gitkeep | 0 MyProject/Info.plist | 23 -- MyProject/Models/.gitkeep | 0 MyProject/Resources/.gitkeep | 0 MyProject/SceneDelegate.swift | 50 ---- MyProject/Services/.gitkeep | 0 MyProject/ViewModels/.gitkeep | 0 MyProject/Views/ViewController.swift | 21 -- MyProjectTests/MyProjectTests.swift | 9 - MyProjectUITests/MyProjectUITests.swift | 15 -- scripts/setup.sh | 300 ++++++++++++++++++++++-- 15 files changed, 328 insertions(+), 191 deletions(-) delete mode 100644 MyProject/AppDelegate.swift delete mode 100644 MyProject/Extensions/.gitkeep delete mode 100644 MyProject/Helpers/.gitkeep delete mode 100644 MyProject/Info.plist delete mode 100644 MyProject/Models/.gitkeep delete mode 100644 MyProject/Resources/.gitkeep delete mode 100644 MyProject/SceneDelegate.swift delete mode 100644 MyProject/Services/.gitkeep delete mode 100644 MyProject/ViewModels/.gitkeep delete mode 100644 MyProject/Views/ViewController.swift delete mode 100644 MyProjectTests/MyProjectTests.swift delete mode 100644 MyProjectUITests/MyProjectUITests.swift diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d067199..8aa33c1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,21 +25,23 @@ jobs: - name: Auto-configure if unconfigured run: | # Detect if this is an unconfigured template - if grep -q "{{PROJECT_NAME}}" project.yml 2>/dev/null || [ ! -f project.yml ]; then - echo "๐Ÿ”ง Unconfigured template detected - running setup with test configuration" + if grep -q "{{PROJECT_NAME}}" project.yml 2>/dev/null; then + echo "๐Ÿ”ง Unconfigured template detected - generating minimal test project" ./scripts/setup.sh \ --project-name "CITestApp" \ --bundle-id-root "com.ci.test" \ --deployment-target 18.0 \ --swift-version 6.2 \ --test-framework "swift-testing" \ + --structure mvvm \ + --generate-minimal \ --no-git-hooks \ --no-commit \ --skip-brew \ --force - echo "โœ… Template auto-configured for CI" + echo "โœ… Template auto-configured for CI testing" else - echo "โœ… Project already configured" + echo "โœ… Project already configured (derived repository)" fi - name: Cache Homebrew packages diff --git a/CLAUDE.md b/CLAUDE.md index 5c45fce..a584149 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -25,6 +25,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co This is a **Swift iOS project template** that uses: - **XcodeGen**: Project files generated from `project.yml` configuration, not manually managed - **In-Place Configuration**: Files contain `{{PLACEHOLDERS}}` that get replaced during setup +- **Dual Mode Setup**: Adopt existing Xcode projects OR generate minimal projects - **Script Automation**: Comprehensive bash script ecosystem for all development tasks - **Quality-First**: Built-in SwiftLint, SwiftFormat, and pre-commit hooks - **Unified CI**: Same workflow runs on template repo and generated projects @@ -32,31 +33,49 @@ This is a **Swift iOS project template** that uses: ### Core Components - **`scripts/` Directory**: Contains all development automation scripts with helper functions in `_helpers.sh` - **`project.yml`**: XcodeGen configuration with placeholders like `{{PROJECT_NAME}}` -- **`MyProject/`**: Placeholder project directory that gets renamed during setup - **`simulator.yml`**: Auto-generated simulator configuration for tests - **`.github/workflows/ci.yml`**: Unified CI workflow that auto-configures if needed - **Brewfile**: Manages all development tool dependencies (yq, jq, xcodegen, swiftlint, etc.) -### Setup Flow -1. User runs `./scripts/setup.sh --project-name MyApp` -2. Script does in-place replacement of `{{PLACEHOLDERS}}` in all files -3. Renames `MyProject/` โ†’ `MyApp/`, `MyProjectTests/` โ†’ `MyAppTests/`, etc. +### Setup Modes + +**ADOPT Mode (Primary Workflow - Xcode-First)** +1. Create project in Xcode with your preferred template +2. Clone template repository into project directory +3. Run `./scripts/setup.sh --project-name YourApp` +4. Script detects existing .xcodeproj and configures tooling around it +5. Optional: Create directory structure with `--structure mvvm` + +**GENERATE Mode (Secondary Workflow - Quick Start)** +1. Clone template repository +2. Run `./scripts/setup.sh --project-name YourApp --generate-minimal` +3. Script creates minimal Swift files and directory structure 4. Runs `xcodegen` to create `.xcodeproj` from configured `project.yml` 5. Configures simulators and installs pre-commit hooks ## Project Generation and Management ### Using This Template -This repository is a **template repository** with placeholder files. To create a new project: -1. Use "Use this template" on GitHub or clone and rename -2. Run `./scripts/setup.sh --project-name YourApp` -3. Setup script replaces placeholders and renames directories in-place + +**Primary Workflow (Xcode-First - Recommended)** +1. Create new project in Xcode using any template (SwiftUI App, UIKit, etc.) +2. Clone this template repository into the same directory +3. Run `./scripts/setup.sh --project-name YourApp` +4. Script adopts your Xcode project and adds tooling/automation +5. Optional: Use `--structure mvvm` to create directory structure + +**Secondary Workflow (Quick Start)** +1. Clone this template repository +2. Run `./scripts/setup.sh --project-name YourApp --generate-minimal` +3. Script generates minimal project and configures everything 4. Result: Configured project with `YourApp/`, `YourAppTests/`, `YourAppUITests/` -### After Project Generation +### After Setup - **Always run `xcodegen`** after modifying `project.yml` or adding/removing files - Use scripts for all development tasks rather than Xcode's built-in build/test -- Generated projects follow MVVM architecture with Models/, Views/, ViewModels/, Services/, Extensions/, Helpers/ +- Directory structure is optional: use `--structure mvvm`, `--structure clean`, or `--structure none` +- In ADOPT mode, script works with your existing Xcode project structure +- In GENERATE mode, creates minimal UIKit project with configurable structure ## Configuration Files @@ -133,7 +152,14 @@ All tools installed via Brewfile: yq, jq, xcodegen, swiftlint, swiftformat, xcbe ### CI Workflow 1. Checkout code 2. Detect if project is configured (check for `{{PROJECT_NAME}}` in project.yml) -3. If unconfigured: run `./scripts/setup.sh` with test parameters +3. If unconfigured: run `./scripts/setup.sh --generate-minimal` with test parameters 4. Install dependencies via Brewfile -5. Generate Xcode project with xcodegen -6. Run `./scripts/preflight.sh` (format, lint, build, test) \ No newline at end of file +5. Generate Xcode project with xcodegen (or use existing in adopt mode) +6. Run `./scripts/preflight.sh` (format, lint, build, test) + +### Template Testing +The template repository's CI automatically: +1. Detects it's unconfigured (sees `{{PLACEHOLDERS}}`) +2. Runs `setup.sh --generate-minimal` to create test project +3. Runs full preflight validation +4. Ensures template works before anyone uses it \ No newline at end of file diff --git a/MyProject/AppDelegate.swift b/MyProject/AppDelegate.swift deleted file mode 100644 index d56b516..0000000 --- a/MyProject/AppDelegate.swift +++ /dev/null @@ -1,37 +0,0 @@ -import UIKit - -@main -class AppDelegate: UIResponder, UIApplicationDelegate { - func application( - _ application: UIApplication, - didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? - ) -> Bool { - // Override point for customization after application launch. - return true - } - - // MARK: UISceneSession Lifecycle - - func application( - _ application: UIApplication, - configurationForConnecting connectingSceneSession: UISceneSession, - options: UIScene.ConnectionOptions - ) -> UISceneConfiguration { - // Called when a new scene session is being created. - // Use this method to select a configuration to create the new scene with. - return UISceneConfiguration( - name: "Default Configuration", - sessionRole: connectingSceneSession.role - ) - } - - func application( - _ application: UIApplication, - didDiscardSceneSessions sceneSessions: Set - ) { - // Called when the user discards a scene session. - // If any sessions were discarded while the application was not running, - // this will be called shortly after application:didFinishLaunchingWithOptions. - // Use this method to release any resources that were specific to the discarded scenes. - } -} diff --git a/MyProject/Extensions/.gitkeep b/MyProject/Extensions/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/MyProject/Helpers/.gitkeep b/MyProject/Helpers/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/MyProject/Info.plist b/MyProject/Info.plist deleted file mode 100644 index 0eb786d..0000000 --- a/MyProject/Info.plist +++ /dev/null @@ -1,23 +0,0 @@ - - - - - UIApplicationSceneManifest - - UIApplicationSupportsMultipleScenes - - UISceneConfigurations - - UIWindowSceneSessionRoleApplication - - - UISceneConfigurationName - Default Configuration - UISceneDelegateClassName - $(PRODUCT_MODULE_NAME).SceneDelegate - - - - - - diff --git a/MyProject/Models/.gitkeep b/MyProject/Models/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/MyProject/Resources/.gitkeep b/MyProject/Resources/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/MyProject/SceneDelegate.swift b/MyProject/SceneDelegate.swift deleted file mode 100644 index 1316b8c..0000000 --- a/MyProject/SceneDelegate.swift +++ /dev/null @@ -1,50 +0,0 @@ -import UIKit - -class SceneDelegate: UIResponder, UIWindowSceneDelegate { - var window: UIWindow? - - func scene( - _ scene: UIScene, - willConnectTo session: UISceneSession, - options connectionOptions: UIScene.ConnectionOptions - ) { - // Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`. - // If using a storyboard, the `window` property will automatically be initialized and attached to the scene. - // This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead). - guard let windowScene = (scene as? UIWindowScene) else { return } - - let window = UIWindow(windowScene: windowScene) - let viewController = ViewController() - window.rootViewController = viewController - window.makeKeyAndVisible() - self.window = window - } - - func sceneDidDisconnect(_ scene: UIScene) { - // Called as the scene is being released by the system. - // This occurs shortly after the scene enters the background, or when its session is discarded. - // Release any resources associated with this scene that can be re-created the next time the scene connects. - // The scene may re-connect later, as its session was not necessarily discarded (see `application:didDiscardSceneSessions` instead). - } - - func sceneDidBecomeActive(_ scene: UIScene) { - // Called when the scene has moved from an inactive state to an active state. - // Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive. - } - - func sceneWillResignActive(_ scene: UIScene) { - // Called when the scene will move from an active state to an inactive state. - // This may occur due to temporary interruptions (ex. an incoming phone call). - } - - func sceneWillEnterForeground(_ scene: UIScene) { - // Called as the scene transitions from the background to the foreground. - // Use this method to undo the changes made on entering the background. - } - - func sceneDidEnterBackground(_ scene: UIScene) { - // Called as the scene transitions from the foreground to the background. - // Use this method to save data, release shared resources, and store enough scene-specific state information - // to restore the scene back to its current state. - } -} diff --git a/MyProject/Services/.gitkeep b/MyProject/Services/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/MyProject/ViewModels/.gitkeep b/MyProject/ViewModels/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/MyProject/Views/ViewController.swift b/MyProject/Views/ViewController.swift deleted file mode 100644 index d471336..0000000 --- a/MyProject/Views/ViewController.swift +++ /dev/null @@ -1,21 +0,0 @@ -import UIKit - -class ViewController: UIViewController { - override func viewDidLoad() { - super.viewDidLoad() - view.backgroundColor = .systemBackground - - let label = UILabel() - label.text = "Hello, {{PROJECT_NAME}}!" - label.font = .preferredFont(forTextStyle: .largeTitle) - label.textAlignment = .center - label.translatesAutoresizingMaskIntoConstraints = false - - view.addSubview(label) - - NSLayoutConstraint.activate([ - label.centerXAnchor.constraint(equalTo: view.centerXAnchor), - label.centerYAnchor.constraint(equalTo: view.centerYAnchor), - ]) - } -} diff --git a/MyProjectTests/MyProjectTests.swift b/MyProjectTests/MyProjectTests.swift deleted file mode 100644 index 183a1af..0000000 --- a/MyProjectTests/MyProjectTests.swift +++ /dev/null @@ -1,9 +0,0 @@ -import Testing -@testable import MyProject - -struct MyProjectTests { - @Test func exampleTest() async throws { - // Write your test here - #expect(true) - } -} diff --git a/MyProjectUITests/MyProjectUITests.swift b/MyProjectUITests/MyProjectUITests.swift deleted file mode 100644 index cdef782..0000000 --- a/MyProjectUITests/MyProjectUITests.swift +++ /dev/null @@ -1,15 +0,0 @@ -import XCTest - -final class MyProjectUITests: XCTestCase { - override func setUpWithError() throws { - continueAfterFailure = false - } - - func testExample() throws { - let app = XCUIApplication() - app.launch() - - // Verify the app launched successfully - XCTAssertTrue(app.exists) - } -} diff --git a/scripts/setup.sh b/scripts/setup.sh index f4cec9b..f6de16b 100755 --- a/scripts/setup.sh +++ b/scripts/setup.sh @@ -22,37 +22,72 @@ USE_GIT_HOOKS=true CREATE_INITIAL_COMMIT=true FORCE_OVERWRITE=false SKIP_BREW=false +GENERATE_MINIMAL=false +STRUCTURE="mvvm" # mvvm, clean, or none +PROJECT_MODE="" # Will be set to "adopt" or "generate" show_help() { cat < [OPTIONS] -OPTIONS: +PRIMARY OPTIONS: --project-name Project name (required) + --generate-minimal Create minimal project (default: adopt existing .xcodeproj) + --structure Directory structure: mvvm, clean, or none (default: mvvm) + +CONFIGURATION OPTIONS: --bundle-id-root Bundle identifier root (default: $BUNDLE_ID_ROOT) --deployment-target iOS deployment target (default: $DEPLOYMENT_TARGET) --swift-version Swift version (default: $SWIFT_VERSION) --test-framework Test framework: swift-testing or xctest (default: $TEST_FRAMEWORK) --source-language Source language (default: $SOURCE_LANGUAGE) + +PROJECT OPTIONS: + --public Make this a public project + --private Make this a private project (default) + +GIT OPTIONS: --git-hooks Enable git pre-commit hooks (default) --no-git-hooks Disable git pre-commit hooks --commit Create initial git commit (default) --no-commit Skip initial git commit - --public Make this a public project - --private Make this a private project (default) + +OTHER OPTIONS: --force Overwrite existing files without prompting --skip-brew Skip Homebrew dependency installation --help Show this help message EXAMPLES: + # Adopt existing Xcode project (primary workflow) $0 --project-name "MyApp" + + # Generate minimal project for quick start + $0 --project-name "MyApp" --generate-minimal + + # Generate with clean structure (no MVVM directories) + $0 --project-name "MyApp" --generate-minimal --structure clean + + # Public project with custom config $0 --project-name "MyApp" --public --deployment-target 17.0 +WORKFLOWS: + 1. Xcode-first (recommended): + - Create project in Xcode + - Clone template into project directory + - Run: ./scripts/setup.sh --project-name YourApp + + 2. Template-first (quick start): + - Clone template + - Run: ./scripts/setup.sh --project-name YourApp --generate-minimal + - Open generated .xcodeproj in Xcode + EOF } @@ -115,6 +150,14 @@ parse_arguments() { SKIP_BREW=true shift ;; + --generate-minimal) + GENERATE_MINIMAL=true + shift + ;; + --structure) + STRUCTURE="$2" + shift 2 + ;; --help|-h) show_help exit 0 @@ -126,6 +169,12 @@ parse_arguments() { ;; esac done + + # Validate structure option + if [[ "$STRUCTURE" != "mvvm" && "$STRUCTURE" != "clean" && "$STRUCTURE" != "none" ]]; then + log_error "Invalid structure: $STRUCTURE. Must be 'mvvm', 'clean', or 'none'" + exit 1 + fi } validate_project_name() { @@ -227,23 +276,230 @@ replace_placeholders() { log_success "Placeholder replacement complete" } -rename_directories() { - log_info "Renaming project directories..." +detect_project_mode() { + log_info "Detecting project mode..." + + # Check if .xcodeproj exists + local xcodeproj_path="${PROJECT_NAME}.xcodeproj" + + if [[ -d "$xcodeproj_path" ]]; then + if [[ "$GENERATE_MINIMAL" == true ]]; then + log_error "Project already exists at $xcodeproj_path" + log_error "Cannot use --generate-minimal with existing project" + exit 1 + fi + PROJECT_MODE="adopt" + log_info "Found existing project: $xcodeproj_path" + log_info "Mode: ADOPT" + else + if [[ "$GENERATE_MINIMAL" == false ]]; then + log_error "No existing project found: $xcodeproj_path" + echo "" + log_info "To create a new project, use one of these options:" + echo " 1. Create project in Xcode first, then run setup again" + echo " 2. Use --generate-minimal flag to create minimal project" + echo "" + echo "Example: $0 --project-name $PROJECT_NAME --generate-minimal" + exit 1 + fi + PROJECT_MODE="generate" + log_info "No existing project found" + log_info "Mode: GENERATE" + fi + + echo +} + +create_directory_structure() { + local base_dir="$1" + + log_info "Creating directory structure: $STRUCTURE" + + case "$STRUCTURE" in + mvvm) + mkdir -p "$base_dir"/{Models,Views,ViewModels,Services,Extensions,Helpers,Resources} + touch "$base_dir"/{Models,ViewModels,Services,Extensions,Helpers}/.gitkeep + log_success "Created MVVM directory structure" + ;; + clean) + mkdir -p "$base_dir/Resources" + log_success "Created clean directory structure" + ;; + none) + mkdir -p "$base_dir" + log_success "Created base directory" + ;; + esac +} + +generate_minimal_project() { + log_info "Generating minimal project..." + + # Create main app directory + create_directory_structure "$PROJECT_NAME" + + # Create AppDelegate.swift + cat > "$PROJECT_NAME/AppDelegate.swift" <<'EOF' +import UIKit + +@main +class AppDelegate: UIResponder, UIApplicationDelegate { + func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + return true + } + + func application( + _ application: UIApplication, + configurationForConnecting connectingSceneSession: UISceneSession, + options: UIScene.ConnectionOptions + ) -> UISceneConfiguration { + return UISceneConfiguration( + name: "Default Configuration", + sessionRole: connectingSceneSession.role + ) + } + + func application( + _ application: UIApplication, + didDiscardSceneSessions sceneSessions: Set + ) { + } +} +EOF + + # Create SceneDelegate.swift + cat > "$PROJECT_NAME/SceneDelegate.swift" <<'EOF' +import UIKit + +class SceneDelegate: UIResponder, UIWindowSceneDelegate { + var window: UIWindow? + + func scene( + _ scene: UIScene, + willConnectTo session: UISceneSession, + options connectionOptions: UIScene.ConnectionOptions + ) { + guard let windowScene = (scene as? UIWindowScene) else { return } + + let window = UIWindow(windowScene: windowScene) + let viewController = ViewController() + window.rootViewController = viewController + window.makeKeyAndVisible() + self.window = window + } +} +EOF - # Rename directories from MyProject to actual project name - if [[ -d "MyProject" ]]; then - mv "MyProject" "$PROJECT_NAME" - log_success "Renamed MyProject โ†’ $PROJECT_NAME" + # Create ViewController.swift in appropriate location + local view_path="$PROJECT_NAME" + if [[ "$STRUCTURE" == "mvvm" ]]; then + view_path="$PROJECT_NAME/Views" fi - if [[ -d "MyProjectTests" ]]; then - mv "MyProjectTests" "${PROJECT_NAME}Tests" - log_success "Renamed MyProjectTests โ†’ ${PROJECT_NAME}Tests" + cat > "$view_path/ViewController.swift" < "$PROJECT_NAME/Info.plist" <<'EOF' + + + + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneConfigurationName + Default Configuration + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + + + + + + +EOF + + # Create test targets + mkdir -p "${PROJECT_NAME}Tests" + cat > "${PROJECT_NAME}Tests/${PROJECT_NAME}Tests.swift" < "${PROJECT_NAME}UITests/${PROJECT_NAME}UITests.swift" <