diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e149523f..65943539 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -131,3 +131,116 @@ jobs: - name: Staleness gate run: swift scripts/localizable-overload-sweep.swift --check + + # The scaffolder's verification (migration design §7), on every PR (ruled + # 2026-08-22; revisit if too draconian). Everything runs via xcodebuild: + # swift test/build hang on macOS runners (see run_tests matrix note), and + # the bootstrap targets are mac-host-gated so ubuntu never sees them. + # + # Layers: + # 1. Fast suite (config/tokens/emitter/consistency tests; skeletons + # self-skip without the env gate). + # 2. Walking skeletons: emit each shape, run its verification doors. + # TEST_RUNNER_ prefix forwards the gate to the test process. + # 3. Generation matrix: generated UI tests on real destinations — the + # automated BIG TEST. The least-proven leg (design §7): signing and + # simulator behavior on hosted runners may need iteration. + bootstrap_generation_matrix: + name: Scaffolder fast suite + skeletons + generated-app tests + runs-on: macos-latest + steps: + - uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: latest-stable + + - uses: actions/checkout@v2 + + - name: Install xcodegen + run: brew install xcodegen + + # The Verifier's inner xcodebuild (building the generated app) is a + # child process, so the outer -skipMacroValidation flag never reaches + # it; disable the fingerprint gate runner-wide instead. + - name: Skip macro fingerprint validation + run: defaults write com.apple.dt.Xcode IDESkipMacroFingerprintValidation -bool YES + + - name: Fast suite + run: | + set -o pipefail + xcrun xcodebuild -IDEClonedSourcePackagesDirPathOverride="$PWD/.dependencies" \ + -skipMacroValidation -skipPackagePluginValidation \ + -derivedDataPath "$PWD/.derivedData" \ + -scheme FOSUtilities-Package -destination "platform=macOS,arch=arm64,name=My Mac" \ + -only-testing:FOSMVVMBootstrapTests test | xcpretty || \ + xcrun xcodebuild -IDEClonedSourcePackagesDirPathOverride="$PWD/.dependencies" \ + -skipMacroValidation -skipPackagePluginValidation \ + -derivedDataPath "$PWD/.derivedData" \ + -scheme FOSUtilities-Package -destination "platform=macOS,arch=arm64,name=My Mac" \ + -only-testing:FOSMVVMBootstrapTests test + + - name: Walking skeletons + env: + TEST_RUNNER_FOSMVVM_BOOTSTRAP_SKELETONS: "1" + run: | + set -o pipefail + xcrun xcodebuild -IDEClonedSourcePackagesDirPathOverride="$PWD/.dependencies" \ + -skipMacroValidation -skipPackagePluginValidation \ + -derivedDataPath "$PWD/.derivedData" \ + -scheme FOSUtilities-Package -destination "platform=macOS,arch=arm64,name=My Mac" \ + -only-testing:FOSMVVMBootstrapTests/IntegrationTests test + + - name: Generate app shapes for the matrix + run: | + set -o pipefail + BIN="$PWD/.derivedData/Build/Products/Debug/fosmvvm-bootstrap" + if [ ! -x "$BIN" ]; then + xcrun xcodebuild -IDEClonedSourcePackagesDirPathOverride="$PWD/.dependencies" \ + -skipMacroValidation -skipPackagePluginValidation \ + -derivedDataPath "$PWD/.derivedData" \ + -scheme fosmvvm-bootstrap -destination "platform=macOS,arch=arm64,name=My Mac" build + fi + mkdir -p "$RUNNER_TEMP/matrix" + cat > "$RUNNER_TEMP/local.json" <<'CONFIG' + { "projectName": "LocalDemo", "shape": "localOnly", + "platforms": { "macOS": "14.0", "iOS": "17.0" }, + "bundleIdRoot": "com.example.localdemo", "teamId": "ABCDE12345" } + CONFIG + cat > "$RUNNER_TEMP/server.json" <<'CONFIG' + { "projectName": "ServerDemo", "shape": "clientServer", + "platforms": { "macOS": "14.0", "iOS": "17.0" }, + "bundleIdRoot": "com.example.serverdemo", "teamId": "ABCDE12345" } + CONFIG + "$BIN" new --config "$RUNNER_TEMP/local.json" --output "$RUNNER_TEMP/matrix/LocalDemo" + "$BIN" new --config "$RUNNER_TEMP/server.json" --output "$RUNNER_TEMP/matrix/ServerDemo" + + # CI simulators are ALWAYS the cold case: fresh boot, first install — + # exactly the measured first-run tap-flake condition (2026-08-22). + # Pre-booting warms the sim; -retry-tests-on-failure keeps a one-off + # settling flake visible in logs without redding the PR, while a real + # failure still fails both iterations. Durable fix is FOSTestingUI's + # first-interaction settle (follow-on) — then retries are belt-and-braces. + - name: Pre-boot iOS simulator + run: | + UDID=$(xcrun simctl list devices available | grep "iPhone 17 (" | head -1 | grep -oE "[0-9A-F-]{36}") + xcrun simctl boot "$UDID" || true + xcrun simctl bootstatus "$UDID" -b || true + + - name: Generated UI tests (iOS Simulator) + run: | + set -o pipefail + for APP in LocalDemo ServerDemo; do + xcrun xcodebuild -project "$RUNNER_TEMP/matrix/$APP/$APP.xcodeproj" \ + -scheme "$APP" -destination "platform=iOS Simulator,name=iPhone 17" \ + -retry-tests-on-failure -test-iterations 2 \ + CODE_SIGNING_ALLOWED=NO test + done + + - name: Generated UI tests (macOS) + run: | + set -o pipefail + for APP in LocalDemo ServerDemo; do + xcrun xcodebuild -project "$RUNNER_TEMP/matrix/$APP/$APP.xcodeproj" \ + -scheme "$APP" -destination "platform=macOS,arch=arm64,name=My Mac" \ + -retry-tests-on-failure -test-iterations 2 \ + CODE_SIGN_IDENTITY=- DEVELOPMENT_TEAM= CODE_SIGN_STYLE=Manual test + done diff --git a/Package.resolved b/Package.resolved index 1ae6039e..b8db5f42 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "a703f3345f604fe6ed352e783423843a4deadfc1abd42c508bff49a9098b4d68", + "originHash" : "d91e12c4bb3a3dd68ff2ff01174e38476bf749d8aa2d3ba80ed4951f1bbf388a", "pins" : [ { "identity" : "async-http-client", @@ -127,6 +127,15 @@ "version" : "1.2.1" } }, + { + "identity" : "swift-argument-parser", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-argument-parser.git", + "state" : { + "revision" : "6a52f3251125d74daf04fcbd5e6f08a75d074382", + "version" : "1.8.2" + } + }, { "identity" : "swift-asn1", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index cf4a71fe..38ef315d 100644 --- a/Package.swift +++ b/Package.swift @@ -58,6 +58,17 @@ let package = Package( )) #endif + #if os(macOS) + // The project scaffolder (see docs/superpowers/plans/ + // 2026-08-22-fosmvvm-bootstrap-migration-design.md). macOS-only tooling: + // it shells out to xcodegen/xcodebuild, so the manifest gates it to Mac + // hosts rather than guarding call sites in code. + result.append(.executable( + name: "fosmvvm-bootstrap", + targets: ["FOSMVVMBootstrapCLI"] + )) + #endif + return result }(), dependencies: { @@ -88,6 +99,10 @@ let package = Package( result.append(.package(url: "https://github.com/vapor/leaf-kit.git", .upToNextMajor(from: "1.11.0"))) #endif + #if os(macOS) + result.append(.package(url: "https://github.com/apple/swift-argument-parser.git", .upToNextMajor(from: "1.3.0"))) + #endif + return result }(), targets: { @@ -258,6 +273,27 @@ let package = Package( )) #endif + #if os(macOS) + result.append(.target( + name: "FOSMVVMBootstrap", + resources: [ + .copy("Templates") + ] + )) + result.append(.executableTarget( + name: "FOSMVVMBootstrapCLI", + dependencies: [ + "FOSMVVMBootstrap", + "FOSFoundation", + .product(name: "ArgumentParser", package: "swift-argument-parser") + ] + )) + result.append(.testTarget( + name: "FOSMVVMBootstrapTests", + dependencies: ["FOSMVVMBootstrap"] + )) + #endif + return result }(), swiftLanguageModes: [.v6] diff --git a/README.md b/README.md index e0e06ebd..04599eb9 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,18 @@ For guides, articles, and API documentation see the [docs]: https://swiftpackageindex.com/foscomputerservices/FOSUtilities/documentation/fosfoundation +## Create a project in one minute + +The package ships a scaffolder that generates a complete FOSMVVM application: a SwiftUI app (`localOnly`), a SwiftUI app plus a Vapor server sharing one ViewModel contract (`clientServer`), or a reusable ViewModel package (`sharedLibrary`). It needs Xcode and [xcodegen](https://github.com/yonaskolb/XcodeGen) (`brew install xcodegen`): + +```bash +git clone https://github.com/foscomputerservices/FOSUtilities.git +cd FOSUtilities +swift run fosmvvm-bootstrap new --output ~/MyApp +``` + +A short interview asks for the project name, shape, platforms, bundle id, and team (add `--verbose` to also print the equivalent `--config` JSON so reruns can skip the questions). Add `--verify` to build the generated project and run its tests on your machine (CI already verifies every release this way). Full details: [Creating a Project](https://swiftpackageindex.com/foscomputerservices/FOSUtilities/main/documentation/fosmvvm/creatingaproject). + ## FOSFoundation FOSFoundation is a library of protocols, patterns, types and routines that I have found generally useful in my projects. Support areas include: diff --git a/Sources/FOSFoundation/FOSFoundation.docc/FOSFoundation.md b/Sources/FOSFoundation/FOSFoundation.docc/FOSFoundation.md index ac85babd..9f4daca6 100644 --- a/Sources/FOSFoundation/FOSFoundation.docc/FOSFoundation.md +++ b/Sources/FOSFoundation/FOSFoundation.docc/FOSFoundation.md @@ -8,6 +8,8 @@ FOSFoundation is a library that provides extensions and patterns to Apple's Foun ``FOSFoundation`` provides the building blocks for its companion framework [FOSMVVM](https://swiftpackageindex.com/foscomputerservices/FOSUtilities/main/documentation/fosmvvm), which provides full client and server support for building [Model-View-ViewModel](https://w.wiki/4T5B) applications. +> Tip: Starting a new project? [Creating a Project](https://swiftpackageindex.com/foscomputerservices/FOSUtilities/main/documentation/fosmvvm/creatingaproject) scaffolds a complete, verified FOSMVVM application in about a minute. + ## Topics ### Networking diff --git a/Sources/FOSMVVM/FOSMVVM.docc/CreatingAProject.md b/Sources/FOSMVVM/FOSMVVM.docc/CreatingAProject.md new file mode 100644 index 00000000..6aeadc34 --- /dev/null +++ b/Sources/FOSMVVM/FOSMVVM.docc/CreatingAProject.md @@ -0,0 +1,60 @@ +# Creating a Project + +Scaffold a complete, verified FOSMVVM application in about a minute. + +## Overview + +FOSUtilities ships a scaffolder, `fosmvvm-bootstrap`, that generates a ready-to-run project wired to the patterns described throughout this documentation: ViewModels with localization, the operations seam, UI-test harnesses, and (for client-server projects) a Vapor + Fluent server with live view-model refresh. + +Three project shapes are supported: + +| Shape | What you get | +| --- | --- | +| `localOnly` | A SwiftUI app with client-hosted ViewModels. No server. | +| `clientServer` | A SwiftUI app plus a Vapor server sharing one ViewModel contract, including a live server-fetched screen backed by Fluent. | +| `sharedLibrary` | A Swift package that publishes a ViewModel contract for other apps to consume. | + +## Prerequisites + +- Xcode (the app shapes generate an Xcode project) +- [xcodegen](https://github.com/yonaskolb/XcodeGen): `brew install xcodegen` + +## Generate + +Clone the package and run the scaffolder. With no `--config`, a short interview collects everything it needs, validating each answer as it is given: + +```bash +git clone https://github.com/foscomputerservices/FOSUtilities.git +cd FOSUtilities +swift run fosmvvm-bootstrap new --output ~/MyApp +``` + +With `--verbose`, the interview also prints the equivalent `--config` JSON. Save it to rerun without the questions, or to script generation: + +```bash +swift run fosmvvm-bootstrap new --config myapp.json --output ~/MyApp +``` + +Configuration fields: + +- `projectName`: the app and target name. +- `shape`: `localOnly`, `clientServer`, or `sharedLibrary`. +- `platforms`: platform to minimum-version map. Versions must meet the FOSUtilities floors. +- `bundleIdRoot`: reverse-DNS root for bundle identifiers. App shapes only. +- `teamId`: your Apple Development Team identifier. App shapes only. + +## Verifying the generated project + +Every release of the scaffolder is verified by CI: the generated projects are built and their test suites run, including the UI tests. To additionally prove the skeleton on your machine, pass `--verify` — the scaffolder then builds the generated project and runs its tests before declaring success: `swift build`, `swift test` (for `clientServer` this includes a real Fluent create-and-refresh round trip on an in-memory database), and an `xcodebuild` build of the app. A successful verified run ends with: + +``` +✅ Walking skeleton verified green +``` + +Either way, generation finishes with a short checklist of the few steps tooling cannot do for you, such as committing the new repository and confirming code signing. + +> Note: The generated project depends on the FOSUtilities release the scaffolder shipped with, using `from:`, so later releases arrive with a normal package update. Running the scaffolder from a checkout between releases pins the most recently stamped release. + +## Diagnosing an existing project + +A `doctor` command that audits an existing project against the same rules the scaffolder uses is planned as an SPM command plugin. diff --git a/Sources/FOSMVVM/FOSMVVM.docc/FOSMVVM.md b/Sources/FOSMVVM/FOSMVVM.docc/FOSMVVM.md index 0f35d956..439a8ea7 100644 --- a/Sources/FOSMVVM/FOSMVVM.docc/FOSMVVM.md +++ b/Sources/FOSMVVM/FOSMVVM.docc/FOSMVVM.md @@ -44,6 +44,7 @@ To enable Xcode Cloud builds to build using macros check out this [Stack Overflo ## Topics +- - - - diff --git a/Sources/FOSMVVMBootstrap/BootstrapConfig.swift b/Sources/FOSMVVMBootstrap/BootstrapConfig.swift new file mode 100644 index 00000000..2adca7d6 --- /dev/null +++ b/Sources/FOSMVVMBootstrap/BootstrapConfig.swift @@ -0,0 +1,120 @@ +// BootstrapConfig.swift +import Foundation + +/// Typed input to the scaffolder. Loaded from JSON (`--config`); the +/// plugin skill authors this file from its conversational interview. +public struct BootstrapConfig: Codable, Sendable { + public let projectName: String + public let shape: ProjectShape + /// Platform → minimum version string ("14.0"). Must be at or above the + /// pinned FOSUtilities floor — `validate()` enforces this. + public let platforms: [TargetPlatform: String] + public let licenseHeader: String? + /// Reverse-DNS root for the app and derived per-module bundle ids + /// (e.g. "com.example.palettepress"). Required for app shapes. + public let bundleIdRoot: String? + /// Apple Development Team ID (10 chars). Required for app shapes. + public let teamId: String? + /// Which iOS device families the app targets when `platforms` includes + /// iOS: any of "iPhone", "iPad". Omitted means both. + public let iosDevices: [String]? + /// Run the unmodified iPad app on Apple Silicon Macs ("Designed for + /// iPad"). Only meaningful when iOS is present and macOS is not; + /// omitted means the platform default (allowed). + public let macDesignedForIPad: Bool? + /// Run the unmodified iPad app on Apple Vision ("Designed for iPad"). + /// Only meaningful when iOS is present and visionOS is not; omitted + /// means the platform default (allowed). + public let visionDesignedForIPad: Bool? + + public init( + projectName: String, + shape: ProjectShape, + platforms: [TargetPlatform: String], + licenseHeader: String? = nil, + bundleIdRoot: String? = nil, + teamId: String? = nil, + iosDevices: [String]? = nil, + macDesignedForIPad: Bool? = nil, + visionDesignedForIPad: Bool? = nil + ) { + self.projectName = projectName + self.shape = shape + self.platforms = platforms + self.licenseHeader = licenseHeader + self.bundleIdRoot = bundleIdRoot + self.teamId = teamId + self.iosDevices = iosDevices + self.macDesignedForIPad = macDesignedForIPad + self.visionDesignedForIPad = visionDesignedForIPad + } + + // Per-field predicates, public so an interactive front end can validate + // each answer as it is given; validate() composes the same rules. + public static func isValidProjectName(_ name: String) -> Bool { + name.range(of: "^[A-Za-z][A-Za-z0-9]*$", options: .regularExpression) != nil + } + + public static func isValidBundleIdRoot(_ root: String) -> Bool { + root.range(of: bundleIdRootPattern, options: .regularExpression) != nil + } + + public static func isValidTeamId(_ id: String) -> Bool { + id.range(of: teamIdPattern, options: .regularExpression) != nil + } + + /// Rejects configs that would generate broken or leak-prone projects. + public func validate() throws { + guard Self.isValidProjectName(projectName) else { + throw BootstrapConfigError.invalidProjectName(projectName) + } + guard !platforms.isEmpty else { + throw BootstrapConfigError.noPlatforms + } + try FOSPlatformFloor.validate(platforms: platforms) + + // App shapes ship a real Xcode app target, so they need a bundle-id + // root and a signing team; sharedLibrary is a plain SPM package and + // needs neither. + guard shape != .sharedLibrary else { return } + + guard let bundleIdRoot else { + throw BootstrapConfigError.missingBundleIdRoot + } + guard Self.isValidBundleIdRoot(bundleIdRoot) else { + throw BootstrapConfigError.invalidBundleIdRoot(bundleIdRoot) + } + + guard let teamId else { + throw BootstrapConfigError.missingTeamId + } + guard Self.isValidTeamId(teamId) else { + throw BootstrapConfigError.invalidTeamId(teamId) + } + + // App shapes need at least one destination the multiplatform app + // target can host (watchOS rides a separate target and cannot be the + // only platform yet). + let appDestinations: [TargetPlatform] = [.macOS, .iOS, .tvOS, .visionOS] + if shape == .localOnly || shape == .clientServer, + !appDestinations.contains(where: { platforms[$0] != nil }) { + throw BootstrapConfigError.noAppDestinations + } + } + + private static let bundleIdRootPattern = "^[a-z][a-z0-9-]*(\\.[a-z][a-z0-9-]*)+$" + private static let teamIdPattern = "^[A-Z0-9]{10}$" +} + +public enum BootstrapConfigError: Error, Equatable { + case invalidProjectName(String) + case noPlatforms + case belowFOSFloor(platform: TargetPlatform, asked: String, floor: String) + case platformUnsupportedByFOS(TargetPlatform) + case missingBundleIdRoot + case invalidBundleIdRoot(String) + case missingTeamId + case invalidTeamId(String) + case missingPlatform(TargetPlatform) + case noAppDestinations +} diff --git a/Sources/FOSMVVMBootstrap/Emitter.swift b/Sources/FOSMVVMBootstrap/Emitter.swift new file mode 100644 index 00000000..831d9405 --- /dev/null +++ b/Sources/FOSMVVMBootstrap/Emitter.swift @@ -0,0 +1,143 @@ +// Emitter.swift +import Foundation + +public enum EmitterError: Error, Equatable { + case outputDirectoryNotEmpty(String) + case templatesNotFound(String) + case shapeNotImplemented(String) +} + +extension EmitterError: CustomStringConvertible { + public var description: String { + switch self { + case let .outputDirectoryNotEmpty(path): + "output directory not empty: \(path)" + case let .templatesNotFound(detail): + "templates not found: \(detail)" + case let .shapeNotImplemented(shape): + "project shape not implemented by this version: \(shape)" + } + } +} + +/// Composes a greenfield FOSMVVM project on disk: +/// `try Emitter.emit(config: config, into: outputDir)` renders +/// `Templates/shared` (doctrine common to every shape) plus +/// `Templates/` into `outputDir`, returning the emitted relative +/// paths. Never overwrites — an existing non-empty `outputDir` throws +/// `EmitterError.outputDirectoryNotEmpty`, because bootstrap is +/// greenfield-only by design. +public enum Emitter { + /// Renders the shared + shape template trees into `outputDir` and + /// returns the emitted relative paths (sorted, for stable assertions): + /// `let paths = try Emitter.emit(config: config, into: url)`. + /// + /// Throws `EmitterError.shapeNotImplemented` when `config.shape` has no + /// template tree in this version, `EmitterError.outputDirectoryNotEmpty` + /// when `outputDir` already holds files, and `TemplateError.unrenderedToken` + /// if any emitted file or path would still contain a `{{TOKEN}}`. + @discardableResult + public static func emit(config: BootstrapConfig, into outputDir: URL) throws -> [String] { + let fm = FileManager.default + + // Shape guard — the VERY FIRST thing emit() does, before TokenSet.derive + // (which validates the config) and before any directory is created. A + // shape whose template tree is absent must fail cleanly here rather than + // part-emit the shared/ tree and blow up opaquely inside the generated + // project's build. Running before validation is deliberate: don't + // validate a config for a shape this version cannot emit. + guard let templatesRoot = Bundle.module.url(forResource: "Templates", withExtension: nil) else { + throw EmitterError.templatesNotFound("Templates not in Bundle.module") + } + let shapeDirName = shapeDirName(config.shape) + var isDir: ObjCBool = false + let shapeTemplateDir = templatesRoot.appendingPathComponent(shapeDirName) + guard fm.fileExists(atPath: shapeTemplateDir.path, isDirectory: &isDir), isDir.boolValue else { + throw EmitterError.shapeNotImplemented(shapeDirName) + } + + let tokens = try TokenSet.derive(from: config) + + if fm.fileExists(atPath: outputDir.path), + let existing = try? fm.contentsOfDirectory(atPath: outputDir.path), + !existing.isEmpty + { + throw EmitterError.outputDirectoryNotEmpty(outputDir.path) + } + try fm.createDirectory(at: outputDir, withIntermediateDirectories: true) + + var emitted: [String] = [] + for sourceDir in ["shared", shapeDirName] { + let root = templatesRoot.appendingPathComponent(sourceDir) + emitted += try emitTree(from: root, into: outputDir, tokens: tokens) + } + return emitted.sorted() + } + + private static func shapeDirName(_ shape: ProjectShape) -> String { + switch shape { + case .localOnly: "local-only" + case .clientServer: "client-server" + case .hybrid: "hybrid" + case .sharedLibrary: "shared-library" + } + } + + private static func emitTree(from root: URL, into outputDir: URL, tokens: [String: String]) throws -> [String] { + let fm = FileManager.default + // Standardize so /var vs /private/var symlink differences don't + // corrupt the prefix arithmetic that derives the relative path. + let root = root.standardizedFileURL + guard let enumerator = fm.enumerator( + at: root, + includingPropertiesForKeys: [.isRegularFileKey], + options: [] // include hidden files (.github, .swiftformat) + ) else { + throw EmitterError.templatesNotFound(root.path) + } + + var emitted: [String] = [] + for case let fileURL as URL in enumerator { + guard try fileURL.resourceValues(forKeys: [.isRegularFileKey]).isRegularFile == true else { continue } + let relative = String(fileURL.standardizedFileURL.path.dropFirst(root.path.count + 1)) + if relative.hasSuffix(".gitkeep") { + continue + } + + // A `.symlink` template emits a symbolic link, not a file: the destination + // is the path minus `.symlink`, and the template's (tokenized) contents are + // the link's target. Keeps a shared, continually-modifiable file in sync + // between two locations (e.g. TestConfiguration in the app + the UITests). + if relative.hasSuffix(".symlink") { + let rendered = try TemplateRenderer.render(relativePath: relative, tokens: tokens) + let linkRelative = String(rendered.dropLast(".symlink".count)) + let target = try TemplateRenderer.render( + content: String(contentsOf: fileURL, encoding: .utf8), + tokens: tokens + ).trimmingCharacters(in: .whitespacesAndNewlines) + let destination = outputDir.appendingPathComponent(linkRelative) + try fm.createDirectory( + at: destination.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try? fm.removeItem(at: destination) + try fm.createSymbolicLink(atPath: destination.path, withDestinationPath: target) + emitted.append(linkRelative) + continue + } + + let renderedRelative = try TemplateRenderer.render(relativePath: relative, tokens: tokens) + let content = try String(contentsOf: fileURL, encoding: .utf8) + let renderedContent = try TemplateRenderer.render(content: content, tokens: tokens) + + let destination = outputDir.appendingPathComponent(renderedRelative) + try fm.createDirectory( + at: destination.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try renderedContent.write(to: destination, atomically: true, encoding: .utf8) + emitted.append(renderedRelative) + } + return emitted + } +} diff --git a/Sources/FOSMVVMBootstrap/FOSPlatformFloor.swift b/Sources/FOSMVVMBootstrap/FOSPlatformFloor.swift new file mode 100644 index 00000000..a844426d --- /dev/null +++ b/Sources/FOSMVVMBootstrap/FOSPlatformFloor.swift @@ -0,0 +1,51 @@ +// FOSPlatformFloor.swift +import Foundation + +/// The pinned FOSUtilities release and its platform minimums. +/// +/// Under source-inclusion, a generated app's real deployment floor comes +/// from the FOS products it links — not from anything the generated repo +/// declares. Generation therefore validates every asked-for target +/// against these values, and `doctor` re-checks them for life. +public enum FOSPlatformFloor { + /// The release-stamped pin (see `Release`); kept as the emitter-facing name. + public static let pinnedFOSVersion = Release.version + + public static let floors: [TargetPlatform: String] = [ + .iOS: "17.0", + .macOS: "14.0", + .macCatalyst: "17.0", + .tvOS: "17.0", + .watchOS: "10.0", + .visionOS: "1.0" + ] + + /// Rejects any target below the pinned FOSUtilities minimum: + /// `try FOSPlatformFloor.validate(platforms: [.macOS: "14.0"])`. + /// + /// Throws `BootstrapConfigError.belowFOSFloor` when a target's asked-for + /// version is under its floor, or `.platformUnsupportedByFOS` for a + /// platform FOS does not ship. + public static func validate(platforms: [TargetPlatform: String]) throws { + for (platform, asked) in platforms { + guard let floor = floors[platform] else { + throw BootstrapConfigError.platformUnsupportedByFOS(platform) + } + if compareVersions(asked, floor) == .orderedAscending { + throw BootstrapConfigError.belowFOSFloor(platform: platform, asked: asked, floor: floor) + } + } + } + + /// Numeric, component-wise version comparison ("10.4" < "10.15"). + static func compareVersions(_ lhs: String, _ rhs: String) -> ComparisonResult { + let l = lhs.split(separator: ".").map { Int($0) ?? 0 } + let r = rhs.split(separator: ".").map { Int($0) ?? 0 } + for i in 0 ..< max(l.count, r.count) { + let a = i < l.count ? l[i] : 0 + let b = i < r.count ? r[i] : 0 + if a != b { return a < b ? .orderedAscending : .orderedDescending } + } + return .orderedSame + } +} diff --git a/Sources/FOSMVVMBootstrap/HandoffChecklist.swift b/Sources/FOSMVVMBootstrap/HandoffChecklist.swift new file mode 100644 index 00000000..4ed2634c --- /dev/null +++ b/Sources/FOSMVVMBootstrap/HandoffChecklist.swift @@ -0,0 +1,75 @@ +// HandoffChecklist.swift + +/// Human finishing steps the tooling structurally cannot do. +public enum HandoffChecklist { + /// The finishing steps to print after a project is scaffolded, keyed to + /// its shape — e.g. + /// `print(HandoffChecklist.text(for: config.shape, projectName: config.projectName))`. + /// For `.sharedLibrary` this lists the git-init, doctrine-read, and + /// first-ViewModel steps the scaffolder cannot perform for the user; + /// for `.localOnly` it also names the Xcode-only finishing moves (signing, ⌘U) + /// around the generated `.xcodeproj` (whose source folders are + /// already Xcode 16 synchronized folders — no manual conversion). + public static func text(for shape: ProjectShape, projectName: String) -> String { + switch shape { + case .sharedLibrary: + """ + + Next steps: + 1. git init && git add -A && git commit (the tool does not create the repo) + 2. Read CLAUDE.md and memory/ — settled doctrine ships with the project. + 3. Add ViewModels via the fosmvvm-viewmodel-generator skill. + """ + case .localOnly: + """ + + Next steps (things tooling structurally cannot do): + 1. git init && git add -A && git commit + 2. Open \(projectName).xcodeproj in Xcode: + a. The source folders are Xcode 16 synchronized folders — files + add/remove automatically, and the pbxproj does not churn in SCC. + project.yml stays the source of truth; regenerate any time with + `xcodegen generate`. + b. Confirm signing: your real DEVELOPMENT_TEAM on every target. + c. Run the test suite (⌘U). Note: `xcodebuild build-for-testing` + on macOS fails at Ld for this layout — a known Xcode + limitation (memory/macos-build-for-testing-faq.md). Tests run + fine from Xcode and on the iOS Simulator. + d. Add iOS/iPadOS destinations if wanted. + 3. Read CLAUDE.md and memory/ — settled doctrine ships with the project. + 4. Add screens via the fosmvvm-viewmodel-generator + + fosmvvm-swiftui-view-generator skills. + """ + case .clientServer: + """ + + Next steps (things tooling structurally cannot do): + 1. git init && git add -A && git commit + 2. Server: + a. swift run \(projectName)Server (boots on http://localhost:8080) + b. It seeds a demo Board+Card into a SQLite file. Set real + production / staging deployment URLs in \(projectName)App.swift. + 3. App — open \(projectName).xcodeproj in Xcode: + a. The source folders are Xcode 16 synchronized folders — files + add/remove automatically, and the shared "\(projectName)ViewModels" + folder is compiled INTO the app on purpose (the SPM lib compiles + it too, for the server). project.yml stays the source of truth; + regenerate any time with `xcodegen generate` — no per-file churn. + b. Attach \(projectName).xctestplan to the scheme's Test action + (Product → Scheme → Edit → Test → + the plan). XcodeGen can't + wire it with stable UUIDs; the scheme already lists the three + test targets, so ⌘U works either way. + c. Confirm signing: your real DEVELOPMENT_TEAM. + d. Run the app (Board tab fetches the live board; add a card and it + refreshes). Add iOS/iPadOS destinations if wanted. + 4. Read CLAUDE.md and memory/ — settled doctrine ships with the project. + 5. Add screens via the fosmvvm-viewmodel-generator + + fosmvvm-serverrequest-generator + fosmvvm-fluent-datamodel-generator + + fosmvvm-swiftui-view-generator skills. Replace the grant-all + SkeletonAuthProvider with a credential-scoped provider. + """ + case .hybrid: + "(finishing checklist for this shape arrives in a later plan)" + } + } +} diff --git a/Sources/FOSMVVMBootstrap/ProjectShape.swift b/Sources/FOSMVVMBootstrap/ProjectShape.swift new file mode 100644 index 00000000..44afe79f --- /dev/null +++ b/Sources/FOSMVVMBootstrap/ProjectShape.swift @@ -0,0 +1,20 @@ +// ProjectShape.swift + +/// The four canonical FOSMVVM project shapes. +public enum ProjectShape: String, Codable, CaseIterable, Sendable { + case localOnly + case clientServer + case hybrid + case sharedLibrary +} + +/// Platforms a generated project may declare. +/// +/// `CodingKeyRepresentable` makes `[TargetPlatform: String]` encode and +/// decode as a JSON *object* (`{ "macOS": "14.0" }`). Without it, +/// Foundation codes enum-keyed dictionaries as an array of alternating +/// pairs and the config-file decode fails. For a String-raw enum the +/// stdlib synthesizes the conformance — no custom Codable glue. +public enum TargetPlatform: String, Codable, CodingKeyRepresentable, CaseIterable, Sendable { + case iOS, macOS, macCatalyst, tvOS, watchOS, visionOS +} diff --git a/Sources/FOSMVVMBootstrap/Release.swift b/Sources/FOSMVVMBootstrap/Release.swift new file mode 100644 index 00000000..17b07189 --- /dev/null +++ b/Sources/FOSMVVMBootstrap/Release.swift @@ -0,0 +1,13 @@ +// Release.swift + +/// The FOSUtilities release this scaffolder ships with. +/// +/// Generated projects pin FOSUtilities `from:` this version, and the CLI reports +/// it as its own version — the scaffolder and the framework release together. +/// +/// RELEASE RITUAL: the CHANGELOG stamp commit updates this constant. The +/// release-stamp test in FOSMVVMBootstrapTests compares it against the topmost +/// stamped CHANGELOG release and fails CI when they disagree. +public enum Release { + public static let version = "0.13.3" +} diff --git a/Sources/FOSMVVMBootstrap/TemplateRenderer.swift b/Sources/FOSMVVMBootstrap/TemplateRenderer.swift new file mode 100644 index 00000000..ab8c346e --- /dev/null +++ b/Sources/FOSMVVMBootstrap/TemplateRenderer.swift @@ -0,0 +1,44 @@ +// TemplateRenderer.swift +import Foundation + +public enum TemplateError: Error, Equatable { + case unrenderedToken(token: String, context: String) +} + +/// Strict `{{TOKEN}}` substitution. No logic, no loops, no filters — +/// derivation happens in typed Swift (TokenSet), never in templates. +/// Any token left unrendered is a fatal error: the tool must never +/// emit a file containing `{{`. +public enum TemplateRenderer { + /// Substitutes every `{{TOKEN}}` in `content` with its value: + /// `try TemplateRenderer.render(content: "hi {{NAME}}", tokens: ["NAME": "Sam"]) // "hi Sam"`. + /// + /// Throws `TemplateError.unrenderedToken` if any `{{TOKEN}}` remains + /// after substitution — a generated file must never contain `{{`. + public static func render(content: String, tokens: [String: String]) throws -> String { + var out = content + for (key, value) in tokens { + out = out.replacingOccurrences(of: "{{\(key)}}", with: value) + } + if let range = out.range(of: #"\{\{[A-Z0-9_]+\}\}"#, options: .regularExpression) { + let token = String(out[range]) + let lineRange = out.lineRange(for: range) + let context = out[lineRange].trimmingCharacters(in: .whitespacesAndNewlines) + throw TemplateError.unrenderedToken(token: token, context: context) + } + return out + } + + /// Renders a template path, then drops a trailing `.tmpl`: + /// `try TemplateRenderer.render(relativePath: "Sources/{{NAME}}/App.swift.tmpl", tokens: ["NAME": "Sam"]) // "Sources/Sam/App.swift"`. + /// + /// Throws `TemplateError.unrenderedToken` if any `{{TOKEN}}` remains + /// in the path after substitution. + public static func render(relativePath: String, tokens: [String: String]) throws -> String { + var path = try render(content: relativePath, tokens: tokens) + if path.hasSuffix(".tmpl") { + path = String(path.dropLast(".tmpl".count)) + } + return path + } +} diff --git a/Sources/FOSMVVMBootstrap/Templates/client-server/Package.swift.tmpl b/Sources/FOSMVVMBootstrap/Templates/client-server/Package.swift.tmpl new file mode 100644 index 00000000..34d4b57d --- /dev/null +++ b/Sources/FOSMVVMBootstrap/Templates/client-server/Package.swift.tmpl @@ -0,0 +1,89 @@ +// swift-tools-version: 6.0 +import PackageDescription + +let package = Package( + name: "{{PROJECT_NAME}}", + platforms: [ + .macOS("{{MACOS_DEPLOYMENT}}") + ], + products: [ + .library(name: "{{PROJECT_NAME}}Foundation", targets: ["{{PROJECT_NAME}}Foundation"]), + .library(name: "{{PROJECT_NAME}}ViewModels", targets: ["{{PROJECT_NAME}}ViewModels"]), + .executable(name: "{{PROJECT_NAME}}Server", targets: ["{{PROJECT_NAME}}Server"]) + ], + dependencies: [ + .package( + url: "https://github.com/foscomputerservices/FOSUtilities.git", + from: "{{FOS_VERSION}}" + ), + .package(url: "https://github.com/vapor/vapor.git", from: "4.102.0"), + .package(url: "https://github.com/vapor/fluent.git", from: "4.9.0"), + .package(url: "https://github.com/vapor/fluent-sqlite-driver.git", from: "4.6.0") + ], + targets: [ + // Shared foundation — the version handshake (SystemVersion) and other base + // types the server needs WITHOUT importing the display contract. FOSFoundation + // only. Both the client app and the server depend on this. + .target( + name: "{{PROJECT_NAME}}Foundation", + dependencies: [ + .product(name: "FOSFoundation", package: "FOSUtilities") + ] + ), + // Shared contract — client + server both compile this. FOS only; NO Vapor, + // NO Fluent. Display-agnostic: the app source-includes this folder and any + // future renderer (Leaf/Ignite/React) links this product. + .target( + name: "{{PROJECT_NAME}}ViewModels", + dependencies: [ + .product(name: "FOSFoundation", package: "FOSUtilities"), + .product(name: "FOSMVVM", package: "FOSUtilities") + ] + ), + .executableTarget( + name: "{{PROJECT_NAME}}Server", + dependencies: [ + .byName(name: "{{PROJECT_NAME}}Foundation"), + .byName(name: "{{PROJECT_NAME}}ViewModels"), + .product(name: "Vapor", package: "vapor"), + .product(name: "Fluent", package: "fluent"), + .product(name: "FluentSQLiteDriver", package: "fluent-sqlite-driver"), + .product(name: "FOSFoundation", package: "FOSUtilities"), + .product(name: "FOSMVVM", package: "FOSUtilities"), + .product(name: "FOSMVVMVapor", package: "FOSUtilities") + ], + resources: [ + .copy("../Resources") + ] + ), + .testTarget( + name: "{{PROJECT_NAME}}ViewModelsTests", + dependencies: [ + .target(name: "{{PROJECT_NAME}}ViewModels"), + .product(name: "FOSFoundation", package: "FOSUtilities"), + .product(name: "FOSMVVM", package: "FOSUtilities"), + .product(name: "FOSTesting", package: "FOSUtilities") + ], + resources: [ + .copy("../../Sources/Resources") + ] + ), + .testTarget( + name: "{{PROJECT_NAME}}ServerTests", + dependencies: [ + .target(name: "{{PROJECT_NAME}}Server"), + .product(name: "Vapor", package: "vapor"), + .product(name: "Fluent", package: "fluent"), + .product(name: "FluentSQLiteDriver", package: "fluent-sqlite-driver"), + .product(name: "FOSFoundation", package: "FOSUtilities"), + .product(name: "FOSMVVM", package: "FOSUtilities"), + .product(name: "FOSMVVMVapor", package: "FOSUtilities"), + .product(name: "FOSTesting", package: "FOSUtilities"), + .product(name: "FOSTestingVapor", package: "FOSUtilities") + ], + resources: [ + .copy("../../Sources/Resources") + ] + ) + ] +) diff --git a/Sources/FOSMVVMBootstrap/Templates/client-server/README.md.tmpl b/Sources/FOSMVVMBootstrap/Templates/client-server/README.md.tmpl new file mode 100644 index 00000000..4705f9d7 --- /dev/null +++ b/Sources/FOSMVVMBootstrap/Templates/client-server/README.md.tmpl @@ -0,0 +1,56 @@ +# {{PROJECT_NAME}} + +A **client-server** FOSMVVM project (the full-featured shape: two doors, a live +server-fetched screen backed by Fluent, and a client-hosted screen). + +## Shape + +**Root SPM package** (`Package.swift`): +- **`{{PROJECT_NAME}}ViewModels`** — the shared contract (ViewModels + requests). + FOS only, **no Vapor/Fluent, display-agnostic**. The app source-includes this + same folder; a future renderer (Leaf/Ignite/React) would link this product. +- **`{{PROJECT_NAME}}Server`** — a Vapor executable. Fluent (SQLite), the + `Board`/`Card` models, the server-side projection + write seam, server-hosted + localization, and live invalidation. + +**App project** (`project.yml` → `{{PROJECT_NAME}}.xcodeproj`): +- **`SPMLibraries`** — the umbrella framework; FOS enters through it **once**, so + types have one identity across the app and its tests. Nothing else links FOS + directly. +- **`{{PROJECT_NAME}}ClientViewModels`** — the client-hosted door: `.clientHostedFactory` + ViewModels with their own bundled localization (`Bundle(for:)`). +- **`{{PROJECT_NAME}}`** — the SwiftUI app. Links + embeds the umbrella + the + client framework; **source-includes** the shared contract. + +## The two doors + +- **Server-fetched (live + writable):** `BoardViewModel` is `@ViewModel(options: [.live])`; + the server projects it from Fluent `Card` records. `CreateCardRequest` adds a card + (`DataModelWriter`) and the framework **auto-refreshes** every open client — no + manual invalidation. The read/create root at the server's apex container + (`useApexContainerResolver`), so the client needs no board id. +- **Client-hosted:** `AboutViewModel` (no server round-trip); its `@LocalizedString`s + resolve on-device from `{{PROJECT_NAME}}ClientViewModels`'s bundle. +- **Correlation seam:** `CardViewModel.ownerTag` (server) and `AboutViewModel.localOwnerTag` + (client) share a key; the **view** joins them (`isMine(card:about:)`). + +## Verify + +- **Server (headless):** `swift build` then `swift test` — boots Fluent on + SQLite-in-memory, creates a card through the real pipeline, asserts the refreshed + board. No database server, no simulator. +- **App:** `swift run {{PROJECT_NAME}}Server` (boots on `http://localhost:8080`), + then open `{{PROJECT_NAME}}.xcodeproj` and run. + +## Extending + +- Add screens with the `fosmvvm-viewmodel-generator`, + `fosmvvm-serverrequest-generator`, `fosmvvm-fluent-datamodel-generator`, and + `fosmvvm-swiftui-view-generator` skills. +- Additional **display technologies** attach as new targets depending on + `{{PROJECT_NAME}}ViewModels` (the contract is display-agnostic) — see the + `fosmvvm-leaf-view-generator` / `fosmvvm-react-view-generator` skills. +- Replace the grant-all `SkeletonAuthProvider` with a credential-scoped provider + behind a middleware group. + +Read `CLAUDE.md` and `memory/` — the settled doctrine ships with the project. diff --git a/Sources/FOSMVVMBootstrap/Templates/client-server/Sources/Resources/ViewModels/BoardViewModel.yml b/Sources/FOSMVVMBootstrap/Templates/client-server/Sources/Resources/ViewModels/BoardViewModel.yml new file mode 100644 index 00000000..5a8d1caf --- /dev/null +++ b/Sources/FOSMVVMBootstrap/Templates/client-server/Sources/Resources/ViewModels/BoardViewModel.yml @@ -0,0 +1,15 @@ +en: + BoardViewModel: + title: "Demo Board" + addCardButtonTitle: "Add Card" + errorTitle: "An Error Occurred" + errorMessage: "The operation failed: %{error}" + dismissButtonLabel: "OK" + +es: + BoardViewModel: + title: "Tablero de Demostración" + addCardButtonTitle: "Agregar Tarjeta" + errorTitle: "Ocurrió un Error" + errorMessage: "La operación falló: %{error}" + dismissButtonLabel: "Aceptar" diff --git a/Sources/FOSMVVMBootstrap/Templates/client-server/Sources/SPMLibraries/SPMLibraries.swift.tmpl b/Sources/FOSMVVMBootstrap/Templates/client-server/Sources/SPMLibraries/SPMLibraries.swift.tmpl new file mode 100644 index 00000000..9d905e7b --- /dev/null +++ b/Sources/FOSMVVMBootstrap/Templates/client-server/Sources/SPMLibraries/SPMLibraries.swift.tmpl @@ -0,0 +1,26 @@ +// SPMLibraries.swift +{{LICENSE_HEADER}} +import Foundation + +// ### SPMLibraries — SPM Dependencies +// +// It is required that ALL external SPM products are linked into THIS +// framework and NOT directly into any other target of the project. +// +// Linking an SPM library statically into multiple targets compiles a +// separate copy of its types into each target. Swift's mangled type +// name carries the linking context, so the "same" type has a different +// runtime identity per target: TypeA != TypeA. `is` / `as?` / `==` / +// `===` fail across target boundaries, at runtime, far from the cause. +// This is a generic Xcode+SPM packaging bug — nothing to do with FOS — +// but FOS internals rely heavily on comparing types (e.g. +// FOSMVVM.localizingEncoder's `value as? (any ViewModel)`). +// +// One umbrella dynamic framework = one canonical copy = one shared +// type identity everywhere. See memory/spm-libraries-settled.md. +// +// Carve-out (ruled 2026-08-19): FOS testing products (FOSTesting, +// FOSTestingUI) stay OUT of this umbrella — it embeds in the shipping +// app, and testing frameworks must not ride along. Test targets link +// them directly; their testing types are never shared across targets, +// so the type-identity rule does not apply to them. diff --git a/Sources/FOSMVVMBootstrap/Templates/client-server/Sources/{{PROJECT_NAME}}/App/TestConfiguration.swift.tmpl b/Sources/FOSMVVMBootstrap/Templates/client-server/Sources/{{PROJECT_NAME}}/App/TestConfiguration.swift.tmpl new file mode 100644 index 00000000..67f5eabb --- /dev/null +++ b/Sources/FOSMVVMBootstrap/Templates/client-server/Sources/{{PROJECT_NAME}}/App/TestConfiguration.swift.tmpl @@ -0,0 +1,17 @@ +// TestConfiguration.swift +{{LICENSE_HEADER}} + +import Foundation + +/// Describes the various base configurations of ``AppState`` for testing purposes +/// +/// > This file is symbolically linked into the UITests target to share the type between the UI tests and +/// > the app. Using an enum eliminates the "stringly-typed" nature of communication +/// > between these points. +/// +/// > The number of configuration should be kept to an absolute minimum. If there is a tendency +/// > for the number of configurations to grow, the Views and ViewModels should be re-evaluated +/// > as most state should be in the ViewModel, not in AppState. +enum TestConfiguration: Codable { + case none +} diff --git a/Sources/FOSMVVMBootstrap/Templates/client-server/Sources/{{PROJECT_NAME}}/App/{{PROJECT_NAME}}App.swift.tmpl b/Sources/FOSMVVMBootstrap/Templates/client-server/Sources/{{PROJECT_NAME}}/App/{{PROJECT_NAME}}App.swift.tmpl new file mode 100644 index 00000000..b58e1396 --- /dev/null +++ b/Sources/FOSMVVMBootstrap/Templates/client-server/Sources/{{PROJECT_NAME}}/App/{{PROJECT_NAME}}App.swift.tmpl @@ -0,0 +1,85 @@ +// {{PROJECT_NAME}}App.swift +{{LICENSE_HEADER}} +import FOSFoundation +import FOSMVVM +import SwiftUI +import {{PROJECT_NAME}}ClientViewModels +import {{PROJECT_NAME}}Foundation + +// NOTE: no `import {{PROJECT_NAME}}ViewModels`. The shared contract folder is +// source-included into this app target, so those types (BoardViewModel, CardViewModel, +// the requests, currentApplicationVersion) are in THIS module. The client-hosted +// door (AboutViewModel) IS a separate framework, so it is imported above. +@main +struct {{PROJECT_NAME}}App: App { + @State private var mvvmEnv = makeMVVMEnvironment() + + var body: some Scene { + WindowGroup { + TabView { + BoardView.bind() + .tabItem { Text("Board") } + AboutView.bind(appState: .init(localOwnerTag: nil)) + .tabItem { Text("About") } + } + .testHost() + } + .environment(mvvmEnv) + } + + init() { + MVVMEnvironment.registerTestingViews() + } +} + +private extension {{PROJECT_NAME}}App { + @MainActor static func makeMVVMEnvironment() -> MVVMEnvironment { + // BOTH doors: `resourceBundles` (+ resourceDirectoryName "") host the + // client-hosted VMs' localization on-device; `deploymentURLs` point the + // server-fetched VMs at the local server. Server-fetched VMs arrive already + // localized; only the client-hosted ones resolve from the bundle here. + MVVMEnvironment( + currentVersion: .currentApplicationVersion, + appBundle: Bundle.main, + resourceBundles: [{{PROJECT_NAME}}ClientViewModels.localizationBundle], + resourceDirectoryName: "", + deploymentURLs: [ + .debug: URL(string: "http://localhost:8080")! + ] + ) + } +} + +extension ViewModelView { + // Both doors resolve their on-device localization from the client-hosted + // framework's bundle (resourceDirectoryName "" — Xcode flattens grouped + // resources to the bundle root). Server-fetched VMs (BoardView) arrive already + // localized in the running app; in a preview/stub their @LocalizedStrings simply + // fall through — the preview still exercises the view's shape and operations. + static func previewHost( + resourceDirectoryName: String = "", + locale: Locale = .current, + viewModel: VM = .stub(), + setStates: ((inout Self) -> Void)? = nil + ) -> some View { + previewHost( + bundle: {{PROJECT_NAME}}ClientViewModels.localizationBundle, + resourceDirectoryName: resourceDirectoryName, + locale: locale, + viewModel: viewModel, + setStates: setStates + ) + } +} + +private extension MVVMEnvironment { + // Every ViewModelView is listed here to enable individualized + // testing of each view. Registration must happen before the first + // render — testHost() resolves the view under test in init(). + @MainActor static func registerTestingViews() { + #if DEBUG + registerTestView(BoardView.self) + registerTestView(AboutView.self) + #endif + } +} diff --git a/Sources/FOSMVVMBootstrap/Templates/client-server/Sources/{{PROJECT_NAME}}/Correlation.swift.tmpl b/Sources/FOSMVVMBootstrap/Templates/client-server/Sources/{{PROJECT_NAME}}/Correlation.swift.tmpl new file mode 100644 index 00000000..aa11c488 --- /dev/null +++ b/Sources/FOSMVVMBootstrap/Templates/client-server/Sources/{{PROJECT_NAME}}/Correlation.swift.tmpl @@ -0,0 +1,16 @@ +// Correlation.swift +{{LICENSE_HEADER}} +import Foundation +import {{PROJECT_NAME}}ClientViewModels + +// The correlation seam, joined HERE at the view layer — not inside either VM. +// +// The server-fetched `CardViewModel` exposes `ownerTag` (a plain String key on its +// wire shape); the client-hosted `AboutViewModel` carries the same key as +// `localOwnerTag`. A card is "mine" when they match. Neither VM serves both doors — +// the app (this function) does the join. `CardViewModel` is source-included (this +// module); `AboutViewModel` comes from the client-hosted framework. +func isMine(card: CardViewModel, about: AboutViewModel) -> Bool { + guard let tag = card.ownerTag, let mine = about.localOwnerTag else { return false } + return tag == mine +} diff --git a/Sources/FOSMVVMBootstrap/Templates/client-server/Sources/{{PROJECT_NAME}}/Info.plist b/Sources/FOSMVVMBootstrap/Templates/client-server/Sources/{{PROJECT_NAME}}/Info.plist new file mode 100644 index 00000000..8c7eadd5 --- /dev/null +++ b/Sources/FOSMVVMBootstrap/Templates/client-server/Sources/{{PROJECT_NAME}}/Info.plist @@ -0,0 +1,27 @@ + + + + + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleName + $(PRODUCT_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSPrincipalClass + NSApplication + + UILaunchScreen + + + diff --git a/Sources/FOSMVVMBootstrap/Templates/client-server/Sources/{{PROJECT_NAME}}/Views/AboutView.swift.tmpl b/Sources/FOSMVVMBootstrap/Templates/client-server/Sources/{{PROJECT_NAME}}/Views/AboutView.swift.tmpl new file mode 100644 index 00000000..fbd86f0a --- /dev/null +++ b/Sources/FOSMVVMBootstrap/Templates/client-server/Sources/{{PROJECT_NAME}}/Views/AboutView.swift.tmpl @@ -0,0 +1,71 @@ +// AboutView.swift +{{LICENSE_HEADER}} +import FOSMVVM +import SwiftUI +import {{PROJECT_NAME}}ClientViewModels + +struct AboutView: ViewModelView { + @State private var isDetailsAlertVisible: Bool = false + + private let viewModel: AboutViewModel + private let operations: AboutViewModelOperations + + #if DEBUG + @State private var repaintToggle = false + #endif + + var body: some View { + VStack(spacing: 12) { + Text(viewModel.appName) + .font(.largeTitle) + .uiTestingIdentifier("appName") + Text(viewModel.tagline) + .uiTestingIdentifier("tagline") + + Button(viewModel.detailsButtonTitle, action: presentDetails) + .uiTestingIdentifier("presentDetailsButton") + } + .alert(viewModel.appName, isPresented: $isDetailsAlertVisible) { + // A plain button (no `role: .confirm`) — `.confirm` is macOS 26.0-only, + // and the skeleton targets the FOS floor. Add the role once you raise + // the deployment target. + Button(action: dismissDetails) { + Text(viewModel.okButtonTitle) + .uiTestingIdentifier("dismissDetailsButton") + } + } message: { + Text(viewModel.tagline) + } + .padding() + #if DEBUG + .testDataTransporter(viewModelOps: operations, repaintToggle: $repaintToggle) + #endif + } + + init(viewModel: AboutViewModel) { + self.viewModel = viewModel + self.operations = viewModel.operations + } +} + +private extension AboutView { + private func presentDetails() { + defer { toggleRepaint() } + operations.presentDetails(output: $isDetailsAlertVisible) + } + + private func dismissDetails() { + defer { toggleRepaint() } + operations.dismissDetails(output: $isDetailsAlertVisible) + } + + private func toggleRepaint() { + #if DEBUG + repaintToggle.toggle() + #endif + } +} + +#Preview { + AboutView.previewHost() +} diff --git a/Sources/FOSMVVMBootstrap/Templates/client-server/Sources/{{PROJECT_NAME}}/Views/BoardView.swift.tmpl b/Sources/FOSMVVMBootstrap/Templates/client-server/Sources/{{PROJECT_NAME}}/Views/BoardView.swift.tmpl new file mode 100644 index 00000000..dc03232d --- /dev/null +++ b/Sources/FOSMVVMBootstrap/Templates/client-server/Sources/{{PROJECT_NAME}}/Views/BoardView.swift.tmpl @@ -0,0 +1,70 @@ +// BoardView.swift +{{LICENSE_HEADER}} +import FOSMVVM +import SwiftUI + +// BoardViewModel + CardViewModel are source-included (this module) — no import. +struct BoardView: ViewModelView { + // The dispatch target for the server-backed operation, injected by the app. + @Environment(MVVMEnvironment.self) private var mvvmEnv + + private let viewModel: BoardViewModel + private let operations: BoardViewModelOperations + + // One error binding per screen: every async button deposits here, and one + // alert(error:) presents it. + @State private var error: Error? + + #if DEBUG + @State private var repaintToggle = false + #endif + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text(viewModel.title) + .font(.largeTitle) + .uiTestingIdentifier("boardTitle") + ForEach(viewModel.cards, id: \.vmId) { card in + Text(card.title) + } + + Button(viewModel.addCardButtonTitle, error: $error, action: addCard) + .uiTestingIdentifier("addCardButton") + } + .padding() + .alert( + error: $error, + title: viewModel.errorTitle, + message: viewModel.errorMessage, + dismissButtonLabel: viewModel.dismissButtonLabel + ) + #if DEBUG + .testDataTransporter(viewModelOps: operations, repaintToggle: $repaintToggle) + #endif + } + + init(viewModel: BoardViewModel) { + self.viewModel = viewModel + self.operations = viewModel.operations + } +} + +private extension BoardView { + // The async Button owns the Task and the catch: a thrown error lands in the + // screen's error binding. The op POSTs a CreateCardRequest; because + // BoardViewModel is [.live], the board refreshes itself once the write commits. + private func addCard() async throws { + defer { toggleRepaint() } + try await operations.createCard(title: "New Card", mvvmEnv: mvvmEnv) + } + + private func toggleRepaint() { + #if DEBUG + repaintToggle.toggle() + #endif + } +} + +#Preview { + BoardView.previewHost() +} diff --git a/Sources/FOSMVVMBootstrap/Templates/client-server/Sources/{{PROJECT_NAME}}/{{PROJECT_NAME}}.entitlements b/Sources/FOSMVVMBootstrap/Templates/client-server/Sources/{{PROJECT_NAME}}/{{PROJECT_NAME}}.entitlements new file mode 100644 index 00000000..1981043b --- /dev/null +++ b/Sources/FOSMVVMBootstrap/Templates/client-server/Sources/{{PROJECT_NAME}}/{{PROJECT_NAME}}.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.network.client + + + diff --git a/Sources/FOSMVVMBootstrap/Templates/client-server/Sources/{{PROJECT_NAME}}ClientViewModels/AboutViewModel.swift.tmpl b/Sources/FOSMVVMBootstrap/Templates/client-server/Sources/{{PROJECT_NAME}}ClientViewModels/AboutViewModel.swift.tmpl new file mode 100644 index 00000000..ea44ad30 --- /dev/null +++ b/Sources/FOSMVVMBootstrap/Templates/client-server/Sources/{{PROJECT_NAME}}ClientViewModels/AboutViewModel.swift.tmpl @@ -0,0 +1,57 @@ +// AboutViewModel.swift +{{LICENSE_HEADER}} +import FOSFoundation +import FOSMVVM +import Foundation + +/// A client-hosted ViewModel — hosted entirely on the client (there is no server +/// response to derive it from). `.clientHostedFactory` synthesizes the +/// `Request`/`AppState`/`model`; this file authors only the projected shape + a +/// `stub()`. Its `@LocalizedString`s resolve on-device from this framework's bundle. +/// +/// `localOwnerTag` is this client's half of the correlation seam: the server's +/// `CardViewModel.ownerTag` carries the same key, and the *view* joins them to mark +/// "my" cards. Never one VM serving both doors — the view does the join. +@ViewModel(options: [.clientHostedFactory]) +public struct AboutViewModel { + @LocalizedString public var appName + @LocalizedString public var tagline + @LocalizedString public var detailsButtonTitle + @LocalizedString public var okButtonTitle + + public let localOwnerTag: String? + + // MARK: Operations Access + // + // AboutView's operations are LOCAL — they mutate on-device view state (an alert + // flag), no server round trip. This is the client-hosted counterpart to + // BoardView's server-backed operations. See WelcomeViewModelOperations in the + // local-only shape for the same shape. + + private let isStub: Bool + + #if canImport(SwiftUI) + public var operations: any AboutViewModelOperations { + isStub ? AboutViewModelStubOps() : AboutViewModelOps() + } + #endif + + public var vmId = ViewModelId() + + public init(localOwnerTag: String? = nil) { + self.init(localOwnerTag: localOwnerTag, isStub: false) + } +} + +public extension AboutViewModel { + static func stub() -> Self { + .init(localOwnerTag: nil, isStub: true) + } +} + +extension AboutViewModel { + private init(localOwnerTag: String?, isStub: Bool) { + self.localOwnerTag = localOwnerTag + self.isStub = isStub + } +} diff --git a/Sources/FOSMVVMBootstrap/Templates/client-server/Sources/{{PROJECT_NAME}}ClientViewModels/AboutViewModelOperations.swift.tmpl b/Sources/FOSMVVMBootstrap/Templates/client-server/Sources/{{PROJECT_NAME}}ClientViewModels/AboutViewModelOperations.swift.tmpl new file mode 100644 index 00000000..d891b51f --- /dev/null +++ b/Sources/FOSMVVMBootstrap/Templates/client-server/Sources/{{PROJECT_NAME}}ClientViewModels/AboutViewModelOperations.swift.tmpl @@ -0,0 +1,59 @@ +// +{{LICENSE_HEADER}} +// AboutViewModelOperations.swift +// {{PROJECT_NAME}} +// +// Created by David Hunt on 8/8/26. +// + +import FOSFoundation +import FOSMVVM +import Foundation +#if canImport(SwiftUI) +import SwiftUI +#endif + +#if canImport(SwiftUI) + +// MARK: Operations +// +// LOCAL operations — they flip an on-device alert flag, no ServerRequest. Contrast +// with BoardViewModelOperations, whose createCard(_:) dispatches a CreateCardRequest. + +public protocol AboutViewModelOperations: ViewModelOperations { + func presentDetails(output detailsStatus: Binding) + func dismissDetails(output detailsStatus: Binding) +} + +public struct AboutViewModelOps: AboutViewModelOperations { + public init() {} + + public func presentDetails(output detailsStatus: Binding) { + detailsStatus.wrappedValue = true + } + public func dismissDetails(output detailsStatus: Binding) { + detailsStatus.wrappedValue = false + } +} + +public final class AboutViewModelStubOps: AboutViewModelOperations, @unchecked Sendable { + public private(set) var presentDetailsCalled: Bool + public private(set) var dismissDetailsCalled: Bool + + public func presentDetails(output detailsStatus: Binding) { + presentDetailsCalled = true + detailsStatus.wrappedValue = true + } + + public func dismissDetails(output detailsStatus: Binding) { + dismissDetailsCalled = true + detailsStatus.wrappedValue = false + } + + public init() { + self.presentDetailsCalled = false + self.dismissDetailsCalled = false + } +} + +#endif diff --git a/Sources/FOSMVVMBootstrap/Templates/client-server/Sources/{{PROJECT_NAME}}ClientViewModels/Resources/ViewModels/AboutViewModel.yml b/Sources/FOSMVVMBootstrap/Templates/client-server/Sources/{{PROJECT_NAME}}ClientViewModels/Resources/ViewModels/AboutViewModel.yml new file mode 100644 index 00000000..68f0d855 --- /dev/null +++ b/Sources/FOSMVVMBootstrap/Templates/client-server/Sources/{{PROJECT_NAME}}ClientViewModels/Resources/ViewModels/AboutViewModel.yml @@ -0,0 +1,13 @@ +en: + AboutViewModel: + appName: "About {{PROJECT_NAME}}" + tagline: "A FOSMVVM client-server app." + detailsButtonTitle: "Details" + okButtonTitle: "OK" + +es: + AboutViewModel: + appName: "Acerca de {{PROJECT_NAME}}" + tagline: "Una aplicación cliente-servidor de FOSMVVM." + detailsButtonTitle: "Detalles" + okButtonTitle: "Aceptar" diff --git a/Sources/FOSMVVMBootstrap/Templates/client-server/Sources/{{PROJECT_NAME}}ClientViewModels/{{PROJECT_NAME}}ClientViewModels.swift.tmpl b/Sources/FOSMVVMBootstrap/Templates/client-server/Sources/{{PROJECT_NAME}}ClientViewModels/{{PROJECT_NAME}}ClientViewModels.swift.tmpl new file mode 100644 index 00000000..872a910a --- /dev/null +++ b/Sources/FOSMVVMBootstrap/Templates/client-server/Sources/{{PROJECT_NAME}}ClientViewModels/{{PROJECT_NAME}}ClientViewModels.swift.tmpl @@ -0,0 +1,16 @@ +// {{PROJECT_NAME}}ClientViewModels.swift +{{LICENSE_HEADER}} +import Foundation + +/// The client-hosted door. This framework holds `.clientHostedFactory` ViewModels +/// (no server round-trip) and their localization YAML, bundled INTO the framework. +public final class {{PROJECT_NAME}}ClientViewModels { + /// This framework's own resource bundle, carrying the client-hosted ViewModels' + /// localization YAML. Pass it to the app's `MVVMEnvironment` as a `resourceBundles` + /// entry so client-hosted `@LocalizedString`s resolve on the client — the server + /// never serves these VMs. (Load it with `resourceDirectoryName: ""`: Xcode's + /// CopyBundle flattens the framework's grouped resources to the bundle root.) + public static var localizationBundle: Bundle { + Bundle(for: {{PROJECT_NAME}}ClientViewModels.self) + } +} diff --git a/Sources/FOSMVVMBootstrap/Templates/client-server/Sources/{{PROJECT_NAME}}Foundation/SystemVersion+App.swift.tmpl b/Sources/FOSMVVMBootstrap/Templates/client-server/Sources/{{PROJECT_NAME}}Foundation/SystemVersion+App.swift.tmpl new file mode 100644 index 00000000..c95c21e6 --- /dev/null +++ b/Sources/FOSMVVMBootstrap/Templates/client-server/Sources/{{PROJECT_NAME}}Foundation/SystemVersion+App.swift.tmpl @@ -0,0 +1,44 @@ +// SystemVersion+App.swift +{{LICENSE_HEADER}} +import FOSFoundation +import Foundation + +public extension SystemVersion { + /// This application's version — the shared source of truth for both doors. + /// + /// // client app: + /// MVVMEnvironment(currentVersion: .currentApplicationVersion, appBundle: .main, ...) + /// // server (configure): + /// SystemVersion.setCurrentVersion(.currentApplicationVersion) + /// + /// It lives in this shared `{{PROJECT_NAME}}Foundation` library precisely because the + /// server needs the version handshake WITHOUT importing the display contract. + /// + /// `major`/`minor` are pinned here in code (bump `v0_1_0` when you cut a release); + /// the patch is the build number, delivered through the app bundle's `CFBundleVersion` + /// (set `CURRENT_PROJECT_VERSION`, or from CI). On the server there is no app bundle, + /// so the patch is `0` there — compatibility is `major.minor`, not patch. In DEBUG this + /// asserts the bundle's `CFBundleShortVersionString` (from `MARKETING_VERSION`) equals + /// `major.minor`, so build settings and code can never silently drift. + static let currentApplicationVersion: SystemVersion = { + let codeVersion = Self.v0_1_0 + let patch = Int(Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "0") ?? 0 + + #if DEBUG + if let marketingVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String { + let expected = "\(codeVersion.major).\(codeVersion.minor)" + guard marketingVersion == expected else { + fatalError( + "MARKETING_VERSION (\(marketingVersion)) != SystemVersion.currentApplicationVersion (\(expected)). Update the project's Version, or v0_1_0 here." + ) + } + } + #endif + + return .init(major: codeVersion.major, minor: codeVersion.minor, patch: patch) + }() + + /// The pinned `major.minor` for this release line. Add the next line's constant + /// (`v0_2_0`, `v1_0_0`, …) when you bump, and point `currentApplicationVersion` at it. + static let v0_1_0: SystemVersion = .init(major: 0, minor: 1) +} diff --git a/Sources/FOSMVVMBootstrap/Templates/client-server/Sources/{{PROJECT_NAME}}Server/Auth/SkeletonAuthProvider.swift.tmpl b/Sources/FOSMVVMBootstrap/Templates/client-server/Sources/{{PROJECT_NAME}}Server/Auth/SkeletonAuthProvider.swift.tmpl new file mode 100644 index 00000000..3e9f299f --- /dev/null +++ b/Sources/FOSMVVMBootstrap/Templates/client-server/Sources/{{PROJECT_NAME}}Server/Auth/SkeletonAuthProvider.swift.tmpl @@ -0,0 +1,40 @@ +// SkeletonAuthProvider.swift +{{LICENSE_HEADER}} +import Fluent +import FOSFoundation +import FOSMVVM +import FOSMVVMVapor +import Foundation +import Vapor + +// SKELETON auth — grants read + create on every Board for Card records, to no one +// in particular. Replace this with a credential-scoped provider that returns only +// the grants the authenticated caller holds (mount the reads/writes behind a +// credential-middleware group). See docs/deferrals.md → "credential-middleware auth". +struct SkeletonGrant: ContainerAuthorization { + let authorizedContainer: ModelIdentity + let operations: [ContainerOperation] + let recordTypes: [ModelNamespace] + + func authorizes( + _ operation: ContainerOperation, + ofType recordType: any FOSMVVM.Model.Type, + in container: ModelIdentity + ) -> Bool { + container == authorizedContainer + && operations.authorizes(operation) + && recordTypes.contains(recordType.modelIdentityNamespace) + } +} + +struct SkeletonAuthProvider: ContainerAuthorizationProvider { + func containerAuthorizations(for request: Vapor.Request) async throws -> [SkeletonGrant] { + try await Board.query(on: request.db).all().map { board in + try SkeletonGrant( + authorizedContainer: board.modelIdentity, + operations: [.readRecords, .createRecords], + recordTypes: [Card.modelIdentityNamespace] + ) + } + } +} diff --git a/Sources/FOSMVVMBootstrap/Templates/client-server/Sources/{{PROJECT_NAME}}Server/DataModels/Board.swift.tmpl b/Sources/FOSMVVMBootstrap/Templates/client-server/Sources/{{PROJECT_NAME}}Server/DataModels/Board.swift.tmpl new file mode 100644 index 00000000..3b92cc6b --- /dev/null +++ b/Sources/FOSMVVMBootstrap/Templates/client-server/Sources/{{PROJECT_NAME}}Server/DataModels/Board.swift.tmpl @@ -0,0 +1,41 @@ +// Board.swift +{{LICENSE_HEADER}} +import Fluent +import FluentKit +import FOSFoundation +import FOSMVVM +import FOSMVVMVapor +import Foundation +import Vapor + +/// The apex container — one Board holds many Cards. Apex-rooted reads/creates +/// resolve their root here (see `useApexContainerResolver` in configure). A +/// container declares BOTH `containedRecordTypes` and `containment`; boot rejects +/// any drift between them. +/// +/// `public` because `Card` (its record) is surfaced through a public write body's +/// `LoadRequirement`, which forces `Card` — and this container — public. +public final class Board: ContainerDataModel, @unchecked Sendable { + public static let schema = "boards" + + public static var containedRecordTypes: [any FOSMVVM.Model.Type] { + [Card.self] + } + + public static var containment: [ContainmentRelation] { + [.children(\Board.$cards)] + } + + @ID(key: .id) public var id: UUID? + @Field(key: "name") public var name: String + @Children(for: \.$board) public var cards: [Card] + + public init() {} + public init(name: String) { + self.name = name + } + + public func validate(fields: [any FormFieldBase]?, validations: FOSMVVM.Validations) -> FOSMVVM.ValidationResult.Status? { + nil + } +} diff --git a/Sources/FOSMVVMBootstrap/Templates/client-server/Sources/{{PROJECT_NAME}}Server/DataModels/Card.swift.tmpl b/Sources/FOSMVVMBootstrap/Templates/client-server/Sources/{{PROJECT_NAME}}Server/DataModels/Card.swift.tmpl new file mode 100644 index 00000000..ed0447f7 --- /dev/null +++ b/Sources/FOSMVVMBootstrap/Templates/client-server/Sources/{{PROJECT_NAME}}Server/DataModels/Card.swift.tmpl @@ -0,0 +1,34 @@ +// Card.swift +{{LICENSE_HEADER}} +import Fluent +import FluentKit +import FOSFoundation +import FOSMVVM +import FOSMVVMVapor +import Foundation +import Vapor + +/// A card on a Board. A plain record (`DataModel`), not a container. `ownerTag` is +/// the correlation-seam key surfaced to the client through `CardViewModel`. +/// +/// `public` because it is the `Target` of a public `DataModelWriter` (the create +/// body), whose `candidates: LoadRequirement` is a public requirement. +public final class Card: DataModel, @unchecked Sendable { + public static let schema = "cards" + + @ID(key: .id) public var id: UUID? + @Field(key: "title") public var title: String + @OptionalField(key: "owner_tag") public var ownerTag: String? + @Parent(key: "board_id") public var board: Board + + public init() {} + public init(title: String, ownerTag: String?, boardId: ModelIdType) { + self.title = title + self.ownerTag = ownerTag + $board.id = boardId + } + + public func validate(fields: [any FormFieldBase]?, validations: FOSMVVM.Validations) -> FOSMVVM.ValidationResult.Status? { + nil + } +} diff --git a/Sources/FOSMVVMBootstrap/Templates/client-server/Sources/{{PROJECT_NAME}}Server/Factories/BoardViewModel+Factory.swift.tmpl b/Sources/FOSMVVMBootstrap/Templates/client-server/Sources/{{PROJECT_NAME}}Server/Factories/BoardViewModel+Factory.swift.tmpl new file mode 100644 index 00000000..9f0a019b --- /dev/null +++ b/Sources/FOSMVVMBootstrap/Templates/client-server/Sources/{{PROJECT_NAME}}Server/Factories/BoardViewModel+Factory.swift.tmpl @@ -0,0 +1,34 @@ +// BoardViewModel+Factory.swift +{{LICENSE_HEADER}} +import Fluent +import FOSFoundation +import FOSMVVM +import FOSMVVMVapor +import Foundation +import Vapor +import {{PROJECT_NAME}}ViewModels + +// Server-side projection — keeps BoardViewModel (the shared contract) Vapor/Fluent-free. +// +// The read declares its Fluent data need as a STORED `static let` LoadRequirement, +// read back through `context.records(_:)`. Records are loaded (auth-scoped) BEFORE +// projection, so `body(context:)` is synchronous. Because Card is a plan-loaded +// Fluent record, its live dependency registers automatically — no manual key. +extension BoardViewModel: ComposableFactory, VaporResponseBodyFactory { + // Apex-rooted read: the apex resolver supplies the Board; Card's container is the + // implicit terminal hop (no `via:` — that lists INTERMEDIATE hops only). Records + // must be `public` because Card is public and this is a public protocol requirement. + public static let cards = LoadRequirement.read(Card.self, in: .newRoot(.apex)) + + public static var dataRequirements: [any DataRequirement] { + [cards] + } + + public static func body(context: ProjectionContext) throws -> Self + where R.ResponseBody == Self { + .init( + cards: try context.records(Self.cards) + .map { CardViewModel(title: $0.title, ownerTag: $0.ownerTag) } + ) + } +} diff --git a/Sources/FOSMVVMBootstrap/Templates/client-server/Sources/{{PROJECT_NAME}}Server/Migrations/Board+Schema.swift.tmpl b/Sources/FOSMVVMBootstrap/Templates/client-server/Sources/{{PROJECT_NAME}}Server/Migrations/Board+Schema.swift.tmpl new file mode 100644 index 00000000..fcfddd5e --- /dev/null +++ b/Sources/FOSMVVMBootstrap/Templates/client-server/Sources/{{PROJECT_NAME}}Server/Migrations/Board+Schema.swift.tmpl @@ -0,0 +1,21 @@ +// Board+Schema.swift +{{LICENSE_HEADER}} +import Fluent +import Foundation + +extension Board { + struct Initial: AsyncMigration { + let name = "boards-initial" + + func prepare(on database: any Database) async throws { + try await database.schema(Board.schema) + .id() + .field("name", .string, .required) + .create() + } + + func revert(on database: any Database) async throws { + try await database.schema(Board.schema).delete() + } + } +} diff --git a/Sources/FOSMVVMBootstrap/Templates/client-server/Sources/{{PROJECT_NAME}}Server/Migrations/Card+Schema.swift.tmpl b/Sources/FOSMVVMBootstrap/Templates/client-server/Sources/{{PROJECT_NAME}}Server/Migrations/Card+Schema.swift.tmpl new file mode 100644 index 00000000..73ec323b --- /dev/null +++ b/Sources/FOSMVVMBootstrap/Templates/client-server/Sources/{{PROJECT_NAME}}Server/Migrations/Card+Schema.swift.tmpl @@ -0,0 +1,23 @@ +// Card+Schema.swift +{{LICENSE_HEADER}} +import Fluent +import Foundation + +extension Card { + struct Initial: AsyncMigration { + let name = "cards-initial" + + func prepare(on database: any Database) async throws { + try await database.schema(Card.schema) + .id() + .field("title", .string, .required) + .field("owner_tag", .string) + .field("board_id", .uuid, .required, .references(Board.schema, "id")) + .create() + } + + func revert(on database: any Database) async throws { + try await database.schema(Card.schema).delete() + } + } +} diff --git a/Sources/FOSMVVMBootstrap/Templates/client-server/Sources/{{PROJECT_NAME}}Server/Writers/CreateCardRequest+Writer.swift.tmpl b/Sources/FOSMVVMBootstrap/Templates/client-server/Sources/{{PROJECT_NAME}}Server/Writers/CreateCardRequest+Writer.swift.tmpl new file mode 100644 index 00000000..af44187f --- /dev/null +++ b/Sources/FOSMVVMBootstrap/Templates/client-server/Sources/{{PROJECT_NAME}}Server/Writers/CreateCardRequest+Writer.swift.tmpl @@ -0,0 +1,25 @@ +// CreateCardRequest+Writer.swift +{{LICENSE_HEADER}} +import Fluent +import FOSFoundation +import FOSMVVM +import FOSMVVMVapor +import Foundation +import {{PROJECT_NAME}}ViewModels + +// Server-side write seam. `apply` is field-assignment ONLY — it cannot touch the +// database. The framework owns everything else: it instantiates a fresh Card, calls +// apply, sets the container foreign key from the apex-resolved Board, saves, and +// re-serves BoardViewModel's factory. The committed save auto-emits live invalidation, +// so every open Board client re-fetches and the new card appears. +extension CreateCardBody: DataModelWriter { + // Apex-rooted create (the root container IS the create scope; create takes no + // `via:`). `public` because CreateCardBody is public and these are public + // protocol requirements referencing the public `Card`. + public static let candidates = LoadRequirement.create(Card.self, in: .newRoot(.apex)) + + public func apply(to card: Card) throws { + card.title = title + card.ownerTag = ownerTag + } +} diff --git a/Sources/FOSMVVMBootstrap/Templates/client-server/Sources/{{PROJECT_NAME}}Server/configure.swift.tmpl b/Sources/FOSMVVMBootstrap/Templates/client-server/Sources/{{PROJECT_NAME}}Server/configure.swift.tmpl new file mode 100644 index 00000000..da2284dc --- /dev/null +++ b/Sources/FOSMVVMBootstrap/Templates/client-server/Sources/{{PROJECT_NAME}}Server/configure.swift.tmpl @@ -0,0 +1,66 @@ +// configure.swift +{{LICENSE_HEADER}} +import Fluent +import FluentSQLiteDriver +import FOSFoundation +import FOSMVVM +import FOSMVVMVapor +import Foundation +import Vapor +import {{PROJECT_NAME}}Foundation +import {{PROJECT_NAME}}ViewModels + +/// Boots the skeleton: SQLite (in-memory under `.testing`, a file otherwise), then +/// the shared registrations, migrations, and a one-time demo seed. +public func configure(_ app: Application) async throws { + switch app.environment { + case .testing: + app.databases.use(.sqlite(.memory), as: .sqlite) + default: + app.databases.use( + .sqlite(.file(Environment.get("DB_PATH") ?? "db.sqlite")), + as: .sqlite + ) + } + + try registerServices(app) + try await app.autoMigrate() + try await seedIfEmpty(app.db) +} + +/// Everything that isn't database/lifecycle — registered once, and reusable from a +/// test (the FOSTestingVapor harness owns the DB + autoMigrate + boot). Containers +/// register via register(_:migration:) (migration + identity descriptor in one call); +/// plain records add their migration directly. The apex resolver answers "which +/// container roots an apex read/create?" so a client can fetch the board without +/// knowing its id. Register containers BEFORE the requests — the requests' load plans +/// are derived and validated against the registry here. +func registerServices(_ app: Application) throws { + SystemVersion.setCurrentVersion(.currentApplicationVersion) + + try app.register(Board.self, migration: Board.Initial()) + app.migrations.add(Card.Initial()) + + try app.useContainerAuthorizationProvider(SkeletonAuthProvider()) + + try app.useApexContainerResolver { req in + guard let board = try await Board.query(on: req.db).first() else { + throw Abort(.internalServerError, reason: "no Board seeded") + } + return try board.modelIdentity + } + + try app.initYamlLocalization(bundle: Bundle.module, resourceDirectoryName: "Resources") + try app.useLiveInvalidation(on: app) + + try routes(app) +} + +/// One demo Board with one Card, so the first fetch shows something and the apex +/// resolver has a container to resolve. +private func seedIfEmpty(_ db: any Database) async throws { + guard try await Board.query(on: db).first() == nil else { return } + let board = Board(name: "Demo Board") + try await board.save(on: db) + try await Card(title: "Welcome to FOSMVVM", ownerTag: nil, boardId: board.requireId()).save(on: db) +} diff --git a/Sources/FOSMVVMBootstrap/Templates/client-server/Sources/{{PROJECT_NAME}}Server/entrypoint.swift.tmpl b/Sources/FOSMVVMBootstrap/Templates/client-server/Sources/{{PROJECT_NAME}}Server/entrypoint.swift.tmpl new file mode 100644 index 00000000..cc7ac27d --- /dev/null +++ b/Sources/FOSMVVMBootstrap/Templates/client-server/Sources/{{PROJECT_NAME}}Server/entrypoint.swift.tmpl @@ -0,0 +1,33 @@ +// entrypoint.swift +{{LICENSE_HEADER}} +import Logging +import NIOCore +import NIOPosix +import Vapor + +@main +enum Entrypoint { + static func main() async throws { + var env = try Environment.detect() + try LoggingSystem.bootstrap(from: &env) + + let app = try await Application.make(env) + + let executorTakeoverSuccess = + NIOSingletons.unsafeTryInstallSingletonPosixEventLoopGroupAsConcurrencyGlobalExecutor() + app.logger.debug( + "Tried to install SwiftNIO's EventLoopGroup as Swift's global concurrency executor", + metadata: ["success": .stringConvertible(executorTakeoverSuccess)] + ) + + do { + try await configure(app) + } catch { + app.logger.report(error: error) + try? await app.asyncShutdown() + throw error + } + try await app.execute() + try await app.asyncShutdown() + } +} diff --git a/Sources/FOSMVVMBootstrap/Templates/client-server/Sources/{{PROJECT_NAME}}Server/routes.swift.tmpl b/Sources/FOSMVVMBootstrap/Templates/client-server/Sources/{{PROJECT_NAME}}Server/routes.swift.tmpl new file mode 100644 index 00000000..e6856072 --- /dev/null +++ b/Sources/FOSMVVMBootstrap/Templates/client-server/Sources/{{PROJECT_NAME}}Server/routes.swift.tmpl @@ -0,0 +1,19 @@ +// routes.swift +{{LICENSE_HEADER}} +import FOSFoundation +import FOSMVVM +import FOSMVVMVapor +import Vapor +import {{PROJECT_NAME}}ViewModels + +func routes(_ app: Application) throws { + app.get { _ async in "It works!" } + + // The served path is derived from the request type — never hand-mount a prefix + // (clients derive the same URL; a prefix desyncs them and is rejected at boot). + // Guard privileged routes by registering on a credential-middleware group instead + // of the public `app`. Swift picks the write door for CreateCardRequest by its + // CreateRequest + DataModelWriter constraints. + try app.register(request: BoardRequest.self, app: app) + try app.register(request: CreateCardRequest.self, app: app) +} diff --git a/Sources/FOSMVVMBootstrap/Templates/client-server/Sources/{{PROJECT_NAME}}ViewModels/Requests/BoardRequest.swift.tmpl b/Sources/FOSMVVMBootstrap/Templates/client-server/Sources/{{PROJECT_NAME}}ViewModels/Requests/BoardRequest.swift.tmpl new file mode 100644 index 00000000..1d7b466d --- /dev/null +++ b/Sources/FOSMVVMBootstrap/Templates/client-server/Sources/{{PROJECT_NAME}}ViewModels/Requests/BoardRequest.swift.tmpl @@ -0,0 +1,27 @@ +// BoardRequest.swift +{{LICENSE_HEADER}} +import FOSFoundation +import FOSMVVM +import Foundation + +/// Fetches the board live. `EmptyQuery`: the server roots the read at its apex +/// container (see `useApexContainerResolver` in the server's `configure`), so the +/// client needs no board identity to start. +public final class BoardRequest: ViewModelRequest, @unchecked Sendable { + public typealias Query = EmptyQuery + public typealias ResponseError = EmptyError + + public let id: String + public var responseBody: BoardViewModel? + + public init( + query: EmptyQuery? = nil, + sort: EmptySort? = nil, + fragment: EmptyFragment? = nil, + requestBody: EmptyBody? = nil, + responseBody: BoardViewModel? = nil + ) { + self.id = .random(length: 10) + self.responseBody = responseBody + } +} diff --git a/Sources/FOSMVVMBootstrap/Templates/client-server/Sources/{{PROJECT_NAME}}ViewModels/Requests/CreateCardRequest.swift.tmpl b/Sources/FOSMVVMBootstrap/Templates/client-server/Sources/{{PROJECT_NAME}}ViewModels/Requests/CreateCardRequest.swift.tmpl new file mode 100644 index 00000000..0ee3f419 --- /dev/null +++ b/Sources/FOSMVVMBootstrap/Templates/client-server/Sources/{{PROJECT_NAME}}ViewModels/Requests/CreateCardRequest.swift.tmpl @@ -0,0 +1,61 @@ +// CreateCardRequest.swift +{{LICENSE_HEADER}} +import FOSFoundation +import FOSMVVM +import Foundation + +/// Adds a card to the board. `EmptyQuery`: the create roots at the apex container +/// (the server's apex resolver), so no board identity is threaded from the client. +/// The `RequestBody`'s `DataModelWriter` conformance lives server-side; on commit the +/// board's live clients auto-refresh and the new card appears. +public final class CreateCardRequest: CreateRequest, @unchecked Sendable { + public typealias Query = EmptyQuery + public typealias RequestBody = CreateCardBody + public typealias Fragment = EmptyFragment + public typealias ResponseError = EmptyError + public typealias ResponseBody = BoardViewModel + + public let id: String + public let requestBody: CreateCardBody? + public var responseBody: BoardViewModel? + + public init( + query: EmptyQuery?, + sort: EmptySort?, + fragment: EmptyFragment?, + requestBody: CreateCardBody?, + responseBody: BoardViewModel? + ) { + self.id = .random(length: 10) + self.requestBody = requestBody + self.responseBody = responseBody + } + + public static func stub() -> Self { + .init(query: nil, sort: nil, fragment: nil, requestBody: nil, responseBody: nil) + } +} + +/// A validated per-request body — never `EmptyBody` for a write. +public struct CreateCardBody: ServerRequestBody, ValidatableModel { + public var title: String + public var ownerTag: String? + + public init(title: String, ownerTag: String? = nil) { + self.title = title + self.ownerTag = ownerTag + } + + public func validate( + fields _: [any FormFieldBase]?, + validations: FOSMVVM.Validations + ) -> FOSMVVM.ValidationResult.Status? { + guard !title.isEmpty else { + validations.validations.append( + .init(status: .error, fieldId: .init(id: "title"), message: .constant("title must not be empty")) + ) + return .error + } + return nil + } +} diff --git a/Sources/FOSMVVMBootstrap/Templates/client-server/Sources/{{PROJECT_NAME}}ViewModels/ViewModels/BoardViewModel.swift.tmpl b/Sources/FOSMVVMBootstrap/Templates/client-server/Sources/{{PROJECT_NAME}}ViewModels/ViewModels/BoardViewModel.swift.tmpl new file mode 100644 index 00000000..04fcac9b --- /dev/null +++ b/Sources/FOSMVVMBootstrap/Templates/client-server/Sources/{{PROJECT_NAME}}ViewModels/ViewModels/BoardViewModel.swift.tmpl @@ -0,0 +1,67 @@ +// BoardViewModel.swift +{{LICENSE_HEADER}} +import FOSFoundation +import FOSMVVM +import Foundation + +/// The demo screen: a board and its cards, served live from the server. +/// +/// `options: [.live]` keeps the client subscribed after the first fetch — when a +/// card is created (or any registered container model commits), connected clients +/// are nudged to re-fetch. The server-side projection + Fluent factory live in the +/// `{{PROJECT_NAME}}Server` target (DIP: this contract stays Vapor/Fluent-free). +@ViewModel(options: [.live]) +public struct BoardViewModel: RequestableViewModel { + public typealias Request = BoardRequest + + @LocalizedString public var title + @LocalizedString public var addCardButtonTitle + + // Error presentation for the async createCard op: the async Button deposits + // a thrown error into the screen's binding, and alert(error:) presents it + // with these strings (%{error} is filled at presentation). + @LocalizedString public var errorTitle + @LocalizedString public var errorMessage + @LocalizedString public var dismissButtonLabel + + public let cards: [CardViewModel] + + // MARK: Operations Access + // + // BoardView's operations are SERVER-BACKED: createCard(_:) dispatches a + // CreateCardRequest (see BoardViewModelOperations). Because this VM is [.live], + // the board re-fetches itself once the write commits. Contrast AboutView, whose + // operations are purely local. + + private let isStub: Bool + + #if canImport(SwiftUI) + public var operations: any BoardViewModelOperations { + isStub ? BoardViewModelStubOps() : BoardViewModelOps() + } + #endif + + public var vmId = ViewModelId() + + public init(cards: [CardViewModel]) { + self.init(cards: cards, isStub: false) + } +} + +public extension BoardViewModel { + static func stub() -> Self { + .init(cards: [.stub()], isStub: true) + } +} + +extension BoardViewModel { + private init(cards: [CardViewModel], isStub: Bool) { + self.cards = cards + self.isStub = isStub + } +} + +// A create request re-serves this same body to refresh. `CreateResponseBody` is a +// FOSMVVM marker, so the conformance lives here in the contract (the CreateRequest +// constraint is checked where CreateCardRequest is declared) — not server-side. +extension BoardViewModel: CreateResponseBody {} diff --git a/Sources/FOSMVVMBootstrap/Templates/client-server/Sources/{{PROJECT_NAME}}ViewModels/ViewModels/BoardViewModelOperations.swift.tmpl b/Sources/FOSMVVMBootstrap/Templates/client-server/Sources/{{PROJECT_NAME}}ViewModels/ViewModels/BoardViewModelOperations.swift.tmpl new file mode 100644 index 00000000..6de5e647 --- /dev/null +++ b/Sources/FOSMVVMBootstrap/Templates/client-server/Sources/{{PROJECT_NAME}}ViewModels/ViewModels/BoardViewModelOperations.swift.tmpl @@ -0,0 +1,57 @@ +// +{{LICENSE_HEADER}} +// BoardViewModelOperations.swift +// {{PROJECT_NAME}} +// +// Created by David Hunt on 8/8/26. +// + +import FOSFoundation +import FOSMVVM +import Foundation + +#if canImport(SwiftUI) + +// MARK: Operations +// +// SERVER-BACKED operations — the live implementation dispatches a ServerRequest and +// the `async throws` is genuine (it awaits network I/O and can fail). Contrast +// AboutViewModelOperations, whose operations are purely local. +// +// The MVVMEnvironment is INJECTED (the View supplies it from @Environment) rather +// than reached for as a global — a singleton would break the DIP the whole stack +// depends on, and testability with it. + +public protocol BoardViewModelOperations: ViewModelOperations { + func createCard(title: String, mvvmEnv: MVVMEnvironment) async throws +} + +public struct BoardViewModelOps: BoardViewModelOperations { + public init() {} + + public func createCard(title: String, mvvmEnv: MVVMEnvironment) async throws { + let request = CreateCardRequest( + query: nil, + sort: nil, + fragment: nil, + requestBody: CreateCardBody(title: title), + responseBody: nil + ) + // POST the create. BoardViewModel is [.live], so once the write commits the + // board re-fetches itself in place — there is no response to consume here. + try await request.processRequest(mvvmEnv: mvvmEnv) + } +} + +public final class BoardViewModelStubOps: BoardViewModelOperations, @unchecked Sendable { + public var createCardCalled: Bool { createCardCalledWithTitle != nil } + public private(set) var createCardCalledWithTitle: String? + + public init() {} + + public func createCard(title: String, mvvmEnv _: MVVMEnvironment) async throws { + createCardCalledWithTitle = title + } +} + +#endif diff --git a/Sources/FOSMVVMBootstrap/Templates/client-server/Sources/{{PROJECT_NAME}}ViewModels/ViewModels/CardViewModel.swift.tmpl b/Sources/FOSMVVMBootstrap/Templates/client-server/Sources/{{PROJECT_NAME}}ViewModels/ViewModels/CardViewModel.swift.tmpl new file mode 100644 index 00000000..5f5fa833 --- /dev/null +++ b/Sources/FOSMVVMBootstrap/Templates/client-server/Sources/{{PROJECT_NAME}}ViewModels/ViewModels/CardViewModel.swift.tmpl @@ -0,0 +1,30 @@ +// CardViewModel.swift +{{LICENSE_HEADER}} +import FOSFoundation +import FOSMVVM +import Foundation + +/// One card on the board. `title` is data (from the server's Fluent record), not a +/// localized UI string. +/// +/// `ownerTag` is the correlation-seam key: the client-hosted `AboutViewModel` carries +/// the same tag (`localOwnerTag`), and the *view* joins them to mark "my" cards — the +/// server VM exposes the key, the view joins, and no single VM ever serves both doors. +@ViewModel +public struct CardViewModel { + public let title: String + public let ownerTag: String? + + public var vmId = ViewModelId() + + public init(title: String, ownerTag: String?) { + self.title = title + self.ownerTag = ownerTag + } +} + +public extension CardViewModel { + static func stub() -> Self { + .init(title: "Sample Card", ownerTag: nil) + } +} diff --git a/Sources/FOSMVVMBootstrap/Templates/client-server/Tests/{{PROJECT_NAME}}ClientViewModelsTests/AboutViewModelTests.swift.tmpl b/Sources/FOSMVVMBootstrap/Templates/client-server/Tests/{{PROJECT_NAME}}ClientViewModelsTests/AboutViewModelTests.swift.tmpl new file mode 100644 index 00000000..49280890 --- /dev/null +++ b/Sources/FOSMVVMBootstrap/Templates/client-server/Tests/{{PROJECT_NAME}}ClientViewModelsTests/AboutViewModelTests.swift.tmpl @@ -0,0 +1,26 @@ +// AboutViewModelTests.swift +{{LICENSE_HEADER}} +import FOSFoundation +import FOSMVVM +import FOSTesting +import Foundation +import Testing +import {{PROJECT_NAME}}ClientViewModels + +@Suite("AboutViewModel") +struct AboutViewModelTests: LocalizableTestCase { + let locStore: LocalizationStore + + @Test func viewModelTests() throws { + try expectFullViewModelTests(AboutViewModel.self) + } + + init() throws { + // "" — Xcode's CopyBundle flattens the framework's grouped resources to the + // bundle root, so the client-hosted YAML loads from the root, not "ViewModels". + self.locStore = try Self.loadLocalizationStore( + bundle: {{PROJECT_NAME}}ClientViewModels.localizationBundle, + resourceDirectoryName: "" + ) + } +} diff --git a/Sources/FOSMVVMBootstrap/Templates/client-server/Tests/{{PROJECT_NAME}}ClientViewModelsTests/LocalizableTestCase+{{PROJECT_NAME}}.swift.tmpl b/Sources/FOSMVVMBootstrap/Templates/client-server/Tests/{{PROJECT_NAME}}ClientViewModelsTests/LocalizableTestCase+{{PROJECT_NAME}}.swift.tmpl new file mode 100644 index 00000000..1692cead --- /dev/null +++ b/Sources/FOSMVVMBootstrap/Templates/client-server/Tests/{{PROJECT_NAME}}ClientViewModelsTests/LocalizableTestCase+{{PROJECT_NAME}}.swift.tmpl @@ -0,0 +1,10 @@ +// LocalizableTestCase+{{PROJECT_NAME}}.swift +{{LICENSE_HEADER}} +import FOSTesting +import Foundation + +// The test target's locale set, declared ONCE for every LocalizableTestCase suite +// in this target (add locales here, not in each suite). +extension LocalizableTestCase { + var locales: Set { [Self.en, Self.es] } +} diff --git a/Sources/FOSMVVMBootstrap/Templates/client-server/Tests/{{PROJECT_NAME}}ServerTests/BoardServerTests.swift.tmpl b/Sources/FOSMVVMBootstrap/Templates/client-server/Tests/{{PROJECT_NAME}}ServerTests/BoardServerTests.swift.tmpl new file mode 100644 index 00000000..159715ea --- /dev/null +++ b/Sources/FOSMVVMBootstrap/Templates/client-server/Tests/{{PROJECT_NAME}}ServerTests/BoardServerTests.swift.tmpl @@ -0,0 +1,55 @@ +// BoardServerTests.swift +{{LICENSE_HEADER}} +import Fluent +import FOSFoundation +import FOSMVVM +import FOSTesting +import FOSTestingVapor +import Foundation +import Testing +import Vapor +@testable import {{PROJECT_NAME}}Server +import {{PROJECT_NAME}}ViewModels + +/// Boots the real server pipeline headlessly on SQLite-in-memory, POSTs a +/// `CreateCardRequest` through the actual route, and asserts the refreshed +/// `BoardViewModel` reflects the new card — proving the live+write loop +/// (Fluent write → auto-refresh → re-served body) end to end, no simulator. +@Suite("BoardServer", .serialized) +struct BoardServerTests { + @Test func createCardRefreshesTheLiveBoard() async throws { + try await withFluentTestApp { app in + // The harness owns the DB + autoMigrate + boot; we register the same + // services the generated configure does. + try registerServices(app) + } _: { app, db in + // The harness path doesn't run the demo seed — seed one board. + let board = Board(name: "Test Board") + try await board.save(on: db) + let before = try await Card.query(on: db).count() + + let create = CreateCardRequest( + query: nil, sort: nil, fragment: nil, + requestBody: CreateCardBody(title: "New Card"), responseBody: nil + ) + let base = try #require(URL(string: "http://localhost")) + let url = try #require(try base.appending(serverRequest: create)) + + var buffer = ByteBufferAllocator().buffer(capacity: 0) + try buffer.writeBytes(JSONEncoder().encode(CreateCardBody(title: "New Card"))) + var headers = HTTPHeaders([(HTTPHeaders.Name.acceptLanguage.description, "en")]) + headers.contentType = .json + let httpReq = Request( + application: app, method: .POST, url: URI(string: url.absoluteString), + headers: headers, collectedBody: buffer, on: app.eventLoopGroup.next() + ) + + let response = try await app.responder.respond(to: httpReq).get() + #expect(response.status == .ok) + + let refreshed: BoardViewModel = try #require(response.body.data).fromJSON() + #expect(refreshed.cards.count == before + 1) + #expect(refreshed.cards.contains { $0.title == "New Card" }) + } + } +} diff --git a/Sources/FOSMVVMBootstrap/Templates/client-server/Tests/{{PROJECT_NAME}}UITests/Resources.symlink b/Sources/FOSMVVMBootstrap/Templates/client-server/Tests/{{PROJECT_NAME}}UITests/Resources.symlink new file mode 100644 index 00000000..75c6885c --- /dev/null +++ b/Sources/FOSMVVMBootstrap/Templates/client-server/Tests/{{PROJECT_NAME}}UITests/Resources.symlink @@ -0,0 +1 @@ +../../Sources/Resources \ No newline at end of file diff --git a/Sources/FOSMVVMBootstrap/Templates/client-server/Tests/{{PROJECT_NAME}}UITests/Support/LocalizableTestCase+{{PROJECT_NAME}}.swift.tmpl b/Sources/FOSMVVMBootstrap/Templates/client-server/Tests/{{PROJECT_NAME}}UITests/Support/LocalizableTestCase+{{PROJECT_NAME}}.swift.tmpl new file mode 100644 index 00000000..60ab5b1a --- /dev/null +++ b/Sources/FOSMVVMBootstrap/Templates/client-server/Tests/{{PROJECT_NAME}}UITests/Support/LocalizableTestCase+{{PROJECT_NAME}}.swift.tmpl @@ -0,0 +1,11 @@ +// LocalizableTestCase+{{PROJECT_NAME}}.swift +{{LICENSE_HEADER}} + +import FOSTesting +import Foundation + +// The test target's locale set, declared ONCE for every LocalizableTestCase suite +// in this target (add locales here, not in each suite). +extension LocalizableTestCase { + var locales: Set { [Self.en, Self.es] } +} diff --git a/Sources/FOSMVVMBootstrap/Templates/client-server/Tests/{{PROJECT_NAME}}UITests/Support/TestConfiguration.swift.symlink b/Sources/FOSMVVMBootstrap/Templates/client-server/Tests/{{PROJECT_NAME}}UITests/Support/TestConfiguration.swift.symlink new file mode 100644 index 00000000..4681b49d --- /dev/null +++ b/Sources/FOSMVVMBootstrap/Templates/client-server/Tests/{{PROJECT_NAME}}UITests/Support/TestConfiguration.swift.symlink @@ -0,0 +1 @@ +../../../Sources/{{PROJECT_NAME}}/App/TestConfiguration.swift diff --git a/Sources/FOSMVVMBootstrap/Templates/client-server/Tests/{{PROJECT_NAME}}UITests/Support/{{PROJECT_NAME}}ViewModelDisplayTestCase.swift.tmpl b/Sources/FOSMVVMBootstrap/Templates/client-server/Tests/{{PROJECT_NAME}}UITests/Support/{{PROJECT_NAME}}ViewModelDisplayTestCase.swift.tmpl new file mode 100644 index 00000000..09656ceb --- /dev/null +++ b/Sources/FOSMVVMBootstrap/Templates/client-server/Tests/{{PROJECT_NAME}}UITests/Support/{{PROJECT_NAME}}ViewModelDisplayTestCase.swift.tmpl @@ -0,0 +1,48 @@ +// {{PROJECT_NAME}}ViewModelDisplayTestCase.swift +{{LICENSE_HEADER}} + +import FOSFoundation +import FOSMVVM +import FOSTestingUI +import Foundation +import {{PROJECT_NAME}}ClientViewModels +import XCTest + +// The DISPLAY-ONLY base: for views with no user-initiated actions — one +// generic parameter, no ViewModelOperations. Do not invent an empty +// Operations type to use the interactive base; this class exists so you +// never have to. +// +// Deliberately non-final: every view test suite subclasses this base. +// swift lint:disable classes_should_be_final +class {{PROJECT_NAME}}ViewModelDisplayTestCase< + VM: ViewModel +>: ViewModelDisplayTestCase, @unchecked Sendable { + @MainActor func presentView( + configuration: TestConfiguration, + viewModel: VM = .stub(), + timeout: TimeInterval = 10 + ) throws -> XCUIApplication { + try presentView( + testConfiguration: configuration.toJSON(), + viewModel: viewModel, + timeout: timeout + ) + } + + override static var runsForEachTargetApplicationUIConfiguration: Bool { + false + } + + override func setUp() async throws { + try await super.setUp( + bundle: {{PROJECT_NAME}}ClientViewModels.localizationBundle, + resourceDirectoryName: "", + appBundleIdentifier: "{{BUNDLE_ID_ROOT}}" + ) + + continueAfterFailure = false // Stop the test and move on + } +} + +// swift lint:enable classes_should_be_final diff --git a/Sources/FOSMVVMBootstrap/Templates/client-server/Tests/{{PROJECT_NAME}}UITests/Support/{{PROJECT_NAME}}ViewModelViewTestCase.swift.tmpl b/Sources/FOSMVVMBootstrap/Templates/client-server/Tests/{{PROJECT_NAME}}UITests/Support/{{PROJECT_NAME}}ViewModelViewTestCase.swift.tmpl new file mode 100644 index 00000000..fbed8ea4 --- /dev/null +++ b/Sources/FOSMVVMBootstrap/Templates/client-server/Tests/{{PROJECT_NAME}}UITests/Support/{{PROJECT_NAME}}ViewModelViewTestCase.swift.tmpl @@ -0,0 +1,78 @@ +// {{PROJECT_NAME}}ViewModelViewTestCase.swift +{{LICENSE_HEADER}} + +import FOSFoundation +import FOSMVVM +import FOSTestingUI +import Foundation +import {{PROJECT_NAME}}ClientViewModels +import XCTest + +// The INTERACTIVE base: for views that dispatch to ViewModelOperations — +// `viewModelOperations()` is available to verify calls. Display-only views +// subclass {{PROJECT_NAME}}ViewModelDisplayTestCase instead. +// +// Deliberately non-final: every view test suite subclasses this base. +// swift lint:disable classes_should_be_final +class {{PROJECT_NAME}}ViewModelViewTestCase< + VM: ViewModel, + VMO: ViewModelOperations +>: ViewModelViewTestCase, @unchecked Sendable { + @MainActor func presentView( + configuration: TestConfiguration, + viewModel: VM = .stub(), + timeout: TimeInterval = 10 + ) throws -> XCUIApplication { + try presentView( + testConfiguration: configuration.toJSON(), + viewModel: viewModel, + timeout: timeout + ) + } + + /// Taps `element`, then polls `witness` for proof the tap landed, + /// re-tapping once at half `timeout`: + /// `tap(presentButton, until: { dismissButton.exists })`. + /// > A freshly launched app can discard its first synthesized event + /// > (per-launch window, measured on CI simulators 2026-08-23), so a + /// > tap is proven by the UI change it causes, never assumed from + /// > dispatch. + @MainActor + @discardableResult + func tap( + _ element: UITestingElement, + until witness: () -> Bool, + timeout: TimeInterval = 10 + ) -> Bool { + element.tap() + if poll(witness, for: timeout / 2) { return true } + element.tap() + return poll(witness, for: timeout / 2) + } + + @MainActor + private func poll(_ witness: () -> Bool, for interval: TimeInterval) -> Bool { + let deadline = Date(timeIntervalSinceNow: interval) + while Date() < deadline { + if witness() { return true } + RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.25)) + } + return witness() + } + + override static var runsForEachTargetApplicationUIConfiguration: Bool { + false + } + + override func setUp() async throws { + try await super.setUp( + bundles: [{{PROJECT_NAME}}ClientViewModels.localizationBundle, Bundle.main], + resourceDirectoryName: "", + appBundleIdentifier: "{{BUNDLE_ID_ROOT}}" + ) + + continueAfterFailure = false // Stop the test and move on + } +} + +// swift lint:enable classes_should_be_final diff --git a/Sources/FOSMVVMBootstrap/Templates/client-server/Tests/{{PROJECT_NAME}}UITests/Views/AboutViewTests.swift.tmpl b/Sources/FOSMVVMBootstrap/Templates/client-server/Tests/{{PROJECT_NAME}}UITests/Views/AboutViewTests.swift.tmpl new file mode 100644 index 00000000..e8dea7b3 --- /dev/null +++ b/Sources/FOSMVVMBootstrap/Templates/client-server/Tests/{{PROJECT_NAME}}UITests/Views/AboutViewTests.swift.tmpl @@ -0,0 +1,58 @@ +// AboutViewTests.swift +{{LICENSE_HEADER}} + +import FOSFoundation +import FOSMVVM +import FOSTestingUI +import Foundation +import SwiftUI +import {{PROJECT_NAME}}ClientViewModels +import XCTest + +// The LOCAL-operation example: a client-hosted VM whose operations mutate on-device +// view state (an alert), with no server round trip. See BoardViewTests for the +// server-backed counterpart. +final class AboutViewTestsUITests: {{PROJECT_NAME}}ViewModelViewTestCase, + @unchecked Sendable { + + func testShowAboutView() throws { + let app = try presentView() + + XCTAssertTrue(app.uiTestingElement("appName").exists) + XCTAssertTrue(app.uiTestingElement("tagline").exists) + } + + func testPresentDetailsAlert() throws { + let app = try presentView() + + // The SwiftUI alert itself cannot carry a testing identifier; its tagged + // dismiss button witnesses the alert's presence. + let dismissDetailsButton = app.uiTestingElement("dismissDetailsButton") + XCTAssertTrue(tap( + app.uiTestingElement("presentDetailsButton"), + until: { dismissDetailsButton.exists } + )) + + let stubOps = try viewModelOperations() + + XCTAssertTrue(stubOps.presentDetailsCalled) + } + + func testDismissDetailsAlert() throws { + let app = try presentView() + + let dismissDetailsButton = app.uiTestingElement("dismissDetailsButton") + XCTAssertTrue(tap( + app.uiTestingElement("presentDetailsButton"), + until: { dismissDetailsButton.exists } + )) + XCTAssertTrue(tap( + dismissDetailsButton, + until: { !dismissDetailsButton.exists } + )) + + let stubOps = try viewModelOperations() + + XCTAssertTrue(stubOps.dismissDetailsCalled) + } +} diff --git a/Sources/FOSMVVMBootstrap/Templates/client-server/Tests/{{PROJECT_NAME}}UITests/Views/BoardViewTests.swift.tmpl b/Sources/FOSMVVMBootstrap/Templates/client-server/Tests/{{PROJECT_NAME}}UITests/Views/BoardViewTests.swift.tmpl new file mode 100644 index 00000000..2597c2ca --- /dev/null +++ b/Sources/FOSMVVMBootstrap/Templates/client-server/Tests/{{PROJECT_NAME}}UITests/Views/BoardViewTests.swift.tmpl @@ -0,0 +1,37 @@ +// BoardViewTests.swift +{{LICENSE_HEADER}} + +import FOSFoundation +import FOSMVVM +import FOSTestingUI +import Foundation +import SwiftUI +import XCTest + +// The SERVER-BACKED operation example: tapping "Add Card" dispatches a +// CreateCardRequest through the injected environment. Under test the operation is +// the recording stub, so no server is involved; the recorded call, read back +// through the transporter, is the witness that the tap landed. +final class BoardViewTestsUITests: {{PROJECT_NAME}}ViewModelViewTestCase, + @unchecked Sendable { + + func testShowBoardView() throws { + let app = try presentView() + + XCTAssertTrue(app.uiTestingElement("boardTitle").exists) + } + + func testAddCardDispatchesCreate() throws { + let app = try presentView() + + XCTAssertTrue(tap( + app.uiTestingElement("addCardButton"), + until: { (try? self.viewModelOperations().createCardCalled) == true } + )) + + let stubOps = try viewModelOperations() + + XCTAssertTrue(stubOps.createCardCalled) + XCTAssertEqual(stubOps.createCardCalledWithTitle, "New Card") + } +} diff --git a/Sources/FOSMVVMBootstrap/Templates/client-server/Tests/{{PROJECT_NAME}}UITests/{{PROJECT_NAME}}UITests.swift.tmpl b/Sources/FOSMVVMBootstrap/Templates/client-server/Tests/{{PROJECT_NAME}}UITests/{{PROJECT_NAME}}UITests.swift.tmpl new file mode 100644 index 00000000..b4b7786f --- /dev/null +++ b/Sources/FOSMVVMBootstrap/Templates/client-server/Tests/{{PROJECT_NAME}}UITests/{{PROJECT_NAME}}UITests.swift.tmpl @@ -0,0 +1,11 @@ +// {{PROJECT_NAME}}UITests.swift +{{LICENSE_HEADER}} +import XCTest + +final class {{PROJECT_NAME}}UITests: XCTestCase { + @MainActor func testAppLaunches() throws { + let app = XCUIApplication() + app.launch() + XCTAssertTrue(app.windows.firstMatch.waitForExistence(timeout: 10)) + } +} diff --git a/Sources/FOSMVVMBootstrap/Templates/client-server/Tests/{{PROJECT_NAME}}UnitTests/{{PROJECT_NAME}}UnitTests.swift.tmpl b/Sources/FOSMVVMBootstrap/Templates/client-server/Tests/{{PROJECT_NAME}}UnitTests/{{PROJECT_NAME}}UnitTests.swift.tmpl new file mode 100644 index 00000000..6460d219 --- /dev/null +++ b/Sources/FOSMVVMBootstrap/Templates/client-server/Tests/{{PROJECT_NAME}}UnitTests/{{PROJECT_NAME}}UnitTests.swift.tmpl @@ -0,0 +1,22 @@ +// {{PROJECT_NAME}}UnitTests.swift +{{LICENSE_HEADER}} +import Testing +@testable import {{PROJECT_NAME}} +import {{PROJECT_NAME}}ClientViewModels + +/// Proves the correlation seam's pure join: a card is "mine" only when the +/// server-exposed `ownerTag` matches this client's `localOwnerTag`. +@Suite("Correlation seam") +struct {{PROJECT_NAME}}UnitTests { + @Test func matchesByOwnerTag() { + let mine = CardViewModel(title: "Mine", ownerTag: "me") + let theirs = CardViewModel(title: "Theirs", ownerTag: "you") + let untagged = CardViewModel(title: "Untagged", ownerTag: nil) + let about = AboutViewModel(localOwnerTag: "me") + + #expect(isMine(card: mine, about: about)) + #expect(!isMine(card: theirs, about: about)) + #expect(!isMine(card: untagged, about: about)) + #expect(!isMine(card: mine, about: AboutViewModel(localOwnerTag: nil))) + } +} diff --git a/Sources/FOSMVVMBootstrap/Templates/client-server/Tests/{{PROJECT_NAME}}ViewModelsTests/BoardViewModelTests.swift.tmpl b/Sources/FOSMVVMBootstrap/Templates/client-server/Tests/{{PROJECT_NAME}}ViewModelsTests/BoardViewModelTests.swift.tmpl new file mode 100644 index 00000000..a4edcfe8 --- /dev/null +++ b/Sources/FOSMVVMBootstrap/Templates/client-server/Tests/{{PROJECT_NAME}}ViewModelsTests/BoardViewModelTests.swift.tmpl @@ -0,0 +1,24 @@ +// BoardViewModelTests.swift +{{LICENSE_HEADER}} +import FOSFoundation +import FOSMVVM +import FOSTesting +import Foundation +import Testing +import {{PROJECT_NAME}}ViewModels + +@Suite("BoardViewModel") +struct BoardViewModelTests: LocalizableTestCase { + let locStore: LocalizationStore + + @Test func viewModelTests() throws { + try expectFullViewModelTests(BoardViewModel.self) + } + + init() throws { + self.locStore = try Self.loadLocalizationStore( + bundle: Bundle.module, + resourceDirectoryName: "Resources" + ) + } +} diff --git a/Sources/FOSMVVMBootstrap/Templates/client-server/Tests/{{PROJECT_NAME}}ViewModelsTests/LocalizableTestCase+{{PROJECT_NAME}}.swift.tmpl b/Sources/FOSMVVMBootstrap/Templates/client-server/Tests/{{PROJECT_NAME}}ViewModelsTests/LocalizableTestCase+{{PROJECT_NAME}}.swift.tmpl new file mode 100644 index 00000000..1692cead --- /dev/null +++ b/Sources/FOSMVVMBootstrap/Templates/client-server/Tests/{{PROJECT_NAME}}ViewModelsTests/LocalizableTestCase+{{PROJECT_NAME}}.swift.tmpl @@ -0,0 +1,10 @@ +// LocalizableTestCase+{{PROJECT_NAME}}.swift +{{LICENSE_HEADER}} +import FOSTesting +import Foundation + +// The test target's locale set, declared ONCE for every LocalizableTestCase suite +// in this target (add locales here, not in each suite). +extension LocalizableTestCase { + var locales: Set { [Self.en, Self.es] } +} diff --git a/Sources/FOSMVVMBootstrap/Templates/client-server/project.yml.tmpl b/Sources/FOSMVVMBootstrap/Templates/client-server/project.yml.tmpl new file mode 100644 index 00000000..363187aa --- /dev/null +++ b/Sources/FOSMVVMBootstrap/Templates/client-server/project.yml.tmpl @@ -0,0 +1,230 @@ +name: {{PROJECT_NAME}} + +options: + # Xcode 16 synchronized folders (PBXFileSystemSynchronizedRootGroup): the + # .xcodeproj auto-syncs with the filesystem, so adding/removing files needs no + # regeneration and the pbxproj does not churn in source control. project.yml + # stays the source of truth — regenerate any time. + projectFormat: xcode16_0 + # Nest the per-target synchronized folders under plain top-level `Sources` and + # `Tests` groups (rather than flat `Sources/X`-named folders at the project root). + createIntermediateGroups: true + deploymentTarget:{{DEPLOYMENT_TARGETS}} + groupSortPosition: top + +settings: + base: + SWIFT_VERSION: "6.0" + SWIFT_STRICT_CONCURRENCY: complete + BUILD_LIBRARY_FOR_DISTRIBUTION: NO + DEVELOPMENT_TEAM: {{TEAM_ID}} + # "Automatically manage signing" — without this, Xcode defaults to Manual + # and ⌘U can't sign/launch the app or its test host. + CODE_SIGN_STYLE: Automatic + +packages: + FOSUtilities: + url: https://github.com/foscomputerservices/FOSUtilities.git + from: "{{FOS_VERSION}}" + +targets: + # The ONE doorway for external SPM products (type identity — see + # memory/spm-libraries-settled.md). + SPMLibraries: + type: framework + supportedDestinations: {{FRAMEWORK_DESTINATIONS}} + sources: + - path: Sources/SPMLibraries + type: syncedFolder + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: {{BUNDLE_ID_ROOT}}.SPMLibraries + GENERATE_INFOPLIST_FILE: YES{{DEVICE_FAMILY_OVERRIDE}}{{XR_COMPAT_OVERRIDE}} + dependencies: + - package: FOSUtilities + product: FOSFoundation + - package: FOSUtilities + product: FOSMVVM + # FOSTesting is deliberately NOT here: the umbrella embeds in the shipping + # app, and testing products don't ride along. Test targets link FOSTesting + # directly — their FOSTesting types are never shared across targets, so + # the type-identity rule doesn't apply to them. (Ruled 2026-08-19.) + + # Shared foundation — SystemVersion (the version handshake) + base types, so the + # server needs neither the display contract nor SwiftUI to set its version. + {{PROJECT_NAME}}Foundation: + type: framework + supportedDestinations: {{FRAMEWORK_DESTINATIONS}} + sources: + - path: Sources/{{PROJECT_NAME}}Foundation + type: syncedFolder + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: {{BUNDLE_ID_ROOT}}.Foundation + GENERATE_INFOPLIST_FILE: YES{{DEVICE_FAMILY_OVERRIDE}}{{XR_COMPAT_OVERRIDE}} + dependencies: + - target: SPMLibraries + embed: false # link only — the app embeds (single-embed rule) + + # Client-hosted ViewModels — their own framework, with bundled YAML. + {{PROJECT_NAME}}ClientViewModels: + type: framework + supportedDestinations: {{FRAMEWORK_DESTINATIONS}} + sources: + - path: Sources/{{PROJECT_NAME}}ClientViewModels + type: syncedFolder + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: {{BUNDLE_ID_ROOT}}.ClientViewModels + GENERATE_INFOPLIST_FILE: YES{{DEVICE_FAMILY_OVERRIDE}}{{XR_COMPAT_OVERRIDE}} + dependencies: + - target: SPMLibraries + embed: false # link only — the app embeds (single-embed rule) + + {{PROJECT_NAME}}: + type: application + supportedDestinations: {{SUPPORTED_DESTINATIONS}} + sources: + # The app's own sources. Info.plist + entitlements stay in the folder but + # are excepted from the build phases (the synced-folder exception set); + # they are referenced by INFOPLIST_FILE / CODE_SIGN_ENTITLEMENTS below. + - path: Sources/{{PROJECT_NAME}} + type: syncedFolder + excludes: + - "Info.plist" + - "{{PROJECT_NAME}}.entitlements" + # Source-include the shared contract — compiled twice (the SPM + # {{PROJECT_NAME}}ViewModels lib compiles the same folder for the server). + # The server-fetched VMs are NOT a separate framework; only the client-hosted + # ones are. FOS enters once via SPMLibraries, which this target embeds. + - path: Sources/{{PROJECT_NAME}}ViewModels + type: syncedFolder + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: {{BUNDLE_ID_ROOT}} + PRODUCT_NAME: {{PROJECT_NAME}} + INFOPLIST_FILE: Sources/{{PROJECT_NAME}}/Info.plist + CODE_SIGN_ENTITLEMENTS: Sources/{{PROJECT_NAME}}/{{PROJECT_NAME}}.entitlements + MARKETING_VERSION: "0.1" + CURRENT_PROJECT_VERSION: 1{{DEVICE_FAMILY_OVERRIDE}}{{XR_COMPAT_OVERRIDE}} + configs: + # Hardened runtime is Release-only. In Debug, Xcode signs SPM package + # frameworks ad-hoc (TeamIdentifier=not set) and library validation + # refuses to map them into a hardened-runtime process ("different Team + # IDs") — the app dies in dyld before main(), which kills macOS UI + # testing. Release keeps it for notarization. + Debug: + ENABLE_HARDENED_RUNTIME: NO + Release: + ENABLE_HARDENED_RUNTIME: YES + dependencies: + - target: SPMLibraries + embed: true + codeSign: true + - target: {{PROJECT_NAME}}Foundation + embed: true + codeSign: true + - target: {{PROJECT_NAME}}ClientViewModels + embed: true + codeSign: true + + {{PROJECT_NAME}}UnitTests: + type: bundle.unit-test + supportedDestinations: {{SUPPORTED_DESTINATIONS}} + sources: + - path: Tests/{{PROJECT_NAME}}UnitTests + type: syncedFolder + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: {{BUNDLE_ID_ROOT}}-unit-tests + GENERATE_INFOPLIST_FILE: YES{{DEVICE_FAMILY_OVERRIDE}}{{XR_COMPAT_OVERRIDE}} + TEST_HOST: "$(BUILT_PRODUCTS_DIR)/{{PROJECT_NAME}}.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/{{PROJECT_NAME}}" + BUNDLE_LOADER: "$(TEST_HOST)" + dependencies: + - target: {{PROJECT_NAME}} + - target: SPMLibraries + embed: false + - target: {{PROJECT_NAME}}ClientViewModels + embed: false + + {{PROJECT_NAME}}ClientViewModelsTests: + type: bundle.unit-test + supportedDestinations: {{SUPPORTED_DESTINATIONS}} + sources: + # CLIENT-hosted VMs only. Server-based VMs (BoardViewModel) are tested by + # the Package.swift tests, which embody the server — the .xcodeproj is the + # client app's. + - path: Tests/{{PROJECT_NAME}}ClientViewModelsTests + type: syncedFolder + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: {{BUNDLE_ID_ROOT}}-client-vm-tests + GENERATE_INFOPLIST_FILE: YES{{DEVICE_FAMILY_OVERRIDE}}{{XR_COMPAT_OVERRIDE}} + TEST_HOST: "$(BUILT_PRODUCTS_DIR)/{{PROJECT_NAME}}.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/{{PROJECT_NAME}}" + BUNDLE_LOADER: "$(TEST_HOST)" + dependencies: + - target: {{PROJECT_NAME}} + - target: SPMLibraries + embed: false + - target: {{PROJECT_NAME}}ClientViewModels + embed: false + # AboutViewModelTests + LocalizableTestCase+ import FOSTesting — linked + # directly per the doctrine: testing products stay out of SPMLibraries, + # and test targets never share FOSTesting types with each other. + - package: FOSUtilities + product: FOSTesting + + {{PROJECT_NAME}}UITests: + type: bundle.ui-testing + supportedDestinations: {{SUPPORTED_DESTINATIONS}} + sources: + # The test dir includes the `Resources` symlink → Sources/Resources: the + # SERVER YAML rides into the harness so server-based VM stubs localize + # with real strings (the base class merges it with the client framework's + # YAML via setUp(bundles:), FOS ≥ 0.13.2). + - path: Tests/{{PROJECT_NAME}}UITests + type: syncedFolder + # Compile the source-included shared contract into the UITests target too, so + # its ViewModels (BoardViewModel + BoardViewModelStubOps) are in-module and + # BoardViewTests names them with no import — same pattern as the app. (The + # black-box test drives a separate process, so the two in-module copies never + # meet: harness params round-trip through JSON.) + - path: Sources/{{PROJECT_NAME}}ViewModels + type: syncedFolder + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: {{BUNDLE_ID_ROOT}}-ui-tests + GENERATE_INFOPLIST_FILE: YES{{DEVICE_FAMILY_OVERRIDE}}{{XR_COMPAT_OVERRIDE}} + dependencies: + - target: {{PROJECT_NAME}} + # AboutViewTests imports the client-hosted framework (its VM + StubOps); the + # shared LocalizableTestCase+ imports FOSTesting; SPMLibraries is the FOS + # type-identity doorway (FOSMVVM/FOSFoundation). + - target: SPMLibraries + embed: false + - target: {{PROJECT_NAME}}ClientViewModels + embed: false + - package: FOSUtilities + product: FOSTesting + - package: FOSUtilities + product: FOSTestingUI +{{WATCH_TARGET}} +schemes: + {{PROJECT_NAME}}: + build: + targets: + {{PROJECT_NAME}}: all + {{PROJECT_NAME}}UnitTests: [test] + {{PROJECT_NAME}}ClientViewModelsTests: [test] + {{PROJECT_NAME}}UITests: [test] + run: + config: Debug + test: + config: Debug + targets: + - {{PROJECT_NAME}}UnitTests + - {{PROJECT_NAME}}ClientViewModelsTests + - {{PROJECT_NAME}}UITests + archive: + config: Release +{{WATCH_SCHEME}} diff --git a/Sources/FOSMVVMBootstrap/Templates/client-server/{{PROJECT_NAME}}.xctestplan b/Sources/FOSMVVMBootstrap/Templates/client-server/{{PROJECT_NAME}}.xctestplan new file mode 100644 index 00000000..de31c7ad --- /dev/null +++ b/Sources/FOSMVVMBootstrap/Templates/client-server/{{PROJECT_NAME}}.xctestplan @@ -0,0 +1,35 @@ +{ + "configurations" : [ + { + "id" : "00000000-0000-0000-0000-000000000001", + "name" : "Configuration 1", + "options" : { + + } + } + ], + "defaultOptions" : { + "codeCoverage" : false + }, + "testTargets" : [ + { + "target" : { + "containerPath" : "container:{{PROJECT_NAME}}.xcodeproj", + "name" : "{{PROJECT_NAME}}UnitTests" + } + }, + { + "target" : { + "containerPath" : "container:{{PROJECT_NAME}}.xcodeproj", + "name" : "{{PROJECT_NAME}}ClientViewModelsTests" + } + }, + { + "target" : { + "containerPath" : "container:{{PROJECT_NAME}}.xcodeproj", + "name" : "{{PROJECT_NAME}}UITests" + } + } + ], + "version" : 1 +} diff --git a/Sources/FOSMVVMBootstrap/Templates/local-only/README.md.tmpl b/Sources/FOSMVVMBootstrap/Templates/local-only/README.md.tmpl new file mode 100644 index 00000000..08d0aa3d --- /dev/null +++ b/Sources/FOSMVVMBootstrap/Templates/local-only/README.md.tmpl @@ -0,0 +1,56 @@ +# {{PROJECT_NAME}} + +A local-only FOSMVVM macOS app, scaffolded by fosmvvm-bootstrap. + +This is a walking skeleton: one screen (`WelcomeView`), no server. + +## Shape + +- **`SPMLibraries`** — the single umbrella framework through which all + external SPM products (FOSFoundation, FOSMVVM) are linked. Never link + an SPM product directly into another target. See + `memory/spm-libraries-settled.md` before touching linkage — this is + settled doctrine, not a preference. +- **`ViewModels`** — the shared contract framework: client-hosted + ViewModels plus their localization YAML. +- **`{{PROJECT_NAME}}`** — the SwiftUI app. It embeds the two frameworks + and hosts the views. + +## Localization is client-hosted + +This app talks to no server, so localized strings resolve on-device. +The `ViewModels` framework ships its YAML as a bundled resource; +`ViewModelsResourceAccess.localizationBundle` exposes it, and the app +passes that bundle to `MVVMEnvironment.resourceBundles`. The +`deploymentURLs` dictionary is intentionally empty — that is the correct +expression of "no server", not a placeholder to fill in. + +## Tests run in Xcode / iOS Simulator + +`xcodebuild build-for-testing` on macOS fails at `Ld` for this layout — +a known Xcode limitation, not a misconfiguration (see +`memory/macos-build-for-testing-faq.md`). Run the suite from Xcode (⌘U) +or on the iOS Simulator; the app itself builds fine on macOS. + +The app-side UI-test wiring (`.testHost()` + `registerTestingViews`) +ships ready. Generate the full per-view `ViewModelViewTestCase` harness +with the `fosmvvm-ui-tests-generator` skill. + +## Finishing checklist + +Things the tooling structurally cannot do — finish these by hand: + +1. `git init && git add -A && git commit`. +2. Open `{{PROJECT_NAME}}.xcodeproj` in Xcode: + - Convert the enumerated source groups to synchronized folders + (File Inspector → each top-level group; XcodeGen cannot emit + `PBXFileSystemSynchronizedRootGroup`). + - Confirm signing: your real `DEVELOPMENT_TEAM` on every target. + - Run the test suite (⌘U). + - Add iOS/iPadOS destinations if wanted. +3. Delete `project.yml`; commit the `.xcodeproj`. It is hand-maintained + from here — do not regenerate. +4. Read `CLAUDE.md` and `memory/` — settled doctrine ships with the + project. +5. Add screens via the `fosmvvm-viewmodel-generator` + + `fosmvvm-swiftui-view-generator` skills. diff --git a/Sources/FOSMVVMBootstrap/Templates/local-only/Sources/SPMLibraries/SPMLibraries.swift.tmpl b/Sources/FOSMVVMBootstrap/Templates/local-only/Sources/SPMLibraries/SPMLibraries.swift.tmpl new file mode 100644 index 00000000..9d905e7b --- /dev/null +++ b/Sources/FOSMVVMBootstrap/Templates/local-only/Sources/SPMLibraries/SPMLibraries.swift.tmpl @@ -0,0 +1,26 @@ +// SPMLibraries.swift +{{LICENSE_HEADER}} +import Foundation + +// ### SPMLibraries — SPM Dependencies +// +// It is required that ALL external SPM products are linked into THIS +// framework and NOT directly into any other target of the project. +// +// Linking an SPM library statically into multiple targets compiles a +// separate copy of its types into each target. Swift's mangled type +// name carries the linking context, so the "same" type has a different +// runtime identity per target: TypeA != TypeA. `is` / `as?` / `==` / +// `===` fail across target boundaries, at runtime, far from the cause. +// This is a generic Xcode+SPM packaging bug — nothing to do with FOS — +// but FOS internals rely heavily on comparing types (e.g. +// FOSMVVM.localizingEncoder's `value as? (any ViewModel)`). +// +// One umbrella dynamic framework = one canonical copy = one shared +// type identity everywhere. See memory/spm-libraries-settled.md. +// +// Carve-out (ruled 2026-08-19): FOS testing products (FOSTesting, +// FOSTestingUI) stay OUT of this umbrella — it embeds in the shipping +// app, and testing frameworks must not ride along. Test targets link +// them directly; their testing types are never shared across targets, +// so the type-identity rule does not apply to them. diff --git a/Sources/FOSMVVMBootstrap/Templates/local-only/Sources/ViewModels/Resources/ViewModels/WelcomeViewModel.yml b/Sources/FOSMVVMBootstrap/Templates/local-only/Sources/ViewModels/Resources/ViewModels/WelcomeViewModel.yml new file mode 100644 index 00000000..7857ce32 --- /dev/null +++ b/Sources/FOSMVVMBootstrap/Templates/local-only/Sources/ViewModels/Resources/ViewModels/WelcomeViewModel.yml @@ -0,0 +1,10 @@ +en: + WelcomeViewModel: + welcomeTitle: "Welcome" + welcomeMessage: "Your FOSMVVM app is alive." + okButtonTitle: "OK" +es: + WelcomeViewModel: + welcomeTitle: "Bienvenido" + welcomeMessage: "Tu aplicación FOSMVVM está viva." + okButtonTitle: "Aceptar" diff --git a/Sources/FOSMVVMBootstrap/Templates/local-only/Sources/ViewModels/Versioning/SystemVersion+App.swift.tmpl b/Sources/FOSMVVMBootstrap/Templates/local-only/Sources/ViewModels/Versioning/SystemVersion+App.swift.tmpl new file mode 100644 index 00000000..0e3a2fe0 --- /dev/null +++ b/Sources/FOSMVVMBootstrap/Templates/local-only/Sources/ViewModels/Versioning/SystemVersion+App.swift.tmpl @@ -0,0 +1,37 @@ +// SystemVersion+App.swift +{{LICENSE_HEADER}} +import FOSFoundation +import Foundation + +public extension SystemVersion { + /// This application's version — pass it to `MVVMEnvironment(currentVersion:)`. + /// + /// MVVMEnvironment(currentVersion: .currentApplicationVersion, appBundle: .main, ...) + /// + /// `major`/`minor` are pinned here in code (bump `v0_1_0` when you cut a release); + /// the patch is the build number, delivered through the bundle's `CFBundleVersion` + /// (set `CURRENT_PROJECT_VERSION` in the project, or from CI). In DEBUG this asserts + /// the bundle's `CFBundleShortVersionString` (from `MARKETING_VERSION`) equals + /// `major.minor`, so build settings and code can never silently drift. + static let currentApplicationVersion: SystemVersion = { + let codeVersion = Self.v0_1_0 + let patch = Int(Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "0") ?? 0 + + #if DEBUG + if let marketingVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String { + let expected = "\(codeVersion.major).\(codeVersion.minor)" + guard marketingVersion == expected else { + fatalError( + "MARKETING_VERSION (\(marketingVersion)) != SystemVersion.currentApplicationVersion (\(expected)). Update the project's Version, or v0_1_0 here." + ) + } + } + #endif + + return .init(major: codeVersion.major, minor: codeVersion.minor, patch: patch) + }() + + /// The pinned `major.minor` for this release line. Add the next line's constant + /// (`v0_2_0`, `v1_0_0`, …) when you bump, and point `currentApplicationVersion` at it. + static let v0_1_0: SystemVersion = .init(major: 0, minor: 1) +} diff --git a/Sources/FOSMVVMBootstrap/Templates/local-only/Sources/ViewModels/ViewModels/WelcomeViewModel.swift.tmpl b/Sources/FOSMVVMBootstrap/Templates/local-only/Sources/ViewModels/ViewModels/WelcomeViewModel.swift.tmpl new file mode 100644 index 00000000..63626be9 --- /dev/null +++ b/Sources/FOSMVVMBootstrap/Templates/local-only/Sources/ViewModels/ViewModels/WelcomeViewModel.swift.tmpl @@ -0,0 +1,43 @@ +// WelcomeViewModel.swift +{{LICENSE_HEADER}} + +import FOSFoundation +import FOSMVVM +import Foundation + +@ViewModel(options: [.clientHostedFactory]) +public struct WelcomeViewModel { + @LocalizedString public var welcomeTitle + @LocalizedString public var welcomeMessage + @LocalizedString public var okButtonTitle + + // MARK: Operations Access + + private let isStub: Bool + + #if canImport(SwiftUI) + public var operations: any WelcomeViewModelOperations { + isStub ? WelcomeViewModelStubOps() : WelcomeViewModelOps() + } + #endif + + public var vmId = ViewModelId() + + public init() { + self.init( + isStub: false + ) + } + + public static func stub() -> Self { + .init( + isStub: true + ) + } +} + +extension WelcomeViewModel { + private init (isStub: Bool) { + self.isStub = isStub + } +} diff --git a/Sources/FOSMVVMBootstrap/Templates/local-only/Sources/ViewModels/ViewModels/WelcomeViewModelOperations.swift.tmpl b/Sources/FOSMVVMBootstrap/Templates/local-only/Sources/ViewModels/ViewModels/WelcomeViewModelOperations.swift.tmpl new file mode 100644 index 00000000..b6bd7d0b --- /dev/null +++ b/Sources/FOSMVVMBootstrap/Templates/local-only/Sources/ViewModels/ViewModels/WelcomeViewModelOperations.swift.tmpl @@ -0,0 +1,54 @@ +// +{{LICENSE_HEADER}} +// WelcomeViewModelOperations.swift +// {{PROJECT_NAME}} +// +// Created by David Hunt on 8/8/26. +// + +import FOSFoundation +import FOSMVVM +import Foundation +#if canImport(SwiftUI) +import SwiftUI +#endif + +#if canImport(SwiftUI) + +// MARK: Operations + +public protocol WelcomeViewModelOperations: ViewModelOperations { + func presentWelcome(output welcomeStatus: Binding) + func dismissWelcome(output welcomeStatus: Binding) +} + +public struct WelcomeViewModelOps: WelcomeViewModelOperations { + public func presentWelcome(output welcomeStatus: Binding) { + welcomeStatus.wrappedValue = true + } + public func dismissWelcome(output welcomeStatus: Binding) { + welcomeStatus.wrappedValue = false + } +} + +public final class WelcomeViewModelStubOps: WelcomeViewModelOperations, @unchecked Sendable { + public private(set) var presentWelcomeCalled: Bool + public private(set) var dismissWelcomeCalled: Bool + + public func presentWelcome(output welcomeStatus: Binding) { + presentWelcomeCalled = true + welcomeStatus.wrappedValue = true + } + + public func dismissWelcome(output welcomeStatus: Binding) { + dismissWelcomeCalled = true + welcomeStatus.wrappedValue = false + } + + public init() { + self.presentWelcomeCalled = false + self.dismissWelcomeCalled = false + } +} + +#endif diff --git a/Sources/FOSMVVMBootstrap/Templates/local-only/Sources/ViewModels/ViewModelsResourceAccess.swift.tmpl b/Sources/FOSMVVMBootstrap/Templates/local-only/Sources/ViewModels/ViewModelsResourceAccess.swift.tmpl new file mode 100644 index 00000000..f9d46cfc --- /dev/null +++ b/Sources/FOSMVVMBootstrap/Templates/local-only/Sources/ViewModels/ViewModelsResourceAccess.swift.tmpl @@ -0,0 +1,24 @@ +// ViewModelsResourceAccess.swift +{{LICENSE_HEADER}} +import Foundation + +/// Access to the ViewModels framework's client-hosted resources. +/// +/// The app passes this bundle to `MVVMEnvironment` so client-hosted +/// ViewModels resolve their localized strings on-device: +/// +/// ```swift +/// MVVMEnvironment( +/// currentVersion: .currentApplicationVersion, +/// appBundle: Bundle.main, +/// resourceBundles: [ViewModelsResourceAccess.localizationBundle], +/// deploymentURLs: [Deployment: MVVMEnvironment.URLPackage]() +/// ) +/// ``` +public enum ViewModelsResourceAccess { + private final class ResourceAccessClass {} + + public static var localizationBundle: Bundle { + Bundle(for: ResourceAccessClass.self) + } +} diff --git a/Sources/FOSMVVMBootstrap/Templates/local-only/Sources/{{PROJECT_NAME}}/App/TestConfiguration.swift.tmpl b/Sources/FOSMVVMBootstrap/Templates/local-only/Sources/{{PROJECT_NAME}}/App/TestConfiguration.swift.tmpl new file mode 100644 index 00000000..67f5eabb --- /dev/null +++ b/Sources/FOSMVVMBootstrap/Templates/local-only/Sources/{{PROJECT_NAME}}/App/TestConfiguration.swift.tmpl @@ -0,0 +1,17 @@ +// TestConfiguration.swift +{{LICENSE_HEADER}} + +import Foundation + +/// Describes the various base configurations of ``AppState`` for testing purposes +/// +/// > This file is symbolically linked into the UITests target to share the type between the UI tests and +/// > the app. Using an enum eliminates the "stringly-typed" nature of communication +/// > between these points. +/// +/// > The number of configuration should be kept to an absolute minimum. If there is a tendency +/// > for the number of configurations to grow, the Views and ViewModels should be re-evaluated +/// > as most state should be in the ViewModel, not in AppState. +enum TestConfiguration: Codable { + case none +} diff --git a/Sources/FOSMVVMBootstrap/Templates/local-only/Sources/{{PROJECT_NAME}}/App/{{PROJECT_NAME}}App.swift.tmpl b/Sources/FOSMVVMBootstrap/Templates/local-only/Sources/{{PROJECT_NAME}}/App/{{PROJECT_NAME}}App.swift.tmpl new file mode 100644 index 00000000..c67c7d6e --- /dev/null +++ b/Sources/FOSMVVMBootstrap/Templates/local-only/Sources/{{PROJECT_NAME}}/App/{{PROJECT_NAME}}App.swift.tmpl @@ -0,0 +1,68 @@ +// {{PROJECT_NAME}}App.swift +{{LICENSE_HEADER}} + +import FOSFoundation +import FOSMVVM +import SwiftUI +import ViewModels + +@main +struct {{PROJECT_NAME}}App: App { + @State private var mvvmEnv = makeMVVMEnvironment() + + var body: some Scene { + WindowGroup { + WelcomeView.bind(appState: .init()) + .testHost() + } + .environment(mvvmEnv) + } + + init() { + MVVMEnvironment.registerTestingViews() + } +} + +private extension {{PROJECT_NAME}}App { + @MainActor static func makeMVVMEnvironment() -> MVVMEnvironment { + MVVMEnvironment( + currentVersion: .currentApplicationVersion, + appBundle: Bundle.main, + resourceBundles: [ + ViewModelsResourceAccess.localizationBundle + ], + // This app talks to no server. An empty dictionary is the + // correct expression of that — do not invent placeholder URLs. + deploymentURLs: [Deployment: MVVMEnvironment.URLPackage]() + ) + } +} + +extension ViewModelView { + static func previewHost( + resourceDirectoryName: String = "", + locale: Locale = .current, + viewModel: VM = .stub(), + setStates: ((inout Self) -> Void)? = nil + ) -> some View { + previewHost( + bundle: ViewModelsResourceAccess.localizationBundle, + resourceDirectoryName: resourceDirectoryName, + locale: locale, + viewModel: viewModel, + setStates: setStates + ) + } + +} + +private extension MVVMEnvironment { + // Every ViewModelView is listed here to enable individualized + // testing of each view. Registration must happen before the first + // render — testHost() resolves the view under test in init(). + @MainActor static func registerTestingViews() { + #if DEBUG + registerTestView(WelcomeView.self) + #endif + } +} diff --git a/Sources/FOSMVVMBootstrap/Templates/local-only/Sources/{{PROJECT_NAME}}/Info.plist b/Sources/FOSMVVMBootstrap/Templates/local-only/Sources/{{PROJECT_NAME}}/Info.plist new file mode 100644 index 00000000..8c7eadd5 --- /dev/null +++ b/Sources/FOSMVVMBootstrap/Templates/local-only/Sources/{{PROJECT_NAME}}/Info.plist @@ -0,0 +1,27 @@ + + + + + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleName + $(PRODUCT_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSPrincipalClass + NSApplication + + UILaunchScreen + + + diff --git a/Sources/FOSMVVMBootstrap/Templates/local-only/Sources/{{PROJECT_NAME}}/Views/WelcomeView.swift.tmpl b/Sources/FOSMVVMBootstrap/Templates/local-only/Sources/{{PROJECT_NAME}}/Views/WelcomeView.swift.tmpl new file mode 100644 index 00000000..93eed1ab --- /dev/null +++ b/Sources/FOSMVVMBootstrap/Templates/local-only/Sources/{{PROJECT_NAME}}/Views/WelcomeView.swift.tmpl @@ -0,0 +1,70 @@ +// WelcomeView.swift +{{LICENSE_HEADER}} + +import FOSMVVM +import SwiftUI +import ViewModels + +struct WelcomeView: ViewModelView { + @State private var isWelcomeAlertVisible: Bool = false + + private let viewModel: WelcomeViewModel + private let operations: WelcomeViewModelOperations + + #if DEBUG + @State private var repaintToggle = false + #endif + + var body: some View { + VStack(spacing: 12) { + Text(viewModel.welcomeTitle) + .font(.largeTitle) + .uiTestingIdentifier("welcomeTitle") + Text(viewModel.welcomeMessage) + .uiTestingIdentifier("welcomeMessage") + + Button(viewModel.welcomeTitle, action: presentWelcomeAlert) + .uiTestingIdentifier("presentWelcomeAlertButton") + } + .alert(viewModel.welcomeTitle, isPresented: $isWelcomeAlertVisible) { + // A plain button (no `role: .confirm`) — `.confirm` is macOS 26.0-only, + // and the skeleton targets the FOS floor. Add the role once you raise + // the deployment target. + Button(action: dismissWelcomeAlert) { + Text(viewModel.okButtonTitle) + .uiTestingIdentifier("dismissWelcomeAlertButton") + } + } + .padding() + #if DEBUG + .testDataTransporter(viewModelOps: operations, repaintToggle: $repaintToggle) + #endif + } + + init(viewModel: WelcomeViewModel) { + self.viewModel = viewModel + self.operations = viewModel.operations + } +} + +private extension WelcomeView { + private func presentWelcomeAlert() { + defer { toggleRepaint() } + operations.presentWelcome(output: $isWelcomeAlertVisible) + } + + private func dismissWelcomeAlert() { + defer { toggleRepaint() } + operations.dismissWelcome(output: $isWelcomeAlertVisible) + } + + private func toggleRepaint() { + #if DEBUG + repaintToggle.toggle() + #endif + } +} + +#Preview { + WelcomeView.previewHost() +} diff --git a/Sources/FOSMVVMBootstrap/Templates/local-only/Sources/{{PROJECT_NAME}}/{{PROJECT_NAME}}.entitlements b/Sources/FOSMVVMBootstrap/Templates/local-only/Sources/{{PROJECT_NAME}}/{{PROJECT_NAME}}.entitlements new file mode 100644 index 00000000..13cb114c --- /dev/null +++ b/Sources/FOSMVVMBootstrap/Templates/local-only/Sources/{{PROJECT_NAME}}/{{PROJECT_NAME}}.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.security.app-sandbox + + + diff --git a/Sources/FOSMVVMBootstrap/Templates/local-only/Tests/{{PROJECT_NAME}}UITests/Support/LocalizableTestCase+{{PROJECT_NAME}}.swift.tmpl b/Sources/FOSMVVMBootstrap/Templates/local-only/Tests/{{PROJECT_NAME}}UITests/Support/LocalizableTestCase+{{PROJECT_NAME}}.swift.tmpl new file mode 100644 index 00000000..60ab5b1a --- /dev/null +++ b/Sources/FOSMVVMBootstrap/Templates/local-only/Tests/{{PROJECT_NAME}}UITests/Support/LocalizableTestCase+{{PROJECT_NAME}}.swift.tmpl @@ -0,0 +1,11 @@ +// LocalizableTestCase+{{PROJECT_NAME}}.swift +{{LICENSE_HEADER}} + +import FOSTesting +import Foundation + +// The test target's locale set, declared ONCE for every LocalizableTestCase suite +// in this target (add locales here, not in each suite). +extension LocalizableTestCase { + var locales: Set { [Self.en, Self.es] } +} diff --git a/Sources/FOSMVVMBootstrap/Templates/local-only/Tests/{{PROJECT_NAME}}UITests/Support/TestConfiguration.swift.symlink b/Sources/FOSMVVMBootstrap/Templates/local-only/Tests/{{PROJECT_NAME}}UITests/Support/TestConfiguration.swift.symlink new file mode 100644 index 00000000..4681b49d --- /dev/null +++ b/Sources/FOSMVVMBootstrap/Templates/local-only/Tests/{{PROJECT_NAME}}UITests/Support/TestConfiguration.swift.symlink @@ -0,0 +1 @@ +../../../Sources/{{PROJECT_NAME}}/App/TestConfiguration.swift diff --git a/Sources/FOSMVVMBootstrap/Templates/local-only/Tests/{{PROJECT_NAME}}UITests/Support/{{PROJECT_NAME}}ViewModelDisplayTestCase.swift.tmpl b/Sources/FOSMVVMBootstrap/Templates/local-only/Tests/{{PROJECT_NAME}}UITests/Support/{{PROJECT_NAME}}ViewModelDisplayTestCase.swift.tmpl new file mode 100644 index 00000000..f9ba04c7 --- /dev/null +++ b/Sources/FOSMVVMBootstrap/Templates/local-only/Tests/{{PROJECT_NAME}}UITests/Support/{{PROJECT_NAME}}ViewModelDisplayTestCase.swift.tmpl @@ -0,0 +1,48 @@ +// {{PROJECT_NAME}}ViewModelDisplayTestCase.swift +{{LICENSE_HEADER}} + +import FOSFoundation +import FOSMVVM +import FOSTestingUI +import Foundation +import ViewModels +import XCTest + +// The DISPLAY-ONLY base: for views with no user-initiated actions — one +// generic parameter, no ViewModelOperations. Do not invent an empty +// Operations type to use the interactive base; this class exists so you +// never have to. +// +// Deliberately non-final: every view test suite subclasses this base. +// swift lint:disable classes_should_be_final +class {{PROJECT_NAME}}ViewModelDisplayTestCase< + VM: ViewModel +>: ViewModelDisplayTestCase, @unchecked Sendable { + @MainActor func presentView( + configuration: TestConfiguration, + viewModel: VM = .stub(), + timeout: TimeInterval = 10 + ) throws -> XCUIApplication { + try presentView( + testConfiguration: configuration.toJSON(), + viewModel: viewModel, + timeout: timeout + ) + } + + override static var runsForEachTargetApplicationUIConfiguration: Bool { + false + } + + override func setUp() async throws { + try await super.setUp( + bundle: ViewModelsResourceAccess.localizationBundle, + resourceDirectoryName: "", + appBundleIdentifier: "{{BUNDLE_ID_ROOT}}" + ) + + continueAfterFailure = false // Stop the test and move on + } +} + +// swift lint:enable classes_should_be_final diff --git a/Sources/FOSMVVMBootstrap/Templates/local-only/Tests/{{PROJECT_NAME}}UITests/Support/{{PROJECT_NAME}}ViewModelViewTestCase.swift.tmpl b/Sources/FOSMVVMBootstrap/Templates/local-only/Tests/{{PROJECT_NAME}}UITests/Support/{{PROJECT_NAME}}ViewModelViewTestCase.swift.tmpl new file mode 100644 index 00000000..8472c476 --- /dev/null +++ b/Sources/FOSMVVMBootstrap/Templates/local-only/Tests/{{PROJECT_NAME}}UITests/Support/{{PROJECT_NAME}}ViewModelViewTestCase.swift.tmpl @@ -0,0 +1,78 @@ +// {{PROJECT_NAME}}ViewModelViewTestCase.swift +{{LICENSE_HEADER}} + +import FOSFoundation +import FOSMVVM +import FOSTestingUI +import Foundation +import ViewModels +import XCTest + +// The INTERACTIVE base: for views that dispatch to ViewModelOperations — +// `viewModelOperations()` is available to verify calls. Display-only views +// subclass {{PROJECT_NAME}}ViewModelDisplayTestCase instead. +// +// Deliberately non-final: every view test suite subclasses this base. +// swift lint:disable classes_should_be_final +class {{PROJECT_NAME}}ViewModelViewTestCase< + VM: ViewModel, + VMO: ViewModelOperations +>: ViewModelViewTestCase, @unchecked Sendable { + @MainActor func presentView( + configuration: TestConfiguration, + viewModel: VM = .stub(), + timeout: TimeInterval = 10 + ) throws -> XCUIApplication { + try presentView( + testConfiguration: configuration.toJSON(), + viewModel: viewModel, + timeout: timeout + ) + } + + /// Taps `element`, then polls `witness` for proof the tap landed, + /// re-tapping once at half `timeout`: + /// `tap(presentButton, until: { dismissButton.exists })`. + /// > A freshly launched app can discard its first synthesized event + /// > (per-launch window, measured on CI simulators 2026-08-23), so a + /// > tap is proven by the UI change it causes, never assumed from + /// > dispatch. + @MainActor + @discardableResult + func tap( + _ element: UITestingElement, + until witness: () -> Bool, + timeout: TimeInterval = 10 + ) -> Bool { + element.tap() + if poll(witness, for: timeout / 2) { return true } + element.tap() + return poll(witness, for: timeout / 2) + } + + @MainActor + private func poll(_ witness: () -> Bool, for interval: TimeInterval) -> Bool { + let deadline = Date(timeIntervalSinceNow: interval) + while Date() < deadline { + if witness() { return true } + RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.25)) + } + return witness() + } + + override static var runsForEachTargetApplicationUIConfiguration: Bool { + false + } + + override func setUp() async throws { + try await super.setUp( + bundle: ViewModelsResourceAccess.localizationBundle, + resourceDirectoryName: "", + appBundleIdentifier: "{{BUNDLE_ID_ROOT}}" + ) + + continueAfterFailure = false // Stop the test and move on + } +} + +// swift lint:enable classes_should_be_final diff --git a/Sources/FOSMVVMBootstrap/Templates/local-only/Tests/{{PROJECT_NAME}}UITests/Views/WelcomeViewTests.swift.tmpl b/Sources/FOSMVVMBootstrap/Templates/local-only/Tests/{{PROJECT_NAME}}UITests/Views/WelcomeViewTests.swift.tmpl new file mode 100644 index 00000000..edbdb212 --- /dev/null +++ b/Sources/FOSMVVMBootstrap/Templates/local-only/Tests/{{PROJECT_NAME}}UITests/Views/WelcomeViewTests.swift.tmpl @@ -0,0 +1,55 @@ +// WelcomeViewTestsTests.swift +{{LICENSE_HEADER}} + +import FOSFoundation +import FOSMVVM +import FOSTestingUI +import Foundation +import SwiftUI +import ViewModels +import XCTest + +final class WelcomeViewTestsUITests: {{PROJECT_NAME}}ViewModelViewTestCase, + @unchecked Sendable { + + func testShowWelcomeView() throws { + let app = try presentView() + + XCTAssertTrue(app.uiTestingElement("welcomeTitle").exists) + XCTAssertTrue(app.uiTestingElement("welcomeMessage").exists) + } + + func testPresentWelcomeAlert() throws { + let app = try presentView() + + // The SwiftUI alert itself cannot carry a testing identifier; its tagged + // dismiss button witnesses the alert's presence. + let dismissWelcomeAlertButton = app.uiTestingElement("dismissWelcomeAlertButton") + XCTAssertTrue(tap( + app.uiTestingElement("presentWelcomeAlertButton"), + until: { dismissWelcomeAlertButton.exists } + )) + + let stubOps = try viewModelOperations() + + XCTAssertTrue(stubOps.presentWelcomeCalled) + } + + func testDismissWelcomeAlert() throws { + let app = try presentView() + + let dismissWelcomeAlertButton = app.uiTestingElement("dismissWelcomeAlertButton") + XCTAssertTrue(tap( + app.uiTestingElement("presentWelcomeAlertButton"), + until: { dismissWelcomeAlertButton.exists } + )) + XCTAssertTrue(tap( + dismissWelcomeAlertButton, + until: { !dismissWelcomeAlertButton.exists } + )) + + let stubOps = try viewModelOperations() + + XCTAssertTrue(stubOps.dismissWelcomeCalled) + } +} diff --git a/Sources/FOSMVVMBootstrap/Templates/local-only/Tests/{{PROJECT_NAME}}UITests/{{PROJECT_NAME}}UITests.swift.tmpl b/Sources/FOSMVVMBootstrap/Templates/local-only/Tests/{{PROJECT_NAME}}UITests/{{PROJECT_NAME}}UITests.swift.tmpl new file mode 100644 index 00000000..2a21a07a --- /dev/null +++ b/Sources/FOSMVVMBootstrap/Templates/local-only/Tests/{{PROJECT_NAME}}UITests/{{PROJECT_NAME}}UITests.swift.tmpl @@ -0,0 +1,12 @@ +// {{PROJECT_NAME}}UITests.swift +{{LICENSE_HEADER}} + +import XCTest + +final class {{PROJECT_NAME}}UITests: XCTestCase { + @MainActor func testAppLaunchesToWelcome() throws { + let app = XCUIApplication() + app.launch() + XCTAssertTrue(app.windows.firstMatch.waitForExistence(timeout: 10)) + } +} diff --git a/Sources/FOSMVVMBootstrap/Templates/local-only/Tests/{{PROJECT_NAME}}UnitTests/LocalizableTestCase+{{PROJECT_NAME}}.swift.tmpl b/Sources/FOSMVVMBootstrap/Templates/local-only/Tests/{{PROJECT_NAME}}UnitTests/LocalizableTestCase+{{PROJECT_NAME}}.swift.tmpl new file mode 100644 index 00000000..1692cead --- /dev/null +++ b/Sources/FOSMVVMBootstrap/Templates/local-only/Tests/{{PROJECT_NAME}}UnitTests/LocalizableTestCase+{{PROJECT_NAME}}.swift.tmpl @@ -0,0 +1,10 @@ +// LocalizableTestCase+{{PROJECT_NAME}}.swift +{{LICENSE_HEADER}} +import FOSTesting +import Foundation + +// The test target's locale set, declared ONCE for every LocalizableTestCase suite +// in this target (add locales here, not in each suite). +extension LocalizableTestCase { + var locales: Set { [Self.en, Self.es] } +} diff --git a/Sources/FOSMVVMBootstrap/Templates/local-only/Tests/{{PROJECT_NAME}}UnitTests/ViewModels/WelcomeViewModelTests.swift.tmpl b/Sources/FOSMVVMBootstrap/Templates/local-only/Tests/{{PROJECT_NAME}}UnitTests/ViewModels/WelcomeViewModelTests.swift.tmpl new file mode 100644 index 00000000..339ca18d --- /dev/null +++ b/Sources/FOSMVVMBootstrap/Templates/local-only/Tests/{{PROJECT_NAME}}UnitTests/ViewModels/WelcomeViewModelTests.swift.tmpl @@ -0,0 +1,28 @@ +// WelcomeViewModelTests.swift +{{LICENSE_HEADER}} + +import FOSFoundation +import FOSMVVM +import FOSTesting +import Foundation +import Testing +import ViewModels + +@Suite("WelcomeViewModel") +struct WelcomeViewModelTests: LocalizableTestCase { + let locStore: LocalizationStore + + @Test func viewModelTests() throws { + try expectFullViewModelTests(WelcomeViewModel.self) + } + + init() throws { + // "" — Xcode flattens a framework's grouped resources to the + // bundle root; the default "Resources" misses on iOS. Empty + // string recurses from the root and is correct on BOTH platforms. + self.locStore = try Self.loadLocalizationStore( + bundle: ViewModelsResourceAccess.localizationBundle, + resourceDirectoryName: "" + ) + } +} diff --git a/Sources/FOSMVVMBootstrap/Templates/local-only/project.yml.tmpl b/Sources/FOSMVVMBootstrap/Templates/local-only/project.yml.tmpl new file mode 100644 index 00000000..39470934 --- /dev/null +++ b/Sources/FOSMVVMBootstrap/Templates/local-only/project.yml.tmpl @@ -0,0 +1,163 @@ +name: {{PROJECT_NAME}} + +options: + # Xcode 16 synchronized folders (PBXFileSystemSynchronizedRootGroup): the + # .xcodeproj auto-syncs with the filesystem, so adding/removing files needs no + # regeneration and the pbxproj does not churn in source control. project.yml + # stays the source of truth — regenerate any time. + projectFormat: xcode16_0 + # Nest the per-target synchronized folders under plain top-level `Sources` and + # `Tests` groups (rather than flat `Sources/X`-named folders at the project root). + createIntermediateGroups: true + deploymentTarget:{{DEPLOYMENT_TARGETS}} + groupSortPosition: top + +settings: + base: + SWIFT_VERSION: "6.0" + SWIFT_STRICT_CONCURRENCY: complete + BUILD_LIBRARY_FOR_DISTRIBUTION: NO + DEVELOPMENT_TEAM: {{TEAM_ID}} + # "Automatically manage signing" — without this, Xcode defaults to Manual + # and ⌘U can't sign/launch the app or its test host. + CODE_SIGN_STYLE: Automatic + +packages: + FOSUtilities: + url: https://github.com/foscomputerservices/FOSUtilities.git + from: "{{FOS_VERSION}}" + +targets: + # The ONE doorway for external SPM products (type identity — see + # memory/spm-libraries-settled.md). + SPMLibraries: + type: framework + supportedDestinations: {{FRAMEWORK_DESTINATIONS}} + sources: + - path: Sources/SPMLibraries + type: syncedFolder + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: {{BUNDLE_ID_ROOT}}.SPMLibraries + GENERATE_INFOPLIST_FILE: YES{{DEVICE_FAMILY_OVERRIDE}}{{XR_COMPAT_OVERRIDE}} + dependencies: + - package: FOSUtilities + product: FOSFoundation + - package: FOSUtilities + product: FOSMVVM + + # Shared contract module — client-hosted ViewModels + their YAML. + ViewModels: + type: framework + supportedDestinations: {{FRAMEWORK_DESTINATIONS}} + sources: + - path: Sources/ViewModels + type: syncedFolder + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: {{BUNDLE_ID_ROOT}}.ViewModels + GENERATE_INFOPLIST_FILE: YES{{DEVICE_FAMILY_OVERRIDE}}{{XR_COMPAT_OVERRIDE}} + dependencies: + - target: SPMLibraries + embed: false # link only — the app embeds (single-embed rule) + + {{PROJECT_NAME}}: + type: application + supportedDestinations: {{SUPPORTED_DESTINATIONS}} + sources: + # Info.plist + entitlements stay in the folder but are excepted from the + # build phases (the synced-folder exception set); they are referenced by + # INFOPLIST_FILE / CODE_SIGN_ENTITLEMENTS below. + - path: Sources/{{PROJECT_NAME}} + type: syncedFolder + excludes: + - "Info.plist" + - "{{PROJECT_NAME}}.entitlements" + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: {{BUNDLE_ID_ROOT}} + PRODUCT_NAME: {{PROJECT_NAME}} + INFOPLIST_FILE: Sources/{{PROJECT_NAME}}/Info.plist + CODE_SIGN_ENTITLEMENTS: Sources/{{PROJECT_NAME}}/{{PROJECT_NAME}}.entitlements + MARKETING_VERSION: "0.1" + CURRENT_PROJECT_VERSION: 1{{DEVICE_FAMILY_OVERRIDE}}{{XR_COMPAT_OVERRIDE}} + configs: + # Hardened runtime is Release-only. In Debug, Xcode signs SPM package + # frameworks ad-hoc (TeamIdentifier=not set) and library validation + # refuses to map them into a hardened-runtime process ("different Team + # IDs") — the app dies in dyld before main(), which kills macOS UI + # testing. Release keeps it for notarization. + Debug: + ENABLE_HARDENED_RUNTIME: NO + Release: + ENABLE_HARDENED_RUNTIME: YES + dependencies: + - target: SPMLibraries + embed: true + codeSign: true + - target: ViewModels + embed: true + codeSign: true + + {{PROJECT_NAME}}UnitTests: + type: bundle.unit-test + supportedDestinations: {{SUPPORTED_DESTINATIONS}} + sources: + - path: Tests/{{PROJECT_NAME}}UnitTests + type: syncedFolder + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: {{BUNDLE_ID_ROOT}}-unit-tests + GENERATE_INFOPLIST_FILE: YES{{DEVICE_FAMILY_OVERRIDE}}{{XR_COMPAT_OVERRIDE}} + TEST_HOST: "$(BUILT_PRODUCTS_DIR)/{{PROJECT_NAME}}.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/{{PROJECT_NAME}}" + BUNDLE_LOADER: "$(TEST_HOST)" + dependencies: + - target: {{PROJECT_NAME}} + - target: SPMLibraries + embed: false + - target: ViewModels + embed: false + - package: FOSUtilities + product: FOSTesting + + {{PROJECT_NAME}}UITests: + type: bundle.ui-testing + supportedDestinations: {{SUPPORTED_DESTINATIONS}} + sources: + - path: Tests/{{PROJECT_NAME}}UITests + type: syncedFolder + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: {{BUNDLE_ID_ROOT}}-ui-tests + GENERATE_INFOPLIST_FILE: YES{{DEVICE_FAMILY_OVERRIDE}}{{XR_COMPAT_OVERRIDE}} + dependencies: + - target: {{PROJECT_NAME}} + # The View UITests import ViewModels (the VM + its StubOps) and the shared + # LocalizableTestCase+ imports FOSTesting; SPMLibraries is the FOS type-identity + # doorway (FOSMVVM/FOSFoundation). Mirror the UnitTests dependency set. + - target: SPMLibraries + embed: false + - target: ViewModels + embed: false + - package: FOSUtilities + product: FOSTesting + - package: FOSUtilities + product: FOSTestingUI +{{WATCH_TARGET}} +schemes: + {{PROJECT_NAME}}: + build: + targets: + {{PROJECT_NAME}}: all + {{PROJECT_NAME}}UnitTests: [test] + {{PROJECT_NAME}}UITests: [test] + run: + config: Debug + test: + config: Debug + targets: + - {{PROJECT_NAME}}UnitTests + - {{PROJECT_NAME}}UITests + archive: + config: Release +{{WATCH_SCHEME}} diff --git a/Sources/FOSMVVMBootstrap/Templates/shared-library/.github/workflows/ci.yml b/Sources/FOSMVVMBootstrap/Templates/shared-library/.github/workflows/ci.yml new file mode 100644 index 00000000..9bfa1eb0 --- /dev/null +++ b/Sources/FOSMVVMBootstrap/Templates/shared-library/.github/workflows/ci.yml @@ -0,0 +1,11 @@ +name: CI +on: + push: { branches: [main] } + pull_request: +jobs: + test: + runs-on: macos-15 + steps: + - uses: actions/checkout@v4 + - name: Test + run: swift test diff --git a/Sources/FOSMVVMBootstrap/Templates/shared-library/Package.swift.tmpl b/Sources/FOSMVVMBootstrap/Templates/shared-library/Package.swift.tmpl new file mode 100644 index 00000000..053e9748 --- /dev/null +++ b/Sources/FOSMVVMBootstrap/Templates/shared-library/Package.swift.tmpl @@ -0,0 +1,39 @@ +// swift-tools-version: 6.0 +{{LICENSE_HEADER}} +import PackageDescription + +let package = Package( + name: "{{PROJECT_NAME}}", + platforms: [ + {{PLATFORMS}} + ], + products: [ + .library( + name: "{{PROJECT_NAME}}ViewModels", + targets: ["{{PROJECT_NAME}}ViewModels"] + ) + ], + dependencies: [ + .package(url: "https://github.com/foscomputerservices/FOSUtilities.git", from: "{{FOS_VERSION}}") + ], + targets: [ + .target( + name: "{{PROJECT_NAME}}ViewModels", + dependencies: [ + .product(name: "FOSFoundation", package: "FOSUtilities"), + .product(name: "FOSMVVM", package: "FOSUtilities") + ], + resources: [ + .copy("Resources/Localizations") + ] + ), + .testTarget( + name: "{{PROJECT_NAME}}ViewModelsTests", + dependencies: [ + "{{PROJECT_NAME}}ViewModels", + .product(name: "FOSTesting", package: "FOSUtilities") + ] + ) + ], + swiftLanguageModes: [.v6] +) diff --git a/Sources/FOSMVVMBootstrap/Templates/shared-library/README.md.tmpl b/Sources/FOSMVVMBootstrap/Templates/shared-library/README.md.tmpl new file mode 100644 index 00000000..a538e258 --- /dev/null +++ b/Sources/FOSMVVMBootstrap/Templates/shared-library/README.md.tmpl @@ -0,0 +1,9 @@ +# {{PROJECT_NAME}} + +A FOSMVVM shared ViewModels library, scaffolded by fosmvvm-bootstrap. + +- `{{PROJECT_NAME}}ViewModels` — the ViewModel product; localization is + package-hosted (`{{PROJECT_NAME}}ResourceAccess.localizationBundle`). +- Consuming apps pass that bundle in `MVVMEnvironment.resourceBundles`. +- Add ViewModels with the `fosmvvm-viewmodel-generator` skill; read + `CLAUDE.md` and `memory/` first. diff --git a/Sources/FOSMVVMBootstrap/Templates/shared-library/Sources/{{PROJECT_NAME}}ViewModels/Resources/Localizations/ViewModels/WelcomeViewModel.yml b/Sources/FOSMVVMBootstrap/Templates/shared-library/Sources/{{PROJECT_NAME}}ViewModels/Resources/Localizations/ViewModels/WelcomeViewModel.yml new file mode 100644 index 00000000..fd9b65db --- /dev/null +++ b/Sources/FOSMVVMBootstrap/Templates/shared-library/Sources/{{PROJECT_NAME}}ViewModels/Resources/Localizations/ViewModels/WelcomeViewModel.yml @@ -0,0 +1,10 @@ +en: + WelcomeViewModel: + welcomeTitle: "Welcome" + welcomeMessage: "Your FOSMVVM shared library is alive." + okButtonTitle: "OK" +es: + WelcomeViewModel: + welcomeTitle: "Bienvenido" + welcomeMessage: "Tu biblioteca compartida FOSMVVM está viva." + okButtonTitle: "Aceptar" diff --git a/Sources/FOSMVVMBootstrap/Templates/shared-library/Sources/{{PROJECT_NAME}}ViewModels/ViewModels/WelcomeViewModel.swift.tmpl b/Sources/FOSMVVMBootstrap/Templates/shared-library/Sources/{{PROJECT_NAME}}ViewModels/ViewModels/WelcomeViewModel.swift.tmpl new file mode 100644 index 00000000..42786b71 --- /dev/null +++ b/Sources/FOSMVVMBootstrap/Templates/shared-library/Sources/{{PROJECT_NAME}}ViewModels/ViewModels/WelcomeViewModel.swift.tmpl @@ -0,0 +1,42 @@ +// WelcomeViewModel.swift +{{LICENSE_HEADER}} +import FOSFoundation +import FOSMVVM +import Foundation + +@ViewModel +public struct WelcomeViewModel { + @LocalizedString public var welcomeTitle + @LocalizedString public var welcomeMessage + @LocalizedString public var okButtonTitle + + // MARK: Operations Access + + private let isStub: Bool + + #if canImport(SwiftUI) + public var operations: any WelcomeViewModelOperations { + isStub ? WelcomeViewModelStubOps() : WelcomeViewModelOps() + } + #endif + + public var vmId = ViewModelId() + + public init() { + self.init( + isStub: false + ) + } + + public static func stub() -> Self { + .init( + isStub: true + ) + } +} + +extension WelcomeViewModel { + private init (isStub: Bool) { + self.isStub = isStub + } +} diff --git a/Sources/FOSMVVMBootstrap/Templates/shared-library/Sources/{{PROJECT_NAME}}ViewModels/ViewModels/WelcomeViewModelOperations.swift.tmpl b/Sources/FOSMVVMBootstrap/Templates/shared-library/Sources/{{PROJECT_NAME}}ViewModels/ViewModels/WelcomeViewModelOperations.swift.tmpl new file mode 100644 index 00000000..b6bd7d0b --- /dev/null +++ b/Sources/FOSMVVMBootstrap/Templates/shared-library/Sources/{{PROJECT_NAME}}ViewModels/ViewModels/WelcomeViewModelOperations.swift.tmpl @@ -0,0 +1,54 @@ +// +{{LICENSE_HEADER}} +// WelcomeViewModelOperations.swift +// {{PROJECT_NAME}} +// +// Created by David Hunt on 8/8/26. +// + +import FOSFoundation +import FOSMVVM +import Foundation +#if canImport(SwiftUI) +import SwiftUI +#endif + +#if canImport(SwiftUI) + +// MARK: Operations + +public protocol WelcomeViewModelOperations: ViewModelOperations { + func presentWelcome(output welcomeStatus: Binding) + func dismissWelcome(output welcomeStatus: Binding) +} + +public struct WelcomeViewModelOps: WelcomeViewModelOperations { + public func presentWelcome(output welcomeStatus: Binding) { + welcomeStatus.wrappedValue = true + } + public func dismissWelcome(output welcomeStatus: Binding) { + welcomeStatus.wrappedValue = false + } +} + +public final class WelcomeViewModelStubOps: WelcomeViewModelOperations, @unchecked Sendable { + public private(set) var presentWelcomeCalled: Bool + public private(set) var dismissWelcomeCalled: Bool + + public func presentWelcome(output welcomeStatus: Binding) { + presentWelcomeCalled = true + welcomeStatus.wrappedValue = true + } + + public func dismissWelcome(output welcomeStatus: Binding) { + dismissWelcomeCalled = true + welcomeStatus.wrappedValue = false + } + + public init() { + self.presentWelcomeCalled = false + self.dismissWelcomeCalled = false + } +} + +#endif diff --git a/Sources/FOSMVVMBootstrap/Templates/shared-library/Sources/{{PROJECT_NAME}}ViewModels/{{PROJECT_NAME}}ViewModels.swift.tmpl b/Sources/FOSMVVMBootstrap/Templates/shared-library/Sources/{{PROJECT_NAME}}ViewModels/{{PROJECT_NAME}}ViewModels.swift.tmpl new file mode 100644 index 00000000..7fb9bac0 --- /dev/null +++ b/Sources/FOSMVVMBootstrap/Templates/shared-library/Sources/{{PROJECT_NAME}}ViewModels/{{PROJECT_NAME}}ViewModels.swift.tmpl @@ -0,0 +1,20 @@ +// {{PROJECT_NAME}}ViewModels.swift +{{LICENSE_HEADER}} +import Foundation + +/// Access to {{PROJECT_NAME}}ViewModels' package-hosted resources. +/// +/// A downstream app passes this library's localization bundle to its +/// `MVVMEnvironment` so client-hosted ViewModels resolve their +/// localized strings on-device: +/// +/// ```swift +/// MVVMEnvironment( +/// appBundle: Bundle.main, +/// resourceBundles: [{{PROJECT_NAME}}ResourceAccess.localizationBundle], +/// deploymentURLs: [...] +/// ) +/// ``` +public enum {{PROJECT_NAME}}ResourceAccess { + public static var localizationBundle: Bundle { Bundle.module } +} diff --git a/Sources/FOSMVVMBootstrap/Templates/shared-library/Tests/{{PROJECT_NAME}}ViewModelsTests/LocalizableTestCase+{{PROJECT_NAME}}.swift.tmpl b/Sources/FOSMVVMBootstrap/Templates/shared-library/Tests/{{PROJECT_NAME}}ViewModelsTests/LocalizableTestCase+{{PROJECT_NAME}}.swift.tmpl new file mode 100644 index 00000000..1692cead --- /dev/null +++ b/Sources/FOSMVVMBootstrap/Templates/shared-library/Tests/{{PROJECT_NAME}}ViewModelsTests/LocalizableTestCase+{{PROJECT_NAME}}.swift.tmpl @@ -0,0 +1,10 @@ +// LocalizableTestCase+{{PROJECT_NAME}}.swift +{{LICENSE_HEADER}} +import FOSTesting +import Foundation + +// The test target's locale set, declared ONCE for every LocalizableTestCase suite +// in this target (add locales here, not in each suite). +extension LocalizableTestCase { + var locales: Set { [Self.en, Self.es] } +} diff --git a/Sources/FOSMVVMBootstrap/Templates/shared-library/Tests/{{PROJECT_NAME}}ViewModelsTests/WelcomeViewModelTests.swift.tmpl b/Sources/FOSMVVMBootstrap/Templates/shared-library/Tests/{{PROJECT_NAME}}ViewModelsTests/WelcomeViewModelTests.swift.tmpl new file mode 100644 index 00000000..22a5562a --- /dev/null +++ b/Sources/FOSMVVMBootstrap/Templates/shared-library/Tests/{{PROJECT_NAME}}ViewModelsTests/WelcomeViewModelTests.swift.tmpl @@ -0,0 +1,24 @@ +// WelcomeViewModelTests.swift +{{LICENSE_HEADER}} +import FOSFoundation +import FOSMVVM +import FOSTesting +import Foundation +import Testing +@testable import {{PROJECT_NAME}}ViewModels + +@Suite("WelcomeViewModel") +struct WelcomeViewModelTests: LocalizableTestCase { + let locStore: LocalizationStore + + @Test func viewModelTests() throws { + try expectFullViewModelTests(WelcomeViewModel.self) + } + + init() throws { + self.locStore = try Self.loadLocalizationStore( + bundle: {{PROJECT_NAME}}ResourceAccess.localizationBundle, + resourceDirectoryName: "Localizations" + ) + } +} diff --git a/Sources/FOSMVVMBootstrap/Templates/shared/.swiftformat b/Sources/FOSMVVMBootstrap/Templates/shared/.swiftformat new file mode 100644 index 00000000..32eeae7a --- /dev/null +++ b/Sources/FOSMVVMBootstrap/Templates/shared/.swiftformat @@ -0,0 +1,2 @@ +--swiftversion 6.0 +--indent 4 diff --git a/Sources/FOSMVVMBootstrap/Templates/shared/CLAUDE.md.tmpl b/Sources/FOSMVVMBootstrap/Templates/shared/CLAUDE.md.tmpl new file mode 100644 index 00000000..5c4e48dd --- /dev/null +++ b/Sources/FOSMVVMBootstrap/Templates/shared/CLAUDE.md.tmpl @@ -0,0 +1,30 @@ +# CLAUDE.md — {{PROJECT_NAME}} + +This project follows the FOS-mvvm way. The rules below are settled +doctrine, mined from real project histories. They are not preferences. + +## SOLID is the foundation + +A ViewModel is a *projection of* data, never the data. The ViewModels +module imports only FOSFoundation + FOSMVVM — never a domain/wire +module (the Factory adapts). Deviations surface far from their cause; +treat a SOLID violation as a hard stop. + +## Your instincts will be wrong — the settled catalog + +- **Routes are type-derived.** Middleware is not a path prefix. Never + munge URLs, never `.grouped("string")` a path onto a ServerRequest. +- **Never hand-roll HTTP gateways or transport parameters.** + `MVVMEnvironment` / ServerRequests are the only door to the network. +- **Views are generated from ViewModels** (fosmvvm-swiftui-view-generator), + never transcribed from a mockup. +- **Code is added via the fosmvvm-* generator skills.** Hand-rolling is + the exception that needs a stated justification. +- **SPM products are consumed through the SPMLibraries umbrella** in + Xcode-project targets. See `memory/spm-libraries-settled.md` before + touching linkage — this is settled; arguing it is a long dead-end. + +## Memory + +Read `memory/*.md` before proposing changes to build wiring, linkage, +entitlements, or test-target configuration. diff --git a/Sources/FOSMVVMBootstrap/Templates/shared/memory/MEMORY.md b/Sources/FOSMVVMBootstrap/Templates/shared/memory/MEMORY.md new file mode 100644 index 00000000..34a79a5b --- /dev/null +++ b/Sources/FOSMVVMBootstrap/Templates/shared/memory/MEMORY.md @@ -0,0 +1,7 @@ +# Memory Index + +- [SPMLibraries is settled doctrine](spm-libraries-settled.md) — one umbrella dynamic framework for all SPM products; do not link SPM packages directly into multiple targets +- [disable-library-validation entitlement](entitlement-is-a-symptom.md) — required with ENABLE_DEBUG_DYLIB + ENABLE_HARDENED_RUNTIME; was a shape-symptom pre-Xcode 16 +- [Xcode 16 dynamic SPM packages](xcode16-dynamic-spm-packages.md) — ENABLE_DEBUG_DYLIB forces SPM packages dynamic; SPMLibraries may be obsolete for Xcode 16+ projects +- [Stale build runbook](stale-build-runbook.md) — Clean Build Folder (not rm -rf DerivedData) for bogus undefined-symbol errors +- [macOS build-for-testing FAQ](macos-build-for-testing-faq.md) — common macOS test build issues diff --git a/Sources/FOSMVVMBootstrap/Templates/shared/memory/entitlement-is-a-symptom.md b/Sources/FOSMVVMBootstrap/Templates/shared/memory/entitlement-is-a-symptom.md new file mode 100644 index 00000000..8b8a5390 --- /dev/null +++ b/Sources/FOSMVVMBootstrap/Templates/shared/memory/entitlement-is-a-symptom.md @@ -0,0 +1,34 @@ +--- +name: entitlement-disable-library-validation +description: When disable-library-validation is required vs. a symptom of wrong shape — updated for Xcode 16+ dynamic SPM behavior +metadata: + type: feedback +--- + +## Xcode 16+ (ENABLE_DEBUG_DYLIB era) + +`com.apple.security.cs.disable-library-validation` is **required** when: + +- `ENABLE_DEBUG_DYLIB = YES` on the app target, AND +- `ENABLE_HARDENED_RUNTIME = YES` on the app target + +**Why:** `ENABLE_DEBUG_DYLIB` forces SPM `.automatic` packages to build as +dynamic frameworks in `PackageFrameworks/`. Those are linker-signed (ad-hoc, +TeamIdentifier=not set). Hardened runtime rejects loading them into a +developer-signed process (Team ID mismatch). The entitlement permits it. + +This is **not** a shape defect — it is the correct companion entitlement for +the dynamic-package build mode Xcode 16 introduced. + +**How to apply:** Add to the app target's `.entitlements` file alongside +`app-sandbox`. See [[xcode16-dynamic-spm-packages]]. + +--- + +## Pre-Xcode 16 (static SPM era) — historical + +Old guidance: you only need the entitlement when embeds ad-hoc PackageFrameworks +(wrong shape) or signing is off. Correct shape (static SPM → SPMLibraries → +signed) made the entitlement unnecessary. + +That guidance is **obsolete** for projects using `ENABLE_DEBUG_DYLIB = YES`. diff --git a/Sources/FOSMVVMBootstrap/Templates/shared/memory/macos-build-for-testing-faq.md b/Sources/FOSMVVMBootstrap/Templates/shared/memory/macos-build-for-testing-faq.md new file mode 100644 index 00000000..b9baa937 --- /dev/null +++ b/Sources/FOSMVVMBootstrap/Templates/shared/memory/macos-build-for-testing-faq.md @@ -0,0 +1,28 @@ +# macOS testing and FOS PackageFrameworks — two known failure faces + +With test targets present, Xcode builds FOS as separate dynamic +`PackageFrameworks/*.framework` dylibs. Two distinct failures trace back +to that, depending on Xcode version: + +**Face 1 — `Ld` failure at build-for-testing.** The umbrella no longer +carries the FOS symbols and the link fails. Seen identically across four +independent apps on older Xcode. + +**Face 2 — dyld abort at app launch under UI testing.** The build +succeeds, but the app dies before `main()`: +`Library not loaded: @rpath/FOSFoundation.framework … code signature … +mapping process and mapped file (non-platform) have different Team IDs`. +Cause: Xcode signs PackageFrameworks **ad-hoc** (`TeamIdentifier=not +set`) in Debug, and an app with **hardened runtime** refuses to map +them (library validation). Deterministic on every clean build; the UI +test reports only "Application '…' does not have a process ID". + +**Fix (emitted by the scaffolder):** `ENABLE_HARDENED_RUNTIME` is +per-config — `NO` for Debug (where ⌘U lives), `YES` for Release (where +notarization needs it). Do not set it in `settings.base`. + +**Still open on macOS:** with launch fixed, interaction tests +(tap → stub-op recorded) can still fail on the macOS destination while +identical tests pass on the iOS simulator — under investigation at the +FOSUtilities level. The iOS simulator is the fully-green UI-test +destination. diff --git a/Sources/FOSMVVMBootstrap/Templates/shared/memory/spm-libraries-settled.md b/Sources/FOSMVVMBootstrap/Templates/shared/memory/spm-libraries-settled.md new file mode 100644 index 00000000..a34ef322 --- /dev/null +++ b/Sources/FOSMVVMBootstrap/Templates/shared/memory/spm-libraries-settled.md @@ -0,0 +1,32 @@ +# SPMLibraries is settled doctrine — do not re-litigate + +**The rule:** every Xcode-project target consumes SPM products through +the single `SPMLibraries` umbrella framework. Never link +FOSFoundation/FOSMVVM (or any SPM product) directly into a second +target or framework. + +**Why (correctness, not hygiene):** linking an SPM library statically +into multiple targets compiles a separate copy of its types into each +target. Swift's mangled type name carries the linking context, so the +"same" type has a different runtime identity per target: +`TypeA != TypeA`. `is` / `as?` / `==` / `===` fail across target +boundaries, at runtime, far from the cause. It compiles clean and +breaks in very weird ways. This is a generic Xcode+SPM packaging bug — +nothing to do with FOS — but FOS internals rely on comparing types. + +**The four counter-arguments, all already lost:** +1. "The umbrella is dead weight / just DRY / optional." — No: see the + mechanism above. One umbrella dynamic framework = one canonical copy + = one shared type identity everywhere. +2. "This second framework needs FOS — I'll link it directly." — No: + two link sites → two non-identical copies (`SystemVersion` from + framework A ≠ framework B). Every framework consumes FOS from + SPMLibraries only. +3. "The boundary broke my iOS build — let's make host code iOS-safe." + — No: the failure is a correct signal. Extract the platform-bound + code; never soften the contract module. +4. "Undefined-symbol errors — the umbrella wiring must be wrong." — + No: that is Xcode incremental-build staleness. See + `stale-build-runbook.md`. + +This is settled. Arguing it = a long dead-end. diff --git a/Sources/FOSMVVMBootstrap/Templates/shared/memory/stale-build-runbook.md b/Sources/FOSMVVMBootstrap/Templates/shared/memory/stale-build-runbook.md new file mode 100644 index 00000000..58d8f1a2 --- /dev/null +++ b/Sources/FOSMVVMBootstrap/Templates/shared/memory/stale-build-runbook.md @@ -0,0 +1,16 @@ +# Stale-build runbook (Xcode + SPM incremental builds) + +**Symptoms:** hundreds of bogus "Undefined symbol" errors in test +builds; or the running app silently executes old code (a fix that is +compiled in but never invoked). + +**Cause:** Xcode's SPM incremental build relinks stale `.o` files. +An Apple bug, not a wiring defect. + +**Fix:** Xcode "Clean Build Folder" (or `xcodebuild clean`). +**Never `rm -rf DerivedData`** — it races Xcode's package re-clone and +corrupts `SourcePackages/checkouts`. + +**Also check:** the xcodeproj's resolved FOSUtilities pin must stay in +lockstep with `Package.swift` — a drifted pin runs old library code +while you debug "impossible" behavior. diff --git a/Sources/FOSMVVMBootstrap/Templates/shared/memory/xcode16-dynamic-spm-packages.md b/Sources/FOSMVVMBootstrap/Templates/shared/memory/xcode16-dynamic-spm-packages.md new file mode 100644 index 00000000..537e92e8 --- /dev/null +++ b/Sources/FOSMVVMBootstrap/Templates/shared/memory/xcode16-dynamic-spm-packages.md @@ -0,0 +1,33 @@ +--- +name: xcode16-dynamic-spm-packages +description: Xcode 16+ ENABLE_DEBUG_DYLIB forces SPM packages dynamic — implications for SPMLibraries and signing +metadata: + type: project +--- + +When `ENABLE_DEBUG_DYLIB = YES` is set on an app target, Xcode 16+ builds SPM +`.automatic` packages as **dynamic frameworks** in `PackageFrameworks/` rather +than as static libs compiled into the consuming framework. + +**Why:** The debug dylib feature splits the compiled app into a stub + +`.debug.dylib`. All dependencies must be separately loadable as dynamic +frameworks to support this split. + +**Implication for SPMLibraries:** The SPMLibraries umbrella was created to solve +the static-SPM type-identity problem (multiple targets → multiple static copies +→ `TypeA != TypeA`). With dynamic SPM packages, the OS dynamic linker loads each +package once per process — the type-identity problem is solved natively. This +means **SPMLibraries may be obsolete for Xcode 16+ projects**. + +**Known cons of SPMLibraries that go away with dynamic packages:** +- Xcode dependency scanner warnings: `'FOSMVVM' is missing a dependency on 'Yams'` +- Spurious stale-build failures (graph opacity) +- Shape confusion for tools and people reading the project + +**Signing side-effect:** Dynamic PackageFrameworks are linker-signed (ad-hoc). +With `ENABLE_HARDENED_RUNTIME = YES`, this requires the +`com.apple.security.cs.disable-library-validation` entitlement. +See [[entitlement-disable-library-validation]]. + +**Status (2026-08-08):** Under active evaluation. TestLocalOnly is the test bed. +SPMLibraries retirement is being considered for all Xcode 16+ FOS projects. diff --git a/Sources/FOSMVVMBootstrap/TokenSet+Watch.swift b/Sources/FOSMVVMBootstrap/TokenSet+Watch.swift new file mode 100644 index 00000000..3850843e --- /dev/null +++ b/Sources/FOSMVVMBootstrap/TokenSet+Watch.swift @@ -0,0 +1,101 @@ +// TokenSet+Watch.swift + +/// Watch support is a SEPARATE app target: a multiplatform app target cannot +/// contain watchOS (xcodegen validation, mirroring Xcode's model — measured +/// 2026-08-22). The shared frameworks CAN carry the watchOS destination +/// (probe-built green the same day), so the watch target embeds the same +/// frameworks the main app does and compiles the same app sources. +extension TokenSet { + /// The emitted watch app target, or empty when the config has no watchOS. + static func watchTargetYAML(config: BootstrapConfig) -> String { + guard config.platforms[.watchOS] != nil, + let bundleIdRoot = config.bundleIdRoot + else { return "" } + let name = config.projectName + + let sources: String + let dependencies: String + switch config.shape { + case .clientServer: + sources = """ + - path: Sources/\(name) + type: syncedFolder + excludes: + - "Info.plist" + - "\(name).entitlements" + - path: Sources/\(name)ViewModels + type: syncedFolder + """ + dependencies = """ + - target: SPMLibraries + embed: true + codeSign: true + - target: \(name)Foundation + embed: true + codeSign: true + - target: \(name)ClientViewModels + embed: true + codeSign: true + """ + case .localOnly: + sources = """ + - path: Sources/\(name) + type: syncedFolder + excludes: + - "Info.plist" + - "\(name).entitlements" + """ + dependencies = """ + - target: SPMLibraries + embed: true + codeSign: true + - target: ViewModels + embed: true + codeSign: true + """ + case .sharedLibrary, .hybrid: + return "" + } + + return """ + + + # Watch companion app: same sources, its own target (a multiplatform + # app target cannot contain watchOS). The frameworks carry the + # watchOS destination, so this target embeds the same ones. + \(name)Watch: + type: application + platform: watchOS + sources: + \(sources) + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: \(bundleIdRoot).watch + PRODUCT_NAME: \(name)Watch + GENERATE_INFOPLIST_FILE: YES + INFOPLIST_KEY_WKApplication: YES + MARKETING_VERSION: "0.1" + CURRENT_PROJECT_VERSION: 1 + dependencies: + \(dependencies) + """ + } + + /// A run scheme for the watch target, or empty. + static func watchSchemeYAML(config: BootstrapConfig) -> String { + guard config.platforms[.watchOS] != nil, + config.shape == .clientServer || config.shape == .localOnly + else { return "" } + let name = config.projectName + return """ + + + \(name)Watch: + build: + targets: + \(name)Watch: all + run: + config: Debug + """ + } +} diff --git a/Sources/FOSMVVMBootstrap/TokenSet.swift b/Sources/FOSMVVMBootstrap/TokenSet.swift new file mode 100644 index 00000000..155e59d8 --- /dev/null +++ b/Sources/FOSMVVMBootstrap/TokenSet.swift @@ -0,0 +1,111 @@ +// TokenSet.swift + +/// Derives every template token from the validated config. +/// Derivation lives here in typed Swift — never in templates and never +/// as free-text inputs — so a token's value cannot drift or leak. +public enum TokenSet { + /// Derives the full `{{TOKEN}}` → value map for `TemplateRenderer`: + /// `let tokens = try TokenSet.derive(from: config)`. Validates + /// `config` first, so a bad config throws here rather than emitting + /// a broken project. + public static func derive(from config: BootstrapConfig) throws -> [String: String] { + try config.validate() + + var tokens = [ + "PROJECT_NAME": config.projectName, + "FOS_VERSION": FOSPlatformFloor.pinnedFOSVersion, + "PLATFORMS": platformsLine(config.platforms), + "LICENSE_HEADER": config.licenseHeader ?? "" + ] + + // App shapes carry the bundle-id root, signing team, and macOS + // deployment target; sharedLibrary keeps exactly the four tokens above. + // validate() has already guaranteed these are present and well-formed. + if config.shape != .sharedLibrary { + tokens["BUNDLE_ID_ROOT"] = config.bundleIdRoot + tokens["TEAM_ID"] = config.teamId + tokens["MACOS_DEPLOYMENT"] = config.platforms[.macOS] + + // The Xcode-project surface follows the config's platforms map — + // every platform, macOS included, is opt-in; validate() requires + // at least one destination the app target can host. + // xcodegen's supportedDestinations (not platform:) is load-bearing: + // it emits SDKROOT=auto and TARGETED_DEVICE_FAMILY, without which + // Xcode's destination editor shows Mac only even though xcodebuild + // can already build every listed destination (measured 2026-08-22). + // watchOS is deliberately absent: a multiplatform app target + // cannot contain it (xcodegen validation) — it is the separate- + // target follow-on of the migration design's §4. + var destinations: [String] = [] + var deploymentLines = "" + var families: [String] = [] + + if let macDeployment = config.platforms[.macOS] { + destinations.append("macOS") + deploymentLines += "\n macOS: \"\(macDeployment)\"" + } + if let iOSDeployment = config.platforms[.iOS] { + destinations.append("iOS") + deploymentLines += "\n iOS: \"\(iOSDeployment)\"" + let devices = config.iosDevices ?? ["iPhone", "iPad"] + if devices.contains("iPhone") { families.append("1") } + if devices.contains("iPad") { families.append("2") } + } + if let tvDeployment = config.platforms[.tvOS] { + destinations.append("tvOS") + deploymentLines += "\n tvOS: \"\(tvDeployment)\"" + families.append("3") + } + if let visionDeployment = config.platforms[.visionOS] { + destinations.append("visionOS") + deploymentLines += "\n visionOS: \"\(visionDeployment)\"" + families.append("7") + } + + // Frameworks additionally carry watchOS (probed green) — the app + // target cannot, so the watch app is its own emitted target. + var frameworkDestinations = destinations + if let watchDeployment = config.platforms[.watchOS] { + frameworkDestinations.append("watchOS") + deploymentLines += "\n watchOS: \"\(watchDeployment)\"" + } + + tokens["SUPPORTED_DESTINATIONS"] = "[\(destinations.joined(separator: ", "))]" + tokens["FRAMEWORK_DESTINATIONS"] = "[\(frameworkDestinations.joined(separator: ", "))]" + tokens["WATCH_TARGET"] = Self.watchTargetYAML(config: config) + tokens["WATCH_SCHEME"] = Self.watchSchemeYAML(config: config) + tokens["DEPLOYMENT_TARGETS"] = deploymentLines + // Both overrides are injected into the APP TARGET's settings + // (8-space YAML indent): xcodegen's supportedDestinations emits + // its own target-level values, and target-level settings override + // project-level ones — a base-settings override silently loses + // (measured 2026-08-22). Empty when xcodegen's derivation stands. + tokens["DEVICE_FAMILY_OVERRIDE"] = families.isEmpty + ? "" + : "\n TARGETED_DEVICE_FAMILY: \"\(families.joined(separator: ","))\"" + // An iOS app is offered on Apple Vision and on Apple Silicon + // Macs in "Designed for iPad" compatibility mode by default + // (Apple's default). The config records an EXPLICIT decline from + // the interview's questions; omitted means allowed. + var compatOverrides = "" + if config.platforms[.iOS] != nil, config.visionDesignedForIPad == false { + compatOverrides += "\n SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD: NO" + } + if config.platforms[.iOS] != nil, config.macDesignedForIPad == false { + compatOverrides += "\n SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD: NO" + } + tokens["XR_COMPAT_OVERRIDE"] = compatOverrides + } + + return tokens + } + + /// `.iOS("17.0"),\n .macOS("14.0")` — string-literal platform + /// form (valid PackageDescription), deterministic ordering. + static func platformsLine(_ platforms: [TargetPlatform: String]) -> String { + platforms + .sorted { $0.key.rawValue < $1.key.rawValue } + .map { ".\($0.key.rawValue)(\"\($0.value)\")" } + .joined(separator: ",\n ") + } +} diff --git a/Sources/FOSMVVMBootstrap/Verifier.swift b/Sources/FOSMVVMBootstrap/Verifier.swift new file mode 100644 index 00000000..9ccd662b --- /dev/null +++ b/Sources/FOSMVVMBootstrap/Verifier.swift @@ -0,0 +1,282 @@ +// Verifier.swift +// +// Copyright 2026 FOS Computer Services, LLC +// +// Licensed under the Apache License, Version 2.0 (the License); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import Foundation + +public enum VerifyStep: String, Sendable, CaseIterable { + case swiftBuild + case swiftTest + case xcodegenGenerate + case xcodebuildBuild + + /// The command to run for this step. `xcodebuildBuild` names the + /// generated project and scheme, so it needs the project name; a nil + /// name renders as the `` placeholder (used by the error + /// description, which must never crash). + func command(projectName: String?, destination: String = "platform=macOS") -> [String] { + switch self { + case .swiftBuild: return ["swift", "build"] + case .swiftTest: return ["swift", "test"] + case .xcodegenGenerate: return ["xcodegen", "generate", "--spec", "project.yml"] + case .xcodebuildBuild: + let name = projectName ?? "" + return [ + "xcodebuild", + "-project", "\(name).xcodeproj", + "-scheme", name, + "-destination", destination, + "build", + "CODE_SIGNING_ALLOWED=NO" + ] + } + } + + /// A CLI tool this step depends on that isn't part of the toolchain, + /// paired with how to install it. When set, `verify` probes for the + /// tool before running the step and throws `.toolMissing` on absence. + var requiredTool: (tool: String, installHint: String)? { + switch self { + case .xcodegenGenerate: ("xcodegen", "brew install xcodegen") + case .swiftBuild, .swiftTest, .xcodebuildBuild: nil + } + } +} + +public enum VerifierError: Error { + case stepFailed(step: VerifyStep, output: String) + case toolMissing(tool: String, installHint: String) +} + +extension VerifierError: CustomStringConvertible { + public var description: String { + switch self { + case .stepFailed(let step, let output): + // Render with a nil project name (`` placeholder) so + // the description can never crash on a name it doesn't carry. + "verification step '\(step.command(projectName: nil).joined(separator: " "))' failed:\n\(output)" + case .toolMissing(let tool, let installHint): + "required tool '\(tool)' not found on PATH — install it with: \(installHint)" + } + } +} + +/// Runs the shape's verification steps inside the generated project. +/// A failure is fatal and carries the tool output — the scaffolder +/// never hands over a broken skeleton. +public enum Verifier { + /// The xcodebuild destination for a config's verify door: macOS when the + /// project has it, else the first simulator platform the config asked for. + public static func buildDestination(for config: BootstrapConfig) -> String { + if config.platforms[.macOS] != nil { + return "platform=macOS" + } + if config.platforms[.iOS] != nil { + return "generic/platform=iOS Simulator" + } + if config.platforms[.tvOS] != nil { + return "generic/platform=tvOS Simulator" + } + if config.platforms[.visionOS] != nil { + return "generic/platform=visionOS Simulator" + } + return "platform=macOS" + } + + /// Steps that PRODUCE part of the project. `xcodegen` writes the + /// `.xcodeproj` from the emitted `project.yml` — it is generation, not + /// verification, so it runs even under `--skip-verify` (a skipped + /// verification must never mean a missing deliverable; found live + /// 2026-08-22 when `--skip-verify` produced a project with no + /// `.xcodeproj`). + public static func generationSteps(for shape: ProjectShape) -> [VerifyStep] { + switch shape { + case .sharedLibrary: [] + case .localOnly, .clientServer: [.xcodegenGenerate] + case .hybrid: [] + } + } + + /// Steps that CHECK the produced project. shared-library: build + test + /// is the entire finish line. local-only builds the generated Xcode + /// project. client-server checks both doors: the package (server boots + + /// serves the route headlessly under `swift test`) and the app + /// (xcodebuild). hybrid keeps the placeholder until Plan 4. + public static func steps(for shape: ProjectShape) -> [VerifyStep] { + switch shape { + case .sharedLibrary: [.swiftBuild, .swiftTest] + case .localOnly: [.xcodebuildBuild] + case .clientServer: [.swiftBuild, .swiftTest, .xcodebuildBuild] + case .hybrid: [.swiftBuild, .swiftTest] // extended in Plan 4 + } + } + + /// Runs each step's command in `projectDir`, throwing on the first + /// non-zero exit with the captured combined output: + /// `try Verifier.verify(projectDir: url, steps: [.swiftBuild, .swiftTest])`. + /// + /// Pass `projectName` for app-bearing shapes so `xcodebuildBuild` can + /// name the `.xcodeproj` and scheme. Pass `environment` to overlay + /// variables (e.g. a pinned `PATH`) onto the inherited process + /// environment for every step and its tool probes. + /// + /// > Steps run in a sanitized copy of the inherited environment: + /// > test-harness session variables are stripped, so verification + /// > behaves the same whether the scaffolder runs from a shell or + /// > inside a test runner. + /// + /// Throws `VerifierError.toolMissing(tool:installHint:)` when a step's + /// required tool isn't on `PATH`, or `VerifierError.stepFailed(step:output:)` + /// carrying the failed step and its stdout+stderr, so the caller can + /// surface exactly why the generated project didn't build or test. + public static func verify( + projectDir: URL, + steps: [VerifyStep], + projectName: String? = nil, + destination: String = "platform=macOS", + environment: [String: String]? = nil + ) throws { + // Overlay the caller's variables onto the sanitized inherited + // environment. Overlaying PATH REPLACES the inherited PATH for the + // children, which the tool-missing test relies on. + let resolvedEnv = childEnvironment( + from: ProcessInfo.processInfo.environment, + overlaying: environment + ) + + for step in steps { + if let required = step.requiredTool { + // Probe via ABSOLUTE `/bin/sh` (not `sh`, which env would + // resolve through the overlaid PATH — a /nonexistent PATH + // then fails to even launch the shell, and a PATH missing + // /bin yields a false toolMissing). The absolute shell is + // PATH-independent while `command -v` still honors the + // overlaid PATH. Plain `-c`, NOT `-lc`: a login shell sources + // profile scripts (path_helper, brew shellenv) that can + // restore a real PATH and defeat the overlay. + let probe = run( + command: ["/bin/sh", "-c", "command -v \(required.tool)"], + in: projectDir, + environment: resolvedEnv + ) + guard probe.status == 0 else { + throw VerifierError.toolMissing( + tool: required.tool, + installHint: required.installHint + ) + } + } + + let result = run( + command: step.command(projectName: projectName, destination: destination), + in: projectDir, + environment: resolvedEnv + ) + guard result.status == 0 else { + throw VerifierError.stepFailed(step: step, output: result.output) + } + } + } + + /// The environment for verification children: `base` (the inherited + /// process environment) minus the variables an XCTest/xcodebuild harness + /// injects into its own test process (`XCTest*`, `DYLD_*`). Inherited, + /// those make the generated project's `swift test` runner attach to the + /// HARNESS's IDE test session — it waits on a handshake that never comes + /// ("Failed to establish connection to the IDE: Timed out while preparing + /// IDE session") and the step hangs to its time limit or exits non-zero + /// with every generated test green. The caller's overlay wins over + /// surviving inherited values. + static func childEnvironment( + from base: [String: String], + overlaying overlay: [String: String]? + ) -> [String: String] { + let cleaned = base.filter { key, _ in + !key.hasPrefix("XCTest") && !key.hasPrefix("DYLD_") + } + guard let overlay else { return cleaned } + return cleaned.merging(overlay) { _, new in new } + } + + /// Runs one command via `/usr/bin/env` in `dir`, draining stdout+stderr, + /// and returns the exit status with the combined output. + private static func run( + command: [String], + in dir: URL, + environment: [String: String] + ) -> (status: Int32, output: String) { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/env") + process.arguments = command + process.currentDirectoryURL = dir + process.environment = environment + + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = pipe + + // Drain the pipe on a background thread BEFORE waitUntilExit(). + // A real `swift build` easily exceeds the ~64KB pipe buffer; + // reading only after the child exits would deadlock (the child + // blocks writing a full pipe while we block waiting for it). + let collected = DrainBox() + let drained = DispatchSemaphore(value: 0) + let drain = Thread { + let data = pipe.fileHandleForReading.readDataToEndOfFile() + collected.store(data) + drained.signal() + } + drain.stackSize = 1 << 20 + drain.start() + + do { + try process.run() + } catch { + // Reached only if /usr/bin/env itself cannot be spawned (it is + // an absolute path that always exists, so this is defensive). A + // missing *tool* surfaces as env's own non-zero exit, not here. + // Close our write end so the drain thread sees EOF, then report. + try? pipe.fileHandleForWriting.close() + drained.wait() + return (status: 127, output: "\(error)") + } + process.waitUntilExit() + + // Join: the read returns EOF once the child's write ends close, + // which waitUntilExit() has already ensured has happened. + drained.wait() + + let output = String(data: collected.data, encoding: .utf8) ?? "" + return (status: process.terminationStatus, output: output) + } +} + +/// A locked hand-off box for the drained pipe data. The drain thread +/// writes once, then signals; the caller reads after the wait returns. +private final class DrainBox: @unchecked Sendable { + private let lock = NSLock() + private var _data = Data() + + func store(_ data: Data) { + lock.lock() + _data = data + lock.unlock() + } + + var data: Data { + lock.lock(); defer { lock.unlock() } + return _data + } +} diff --git a/Sources/FOSMVVMBootstrapCLI/Interview.swift b/Sources/FOSMVVMBootstrapCLI/Interview.swift new file mode 100644 index 00000000..ff8e7426 --- /dev/null +++ b/Sources/FOSMVVMBootstrapCLI/Interview.swift @@ -0,0 +1,178 @@ +// Interview.swift +import ArgumentParser +import FOSFoundation +import FOSMVVMBootstrap +import Foundation + +/// Collects a `BootstrapConfig` conversationally when `new` is run without +/// `--config` — each answer is validated as it is given (the same rules +/// `validate()` enforces), with the FOSUtilities floors as defaults. +/// Answers can be piped through stdin; end-of-input without a complete +/// config fails with a pointer to `--config` for non-interactive use. +enum Interview { + static func conduct(outputDir: URL) throws -> BootstrapConfig { + // The output directory's name, CamelCased (FOSFoundation), is the + // default project name — camelCased() splits on underscores, so the + // usual directory separators normalize to them first. An invalid + // candidate (e.g. a leading digit) simply yields no default. + let candidate = outputDir.lastPathComponent + .replacingOccurrences(of: "-", with: "_") + .replacingOccurrences(of: ".", with: "_") + .replacingOccurrences(of: " ", with: "_") + .camelCased() + let projectName = try ask( + "Project name", + default: BootstrapConfig.isValidProjectName(candidate) ? candidate : nil, + invalid: "must start with a letter and contain only letters and digits", + valid: BootstrapConfig.isValidProjectName + ) + + let shape = try askShape() + + // Per-device questions, every platform opt-in; iPhone/iPad share the + // iOS destination and differ by device family. At least one of + // Mac/iPhone/iPad/TV/Vision must be chosen (the watch rides a + // separate target and cannot yet be the only platform). + var platforms: [TargetPlatform: String] = [:] + var iosDevices: [String] = [] + repeat { + if try askYesNo("Include Mac?") { + platforms[.macOS] = try askVersion(for: .macOS) + } + if try askYesNo("Include iPhone?") { iosDevices.append("iPhone") } + if try askYesNo("Include iPad?") { iosDevices.append("iPad") } + if !iosDevices.isEmpty { + platforms[.iOS] = try askVersion(for: .iOS) + } + if try askYesNo("Include Apple TV?") { + platforms[.tvOS] = try askVersion(for: .tvOS) + } + if try askYesNo("Include Apple Vision?") { + platforms[.visionOS] = try askVersion(for: .visionOS) + } + if try askYesNo("Include Apple Watch?") { + platforms[.watchOS] = try askVersion(for: .watchOS) + } + if platforms.keys.contains(where: { $0 != .watchOS }) { break } + print(" ✗ choose at least one of Mac, iPhone, iPad, Apple TV, or Apple Vision") + } while true + + // "Designed for iPad" compatibility: the unmodified iPad app on Apple + // Silicon Macs / Apple Vision. Asked only when iOS is chosen and the + // native platform is not; Apple's default is yes. + var macDesignedForIPad: Bool? + var visionDesignedForIPad: Bool? + if platforms[.iOS] != nil { + // The compat modes carry whichever iOS device family exists — + // Apple labels the destination "Designed for iPad" when the app + // has an iPad variant, "Designed for iPhone" otherwise. + let app = iosDevices == ["iPhone"] ? "iPhone app" : + iosDevices == ["iPad"] ? "iPad app" : "iPhone/iPad app" + let label = iosDevices.contains("iPad") ? "iPad" : "iPhone" + if platforms[.macOS] == nil { + macDesignedForIPad = try askYesNo( + "Allow the \(app) on Apple Silicon Macs (Designed for \(label))?", + default: true + ) + } + if platforms[.visionOS] == nil { + visionDesignedForIPad = try askYesNo( + "Allow the \(app) on Apple Vision (Designed for \(label))?", + default: true + ) + } + } + + var bundleIdRoot: String? + var teamId: String? + if shape != .sharedLibrary { + bundleIdRoot = try ask( + "Bundle id root (reverse-DNS, e.g. com.example.\(projectName.lowercased()))", + invalid: "must be lowercase reverse-DNS with at least two segments", + valid: BootstrapConfig.isValidBundleIdRoot + ) + teamId = try ask( + "Apple Development Team ID (10 characters)", + invalid: "must be exactly 10 characters, A-Z and 0-9", + valid: BootstrapConfig.isValidTeamId + ) + } + + let config = BootstrapConfig( + projectName: projectName, + shape: shape, + platforms: platforms, + bundleIdRoot: bundleIdRoot, + teamId: teamId, + iosDevices: iosDevices.count == 1 ? iosDevices : nil, + macDesignedForIPad: macDesignedForIPad, + visionDesignedForIPad: visionDesignedForIPad + ) + try config.validate() + + return config + } + + // MARK: Prompt primitives + + private static func readAnswer(_ prompt: String) throws -> String { + print(prompt, terminator: " ") + guard let line = readLine() else { + throw ValidationError( + "stdin ended before the interview finished — pass --config for non-interactive use" + ) + } + return line.trimmingCharacters(in: .whitespaces) + } + + private static func ask( + _ prompt: String, + default defaultValue: String? = nil, + invalid: String, + valid: (String) -> Bool + ) throws -> String { + while true { + let suffix = defaultValue.map { " (\($0))" } ?? "" + let answer = try readAnswer("\(prompt)\(suffix):") + let value = answer.isEmpty ? (defaultValue ?? "") : answer + if valid(value) { return value } + print(" ✗ \(invalid)") + } + } + + private static func askYesNo(_ prompt: String, default defaultValue: Bool = false) throws -> Bool { + let suffix = defaultValue ? "[Y/n]" : "[y/N]" + while true { + switch try readAnswer("\(prompt) \(suffix):").lowercased() { + case "": return defaultValue + case "n", "no": return false + case "y", "yes": return true + default: print(" ✗ answer y or n") + } + } + } + + private static func askShape() throws -> ProjectShape { + while true { + let answer = try readAnswer( + "Shape [1 localOnly · 2 clientServer · 3 sharedLibrary] (2):" + ) + switch answer { + case "", "2", "clientServer": return .clientServer + case "1", "localOnly": return .localOnly + case "3", "sharedLibrary": return .sharedLibrary + default: print(" ✗ answer 1, 2, or 3") + } + } + } + + private static func askVersion(for platform: TargetPlatform) throws -> String { + let floor = FOSPlatformFloor.floors[platform] ?? "1.0" + return try ask( + "\(platform.rawValue) minimum version", + default: floor, + invalid: "must be a version at or above the FOSUtilities floor of \(floor)", + valid: { (try? FOSPlatformFloor.validate(platforms: [platform: $0])) != nil } + ) + } +} diff --git a/Sources/FOSMVVMBootstrapCLI/Main.swift b/Sources/FOSMVVMBootstrapCLI/Main.swift new file mode 100644 index 00000000..b8d934f4 --- /dev/null +++ b/Sources/FOSMVVMBootstrapCLI/Main.swift @@ -0,0 +1,13 @@ +// Main.swift +import ArgumentParser +import FOSMVVMBootstrap + +@main +struct FOSMVVMBootstrap: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "fosmvvm-bootstrap", + abstract: "Scaffold FOSMVVM projects the FOS-mvvm way.", + version: Release.version, + subcommands: [New.self] + ) +} diff --git a/Sources/FOSMVVMBootstrapCLI/NewCommand.swift b/Sources/FOSMVVMBootstrapCLI/NewCommand.swift new file mode 100644 index 00000000..6b7c65b9 --- /dev/null +++ b/Sources/FOSMVVMBootstrapCLI/NewCommand.swift @@ -0,0 +1,74 @@ +// NewCommand.swift +import ArgumentParser +import FOSMVVMBootstrap +import Foundation + +struct New: ParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Scaffold a new FOSMVVM project from a config file." + ) + + @Option(name: .shortAndLong, help: "Path to a BootstrapConfig JSON file. Omit to answer a short interview instead.") + var config: String? + + @Option(name: .shortAndLong, help: "Output directory for the new project (must be empty or absent).") + var output: String + + @Flag(help: "After generating, build the project and run its tests (swift build / swift test / xcodebuild). CI verifies every release the same way; use this to prove the skeleton on THIS machine.") + var verify = false + + @Flag(help: "Extra output, including the interview's equivalent --config JSON.") + var verbose = false + + func run() throws { + let outputURL = URL(fileURLWithPath: output) + + let bootstrapConfig: BootstrapConfig + if let config { + bootstrapConfig = try JSONDecoder().decode( + BootstrapConfig.self, + from: Data(contentsOf: URL(fileURLWithPath: config)) + ) + } else { + bootstrapConfig = try Interview.conduct(outputDir: outputURL) + if verbose { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + if let json = try? String(data: encoder.encode(bootstrapConfig), encoding: .utf8) { + print("\nEquivalent --config file (save to rerun without the interview):") + print(json + "\n") + } + } + } + + print("Scaffolding \(bootstrapConfig.projectName) (\(bootstrapConfig.shape.rawValue)) …") + let emitted = try Emitter.emit(config: bootstrapConfig, into: outputURL) + print("Emitted \(emitted.count) files.") + + // Project generation (the .xcodeproj from project.yml) is part of the + // deliverable — it runs regardless of --skip-verify. + let generationSteps = Verifier.generationSteps(for: bootstrapConfig.shape) + if !generationSteps.isEmpty { + print("Generating Xcode project …") + try Verifier.verify( + projectDir: outputURL, + steps: generationSteps, + projectName: bootstrapConfig.projectName + ) + } + + if verify { + let steps = Verifier.steps(for: bootstrapConfig.shape) + print("Verifying (\(steps.map(\.rawValue).joined(separator: " / "))) …") + try Verifier.verify( + projectDir: outputURL, + steps: steps, + projectName: bootstrapConfig.projectName, + destination: Verifier.buildDestination(for: bootstrapConfig) + ) + print("✅ Walking skeleton verified green.") + } + + print(HandoffChecklist.text(for: bootstrapConfig.shape, projectName: bootstrapConfig.projectName)) + } +} diff --git a/Tests/FOSMVVMBootstrapTests/BootstrapConfigTests.swift b/Tests/FOSMVVMBootstrapTests/BootstrapConfigTests.swift new file mode 100644 index 00000000..a823cb87 --- /dev/null +++ b/Tests/FOSMVVMBootstrapTests/BootstrapConfigTests.swift @@ -0,0 +1,98 @@ +import FOSMVVMBootstrap +import Foundation +import Testing + +@Suite struct BootstrapConfigTests { + @Test func decodesSharedLibraryConfig() throws { + let json = """ + { + "projectName": "PalettePress", + "shape": "sharedLibrary", + "platforms": { "macOS": "14.0", "iOS": "17.0" } + } + """ + let config = try JSONDecoder().decode(BootstrapConfig.self, from: Data(json.utf8)) + #expect(config.projectName == "PalettePress") + #expect(config.shape == .sharedLibrary) + #expect(config.platforms[.macOS] == "14.0") + } + + @Test func rejectsInvalidProjectName() throws { + let config = BootstrapConfig( + projectName: "Palette Press!", + shape: .sharedLibrary, + platforms: [.macOS: "14.0"] + ) + #expect(throws: BootstrapConfigError.invalidProjectName("Palette Press!")) { + try config.validate() + } + } + + @Test func validSharedLibraryConfigPasses() throws { + let config = BootstrapConfig( + projectName: "PalettePress", + shape: .sharedLibrary, + platforms: [.macOS: "14.0"] + ) + try config.validate() + } + + @Test func localOnlyRequiresBundleIdRootAndTeam() throws { + let config = BootstrapConfig( + projectName: "PalettePress", + shape: .localOnly, + platforms: [.macOS: "14.0"] + ) + #expect(throws: BootstrapConfigError.missingBundleIdRoot) { + try config.validate() + } + } + + @Test func validLocalOnlyConfigPasses() throws { + let config = BootstrapConfig( + projectName: "PalettePress", + shape: .localOnly, + platforms: [.macOS: "14.0"], + bundleIdRoot: "com.example.palettepress", + teamId: "ABCDE12345" + ) + try config.validate() + } + + @Test func rejectsMalformedTeamId() throws { + let config = BootstrapConfig( + projectName: "PalettePress", + shape: .localOnly, + platforms: [.macOS: "14.0"], + bundleIdRoot: "com.example.palettepress", + teamId: "abc" + ) + #expect(throws: BootstrapConfigError.invalidTeamId("abc")) { + try config.validate() + } + } + + @Test func appShapesRequireAtLeastOneAppDestination() throws { + let config = BootstrapConfig( + projectName: "PalettePress", + shape: .clientServer, + platforms: [.watchOS: "10.0"], + bundleIdRoot: "com.example.palettepress", + teamId: "ABCDE12345" + ) + #expect(throws: BootstrapConfigError.noAppDestinations) { + try config.validate() + } + } + + @Test func validClientServerConfigPasses() throws { + let config = BootstrapConfig( + projectName: "PalettePress", + shape: .clientServer, + platforms: [.macOS: "14.0"], + bundleIdRoot: "com.example.palettepress", + teamId: "ABCDE12345" + ) + try config.validate() + } +} diff --git a/Tests/FOSMVVMBootstrapTests/EmitterTests.swift b/Tests/FOSMVVMBootstrapTests/EmitterTests.swift new file mode 100644 index 00000000..43ca7057 --- /dev/null +++ b/Tests/FOSMVVMBootstrapTests/EmitterTests.swift @@ -0,0 +1,270 @@ +import FOSMVVMBootstrap +import Foundation +import Testing + +@Suite struct EmitterTests { + /// The token-scannable text of an emitted entry: a symlink's own target + /// string (a link may point at a directory), a regular file's contents. + func emittedText(_ out: URL, _ path: String) throws -> String { + let full = out.appendingPathComponent(path).path + if let dest = try? FileManager.default.destinationOfSymbolicLink(atPath: full) { + return dest + } + return try String(contentsOf: URL(fileURLWithPath: full), encoding: .utf8) + } + + func makeConfig() -> BootstrapConfig { + BootstrapConfig( + projectName: "PalettePress", + shape: .sharedLibrary, + platforms: [.macOS: "14.0", .iOS: "17.0"] + ) + } + + @Test func emitsSharedLibraryFileSet() throws { + let out = FileManager.default.temporaryDirectory + .appendingPathComponent("emit-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: out) } + + let emitted = try Emitter.emit(config: makeConfig(), into: out) + + let expected = [ + "Package.swift", + "CLAUDE.md", + "README.md", + ".swiftformat", + "memory/MEMORY.md", + "memory/spm-libraries-settled.md", + "memory/stale-build-runbook.md", + "memory/entitlement-is-a-symptom.md", + "memory/macos-build-for-testing-faq.md", + "memory/xcode16-dynamic-spm-packages.md", + ".github/workflows/ci.yml", + "Sources/PalettePressViewModels/PalettePressViewModels.swift", + "Sources/PalettePressViewModels/ViewModels/WelcomeViewModel.swift", + "Sources/PalettePressViewModels/ViewModels/WelcomeViewModelOperations.swift", + "Sources/PalettePressViewModels/Resources/Localizations/ViewModels/WelcomeViewModel.yml", + "Tests/PalettePressViewModelsTests/WelcomeViewModelTests.swift", + "Tests/PalettePressViewModelsTests/LocalizableTestCase+PalettePress.swift", + ] + for path in expected { + #expect(emitted.contains(path), "missing \(path)") + #expect(FileManager.default.fileExists(atPath: out.appendingPathComponent(path).path)) + } + #expect(Set(emitted) == Set(expected), "emitted set ≠ expected set") + } + + @Test func emittedFilesContainNoTokens() throws { + let out = FileManager.default.temporaryDirectory + .appendingPathComponent("emit-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: out) } + + let emitted = try Emitter.emit(config: makeConfig(), into: out) + for path in emitted { + let content = try emittedText(out, path) + #expect(!content.contains("{{"), "unrendered token in \(path)") + } + } + + @Test func refusesNonEmptyOutputDirectory() throws { + let out = FileManager.default.temporaryDirectory + .appendingPathComponent("emit-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: out, withIntermediateDirectories: true) + FileManager.default.createFile( + atPath: out.appendingPathComponent("existing.txt").path, + contents: Data("x".utf8) + ) + defer { try? FileManager.default.removeItem(at: out) } + + #expect(throws: EmitterError.outputDirectoryNotEmpty(out.path)) { + _ = try Emitter.emit(config: makeConfig(), into: out) + } + } + + func makeLocalOnlyConfig() -> BootstrapConfig { + BootstrapConfig( + projectName: "PalettePress", + shape: .localOnly, + platforms: [.macOS: "14.0"], + bundleIdRoot: "com.example.palettepress", + teamId: "ABCDE12345" + ) + } + + @Test func emitsLocalOnlyFileSet() throws { + let out = FileManager.default.temporaryDirectory + .appendingPathComponent("emit-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: out) } + + let emitted = try Emitter.emit(config: makeLocalOnlyConfig(), into: out) + + let expected = [ + "project.yml", + "README.md", + "Sources/SPMLibraries/SPMLibraries.swift", + "Sources/ViewModels/ViewModelsResourceAccess.swift", + "Sources/ViewModels/ViewModels/WelcomeViewModel.swift", + "Sources/ViewModels/ViewModels/WelcomeViewModelOperations.swift", + "Sources/ViewModels/Resources/ViewModels/WelcomeViewModel.yml", + "Sources/ViewModels/Versioning/SystemVersion+App.swift", + "Sources/PalettePress/App/PalettePressApp.swift", + "Sources/PalettePress/App/TestConfiguration.swift", + "Sources/PalettePress/Views/WelcomeView.swift", + "Sources/PalettePress/Info.plist", + "Sources/PalettePress/PalettePress.entitlements", + "Tests/PalettePressUnitTests/ViewModels/WelcomeViewModelTests.swift", + "Tests/PalettePressUnitTests/LocalizableTestCase+PalettePress.swift", + "Tests/PalettePressUITests/Support/PalettePressViewModelViewTestCase.swift", + "Tests/PalettePressUITests/Support/PalettePressViewModelDisplayTestCase.swift", + "Tests/PalettePressUITests/PalettePressUITests.swift", + "Tests/PalettePressUITests/Views/WelcomeViewTests.swift", + "Tests/PalettePressUITests/Support/LocalizableTestCase+PalettePress.swift", + "Tests/PalettePressUITests/Support/TestConfiguration.swift", // symlink → the app's copy + // shared doctrine set + "CLAUDE.md", + ".swiftformat", + "memory/MEMORY.md", + "memory/spm-libraries-settled.md", + "memory/stale-build-runbook.md", + "memory/entitlement-is-a-symptom.md", + "memory/macos-build-for-testing-faq.md", + "memory/xcode16-dynamic-spm-packages.md" + ] + for path in expected { + #expect(emitted.contains(path), "missing \(path)") + #expect(FileManager.default.fileExists(atPath: out.appendingPathComponent(path).path)) + } + #expect(Set(emitted) == Set(expected), "emitted set ≠ expected set") + } + + @Test func localOnlyEmissionContainsNoTokens() throws { + let out = FileManager.default.temporaryDirectory + .appendingPathComponent("emit-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: out) } + + let emitted = try Emitter.emit(config: makeLocalOnlyConfig(), into: out) + for path in emitted { + let content = try emittedText(out, path) + #expect(!content.contains("{{"), "unrendered token in \(path)") + } + } + + func makeClientServerConfig() -> BootstrapConfig { + BootstrapConfig( + projectName: "PalettePress", + shape: .clientServer, + platforms: [.macOS: "14.0"], + bundleIdRoot: "com.example.palettepress", + teamId: "ABCDE12345" + ) + } + + @Test func emitsClientServerFileSet() throws { + let out = FileManager.default.temporaryDirectory + .appendingPathComponent("emit-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: out) } + + let emitted = try Emitter.emit(config: makeClientServerConfig(), into: out) + + let expected = [ + "Package.swift", + "project.yml", + "PalettePress.xctestplan", + // shared contract (SPM lib + source-included into the app) + "Sources/PalettePressViewModels/ViewModels/BoardViewModel.swift", + "Sources/PalettePressViewModels/ViewModels/BoardViewModelOperations.swift", + "Sources/PalettePressViewModels/ViewModels/CardViewModel.swift", + "Sources/PalettePressViewModels/Requests/BoardRequest.swift", + "Sources/PalettePressViewModels/Requests/CreateCardRequest.swift", + // shared foundation — version handshake for both the app and the server + "Sources/PalettePressFoundation/SystemVersion+App.swift", + // server-hosted YAML + "Sources/Resources/ViewModels/BoardViewModel.yml", + // Vapor server (Fluent) + "Sources/PalettePressServer/entrypoint.swift", + "Sources/PalettePressServer/configure.swift", + "Sources/PalettePressServer/routes.swift", + "Sources/PalettePressServer/DataModels/Board.swift", + "Sources/PalettePressServer/DataModels/Card.swift", + "Sources/PalettePressServer/Migrations/Board+Schema.swift", + "Sources/PalettePressServer/Migrations/Card+Schema.swift", + "Sources/PalettePressServer/Factories/BoardViewModel+Factory.swift", + "Sources/PalettePressServer/Writers/CreateCardRequest+Writer.swift", + "Sources/PalettePressServer/Auth/SkeletonAuthProvider.swift", + // umbrella (Xcode-only) + "Sources/SPMLibraries/SPMLibraries.swift", + // client-hosted framework (Xcode-only) + "Sources/PalettePressClientViewModels/PalettePressClientViewModels.swift", + "Sources/PalettePressClientViewModels/AboutViewModel.swift", + "Sources/PalettePressClientViewModels/AboutViewModelOperations.swift", + "Sources/PalettePressClientViewModels/Resources/ViewModels/AboutViewModel.yml", + // app (Xcode-only) + "Sources/PalettePress/App/PalettePressApp.swift", + "Sources/PalettePress/App/TestConfiguration.swift", + "Sources/PalettePress/Views/BoardView.swift", + "Sources/PalettePress/Views/AboutView.swift", + "Sources/PalettePress/Correlation.swift", + "Sources/PalettePress/Info.plist", + "Sources/PalettePress/PalettePress.entitlements", + // tests + "Tests/PalettePressViewModelsTests/BoardViewModelTests.swift", + "Tests/PalettePressViewModelsTests/LocalizableTestCase+PalettePress.swift", + "Tests/PalettePressServerTests/BoardServerTests.swift", + "Tests/PalettePressUnitTests/PalettePressUnitTests.swift", + "Tests/PalettePressClientViewModelsTests/AboutViewModelTests.swift", + "Tests/PalettePressClientViewModelsTests/LocalizableTestCase+PalettePress.swift", + "Tests/PalettePressUITests/Resources", // symlink → the server YAML tree (harness merged store) + "Tests/PalettePressUITests/PalettePressUITests.swift", + "Tests/PalettePressUITests/Support/PalettePressViewModelViewTestCase.swift", + "Tests/PalettePressUITests/Support/PalettePressViewModelDisplayTestCase.swift", + "Tests/PalettePressUITests/Support/LocalizableTestCase+PalettePress.swift", + "Tests/PalettePressUITests/Support/TestConfiguration.swift", // symlink → the app's copy + "Tests/PalettePressUITests/Views/AboutViewTests.swift", + "Tests/PalettePressUITests/Views/BoardViewTests.swift", + "README.md", + // shared doctrine set + "CLAUDE.md", + ".swiftformat", + "memory/MEMORY.md", + "memory/spm-libraries-settled.md", + "memory/stale-build-runbook.md", + "memory/entitlement-is-a-symptom.md", + "memory/macos-build-for-testing-faq.md", + "memory/xcode16-dynamic-spm-packages.md" + ] + for path in expected { + #expect(emitted.contains(path), "missing \(path)") + #expect(FileManager.default.fileExists(atPath: out.appendingPathComponent(path).path)) + } + #expect(Set(emitted) == Set(expected), "emitted set ≠ expected set") + } + + @Test func clientServerEmissionContainsNoTokens() throws { + let out = FileManager.default.temporaryDirectory + .appendingPathComponent("emit-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: out) } + + let emitted = try Emitter.emit(config: makeClientServerConfig(), into: out) + for path in emitted { + let content = try emittedText(out, path) + #expect(!content.contains("{{"), "unrendered token in \(path)") + } + } + + @Test func refusesShapeWithoutTemplates() throws { + let out = FileManager.default.temporaryDirectory + .appendingPathComponent("emit-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: out) } + + let config = BootstrapConfig( + projectName: "PalettePress", + shape: .hybrid, + platforms: [.macOS: "14.0"] + ) + #expect(throws: EmitterError.shapeNotImplemented("hybrid")) { + _ = try Emitter.emit(config: config, into: out) + } + // Nothing may be written before the guard fires. + #expect(!FileManager.default.fileExists(atPath: out.path)) + } +} diff --git a/Tests/FOSMVVMBootstrapTests/FOSPlatformFloorTests.swift b/Tests/FOSMVVMBootstrapTests/FOSPlatformFloorTests.swift new file mode 100644 index 00000000..e169e41a --- /dev/null +++ b/Tests/FOSMVVMBootstrapTests/FOSPlatformFloorTests.swift @@ -0,0 +1,27 @@ +import Foundation +@testable import FOSMVVMBootstrap +import Testing + +@Suite struct FOSPlatformFloorTests { + @Test func atFloorPasses() throws { + try FOSPlatformFloor.validate(platforms: [.macOS: "14.0", .iOS: "17.0"]) + } + + @Test func aboveFloorPasses() throws { + try FOSPlatformFloor.validate(platforms: [.macOS: "26.0"]) + } + + @Test func belowFloorThrows() { + #expect(throws: BootstrapConfigError.belowFOSFloor(platform: .macOS, asked: "13.0", floor: "14.0")) { + try FOSPlatformFloor.validate(platforms: [.macOS: "13.0"]) + } + } + + @Test func minorVersionComparesNumerically() { + // "10.4" < "10.15" numerically even though it sorts *after* lexically — + // this is the case a lexical-compare regression would get wrong. + #expect(FOSPlatformFloor.compareVersions("10.4", "10.15") == .orderedAscending) + // A missing component defaults to 0, so "14" and "14.0" are equal. + #expect(FOSPlatformFloor.compareVersions("14", "14.0") == .orderedSame) + } +} diff --git a/Tests/FOSMVVMBootstrapTests/IntegrationTests.swift b/Tests/FOSMVVMBootstrapTests/IntegrationTests.swift new file mode 100644 index 00000000..283c841a --- /dev/null +++ b/Tests/FOSMVVMBootstrapTests/IntegrationTests.swift @@ -0,0 +1,106 @@ +// IntegrationTests.swift +// +// Copyright 2026 FOS Computer Services, LLC +// +// Licensed under the Apache License, Version 2.0 (the License); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import FOSMVVMBootstrap +import Foundation +import Testing + +extension Tag { + @Tag static var integration: Tag +} + +// Walking skeletons (migration design §7): emit each shape and run its +// verification doors. Slow (~8 min) and network-resolving, so they only run +// when FOSMVVM_BOOTSTRAP_SKELETONS=1 — CI's generation-matrix job sets it; +// bare `swift test` skips them. +// .serialized: each skeleton resolves and compiles the full FOSUtilities +// dependency graph; in parallel they contend for a 3–4 core hosted runner +// (localOnly: 123s alone vs 300s contended), and one timed-out test's +// process kill discards the skeletons that haven't run yet. +@Suite( + .tags(.integration), + .serialized, + .enabled(if: ProcessInfo.processInfo.environment["FOSMVVM_BOOTSTRAP_SKELETONS"] == "1") +) struct IntegrationTests { + /// Full walking-skeleton proof for the shared-library shape: + /// emit → swift build → swift test inside the generated project, + /// exercising the real FOSUtilities dependency, the YAML + /// localization round-trip, and the codable round-trip. + @Test(.timeLimit(.minutes(10))) + func sharedLibraryWalkingSkeletonIsGreen() throws { + let out = FileManager.default.temporaryDirectory + .appendingPathComponent("skeleton-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: out) } + + let config = BootstrapConfig( + projectName: "PalettePress", + shape: .sharedLibrary, + platforms: [.macOS: "14.0", .iOS: "17.0"] + ) + try Emitter.emit(config: config, into: out) + try Verifier.verify(projectDir: out, steps: Verifier.steps(for: .sharedLibrary)) + } + + /// Local-only walking-skeleton proof: emit → xcodegen generate → + /// xcodebuild build (macOS, unsigned). Fails with a typed + /// toolMissing when xcodegen is not installed (brew install xcodegen). + @Test(.timeLimit(.minutes(15))) + func localOnlyWalkingSkeletonBuilds() throws { + let out = FileManager.default.temporaryDirectory + .appendingPathComponent("skeleton-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: out) } + + let config = BootstrapConfig( + projectName: "PalettePress", + shape: .localOnly, + platforms: [.macOS: "14.0"], + bundleIdRoot: "com.example.palettepress", + teamId: "ABCDE12345" + ) + try Emitter.emit(config: config, into: out) + try Verifier.verify( + projectDir: out, + steps: Verifier.generationSteps(for: .localOnly) + Verifier.steps(for: .localOnly), + projectName: "PalettePress" + ) + } + + /// Client-server (= hybrid) walking-skeleton proof: emit → the four-step door. + /// `swift build` + `swift test` boot Fluent on SQLite-in-memory, create a card + /// through the real pipeline, and assert the refreshed live board (no database + /// server, no simulator); `xcodegen` + `xcodebuild` build the app (umbrella + + /// client-hosted framework + source-included contract). + @Test(.timeLimit(.minutes(25))) + func clientServerWalkingSkeletonBuilds() throws { + let out = FileManager.default.temporaryDirectory + .appendingPathComponent("cs-skeleton-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: out) } + + let config = BootstrapConfig( + projectName: "PalettePress", + shape: .clientServer, + platforms: [.macOS: "14.0"], + bundleIdRoot: "com.example.palettepress", + teamId: "ABCDE12345" + ) + try Emitter.emit(config: config, into: out) + try Verifier.verify( + projectDir: out, + steps: Verifier.generationSteps(for: .clientServer) + Verifier.steps(for: .clientServer), + projectName: "PalettePress" + ) + } +} diff --git a/Tests/FOSMVVMBootstrapTests/ReleaseConsistencyTests.swift b/Tests/FOSMVVMBootstrapTests/ReleaseConsistencyTests.swift new file mode 100644 index 00000000..e03c9862 --- /dev/null +++ b/Tests/FOSMVVMBootstrapTests/ReleaseConsistencyTests.swift @@ -0,0 +1,44 @@ +// ReleaseConsistencyTests.swift +import FOSMVVMBootstrap +import Foundation +import Testing + +// The two drift-proofing tests from the migration design (§6): the release +// ritual stamps CHANGELOG.md and Release.version in one commit, and the +// floors table must mirror this package's own platforms. Both are compared +// against the repo files via #filePath, so drift fails CI instead of a user. +@Suite struct ReleaseConsistencyTests { + private var repoRoot: URL { + URL(fileURLWithPath: #filePath) // .../Tests/FOSMVVMBootstrapTests/ReleaseConsistencyTests.swift + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + } + + @Test func releaseVersionMatchesTopmostChangelogStamp() throws { + let changelog = try String(contentsOf: repoRoot.appendingPathComponent("CHANGELOG.md"), encoding: .utf8) + let pattern = #/^## \[(\d+\.\d+\.\d+)\] - /#.anchorsMatchLineEndings() + let stamped = try #require(changelog.firstMatch(of: pattern)?.1) + #expect( + String(stamped) == Release.version, + "Release.version (\(Release.version)) must match the topmost stamped CHANGELOG release (\(stamped)) — the stamp commit updates both." + ) + } + + @Test func floorsMatchThisPackagesPlatforms() throws { + let manifest = try String(contentsOf: repoRoot.appendingPathComponent("Package.swift"), encoding: .utf8) + let platformNames: [TargetPlatform: String] = [ + .iOS: "iOS", .macOS: "macOS", .macCatalyst: "macCatalyst", + .tvOS: "tvOS", .watchOS: "watchOS", .visionOS: "visionOS" + ] + for (platform, manifestName) in platformNames { + let floor = try #require(FOSPlatformFloor.floors[platform], "floors table is missing \(manifestName)") + // Manifest form: .iOS(.v17) / .macOS(.v14) — major-only versions. + let major = floor.split(separator: ".").first.map(String.init) ?? floor + #expect( + manifest.contains(".\(manifestName)(.v\(major))"), + "floors[\(manifestName)] = \(floor) has no matching .\(manifestName)(.v\(major)) in Package.swift platforms:" + ) + } + } +} diff --git a/Tests/FOSMVVMBootstrapTests/SmokeTests.swift b/Tests/FOSMVVMBootstrapTests/SmokeTests.swift new file mode 100644 index 00000000..52097c0e --- /dev/null +++ b/Tests/FOSMVVMBootstrapTests/SmokeTests.swift @@ -0,0 +1,8 @@ +import FOSMVVMBootstrap +import Testing + +@Suite struct SmokeTests { + @Test func versionExists() { + #expect(!Release.version.isEmpty) + } +} diff --git a/Tests/FOSMVVMBootstrapTests/TemplateRendererTests.swift b/Tests/FOSMVVMBootstrapTests/TemplateRendererTests.swift new file mode 100644 index 00000000..34c855d1 --- /dev/null +++ b/Tests/FOSMVVMBootstrapTests/TemplateRendererTests.swift @@ -0,0 +1,29 @@ +import FOSMVVMBootstrap +import Foundation +import Testing + +@Suite struct TemplateRendererTests { + let tokens = ["PROJECT_NAME": "PalettePress", "FOS_VERSION": "0.10.0"] + + @Test func substitutesTokensInContent() throws { + let out = try TemplateRenderer.render( + content: "let name = \"{{PROJECT_NAME}}\" // needs {{FOS_VERSION}}", + tokens: tokens + ) + #expect(out == "let name = \"PalettePress\" // needs 0.10.0") + } + + @Test func unrenderedTokenIsFatal() { + #expect(throws: TemplateError.unrenderedToken(token: "{{TEAM_ID}}", context: "id: {{TEAM_ID}}")) { + _ = try TemplateRenderer.render(content: "id: {{TEAM_ID}}", tokens: tokens) + } + } + + @Test func rendersPathsAndStripsTmplSuffix() throws { + let path = try TemplateRenderer.render( + relativePath: "Sources/{{PROJECT_NAME}}ViewModels/Package.swift.tmpl", + tokens: tokens + ) + #expect(path == "Sources/PalettePressViewModels/Package.swift") + } +} diff --git a/Tests/FOSMVVMBootstrapTests/TokenSetTests.swift b/Tests/FOSMVVMBootstrapTests/TokenSetTests.swift new file mode 100644 index 00000000..ee0b9d36 --- /dev/null +++ b/Tests/FOSMVVMBootstrapTests/TokenSetTests.swift @@ -0,0 +1,148 @@ +import FOSMVVMBootstrap +import Testing + +@Suite struct TokenSetTests { + @Test func derivesSharedLibraryTokens() throws { + let config = BootstrapConfig( + projectName: "PalettePress", + shape: .sharedLibrary, + platforms: [.macOS: "14.0", .iOS: "17.0"] + ) + let tokens = try TokenSet.derive(from: config) + #expect(tokens["PROJECT_NAME"] == "PalettePress") + #expect(tokens["FOS_VERSION"] == FOSPlatformFloor.pinnedFOSVersion) + // platforms render deterministically (alphabetical by platform name) + #expect(tokens["PLATFORMS"] == ".iOS(\"17.0\"),\n .macOS(\"14.0\")") + } + + @Test func defaultLicenseHeaderIsEmpty() throws { + let config = BootstrapConfig( + projectName: "PalettePress", + shape: .sharedLibrary, + platforms: [.macOS: "14.0"] + ) + let tokens = try TokenSet.derive(from: config) + #expect(tokens["LICENSE_HEADER"] == "") + } + + @Test func derivesLocalOnlyTokens() throws { + let config = BootstrapConfig( + projectName: "PalettePress", + shape: .localOnly, + platforms: [.macOS: "14.0"], + bundleIdRoot: "com.example.palettepress", + teamId: "ABCDE12345" + ) + let tokens = try TokenSet.derive(from: config) + #expect(tokens["BUNDLE_ID_ROOT"] == "com.example.palettepress") + #expect(tokens["TEAM_ID"] == "ABCDE12345") + #expect(tokens["MACOS_DEPLOYMENT"] == "14.0") + } + + @Test func destinationsFollowThePlatformsMap() throws { + // macOS-only: the Xcode surface stays Mac-only. + let macOnly = try TokenSet.derive(from: BootstrapConfig( + projectName: "PalettePress", + shape: .localOnly, + platforms: [.macOS: "14.0"], + bundleIdRoot: "com.example.palettepress", + teamId: "ABCDE12345" + )) + #expect(macOnly["SUPPORTED_DESTINATIONS"] == "[macOS]") + #expect(macOnly["DEPLOYMENT_TARGETS"] == "\n macOS: \"14.0\"") + #expect(macOnly["DEVICE_FAMILY_OVERRIDE"] == "") + #expect(macOnly["XR_COMPAT_OVERRIDE"] == "") + + // Asking for iOS adds the iPhone destinations and its deployment floor. + let withIOS = try TokenSet.derive(from: BootstrapConfig( + projectName: "PalettePress", + shape: .clientServer, + platforms: [.macOS: "14.0", .iOS: "17.0"], + bundleIdRoot: "com.example.palettepress", + teamId: "ABCDE12345" + )) + #expect(withIOS["SUPPORTED_DESTINATIONS"] == "[macOS, iOS]") + #expect(withIOS["DEPLOYMENT_TARGETS"] == "\n macOS: \"14.0\"\n iOS: \"17.0\"") + #expect(withIOS["DEVICE_FAMILY_OVERRIDE"] == "\n TARGETED_DEVICE_FAMILY: \"1,2\"") + #expect(withIOS["XR_COMPAT_OVERRIDE"] == "") + + // iPhone-only, with TV and Vision: families compose; XR compat stands. + let kitchenSink = try TokenSet.derive(from: BootstrapConfig( + projectName: "PalettePress", + shape: .clientServer, + platforms: [.macOS: "14.0", .iOS: "17.0", .tvOS: "17.0", .visionOS: "1.0"], + bundleIdRoot: "com.example.palettepress", + teamId: "ABCDE12345", + iosDevices: ["iPhone"] + )) + #expect(kitchenSink["SUPPORTED_DESTINATIONS"] == "[macOS, iOS, tvOS, visionOS]") + #expect(kitchenSink["DEVICE_FAMILY_OVERRIDE"] == "\n TARGETED_DEVICE_FAMILY: \"1,3,7\"") + #expect(kitchenSink["XR_COMPAT_OVERRIDE"] == "") + + // Explicit declines emit the compat opt-outs; omitted means allowed. + let declined = try TokenSet.derive(from: BootstrapConfig( + projectName: "PalettePress", + shape: .localOnly, + platforms: [.iOS: "17.0"], + bundleIdRoot: "com.example.palettepress", + teamId: "ABCDE12345", + macDesignedForIPad: false, + visionDesignedForIPad: false + )) + #expect(declined["XR_COMPAT_OVERRIDE"] == + "\n SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD: NO" + + "\n SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD: NO") + + // No Mac, no explicit compat answers: iOS-only destinations, and the + // compat modes stay at Apple's default (allowed — no overrides). + let noMac = try TokenSet.derive(from: BootstrapConfig( + projectName: "PalettePress", + shape: .localOnly, + platforms: [.iOS: "17.0"], + bundleIdRoot: "com.example.palettepress", + teamId: "ABCDE12345" + )) + #expect(noMac["SUPPORTED_DESTINATIONS"] == "[iOS]") + #expect(noMac["XR_COMPAT_OVERRIDE"] == "") + + // Watch: frameworks gain the destination; the app target does NOT + // (separate watch target instead, emitted via WATCH_TARGET). + let withWatch = try TokenSet.derive(from: BootstrapConfig( + projectName: "PalettePress", + shape: .localOnly, + platforms: [.macOS: "14.0", .iOS: "17.0", .watchOS: "10.0"], + bundleIdRoot: "com.example.palettepress", + teamId: "ABCDE12345" + )) + #expect(withWatch["SUPPORTED_DESTINATIONS"] == "[macOS, iOS]") + #expect(withWatch["FRAMEWORK_DESTINATIONS"] == "[macOS, iOS, watchOS]") + #expect(withWatch["WATCH_TARGET"]?.contains("PalettePressWatch:") == true) + #expect(withWatch["WATCH_SCHEME"]?.contains("PalettePressWatch:") == true) + #expect(withWatch["DEPLOYMENT_TARGETS"]?.contains("watchOS: \"10.0\"") == true) + } + + @Test func derivesClientServerTokens() throws { + let config = BootstrapConfig( + projectName: "PalettePress", + shape: .clientServer, + platforms: [.macOS: "14.0"], + bundleIdRoot: "com.example.palettepress", + teamId: "ABCDE12345" + ) + let tokens = try TokenSet.derive(from: config) + #expect(tokens["BUNDLE_ID_ROOT"] == "com.example.palettepress") + #expect(tokens["TEAM_ID"] == "ABCDE12345") + #expect(tokens["MACOS_DEPLOYMENT"] == "14.0") + } + + @Test func sharedLibraryTokensUnchanged() throws { + let config = BootstrapConfig( + projectName: "PalettePress", + shape: .sharedLibrary, + platforms: [.macOS: "14.0"] + ) + let tokens = try TokenSet.derive(from: config) + #expect(tokens["BUNDLE_ID_ROOT"] == nil) + #expect(tokens.count == 4) + } +} diff --git a/Tests/FOSMVVMBootstrapTests/VerifierEnvironmentTests.swift b/Tests/FOSMVVMBootstrapTests/VerifierEnvironmentTests.swift new file mode 100644 index 00000000..f5116bd5 --- /dev/null +++ b/Tests/FOSMVVMBootstrapTests/VerifierEnvironmentTests.swift @@ -0,0 +1,51 @@ +// VerifierEnvironmentTests.swift +// +// Copyright 2026 FOS Computer Services, LLC +// +// Licensed under the Apache License, Version 2.0 (the License); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +@testable import FOSMVVMBootstrap +import Foundation +import Testing + +struct VerifierEnvironmentTests { + @Test func stripsHarnessSessionVariables() { + let base = [ + "PATH": "/usr/bin", + "HOME": "/Users/dev", + "XCTestConfigurationFilePath": "/tmp/session.xctestconfiguration", + "XCTestSessionIdentifier": "ABC-123", + "XCTestBundlePath": "/tmp/Some.xctest", + "DYLD_INSERT_LIBRARIES": "/tmp/inject.dylib", + "DYLD_FRAMEWORK_PATH": "/tmp/frameworks" + ] + + let child = Verifier.childEnvironment(from: base, overlaying: nil) + + #expect(child == ["PATH": "/usr/bin", "HOME": "/Users/dev"]) + } + + @Test func overlayWinsOverInheritedValues() { + let base = [ + "PATH": "/usr/bin", + "XCTestSessionIdentifier": "ABC-123" + ] + + let child = Verifier.childEnvironment( + from: base, + overlaying: ["PATH": "/nonexistent", "EXTRA": "1"] + ) + + #expect(child == ["PATH": "/nonexistent", "EXTRA": "1"]) + } +} diff --git a/Tests/FOSMVVMBootstrapTests/VerifierTests.swift b/Tests/FOSMVVMBootstrapTests/VerifierTests.swift new file mode 100644 index 00000000..3572ab30 --- /dev/null +++ b/Tests/FOSMVVMBootstrapTests/VerifierTests.swift @@ -0,0 +1,78 @@ +import FOSMVVMBootstrap +import Foundation +import Testing + +@Suite struct VerifierTests { + /// Writes a minimal valid SPM package to a temp dir. + func writeTinyPackage(brokenSource: Bool) throws -> URL { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("verify-\(UUID().uuidString)") + try FileManager.default.createDirectory( + at: dir.appendingPathComponent("Sources/Tiny"), + withIntermediateDirectories: true + ) + try """ + // swift-tools-version: 6.0 + import PackageDescription + let package = Package(name: "Tiny", targets: [.target(name: "Tiny")]) + """.write(to: dir.appendingPathComponent("Package.swift"), atomically: true, encoding: .utf8) + try (brokenSource ? "let x: Int = \"nope\"" : "let x = 1") + .write(to: dir.appendingPathComponent("Sources/Tiny/Tiny.swift"), atomically: true, encoding: .utf8) + return dir + } + + @Test func passesOnBuildableProject() throws { + let dir = try writeTinyPackage(brokenSource: false) + defer { try? FileManager.default.removeItem(at: dir) } + try Verifier.verify(projectDir: dir, steps: [.swiftBuild]) + } + + @Test func failsWithCapturedOutputOnBrokenProject() throws { + let dir = try writeTinyPackage(brokenSource: true) + defer { try? FileManager.default.removeItem(at: dir) } + do { + try Verifier.verify(projectDir: dir, steps: [.swiftBuild]) + Issue.record("expected verification failure") + } catch let error as VerifierError { + guard case let .stepFailed(step, output) = error else { + Issue.record("wrong error: \(error)"); return + } + #expect(step == .swiftBuild) + #expect(output.contains("error:")) + // The CustomStringConvertible rendering must name the failed + // command, so an ArgumentParser dump reads as a legible block. + #expect(String(describing: error).contains("swift build")) + } + } + + @Test func localOnlyStepsAreXcodeSteps() { + #expect(Verifier.generationSteps(for: .localOnly) == [.xcodegenGenerate]) + #expect(Verifier.steps(for: .localOnly) == [.xcodebuildBuild]) + } + + @Test func clientServerStepsAreTheFourDoorSteps() { + #expect(Verifier.generationSteps(for: .clientServer) == [.xcodegenGenerate]) + #expect(Verifier.steps(for: .clientServer) + == [.swiftBuild, .swiftTest, .xcodebuildBuild]) + } + + @Test func missingToolThrowsToolMissing() throws { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("verify-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: dir) } + do { + try Verifier.verify( + projectDir: dir, + steps: [.xcodegenGenerate], + environment: ["PATH": "/nonexistent"] + ) + Issue.record("expected toolMissing") + } catch let error as VerifierError { + guard case let .toolMissing(tool, _) = error else { + Issue.record("wrong error: \(error)"); return + } + #expect(tool == "xcodegen") + } + } +} diff --git a/docs/RELEASING.md b/docs/RELEASING.md new file mode 100644 index 00000000..aebe4f3d --- /dev/null +++ b/docs/RELEASING.md @@ -0,0 +1,9 @@ +# Releasing FOSUtilities + +The ritual, in order. One commit covers steps 1 and 2 (the stamp commit); the release-consistency test in `FOSMVVMBootstrapTests` fails CI when they disagree. + +1. `CHANGELOG.md`: move the `[Unreleased]` content under a new `## [X.Y.Z] - YYYY-MM-DD` heading. +2. `Sources/FOSMVVMBootstrap/Release.swift`: set `Release.version` to `X.Y.Z`. Projects generated by `fosmvvm-bootstrap` pin this release. +3. Commit as `docs(CHANGELOG): stamp X.Y.Z release (YYYY-MM-DD)` and push `main`. +4. Tag: `git tag X.Y.Z && git push origin X.Y.Z`. +5. GitHub release: `gh release create X.Y.Z --title X.Y.Z --notes-file `. diff --git a/docs/superpowers/plans/2026-08-22-deferrals-ledger-to-workflow-handoff.md b/docs/superpowers/plans/2026-08-22-deferrals-ledger-to-workflow-handoff.md new file mode 100644 index 00000000..a77b1272 --- /dev/null +++ b/docs/superpowers/plans/2026-08-22-deferrals-ledger-to-workflow-handoff.md @@ -0,0 +1,22 @@ +# Handoff: deferrals-ledger incorporation — for the fos-development-workflow session + +**Thread position:** answers the ledger-location question (§10.2) of `2026-08-22-fosmvvm-bootstrap-migration-design.md`. Written by the bootstrap-migration session; to be picked up by the session working in `/Users/david/Repository/FOS/FOSUtilities-workflow` (`feature/fos-development-workflow`) **when it merges its process out**. + +**Status:** WAITING — no action needed until the workflow process merges to main. + +## What you are picking up + +David ruled (2026-08-22) that the planning process you are building is what FOSUtilities will support, and that the bootstrap deferrals ledger should be incorporated into *your* process rather than parked in an interim location. Until you merge, the ledger stays at its current home: `/Users/david/Repository/FOS/fosmvvm-bootstrap/docs/deferrals.md` (repo may be archived by then — the file travels with wherever the migration design's §9 put it; check that design's execution state first). + +## What needs to happen when you merge + +1. Absorb the ledger's open items into your process's equivalent structure. As of this writing the open items are: multi-platform app emission (partially superseded — the migration design's §4 now owns the immediate Apple-five scope; reconcile rather than duplicate), the Plan 5 roadmap items (`doctor` implementation, plugin-skill wrapper, example publishing — release CI is absorbed by the migration design's §7), credential-middleware auth group, serverrequest-generator skill update, key-echo generator doors, iCloud-output-path check for `doctor`, and the Deferred/future section (display technologies, FOS release automation). +2. Preserve the ledger's maintenance rule in whatever form your process uses: deferrals are recorded in the same pass that defers them, cross-referenced to the originating doc, and reviewed before the next plan is written. +3. The migration design itself (§10.2) records that ledger-location was deferred to you — close that loop by noting where the items landed. + +## Findings that may matter to your process design + +- The ledger's two-tier split (Tracked = real pending work with a home; Deferred = future/unproven) has worked well across five plans — worth keeping as a distinction. +- Items age poorly when their origin docs move repos; your process may want origin references that survive migration (the bootstrap → FOSUtilities move is the live example). + +No confidentiality wall applies — both sides of this handoff are FOS-internal. diff --git a/docs/superpowers/plans/2026-08-22-fosmvvm-bootstrap-migration-design.md b/docs/superpowers/plans/2026-08-22-fosmvvm-bootstrap-migration-design.md new file mode 100644 index 00000000..7a9d03bc --- /dev/null +++ b/docs/superpowers/plans/2026-08-22-fosmvvm-bootstrap-migration-design.md @@ -0,0 +1,136 @@ +# fosmvvm-bootstrap → FOSUtilities — migration design + +**Status:** APPROVED (David, 2026-08-22) — all §10 points ruled; execution began same day. + +**Provenance:** David's distribution framing (2026-08-22): the standard use case starts at swiftpackageindex.com / the FOSUtilities README / the FOSFoundation & FOSMVVM DocC — and from any of those it must be trivial to (a) create a project that incorporates FOSUtilities or (b) incorporate FOSUtilities into an existing project and diagnose it with `doctor`. Plus his standing note (2026-08-11): the hard-coded version pin is wrong in principle, and the bootstrap repo likely folds back into FOSUtilities. + +--- + +## 1. Context — observations this design builds on + +The decision under review is David's (2026-08-22): distribute the scaffolder through FOSUtilities. This section records the observed facts the design uses; it is not an argument for the decision — §10's questions and the red pen decide. + +- Discovery today happens at FOSUtilities (SPI, README, DocC); the bootstrap repo is separate and unpublished. +- The scaffolder currently pins FOSUtilities via a hand-maintained string (`pinnedFOSVersion` + an UPDATE-BOTH comment); four bump rounds were performed during 0.12.6 → 0.13.3. +- FOSUtilities already hosts developer-facing tooling: the `fosmvvm-generators` skills (`.claude/`) and `Tools/UITestingProbe`. +- Three open bootstrap deferrals (release CI, example publishing, plugin-skill wrapper) overlap infrastructure this repo already has or would gain. + +## 2. Costs and risks + +- FOSUtilities' dependency graph gains `swift-argument-parser` (resolution-only for library consumers) and its tree gains ~30 template files plus the scaffolder sources/tests. +- The scaffolder's emit-and-verify integration tests — the repo calls them **walking skeletons**: each generates a complete project of one shape into a temp directory and runs that project's own verification doors (`swift build`, `swift test`, `xcodegen`, unsigned `xcodebuild build`) — are slow (~8 min) and network-resolving; ungated they would degrade this repo's `swift test`. §7 proposes the gate; if the gate is judged insufficient, that weighs against the move. +- The release ritual gains a mandatory step (§6's stamp); a test converts forgetting into CI failure, but the ritual is still longer. +- Repo scope widens: issues/PRs about scaffolding land on the framework repo. +- Reversal is cheap before the first release that ships the scaffolder, and expensive after (users will hold clone-and-run instructions). + +## 3. What the user sees + +**(a) Create a project** — README and DocC show one block, and it works from a bare clone. RULED during execution (David, 2026-08-22): with no `--config`, `new` runs a short validated interview and echoes the equivalent config JSON — the file is the automation path, not the first-touch UX (a hand-authored file invites typo'd keys that decode silently). + +```bash +git clone https://github.com/foscomputerservices/FOSUtilities.git +cd FOSUtilities +cat > /tmp/myapp.json <<'EOF' +{ "projectName": "MyApp", "shape": "clientServer", + "platforms": { "macOS": "14.0" }, + "bundleIdRoot": "com.example.myapp", "teamId": "YOURTEAMID" } +EOF +swift run fosmvvm-bootstrap new --config /tmp/myapp.json --output ~/MyApp +``` + +Shapes: `localOnly`, `clientServer`, `sharedLibrary`. The generated project pins the FOSUtilities release the scaffolder shipped with (§6). RULED during execution (David, 2026-08-22): generation-time verification is opt-in via `--verify` — with §7's per-PR CI carrying the continuous proof, the doors' remaining value (this machine, this config, this dependency graph) does not justify minutes of every user's default run. The `.xcodeproj` is produced by generation itself, never by verification. A prebuilt-binary path (GitHub release artifact → optional Homebrew) is scoped to Plan 5, not this migration. + +**(b) Incorporate + diagnose** — `doctor` ships as an **SPM command plugin**, so a user whose package already depends on FOSUtilities runs, with zero installation: + +```bash +swift package fosmvvm-doctor +``` + +The plugin is the *distribution decision made now*; the doctor *implementation* remains the Plan 5 item. It shares the rules table with the emitter (same target), per the original spec. + +## 4. Platform support for generated projects + +**Ruled scope (David, 2026-08-22, red pen on §3):** generated projects must support all Swift-supported platforms in the end; immediately: **iOS, tvOS, watchOS, visionOS, macOS**. + +Where this stands and what the design proposes: + +- The config layer already accepts and floor-validates all six FOS platforms (the floors table carries iOS 17 / macOS 14 / macCatalyst 17 / tvOS 17 / watchOS 10 / visionOS 1), and the `platforms:` line already flows into the emitted `Package.swift`. The gap is the Xcode layer: `SUPPORTED_PLATFORMS` is pinned to `macosx` and only `MACOS_DEPLOYMENT` is derived (the tracked multi-platform ledger item). macCatalyst is dropped from the supported set (ruled, §10.7). +- **Proposed shape: one multi-platform app target** (supported-destinations style, one bundle id), not per-platform targets. Evidence for feasibility: David hand-added the iPhone destination to the 0.13.3 ServerDemo and the full suite passed both destinations unmodified. +- Emission derives `SUPPORTED_PLATFORMS` and per-platform deployment settings from the config's `platforms` keys, replacing the macosx pin. +- Verification per platform: macOS and iOS run the full UI-test matrix (proven, §7). tvOS and visionOS run on their simulators. **watchOS UI testing is new in Xcode 27** (release notes, Testing → "Fixed: watchOS Unit and UI tests may not run on device", 178874363) — so all five platforms can carry UI tests on current toolchains; the probe README's "no XCUITest on watchOS" predates this cycle and should be updated when the watch harness lands. As with every platform, the probe verifies the tag/tap contract behaviorally when the watch harness is added — availability is Apple's documented word; behavior is measured. The templates' watch UI idioms need their own audit. +- **Proposed sequencing:** the migration lands with macOS + iOS emitted and CI-verified (both proven green today); tvOS, visionOS, watchOS follow inside this arc, each gated by its own probe/skeleton verification, before any release that advertises them. Open ruling: §9.6. + +## 5. Target shape in FOSUtilities + +Stacked, per piece (names are proposals — see §10): + +- **`FOSMVVMBootstrap`** (library target) ← `Sources/BootstrapKit`, including `Templates/` as `.copy` resources. Renamed from `BootstrapKit`: in FOSUtilities the unprefixed name is too generic, and the FOS prefix matches every sibling. +- **`fosmvvm-bootstrap`** (executableTarget + executable product) ← `Sources/BootstrapCLI`. Product name unchanged — it is the documented command. +- **`FOSMVVMBootstrapTests`** ← `Tests/BootstrapKitTests`, split per §7. +- **`fosmvvm-doctor`** (command plugin) — declared in this design, built in Plan 5. + +**Dependency:** the CLI adds `swift-argument-parser` to the graph (§2). + +**Platform guard:** the scaffolder shells out (`Process`: xcodegen, xcodebuild, swift) and is macOS-only tooling inside a multi-platform package. The targets compile everywhere but the `Process`-using code is `#if os(macOS)`-guarded (pattern already used across FOSUtilities); the CLI prints a clear refusal elsewhere. + +## 6. Version pin and floors — proposed derivation + +**Pin:** `FOSMVVMBootstrap` carries one release-stamped constant (e.g. `Release.version`). The release ritual — already "stamp CHANGELOG, tag, gh release" — gains one mechanical step: the stamp commit also updates this constant. A unit test parses `CHANGELOG.md`'s topmost stamped release and asserts the constant matches, so a forgotten stamp fails CI, not a user. Generated projects keep the `from:` form, so they float onto later releases with a plain package update (proven by the 0.13.3 round). + +**Between releases**, a scaffolder run from a working tree pins the last stamped release — correct behavior, worth one sentence in the DocC article. + +**Floors:** the `FOSPlatformFloor` table stays (validation needs it at runtime) but gains a unit test that parses this repo's own `Package.swift` `platforms:` (via `#filePath`) and asserts equality. The UPDATE-BOTH comment dies; drift becomes a test failure. + +## 7. Tests and CI — the two-speed split, extended to the full matrix + +Ruled intent (David, red pen): CI must automate what the manual verification rounds did — the generated projects' own test suites, across all supported shapes, not just their builds. + +- **Fast suite** (config, tokens, emitter file-sets, renderer, verifier unit tests — ~7 s) joins the normal CI matrix' macOS job unconditionally. +- **Walking skeletons** (defined in §2: one emit-and-verify test per shape, network-resolving, ~8 min total) gate behind an env var (e.g. `FOSMVVM_BOOTSTRAP_SKELETONS=1`) using a Swift Testing `.enabled(if:)` trait — bare `swift test` stays fast for everyone. The skeletons keep their current doors (emit + build + package tests + xcodegen + xcodebuild build); they do NOT run the generated UI tests — that is the matrix job's job, next. +- **The generation-matrix CI job** (macOS runner, `brew install xcodegen`) goes further than the skeletons — it automates the BIG TEST: for each app shape, `xcodebuild test` on the macOS destination and on an iOS simulator (the generated UI tests, exactly what David has been running by hand); `swift test` for the server/package sides and the shared-library shape. As platforms land per §4, their simulator destinations join this matrix (watchOS capped at build + unit tests). Runs on every PR (ruled, §10.4 — revisit if too draconian) and on release tags; covers the deferred "release CI" item and later carries example publishing. + +CI-runner risk, stated: the local focus-battle activation failures we saw do not typically afflict dedicated CI runners, but the matrix job should retry once on activation failure and surface the xcresult on red — this is the least-proven part of the design. + +Note: the skeletons resolve the *released* FOSUtilities from GitHub — they prove the cold-start user experience, not the working tree. A working-tree variant (path-override emission) is possible later but is not this migration. + +## 8. Docs surfaces (DocC-first) + +- **FOSMVVM DocC** gains the canonical article — `CreatingAProject.md`: the three shapes, the config JSON, the §3 block, what the walking skeleton verifies, the finishing checklist pointer, and the `doctor` teaser. Linked from the FOSMVVM landing page's topics. +- **FOSFoundation DocC** landing gets a short "Starting a new project?" callout linking to that article (FOSFoundation is the SPI default-doc landing, per David's step 2). +- **README.md** gets a "Create a project in one minute" section with the same block — the GitHub-front-door copy of the DocC truth. + +One source of truth: the article; README carries the block plus a link, not a fork of the prose. + +## 9. Mechanics of the move + +**History:** plain copy at a chosen bootstrap SHA (ruled, §10.5). The bootstrap repo was never published and is simply retired — no archival ceremony; its history remains in David's local repo and Time Machine. + +**What moves:** `Sources/BootstrapKit` (→ rename), `Sources/BootstrapCLI`, `Tests/BootstrapKitTests`, and the emitted-doctrine template tree (already inside `Templates/`). + +**What migrates as content, not files:** the open deferrals-ledger items move into a `docs/deferrals.md` at FOSUtilities (same maintenance rule verbatim); RESUME.md gets a final entry and retires with the repo; bootstrap's CLAUDE.md doctrine merges into FOSUtilities' CLAUDE.md where not already present. + +**What dies:** `pinnedFOSVersion` as a maintained string (→ release-stamped constant, §6), the UPDATE-BOTH comment (→ floors test, §6), the bootstrap repo's own CI ambitions (→ §7). + +**Witness projects** (TestLocalOnly, TestClientServer) are untouched — they are David's truth layer, not the scaffolder's output. + +## 10. Open questions for the red pen + +1. **Library target name** — RULED (David, 2026-08-22): `FOSMVVMBootstrap`. +2. **Ledger location** — RULED (David, 2026-08-22): deferred. The planning process is being redesigned in the FOSUtilities-workflow worktree (`feature/fos-development-workflow`); the bootstrap ledger stays where it is until that process merges. The handoff telling that session what to incorporate is `2026-08-22-deferrals-ledger-to-workflow-handoff.md`, beside this design. +3. **Generated pin form** — RULED (David, 2026-08-22): keep `from:`. +4. **CI cadence** — RULED (David, 2026-08-22): the skeleton + generation-matrix jobs run on **every PR** for now; revisit if it proves too draconian. +5. **History** — RULED (David, 2026-08-22): plain copy; the bootstrap repo is simply retired (never pushed; Time Machine holds its history). No archival ceremony. +6. **Platform sequencing (§4)** — RULED (David, 2026-08-22): as proposed — migrate with macOS + iOS emitted and CI-verified; tvOS / visionOS / watchOS follow inside this arc. +7. **macCatalyst** — RULED (David, 2026-08-22): dropped ("I don't think that there's much of a future for macCatalyst"). Its floors entry retires with the multi-platform work; it is not advertised in any set. + +## 11. Migration steps (each gate verifiable) + +1. This design ratified (red pen → APPROVED). +2. Worktree in FOSUtilities; copy sources/tests; rename target; wire `Package.swift` (products, ArgumentParser dep, platform guards). Gate: `swift build` all products, macOS. +3. Port tests; add the two new enforcement tests (release-stamp ↔ CHANGELOG, floors ↔ platforms); env-gate the skeletons. Gate: fast `swift test` green; `FOSMVVM_BOOTSTRAP_SKELETONS=1 swift test` green. +4. Release-ritual change: stamp step + the constant; document in the release notes/process doc. +5. DocC article + FOSFoundation callout + README section. Gate: `swift package generate-documentation` clean; David reads the article as a first-time user. +6. CI: fast tests into the matrix; the skeleton job. Gate: both workflows green on the PR. +7. The generation-matrix job (§7) runs the automated BIG TEST — all shapes, macOS + iOS destinations, generated UI tests included. Gate: matrix green in CI; David's ⌘U becomes an optional spot-check, not a required step. +8. PR → merge → next release ships the scaffolder. Bootstrap repo: retired (§10.5). +9. Ledger + RESUME updates on both sides; the `fosmvvm-generators` skills' references to the scaffolder updated to the in-repo path.