From 50ac5362df12e704c1456a1c8c106950bdf87058 Mon Sep 17 00:00:00 2001 From: David Hunt Date: Sat, 22 Aug 2026 15:17:12 +0200 Subject: [PATCH 01/11] docs: fosmvvm-bootstrap migration design (APPROVED) + workflow-session handoff --- ...22-deferrals-ledger-to-workflow-handoff.md | 22 +++ ...8-22-fosmvvm-bootstrap-migration-design.md | 136 ++++++++++++++++++ 2 files changed, 158 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-22-deferrals-ledger-to-workflow-handoff.md create mode 100644 docs/superpowers/plans/2026-08-22-fosmvvm-bootstrap-migration-design.md 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 0000000..a77b127 --- /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 0000000..c20c811 --- /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: + +```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). 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. From 60172770818459018b42bec38c3976ccdcbf2a8c Mon Sep 17 00:00:00 2001 From: David Hunt Date: Sat, 22 Aug 2026 18:31:33 +0200 Subject: [PATCH 02/11] feat(FOSMVVMBootstrap): migrate the scaffolder into FOSUtilities Sources/tests move in from fosmvvm-bootstrap as FOSMVVMBootstrap(+CLI/Tests), mac-host-gated at the manifest. The release-stamped Release.version replaces the hand-maintained FOS pin, ReleaseConsistencyTests fail CI on stamp or platform-floor drift, and the walking skeletons gate behind FOSMVVM_BOOTSTRAP_SKELETONS=1 so bare swift test stays fast. --- Package.resolved | 11 +- Package.swift | 35 +++ .../FOSMVVMBootstrap/BootstrapConfig.swift | 85 ++++++ Sources/FOSMVVMBootstrap/Emitter.swift | 143 ++++++++++ .../FOSMVVMBootstrap/FOSPlatformFloor.swift | 51 ++++ .../FOSMVVMBootstrap/HandoffChecklist.swift | 75 +++++ Sources/FOSMVVMBootstrap/ProjectShape.swift | 20 ++ Sources/FOSMVVMBootstrap/Release.swift | 13 + .../FOSMVVMBootstrap/TemplateRenderer.swift | 44 +++ .../client-server/Package.swift.tmpl | 89 ++++++ .../Templates/client-server/README.md.tmpl | 56 ++++ .../Resources/ViewModels/BoardViewModel.yml | 15 + .../SPMLibraries/SPMLibraries.swift.tmpl | 26 ++ .../App/TestConfiguration.swift.tmpl | 17 ++ .../App/{{PROJECT_NAME}}App.swift.tmpl | 85 ++++++ .../{{PROJECT_NAME}}/Correlation.swift.tmpl | 16 ++ .../Sources/{{PROJECT_NAME}}/Info.plist | 22 ++ .../Views/AboutView.swift.tmpl | 71 +++++ .../Views/BoardView.swift.tmpl | 70 +++++ .../{{PROJECT_NAME}}.entitlements | 10 + .../AboutViewModel.swift.tmpl | 57 ++++ .../AboutViewModelOperations.swift.tmpl | 59 ++++ .../Resources/ViewModels/AboutViewModel.yml | 13 + ...{PROJECT_NAME}}ClientViewModels.swift.tmpl | 16 ++ .../SystemVersion+App.swift.tmpl | 44 +++ .../Auth/SkeletonAuthProvider.swift.tmpl | 40 +++ .../DataModels/Board.swift.tmpl | 41 +++ .../DataModels/Card.swift.tmpl | 34 +++ .../BoardViewModel+Factory.swift.tmpl | 34 +++ .../Migrations/Board+Schema.swift.tmpl | 21 ++ .../Migrations/Card+Schema.swift.tmpl | 23 ++ .../CreateCardRequest+Writer.swift.tmpl | 25 ++ .../configure.swift.tmpl | 66 +++++ .../entrypoint.swift.tmpl | 33 +++ .../{{PROJECT_NAME}}Server/routes.swift.tmpl | 19 ++ .../Requests/BoardRequest.swift.tmpl | 27 ++ .../Requests/CreateCardRequest.swift.tmpl | 61 ++++ .../ViewModels/BoardViewModel.swift.tmpl | 67 +++++ .../BoardViewModelOperations.swift.tmpl | 57 ++++ .../ViewModels/CardViewModel.swift.tmpl | 30 ++ .../AboutViewModelTests.swift.tmpl | 26 ++ ...izableTestCase+{{PROJECT_NAME}}.swift.tmpl | 10 + .../BoardServerTests.swift.tmpl | 55 ++++ .../{{PROJECT_NAME}}UITests/Resources.symlink | 1 + ...izableTestCase+{{PROJECT_NAME}}.swift.tmpl | 11 + .../Support/TestConfiguration.swift.symlink | 1 + ..._NAME}}ViewModelDisplayTestCase.swift.tmpl | 48 ++++ ...ECT_NAME}}ViewModelViewTestCase.swift.tmpl | 48 ++++ .../Views/AboutViewTests.swift.tmpl | 52 ++++ .../Views/BoardViewTests.swift.tmpl | 36 +++ .../{{PROJECT_NAME}}UITests.swift.tmpl | 11 + .../{{PROJECT_NAME}}UnitTests.swift.tmpl | 22 ++ .../BoardViewModelTests.swift.tmpl | 24 ++ ...izableTestCase+{{PROJECT_NAME}}.swift.tmpl | 10 + .../Templates/client-server/project.yml.tmpl | 236 +++++++++++++++ .../client-server/{{PROJECT_NAME}}.xctestplan | 35 +++ .../Templates/local-only/README.md.tmpl | 56 ++++ .../SPMLibraries/SPMLibraries.swift.tmpl | 26 ++ .../Resources/ViewModels/WelcomeViewModel.yml | 10 + .../Versioning/SystemVersion+App.swift.tmpl | 37 +++ .../ViewModels/WelcomeViewModel.swift.tmpl | 43 +++ .../WelcomeViewModelOperations.swift.tmpl | 54 ++++ .../ViewModelsResourceAccess.swift.tmpl | 24 ++ .../App/TestConfiguration.swift.tmpl | 17 ++ .../App/{{PROJECT_NAME}}App.swift.tmpl | 68 +++++ .../Sources/{{PROJECT_NAME}}/Info.plist | 22 ++ .../Views/WelcomeView.swift.tmpl | 70 +++++ .../{{PROJECT_NAME}}.entitlements | 8 + ...izableTestCase+{{PROJECT_NAME}}.swift.tmpl | 11 + .../Support/TestConfiguration.swift.symlink | 1 + ..._NAME}}ViewModelDisplayTestCase.swift.tmpl | 48 ++++ ...ECT_NAME}}ViewModelViewTestCase.swift.tmpl | 48 ++++ .../Views/WelcomeViewTests.swift.tmpl | 49 ++++ .../{{PROJECT_NAME}}UITests.swift.tmpl | 12 + ...izableTestCase+{{PROJECT_NAME}}.swift.tmpl | 10 + .../WelcomeViewModelTests.swift.tmpl | 28 ++ .../Templates/local-only/project.yml.tmpl | 169 +++++++++++ .../shared-library/.github/workflows/ci.yml | 11 + .../shared-library/Package.swift.tmpl | 39 +++ .../Templates/shared-library/README.md.tmpl | 9 + .../ViewModels/WelcomeViewModel.yml | 10 + .../ViewModels/WelcomeViewModel.swift.tmpl | 42 +++ .../WelcomeViewModelOperations.swift.tmpl | 54 ++++ .../{{PROJECT_NAME}}ViewModels.swift.tmpl | 20 ++ ...izableTestCase+{{PROJECT_NAME}}.swift.tmpl | 10 + .../WelcomeViewModelTests.swift.tmpl | 24 ++ .../Templates/shared/.swiftformat | 2 + .../Templates/shared/CLAUDE.md.tmpl | 30 ++ .../Templates/shared/memory/MEMORY.md | 7 + .../shared/memory/entitlement-is-a-symptom.md | 34 +++ .../memory/macos-build-for-testing-faq.md | 28 ++ .../shared/memory/spm-libraries-settled.md | 32 +++ .../shared/memory/stale-build-runbook.md | 16 ++ .../memory/xcode16-dynamic-spm-packages.md | 33 +++ Sources/FOSMVVMBootstrap/TokenSet.swift | 41 +++ Sources/FOSMVVMBootstrap/Verifier.swift | 210 ++++++++++++++ Sources/FOSMVVMBootstrapCLI/Main.swift | 13 + Sources/FOSMVVMBootstrapCLI/NewCommand.swift | 46 +++ .../BootstrapConfigTests.swift | 98 +++++++ .../FOSMVVMBootstrapTests/EmitterTests.swift | 270 ++++++++++++++++++ .../FOSPlatformFloorTests.swift | 27 ++ .../IntegrationTests.swift | 85 ++++++ .../ReleaseConsistencyTests.swift | 44 +++ Tests/FOSMVVMBootstrapTests/SmokeTests.swift | 8 + .../TemplateRendererTests.swift | 29 ++ .../FOSMVVMBootstrapTests/TokenSetTests.swift | 66 +++++ .../FOSMVVMBootstrapTests/VerifierTests.swift | 76 +++++ 107 files changed, 4571 insertions(+), 1 deletion(-) create mode 100644 Sources/FOSMVVMBootstrap/BootstrapConfig.swift create mode 100644 Sources/FOSMVVMBootstrap/Emitter.swift create mode 100644 Sources/FOSMVVMBootstrap/FOSPlatformFloor.swift create mode 100644 Sources/FOSMVVMBootstrap/HandoffChecklist.swift create mode 100644 Sources/FOSMVVMBootstrap/ProjectShape.swift create mode 100644 Sources/FOSMVVMBootstrap/Release.swift create mode 100644 Sources/FOSMVVMBootstrap/TemplateRenderer.swift create mode 100644 Sources/FOSMVVMBootstrap/Templates/client-server/Package.swift.tmpl create mode 100644 Sources/FOSMVVMBootstrap/Templates/client-server/README.md.tmpl create mode 100644 Sources/FOSMVVMBootstrap/Templates/client-server/Sources/Resources/ViewModels/BoardViewModel.yml create mode 100644 Sources/FOSMVVMBootstrap/Templates/client-server/Sources/SPMLibraries/SPMLibraries.swift.tmpl create mode 100644 Sources/FOSMVVMBootstrap/Templates/client-server/Sources/{{PROJECT_NAME}}/App/TestConfiguration.swift.tmpl create mode 100644 Sources/FOSMVVMBootstrap/Templates/client-server/Sources/{{PROJECT_NAME}}/App/{{PROJECT_NAME}}App.swift.tmpl create mode 100644 Sources/FOSMVVMBootstrap/Templates/client-server/Sources/{{PROJECT_NAME}}/Correlation.swift.tmpl create mode 100644 Sources/FOSMVVMBootstrap/Templates/client-server/Sources/{{PROJECT_NAME}}/Info.plist create mode 100644 Sources/FOSMVVMBootstrap/Templates/client-server/Sources/{{PROJECT_NAME}}/Views/AboutView.swift.tmpl create mode 100644 Sources/FOSMVVMBootstrap/Templates/client-server/Sources/{{PROJECT_NAME}}/Views/BoardView.swift.tmpl create mode 100644 Sources/FOSMVVMBootstrap/Templates/client-server/Sources/{{PROJECT_NAME}}/{{PROJECT_NAME}}.entitlements create mode 100644 Sources/FOSMVVMBootstrap/Templates/client-server/Sources/{{PROJECT_NAME}}ClientViewModels/AboutViewModel.swift.tmpl create mode 100644 Sources/FOSMVVMBootstrap/Templates/client-server/Sources/{{PROJECT_NAME}}ClientViewModels/AboutViewModelOperations.swift.tmpl create mode 100644 Sources/FOSMVVMBootstrap/Templates/client-server/Sources/{{PROJECT_NAME}}ClientViewModels/Resources/ViewModels/AboutViewModel.yml create mode 100644 Sources/FOSMVVMBootstrap/Templates/client-server/Sources/{{PROJECT_NAME}}ClientViewModels/{{PROJECT_NAME}}ClientViewModels.swift.tmpl create mode 100644 Sources/FOSMVVMBootstrap/Templates/client-server/Sources/{{PROJECT_NAME}}Foundation/SystemVersion+App.swift.tmpl create mode 100644 Sources/FOSMVVMBootstrap/Templates/client-server/Sources/{{PROJECT_NAME}}Server/Auth/SkeletonAuthProvider.swift.tmpl create mode 100644 Sources/FOSMVVMBootstrap/Templates/client-server/Sources/{{PROJECT_NAME}}Server/DataModels/Board.swift.tmpl create mode 100644 Sources/FOSMVVMBootstrap/Templates/client-server/Sources/{{PROJECT_NAME}}Server/DataModels/Card.swift.tmpl create mode 100644 Sources/FOSMVVMBootstrap/Templates/client-server/Sources/{{PROJECT_NAME}}Server/Factories/BoardViewModel+Factory.swift.tmpl create mode 100644 Sources/FOSMVVMBootstrap/Templates/client-server/Sources/{{PROJECT_NAME}}Server/Migrations/Board+Schema.swift.tmpl create mode 100644 Sources/FOSMVVMBootstrap/Templates/client-server/Sources/{{PROJECT_NAME}}Server/Migrations/Card+Schema.swift.tmpl create mode 100644 Sources/FOSMVVMBootstrap/Templates/client-server/Sources/{{PROJECT_NAME}}Server/Writers/CreateCardRequest+Writer.swift.tmpl create mode 100644 Sources/FOSMVVMBootstrap/Templates/client-server/Sources/{{PROJECT_NAME}}Server/configure.swift.tmpl create mode 100644 Sources/FOSMVVMBootstrap/Templates/client-server/Sources/{{PROJECT_NAME}}Server/entrypoint.swift.tmpl create mode 100644 Sources/FOSMVVMBootstrap/Templates/client-server/Sources/{{PROJECT_NAME}}Server/routes.swift.tmpl create mode 100644 Sources/FOSMVVMBootstrap/Templates/client-server/Sources/{{PROJECT_NAME}}ViewModels/Requests/BoardRequest.swift.tmpl create mode 100644 Sources/FOSMVVMBootstrap/Templates/client-server/Sources/{{PROJECT_NAME}}ViewModels/Requests/CreateCardRequest.swift.tmpl create mode 100644 Sources/FOSMVVMBootstrap/Templates/client-server/Sources/{{PROJECT_NAME}}ViewModels/ViewModels/BoardViewModel.swift.tmpl create mode 100644 Sources/FOSMVVMBootstrap/Templates/client-server/Sources/{{PROJECT_NAME}}ViewModels/ViewModels/BoardViewModelOperations.swift.tmpl create mode 100644 Sources/FOSMVVMBootstrap/Templates/client-server/Sources/{{PROJECT_NAME}}ViewModels/ViewModels/CardViewModel.swift.tmpl create mode 100644 Sources/FOSMVVMBootstrap/Templates/client-server/Tests/{{PROJECT_NAME}}ClientViewModelsTests/AboutViewModelTests.swift.tmpl create mode 100644 Sources/FOSMVVMBootstrap/Templates/client-server/Tests/{{PROJECT_NAME}}ClientViewModelsTests/LocalizableTestCase+{{PROJECT_NAME}}.swift.tmpl create mode 100644 Sources/FOSMVVMBootstrap/Templates/client-server/Tests/{{PROJECT_NAME}}ServerTests/BoardServerTests.swift.tmpl create mode 100644 Sources/FOSMVVMBootstrap/Templates/client-server/Tests/{{PROJECT_NAME}}UITests/Resources.symlink create mode 100644 Sources/FOSMVVMBootstrap/Templates/client-server/Tests/{{PROJECT_NAME}}UITests/Support/LocalizableTestCase+{{PROJECT_NAME}}.swift.tmpl create mode 100644 Sources/FOSMVVMBootstrap/Templates/client-server/Tests/{{PROJECT_NAME}}UITests/Support/TestConfiguration.swift.symlink create mode 100644 Sources/FOSMVVMBootstrap/Templates/client-server/Tests/{{PROJECT_NAME}}UITests/Support/{{PROJECT_NAME}}ViewModelDisplayTestCase.swift.tmpl create mode 100644 Sources/FOSMVVMBootstrap/Templates/client-server/Tests/{{PROJECT_NAME}}UITests/Support/{{PROJECT_NAME}}ViewModelViewTestCase.swift.tmpl create mode 100644 Sources/FOSMVVMBootstrap/Templates/client-server/Tests/{{PROJECT_NAME}}UITests/Views/AboutViewTests.swift.tmpl create mode 100644 Sources/FOSMVVMBootstrap/Templates/client-server/Tests/{{PROJECT_NAME}}UITests/Views/BoardViewTests.swift.tmpl create mode 100644 Sources/FOSMVVMBootstrap/Templates/client-server/Tests/{{PROJECT_NAME}}UITests/{{PROJECT_NAME}}UITests.swift.tmpl create mode 100644 Sources/FOSMVVMBootstrap/Templates/client-server/Tests/{{PROJECT_NAME}}UnitTests/{{PROJECT_NAME}}UnitTests.swift.tmpl create mode 100644 Sources/FOSMVVMBootstrap/Templates/client-server/Tests/{{PROJECT_NAME}}ViewModelsTests/BoardViewModelTests.swift.tmpl create mode 100644 Sources/FOSMVVMBootstrap/Templates/client-server/Tests/{{PROJECT_NAME}}ViewModelsTests/LocalizableTestCase+{{PROJECT_NAME}}.swift.tmpl create mode 100644 Sources/FOSMVVMBootstrap/Templates/client-server/project.yml.tmpl create mode 100644 Sources/FOSMVVMBootstrap/Templates/client-server/{{PROJECT_NAME}}.xctestplan create mode 100644 Sources/FOSMVVMBootstrap/Templates/local-only/README.md.tmpl create mode 100644 Sources/FOSMVVMBootstrap/Templates/local-only/Sources/SPMLibraries/SPMLibraries.swift.tmpl create mode 100644 Sources/FOSMVVMBootstrap/Templates/local-only/Sources/ViewModels/Resources/ViewModels/WelcomeViewModel.yml create mode 100644 Sources/FOSMVVMBootstrap/Templates/local-only/Sources/ViewModels/Versioning/SystemVersion+App.swift.tmpl create mode 100644 Sources/FOSMVVMBootstrap/Templates/local-only/Sources/ViewModels/ViewModels/WelcomeViewModel.swift.tmpl create mode 100644 Sources/FOSMVVMBootstrap/Templates/local-only/Sources/ViewModels/ViewModels/WelcomeViewModelOperations.swift.tmpl create mode 100644 Sources/FOSMVVMBootstrap/Templates/local-only/Sources/ViewModels/ViewModelsResourceAccess.swift.tmpl create mode 100644 Sources/FOSMVVMBootstrap/Templates/local-only/Sources/{{PROJECT_NAME}}/App/TestConfiguration.swift.tmpl create mode 100644 Sources/FOSMVVMBootstrap/Templates/local-only/Sources/{{PROJECT_NAME}}/App/{{PROJECT_NAME}}App.swift.tmpl create mode 100644 Sources/FOSMVVMBootstrap/Templates/local-only/Sources/{{PROJECT_NAME}}/Info.plist create mode 100644 Sources/FOSMVVMBootstrap/Templates/local-only/Sources/{{PROJECT_NAME}}/Views/WelcomeView.swift.tmpl create mode 100644 Sources/FOSMVVMBootstrap/Templates/local-only/Sources/{{PROJECT_NAME}}/{{PROJECT_NAME}}.entitlements create mode 100644 Sources/FOSMVVMBootstrap/Templates/local-only/Tests/{{PROJECT_NAME}}UITests/Support/LocalizableTestCase+{{PROJECT_NAME}}.swift.tmpl create mode 100644 Sources/FOSMVVMBootstrap/Templates/local-only/Tests/{{PROJECT_NAME}}UITests/Support/TestConfiguration.swift.symlink create mode 100644 Sources/FOSMVVMBootstrap/Templates/local-only/Tests/{{PROJECT_NAME}}UITests/Support/{{PROJECT_NAME}}ViewModelDisplayTestCase.swift.tmpl create mode 100644 Sources/FOSMVVMBootstrap/Templates/local-only/Tests/{{PROJECT_NAME}}UITests/Support/{{PROJECT_NAME}}ViewModelViewTestCase.swift.tmpl create mode 100644 Sources/FOSMVVMBootstrap/Templates/local-only/Tests/{{PROJECT_NAME}}UITests/Views/WelcomeViewTests.swift.tmpl create mode 100644 Sources/FOSMVVMBootstrap/Templates/local-only/Tests/{{PROJECT_NAME}}UITests/{{PROJECT_NAME}}UITests.swift.tmpl create mode 100644 Sources/FOSMVVMBootstrap/Templates/local-only/Tests/{{PROJECT_NAME}}UnitTests/LocalizableTestCase+{{PROJECT_NAME}}.swift.tmpl create mode 100644 Sources/FOSMVVMBootstrap/Templates/local-only/Tests/{{PROJECT_NAME}}UnitTests/ViewModels/WelcomeViewModelTests.swift.tmpl create mode 100644 Sources/FOSMVVMBootstrap/Templates/local-only/project.yml.tmpl create mode 100644 Sources/FOSMVVMBootstrap/Templates/shared-library/.github/workflows/ci.yml create mode 100644 Sources/FOSMVVMBootstrap/Templates/shared-library/Package.swift.tmpl create mode 100644 Sources/FOSMVVMBootstrap/Templates/shared-library/README.md.tmpl create mode 100644 Sources/FOSMVVMBootstrap/Templates/shared-library/Sources/{{PROJECT_NAME}}ViewModels/Resources/Localizations/ViewModels/WelcomeViewModel.yml create mode 100644 Sources/FOSMVVMBootstrap/Templates/shared-library/Sources/{{PROJECT_NAME}}ViewModels/ViewModels/WelcomeViewModel.swift.tmpl create mode 100644 Sources/FOSMVVMBootstrap/Templates/shared-library/Sources/{{PROJECT_NAME}}ViewModels/ViewModels/WelcomeViewModelOperations.swift.tmpl create mode 100644 Sources/FOSMVVMBootstrap/Templates/shared-library/Sources/{{PROJECT_NAME}}ViewModels/{{PROJECT_NAME}}ViewModels.swift.tmpl create mode 100644 Sources/FOSMVVMBootstrap/Templates/shared-library/Tests/{{PROJECT_NAME}}ViewModelsTests/LocalizableTestCase+{{PROJECT_NAME}}.swift.tmpl create mode 100644 Sources/FOSMVVMBootstrap/Templates/shared-library/Tests/{{PROJECT_NAME}}ViewModelsTests/WelcomeViewModelTests.swift.tmpl create mode 100644 Sources/FOSMVVMBootstrap/Templates/shared/.swiftformat create mode 100644 Sources/FOSMVVMBootstrap/Templates/shared/CLAUDE.md.tmpl create mode 100644 Sources/FOSMVVMBootstrap/Templates/shared/memory/MEMORY.md create mode 100644 Sources/FOSMVVMBootstrap/Templates/shared/memory/entitlement-is-a-symptom.md create mode 100644 Sources/FOSMVVMBootstrap/Templates/shared/memory/macos-build-for-testing-faq.md create mode 100644 Sources/FOSMVVMBootstrap/Templates/shared/memory/spm-libraries-settled.md create mode 100644 Sources/FOSMVVMBootstrap/Templates/shared/memory/stale-build-runbook.md create mode 100644 Sources/FOSMVVMBootstrap/Templates/shared/memory/xcode16-dynamic-spm-packages.md create mode 100644 Sources/FOSMVVMBootstrap/TokenSet.swift create mode 100644 Sources/FOSMVVMBootstrap/Verifier.swift create mode 100644 Sources/FOSMVVMBootstrapCLI/Main.swift create mode 100644 Sources/FOSMVVMBootstrapCLI/NewCommand.swift create mode 100644 Tests/FOSMVVMBootstrapTests/BootstrapConfigTests.swift create mode 100644 Tests/FOSMVVMBootstrapTests/EmitterTests.swift create mode 100644 Tests/FOSMVVMBootstrapTests/FOSPlatformFloorTests.swift create mode 100644 Tests/FOSMVVMBootstrapTests/IntegrationTests.swift create mode 100644 Tests/FOSMVVMBootstrapTests/ReleaseConsistencyTests.swift create mode 100644 Tests/FOSMVVMBootstrapTests/SmokeTests.swift create mode 100644 Tests/FOSMVVMBootstrapTests/TemplateRendererTests.swift create mode 100644 Tests/FOSMVVMBootstrapTests/TokenSetTests.swift create mode 100644 Tests/FOSMVVMBootstrapTests/VerifierTests.swift diff --git a/Package.resolved b/Package.resolved index 1ae6039..b8db5f4 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 cf4a71f..b359340 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,26 @@ let package = Package( )) #endif + #if os(macOS) + result.append(.target( + name: "FOSMVVMBootstrap", + resources: [ + .copy("Templates") + ] + )) + result.append(.executableTarget( + name: "FOSMVVMBootstrapCLI", + dependencies: [ + "FOSMVVMBootstrap", + .product(name: "ArgumentParser", package: "swift-argument-parser") + ] + )) + result.append(.testTarget( + name: "FOSMVVMBootstrapTests", + dependencies: ["FOSMVVMBootstrap"] + )) + #endif + return result }(), swiftLanguageModes: [.v6] diff --git a/Sources/FOSMVVMBootstrap/BootstrapConfig.swift b/Sources/FOSMVVMBootstrap/BootstrapConfig.swift new file mode 100644 index 0000000..a6260eb --- /dev/null +++ b/Sources/FOSMVVMBootstrap/BootstrapConfig.swift @@ -0,0 +1,85 @@ +// 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? + + public init( + projectName: String, + shape: ProjectShape, + platforms: [TargetPlatform: String], + licenseHeader: String? = nil, + bundleIdRoot: String? = nil, + teamId: String? = nil + ) { + self.projectName = projectName + self.shape = shape + self.platforms = platforms + self.licenseHeader = licenseHeader + self.bundleIdRoot = bundleIdRoot + self.teamId = teamId + } + + /// Rejects configs that would generate broken or leak-prone projects. + public func validate() throws { + guard projectName.range(of: "^[A-Za-z][A-Za-z0-9]*$", options: .regularExpression) != nil 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 bundleIdRoot.range(of: Self.bundleIdRootPattern, options: .regularExpression) != nil else { + throw BootstrapConfigError.invalidBundleIdRoot(bundleIdRoot) + } + + guard let teamId else { + throw BootstrapConfigError.missingTeamId + } + guard teamId.range(of: Self.teamIdPattern, options: .regularExpression) != nil else { + throw BootstrapConfigError.invalidTeamId(teamId) + } + + // Both app shapes this repo emits generate a macOS Xcode project, so + // both require a macOS deployment target. + if shape == .localOnly || shape == .clientServer, platforms[.macOS] == nil { + throw BootstrapConfigError.missingPlatform(.macOS) + } + } + + 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) +} diff --git a/Sources/FOSMVVMBootstrap/Emitter.swift b/Sources/FOSMVVMBootstrap/Emitter.swift new file mode 100644 index 0000000..831d940 --- /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 0000000..a844426 --- /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 0000000..4ed2634 --- /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 0000000..44afe79 --- /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 0000000..17b0718 --- /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 0000000..ab8c346 --- /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 0000000..34d4b57 --- /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 0000000..4705f9d --- /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 0000000..5a8d1ca --- /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 0000000..9d905e7 --- /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 0000000..67f5eab --- /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 0000000..b58e139 --- /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 0000000..aa11c48 --- /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 0000000..8d70380 --- /dev/null +++ b/Sources/FOSMVVMBootstrap/Templates/client-server/Sources/{{PROJECT_NAME}}/Info.plist @@ -0,0 +1,22 @@ + + + + + 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 + + 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 0000000..fbd86f0 --- /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 0000000..dc03232 --- /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 0000000..1981043 --- /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 0000000..ea44ad3 --- /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 0000000..d891b51 --- /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 0000000..68f0d85 --- /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 0000000..872a910 --- /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 0000000..c95c21e --- /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 0000000..3e9f299 --- /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 0000000..3b92cc6 --- /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 0000000..ed0447f --- /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 0000000..9f0a019 --- /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 0000000..fcfddd5 --- /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 0000000..73ec323 --- /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 0000000..af44187 --- /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 0000000..da2284d --- /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 0000000..cc7ac27 --- /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 0000000..e685607 --- /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 0000000..1d7b466 --- /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 0000000..0ee3f41 --- /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 0000000..04fcac9 --- /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 0000000..6de5e64 --- /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 0000000..5f5fa83 --- /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 0000000..4928089 --- /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 0000000..1692cea --- /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 0000000..159715e --- /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 0000000..75c6885 --- /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 0000000..60ab5b1 --- /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 0000000..4681b49 --- /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 0000000..09656ce --- /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 0000000..0569195 --- /dev/null +++ b/Sources/FOSMVVMBootstrap/Templates/client-server/Tests/{{PROJECT_NAME}}UITests/Support/{{PROJECT_NAME}}ViewModelViewTestCase.swift.tmpl @@ -0,0 +1,48 @@ +// {{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 + ) + } + + 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 0000000..2cf7579 --- /dev/null +++ b/Sources/FOSMVVMBootstrap/Templates/client-server/Tests/{{PROJECT_NAME}}UITests/Views/AboutViewTests.swift.tmpl @@ -0,0 +1,52 @@ +// 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() + + app.uiTestingElement("presentDetailsButton").tap() + + let stubOps = try viewModelOperations() + + XCTAssertTrue(stubOps.presentDetailsCalled) + // The SwiftUI alert itself cannot carry a testing identifier; its tagged + // dismiss button witnesses the alert's presence. + XCTAssertTrue(app.uiTestingElement("dismissDetailsButton").waitForExistence()) + } + + func testDismissDetailsAlert() throws { + let app = try presentView() + + app.uiTestingElement("presentDetailsButton").tap() + + let dismissDetailsButton = app.uiTestingElement("dismissDetailsButton") + XCTAssertTrue(dismissDetailsButton.waitForExistence()) + dismissDetailsButton.tap() + + let stubOps = try viewModelOperations() + + XCTAssertTrue(stubOps.dismissDetailsCalled) + XCTAssertTrue(dismissDetailsButton.waitForDisappearance()) + } +} 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 0000000..cada16c --- /dev/null +++ b/Sources/FOSMVVMBootstrap/Templates/client-server/Tests/{{PROJECT_NAME}}UITests/Views/BoardViewTests.swift.tmpl @@ -0,0 +1,36 @@ +// 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: stubs record synchronously +// regardless of signature, and the view repaints the transporter after the op +// completes — the tap round-trip alone outlasts the Task hop, so the recorded +// state is readable directly after tap() with no waiting. +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() + + app.uiTestingElement("addCardButton").tap() + + 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 0000000..b4b7786 --- /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 0000000..6460d21 --- /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 0000000..a4edcfe --- /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 0000000..1692cea --- /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 0000000..11c9811 --- /dev/null +++ b/Sources/FOSMVVMBootstrap/Templates/client-server/project.yml.tmpl @@ -0,0 +1,236 @@ +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: + macOS: "{{MACOS_DEPLOYMENT}}" + groupSortPosition: top + +settings: + base: + # macOS is the only supported destination today (matches the witness + # projects). Without this pin, xcodegen ≥2.46 stamps + # "iphoneos iphonesimulator macosx", and running tests on the advertised + # iOS simulator hits the FOS type-identity trap (duplicate FOS copies via + # SPMLibraries + directly-linked FOSTesting) that macOS tolerates. + SUPPORTED_PLATFORMS: macosx + 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 + platform: macOS + sources: + - path: Sources/SPMLibraries + type: syncedFolder + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: {{BUNDLE_ID_ROOT}}.SPMLibraries + GENERATE_INFOPLIST_FILE: YES + 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 + platform: macOS + sources: + - path: Sources/{{PROJECT_NAME}}Foundation + type: syncedFolder + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: {{BUNDLE_ID_ROOT}}.Foundation + GENERATE_INFOPLIST_FILE: YES + 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 + platform: macOS + sources: + - path: Sources/{{PROJECT_NAME}}ClientViewModels + type: syncedFolder + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: {{BUNDLE_ID_ROOT}}.ClientViewModels + GENERATE_INFOPLIST_FILE: YES + dependencies: + - target: SPMLibraries + embed: false # link only — the app embeds (single-embed rule) + + {{PROJECT_NAME}}: + type: application + platform: macOS + 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 + 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 + platform: macOS + sources: + - path: Tests/{{PROJECT_NAME}}UnitTests + type: syncedFolder + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: {{BUNDLE_ID_ROOT}}-unit-tests + GENERATE_INFOPLIST_FILE: YES + 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 + platform: macOS + 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 + 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 + platform: macOS + 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 + 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 + +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 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 0000000..de31c7a --- /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 0000000..08d0aa3 --- /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 0000000..9d905e7 --- /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 0000000..7857ce3 --- /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 0000000..0e3a2fe --- /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 0000000..63626be --- /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 0000000..b6bd7d0 --- /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 0000000..f9d46cf --- /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 0000000..67f5eab --- /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 0000000..c67c7d6 --- /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 0000000..8d70380 --- /dev/null +++ b/Sources/FOSMVVMBootstrap/Templates/local-only/Sources/{{PROJECT_NAME}}/Info.plist @@ -0,0 +1,22 @@ + + + + + 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 + + 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 0000000..93eed1a --- /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 0000000..13cb114 --- /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 0000000..60ab5b1 --- /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 0000000..4681b49 --- /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 0000000..f9ba04c --- /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 0000000..97ac78f --- /dev/null +++ b/Sources/FOSMVVMBootstrap/Templates/local-only/Tests/{{PROJECT_NAME}}UITests/Support/{{PROJECT_NAME}}ViewModelViewTestCase.swift.tmpl @@ -0,0 +1,48 @@ +// {{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 + ) + } + + 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 0000000..4a299c2 --- /dev/null +++ b/Sources/FOSMVVMBootstrap/Templates/local-only/Tests/{{PROJECT_NAME}}UITests/Views/WelcomeViewTests.swift.tmpl @@ -0,0 +1,49 @@ +// 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() + + app.uiTestingElement("presentWelcomeAlertButton").tap() + + let stubOps = try viewModelOperations() + + XCTAssertTrue(stubOps.presentWelcomeCalled) + // The SwiftUI alert itself cannot carry a testing identifier; its tagged + // dismiss button witnesses the alert's presence. + XCTAssertTrue(app.uiTestingElement("dismissWelcomeAlertButton").waitForExistence()) + } + + func testDismissWelcomeAlert() throws { + let app = try presentView() + + app.uiTestingElement("presentWelcomeAlertButton").tap() + + let dismissWelcomeAlertButton = app.uiTestingElement("dismissWelcomeAlertButton") + XCTAssertTrue(dismissWelcomeAlertButton.waitForExistence()) + dismissWelcomeAlertButton.tap() + + let stubOps = try viewModelOperations() + + XCTAssertTrue(stubOps.dismissWelcomeCalled) + XCTAssertTrue(dismissWelcomeAlertButton.waitForDisappearance()) + } +} 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 0000000..2a21a07 --- /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 0000000..1692cea --- /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 0000000..339ca18 --- /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 0000000..82e7456 --- /dev/null +++ b/Sources/FOSMVVMBootstrap/Templates/local-only/project.yml.tmpl @@ -0,0 +1,169 @@ +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: + macOS: "{{MACOS_DEPLOYMENT}}" + groupSortPosition: top + +settings: + base: + # macOS is the only supported destination today (matches the witness + # projects). Without this pin, xcodegen ≥2.46 stamps + # "iphoneos iphonesimulator macosx", and running tests on the advertised + # iOS simulator hits the FOS type-identity trap (duplicate FOS copies via + # SPMLibraries + directly-linked FOSTesting) that macOS tolerates. + SUPPORTED_PLATFORMS: macosx + 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 + platform: macOS + sources: + - path: Sources/SPMLibraries + type: syncedFolder + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: {{BUNDLE_ID_ROOT}}.SPMLibraries + GENERATE_INFOPLIST_FILE: YES + dependencies: + - package: FOSUtilities + product: FOSFoundation + - package: FOSUtilities + product: FOSMVVM + + # Shared contract module — client-hosted ViewModels + their YAML. + ViewModels: + type: framework + platform: macOS + sources: + - path: Sources/ViewModels + type: syncedFolder + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: {{BUNDLE_ID_ROOT}}.ViewModels + GENERATE_INFOPLIST_FILE: YES + dependencies: + - target: SPMLibraries + embed: false # link only — the app embeds (single-embed rule) + + {{PROJECT_NAME}}: + type: application + platform: macOS + 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 + 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 + platform: macOS + sources: + - path: Tests/{{PROJECT_NAME}}UnitTests + type: syncedFolder + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: {{BUNDLE_ID_ROOT}}-unit-tests + GENERATE_INFOPLIST_FILE: YES + 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 + platform: macOS + sources: + - path: Tests/{{PROJECT_NAME}}UITests + type: syncedFolder + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: {{BUNDLE_ID_ROOT}}-ui-tests + GENERATE_INFOPLIST_FILE: YES + 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 + +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 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 0000000..9bfa1eb --- /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 0000000..053e974 --- /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 0000000..a538e25 --- /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 0000000..fd9b65d --- /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 0000000..42786b7 --- /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 0000000..b6bd7d0 --- /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 0000000..7fb9bac --- /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 0000000..1692cea --- /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 0000000..22a5562 --- /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 0000000..32eeae7 --- /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 0000000..5c4e48d --- /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 0000000..34a79a5 --- /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 0000000..8b8a539 --- /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 0000000..b9baa93 --- /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 0000000..a34ef32 --- /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 0000000..58d8f1a --- /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 0000000..537e92e --- /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.swift b/Sources/FOSMVVMBootstrap/TokenSet.swift new file mode 100644 index 0000000..d1fb309 --- /dev/null +++ b/Sources/FOSMVVMBootstrap/TokenSet.swift @@ -0,0 +1,41 @@ +// 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] + } + + 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 0000000..40c5832 --- /dev/null +++ b/Sources/FOSMVVMBootstrap/Verifier.swift @@ -0,0 +1,210 @@ +// Verifier.swift +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?) -> [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", "platform=macOS", + "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: return ("xcodegen", "brew install xcodegen") + case .swiftBuild, .swiftTest, .xcodebuildBuild: return 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 let .stepFailed(step, output): + // Render with a nil project name (`` placeholder) so + // the description can never crash on a name it doesn't carry. + return "verification step '\(step.command(projectName: nil).joined(separator: " "))' failed:\n\(output)" + case let .toolMissing(tool, installHint): + return "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 { + /// Steps for a shape. shared-library: build + test is the entire + /// finish line. local-only builds the generated Xcode project. + /// client-server runs both doors: the package (server boots + serves the + /// route headlessly under `swift test`) and the app (xcodegen + xcodebuild). + /// hybrid keeps the placeholder until Plan 4. + public static func steps(for shape: ProjectShape) -> [VerifyStep] { + switch shape { + case .sharedLibrary: [.swiftBuild, .swiftTest] + case .localOnly: [.xcodegenGenerate, .xcodebuildBuild] + case .clientServer: [.swiftBuild, .swiftTest, .xcodegenGenerate, .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. + /// + /// 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, + environment: [String: String]? = nil + ) throws { + // Overlay the caller's variables onto the inherited environment. + // Overlaying PATH REPLACES the inherited PATH for the children, + // which the tool-missing test relies on. nil means inherit as-is. + let resolvedEnv = environment.map { overlay in + ProcessInfo.processInfo.environment.merging(overlay) { _, new in new } + } + + 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), + in: projectDir, + environment: resolvedEnv + ) + guard result.status == 0 else { + throw VerifierError.stepFailed(step: step, output: result.output) + } + } + } + + /// 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 + if let environment { + 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/Main.swift b/Sources/FOSMVVMBootstrapCLI/Main.swift new file mode 100644 index 0000000..b8d934f --- /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 0000000..bc24460 --- /dev/null +++ b/Sources/FOSMVVMBootstrapCLI/NewCommand.swift @@ -0,0 +1,46 @@ +// 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.") + var config: String + + @Option(name: .shortAndLong, help: "Output directory for the new project (must be empty or absent).") + var output: String + + @Flag(help: "Skip the build/test verification phase (CI of this repo only — never for real use).") + var skipVerify = false + + func run() throws { + let configURL = URL(fileURLWithPath: config) + let outputURL = URL(fileURLWithPath: output) + + let bootstrapConfig = try JSONDecoder().decode( + BootstrapConfig.self, + from: Data(contentsOf: configURL) + ) + + print("Scaffolding \(bootstrapConfig.projectName) (\(bootstrapConfig.shape.rawValue)) …") + let emitted = try Emitter.emit(config: bootstrapConfig, into: outputURL) + print("Emitted \(emitted.count) files.") + + if !skipVerify { + let steps = Verifier.steps(for: bootstrapConfig.shape) + print("Verifying (\(steps.map(\.rawValue).joined(separator: " / "))) …") + try Verifier.verify( + projectDir: outputURL, + steps: steps, + projectName: bootstrapConfig.projectName + ) + 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 0000000..6c1460e --- /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 clientServerRequiresMacOSPlatform() throws { + let config = BootstrapConfig( + projectName: "PalettePress", + shape: .clientServer, + platforms: [.iOS: "17.0"], + bundleIdRoot: "com.example.palettepress", + teamId: "ABCDE12345" + ) + #expect(throws: BootstrapConfigError.missingPlatform(.macOS)) { + 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 0000000..43ca705 --- /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 0000000..e169e41 --- /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 0000000..d6bc0b3 --- /dev/null +++ b/Tests/FOSMVVMBootstrapTests/IntegrationTests.swift @@ -0,0 +1,85 @@ +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. +@Suite( + .tags(.integration), + .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.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.steps(for: .clientServer), + projectName: "PalettePress" + ) + } +} diff --git a/Tests/FOSMVVMBootstrapTests/ReleaseConsistencyTests.swift b/Tests/FOSMVVMBootstrapTests/ReleaseConsistencyTests.swift new file mode 100644 index 0000000..e03c986 --- /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 0000000..52097c0 --- /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 0000000..34c855d --- /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 0000000..18e0996 --- /dev/null +++ b/Tests/FOSMVVMBootstrapTests/TokenSetTests.swift @@ -0,0 +1,66 @@ +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 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/VerifierTests.swift b/Tests/FOSMVVMBootstrapTests/VerifierTests.swift new file mode 100644 index 0000000..1927954 --- /dev/null +++ b/Tests/FOSMVVMBootstrapTests/VerifierTests.swift @@ -0,0 +1,76 @@ +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.steps(for: .localOnly) == [.xcodegenGenerate, .xcodebuildBuild]) + } + + @Test func clientServerStepsAreTheFourDoorSteps() { + #expect(Verifier.steps(for: .clientServer) + == [.swiftBuild, .swiftTest, .xcodegenGenerate, .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") + } + } +} From 6ca2ec56b91ee19302a73c3936f20b52a895bdbb Mon Sep 17 00:00:00 2001 From: David Hunt Date: Sat, 22 Aug 2026 18:31:33 +0200 Subject: [PATCH 03/11] docs+ci: scaffolder documentation and per-PR verification CreatingAProject.md (FOSMVVM DocC, linked from the FOSFoundation landing), the README one-minute block, docs/RELEASING.md, and the bootstrap_generation_matrix CI job: fast suite, gated skeletons, and the generated apps' own UI tests on iOS Simulator and macOS, every PR. --- .github/workflows/ci.yml | 93 +++++++++++++++++++ README.md | 19 ++++ .../FOSFoundation.docc/FOSFoundation.md | 2 + .../FOSMVVM/FOSMVVM.docc/CreatingAProject.md | 61 ++++++++++++ Sources/FOSMVVM/FOSMVVM.docc/FOSMVVM.md | 1 + docs/RELEASING.md | 9 ++ 6 files changed, 185 insertions(+) create mode 100644 Sources/FOSMVVM/FOSMVVM.docc/CreatingAProject.md create mode 100644 docs/RELEASING.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e149523..c5ca2cb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -131,3 +131,96 @@ 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 + + - 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" }, + "bundleIdRoot": "com.example.localdemo", "teamId": "ABCDE12345" } + CONFIG + cat > "$RUNNER_TEMP/server.json" <<'CONFIG' + { "projectName": "ServerDemo", "shape": "clientServer", + "platforms": { "macOS": "14.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" + + - 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" \ + CODE_SIGNING_ALLOWED=NO SUPPORTED_PLATFORMS="iphonesimulator macosx" 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" \ + CODE_SIGN_IDENTITY=- DEVELOPMENT_TEAM= CODE_SIGN_STYLE=Manual test + done diff --git a/README.md b/README.md index e0e06eb..8aff33b 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,25 @@ 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, verified 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 + +cat > /tmp/myapp.json <<'CONFIG' +{ "projectName": "MyApp", "shape": "clientServer", + "platforms": { "macOS": "14.0" }, + "bundleIdRoot": "com.example.myapp", "teamId": "YOURTEAMID" } +CONFIG + +swift run fosmvvm-bootstrap new --config /tmp/myapp.json --output ~/MyApp +``` + +Generation builds the new project and runs its tests before declaring success. 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 ac85bab..9f4daca 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 0000000..47cb516 --- /dev/null +++ b/Sources/FOSMVVM/FOSMVVM.docc/CreatingAProject.md @@ -0,0 +1,61 @@ +# 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 a small JSON configuration: + +```bash +git clone https://github.com/foscomputerservices/FOSUtilities.git +cd FOSUtilities + +cat > /tmp/myapp.json <<'CONFIG' +{ "projectName": "MyApp", "shape": "clientServer", + "platforms": { "macOS": "14.0" }, + "bundleIdRoot": "com.example.myapp", "teamId": "YOURTEAMID" } +CONFIG + +swift run fosmvvm-bootstrap new --config /tmp/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. + +## What generation verifies + +Generation does not stop at writing files. The scaffolder 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), `xcodegen`, and an `xcodebuild` build of the app. A successful run ends with: + +``` +✅ Walking skeleton verified green +``` + +followed by 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 0f35d95..439a8ea 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/docs/RELEASING.md b/docs/RELEASING.md new file mode 100644 index 0000000..aebe4f3 --- /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 `. From c671f23d595c1b2e2945a24194cedb9690056b7d Mon Sep 17 00:00:00 2001 From: David Hunt Date: Sat, 22 Aug 2026 18:31:33 +0200 Subject: [PATCH 04/11] feat(FOSMVVMBootstrap): interview-first CLI; generation follows the config's platforms exactly new without --config runs a validated interview (name defaulted from the output directory; Mac/iPhone/iPad/TV/Vision/Watch each opt-in; the two Designed-for-iPad/iPhone compat modes asked with Apple's default; --verbose echoes the equivalent config; --verify opts into the doors, which pick their destination from the config; xcodegen runs as generation, never as skippable verification). Emission uses xcodegen supportedDestinations with target-level device-family/compat overrides, so every target's destination surface reads exactly the answers; the watch ships as its own target (a multiplatform app target cannot contain watchOS), embedding the same frameworks, which carry the watchOS destination. --- Package.swift | 1 + README.md | 13 +- .../FOSMVVM/FOSMVVM.docc/CreatingAProject.md | 19 +- .../FOSMVVMBootstrap/BootstrapConfig.swift | 51 ++++- .../Templates/client-server/project.yml.tmpl | 40 ++-- .../Templates/local-only/project.yml.tmpl | 32 ++-- Sources/FOSMVVMBootstrap/TokenSet+Watch.swift | 101 ++++++++++ Sources/FOSMVVMBootstrap/TokenSet.swift | 70 +++++++ Sources/FOSMVVMBootstrap/Verifier.swift | 45 ++++- Sources/FOSMVVMBootstrapCLI/Interview.swift | 178 ++++++++++++++++++ Sources/FOSMVVMBootstrapCLI/NewCommand.swift | 50 +++-- .../BootstrapConfigTests.swift | 6 +- .../IntegrationTests.swift | 4 +- .../FOSMVVMBootstrapTests/TokenSetTests.swift | 82 ++++++++ .../FOSMVVMBootstrapTests/VerifierTests.swift | 6 +- ...8-22-fosmvvm-bootstrap-migration-design.md | 4 +- 16 files changed, 602 insertions(+), 100 deletions(-) create mode 100644 Sources/FOSMVVMBootstrap/TokenSet+Watch.swift create mode 100644 Sources/FOSMVVMBootstrapCLI/Interview.swift diff --git a/Package.swift b/Package.swift index b359340..38ef315 100644 --- a/Package.swift +++ b/Package.swift @@ -284,6 +284,7 @@ let package = Package( name: "FOSMVVMBootstrapCLI", dependencies: [ "FOSMVVMBootstrap", + "FOSFoundation", .product(name: "ArgumentParser", package: "swift-argument-parser") ] )) diff --git a/README.md b/README.md index 8aff33b..04599eb 100644 --- a/README.md +++ b/README.md @@ -13,22 +13,15 @@ For guides, articles, and API documentation see the ## Create a project in one minute -The package ships a scaffolder that generates a complete, verified 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`): +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 - -cat > /tmp/myapp.json <<'CONFIG' -{ "projectName": "MyApp", "shape": "clientServer", - "platforms": { "macOS": "14.0" }, - "bundleIdRoot": "com.example.myapp", "teamId": "YOURTEAMID" } -CONFIG - -swift run fosmvvm-bootstrap new --config /tmp/myapp.json --output ~/MyApp +swift run fosmvvm-bootstrap new --output ~/MyApp ``` -Generation builds the new project and runs its tests before declaring success. Full details: [Creating a Project](https://swiftpackageindex.com/foscomputerservices/FOSUtilities/main/documentation/fosmvvm/creatingaproject). +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 diff --git a/Sources/FOSMVVM/FOSMVVM.docc/CreatingAProject.md b/Sources/FOSMVVM/FOSMVVM.docc/CreatingAProject.md index 47cb516..6aeadc3 100644 --- a/Sources/FOSMVVM/FOSMVVM.docc/CreatingAProject.md +++ b/Sources/FOSMVVM/FOSMVVM.docc/CreatingAProject.md @@ -21,19 +21,18 @@ Three project shapes are supported: ## Generate -Clone the package and run the scaffolder with a small JSON configuration: +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 +``` -cat > /tmp/myapp.json <<'CONFIG' -{ "projectName": "MyApp", "shape": "clientServer", - "platforms": { "macOS": "14.0" }, - "bundleIdRoot": "com.example.myapp", "teamId": "YOURTEAMID" } -CONFIG +With `--verbose`, the interview also prints the equivalent `--config` JSON. Save it to rerun without the questions, or to script generation: -swift run fosmvvm-bootstrap new --config /tmp/myapp.json --output ~/MyApp +```bash +swift run fosmvvm-bootstrap new --config myapp.json --output ~/MyApp ``` Configuration fields: @@ -44,15 +43,15 @@ Configuration fields: - `bundleIdRoot`: reverse-DNS root for bundle identifiers. App shapes only. - `teamId`: your Apple Development Team identifier. App shapes only. -## What generation verifies +## Verifying the generated project -Generation does not stop at writing files. The scaffolder 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), `xcodegen`, and an `xcodebuild` build of the app. A successful run ends with: +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 ``` -followed by a short checklist of the few steps tooling cannot do for you, such as committing the new repository and confirming code signing. +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. diff --git a/Sources/FOSMVVMBootstrap/BootstrapConfig.swift b/Sources/FOSMVVMBootstrap/BootstrapConfig.swift index a6260eb..2adca7d 100644 --- a/Sources/FOSMVVMBootstrap/BootstrapConfig.swift +++ b/Sources/FOSMVVMBootstrap/BootstrapConfig.swift @@ -15,6 +15,17 @@ public struct BootstrapConfig: Codable, Sendable { 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, @@ -22,7 +33,10 @@ public struct BootstrapConfig: Codable, Sendable { platforms: [TargetPlatform: String], licenseHeader: String? = nil, bundleIdRoot: String? = nil, - teamId: String? = nil + teamId: String? = nil, + iosDevices: [String]? = nil, + macDesignedForIPad: Bool? = nil, + visionDesignedForIPad: Bool? = nil ) { self.projectName = projectName self.shape = shape @@ -30,11 +44,28 @@ public struct BootstrapConfig: Codable, Sendable { 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 projectName.range(of: "^[A-Za-z][A-Za-z0-9]*$", options: .regularExpression) != nil else { + guard Self.isValidProjectName(projectName) else { throw BootstrapConfigError.invalidProjectName(projectName) } guard !platforms.isEmpty else { @@ -50,21 +81,24 @@ public struct BootstrapConfig: Codable, Sendable { guard let bundleIdRoot else { throw BootstrapConfigError.missingBundleIdRoot } - guard bundleIdRoot.range(of: Self.bundleIdRootPattern, options: .regularExpression) != nil else { + guard Self.isValidBundleIdRoot(bundleIdRoot) else { throw BootstrapConfigError.invalidBundleIdRoot(bundleIdRoot) } guard let teamId else { throw BootstrapConfigError.missingTeamId } - guard teamId.range(of: Self.teamIdPattern, options: .regularExpression) != nil else { + guard Self.isValidTeamId(teamId) else { throw BootstrapConfigError.invalidTeamId(teamId) } - // Both app shapes this repo emits generate a macOS Xcode project, so - // both require a macOS deployment target. - if shape == .localOnly || shape == .clientServer, platforms[.macOS] == nil { - throw BootstrapConfigError.missingPlatform(.macOS) + // 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 } } @@ -82,4 +116,5 @@ public enum BootstrapConfigError: Error, Equatable { case missingTeamId case invalidTeamId(String) case missingPlatform(TargetPlatform) + case noAppDestinations } diff --git a/Sources/FOSMVVMBootstrap/Templates/client-server/project.yml.tmpl b/Sources/FOSMVVMBootstrap/Templates/client-server/project.yml.tmpl index 11c9811..363187a 100644 --- a/Sources/FOSMVVMBootstrap/Templates/client-server/project.yml.tmpl +++ b/Sources/FOSMVVMBootstrap/Templates/client-server/project.yml.tmpl @@ -9,18 +9,11 @@ options: # 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: - macOS: "{{MACOS_DEPLOYMENT}}" + deploymentTarget:{{DEPLOYMENT_TARGETS}} groupSortPosition: top settings: base: - # macOS is the only supported destination today (matches the witness - # projects). Without this pin, xcodegen ≥2.46 stamps - # "iphoneos iphonesimulator macosx", and running tests on the advertised - # iOS simulator hits the FOS type-identity trap (duplicate FOS copies via - # SPMLibraries + directly-linked FOSTesting) that macOS tolerates. - SUPPORTED_PLATFORMS: macosx SWIFT_VERSION: "6.0" SWIFT_STRICT_CONCURRENCY: complete BUILD_LIBRARY_FOR_DISTRIBUTION: NO @@ -39,14 +32,14 @@ targets: # memory/spm-libraries-settled.md). SPMLibraries: type: framework - platform: macOS + supportedDestinations: {{FRAMEWORK_DESTINATIONS}} sources: - path: Sources/SPMLibraries type: syncedFolder settings: base: PRODUCT_BUNDLE_IDENTIFIER: {{BUNDLE_ID_ROOT}}.SPMLibraries - GENERATE_INFOPLIST_FILE: YES + GENERATE_INFOPLIST_FILE: YES{{DEVICE_FAMILY_OVERRIDE}}{{XR_COMPAT_OVERRIDE}} dependencies: - package: FOSUtilities product: FOSFoundation @@ -61,14 +54,14 @@ targets: # server needs neither the display contract nor SwiftUI to set its version. {{PROJECT_NAME}}Foundation: type: framework - platform: macOS + supportedDestinations: {{FRAMEWORK_DESTINATIONS}} sources: - path: Sources/{{PROJECT_NAME}}Foundation type: syncedFolder settings: base: PRODUCT_BUNDLE_IDENTIFIER: {{BUNDLE_ID_ROOT}}.Foundation - GENERATE_INFOPLIST_FILE: YES + GENERATE_INFOPLIST_FILE: YES{{DEVICE_FAMILY_OVERRIDE}}{{XR_COMPAT_OVERRIDE}} dependencies: - target: SPMLibraries embed: false # link only — the app embeds (single-embed rule) @@ -76,21 +69,21 @@ targets: # Client-hosted ViewModels — their own framework, with bundled YAML. {{PROJECT_NAME}}ClientViewModels: type: framework - platform: macOS + supportedDestinations: {{FRAMEWORK_DESTINATIONS}} sources: - path: Sources/{{PROJECT_NAME}}ClientViewModels type: syncedFolder settings: base: PRODUCT_BUNDLE_IDENTIFIER: {{BUNDLE_ID_ROOT}}.ClientViewModels - GENERATE_INFOPLIST_FILE: YES + 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 - platform: macOS + 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); @@ -113,7 +106,7 @@ targets: INFOPLIST_FILE: Sources/{{PROJECT_NAME}}/Info.plist CODE_SIGN_ENTITLEMENTS: Sources/{{PROJECT_NAME}}/{{PROJECT_NAME}}.entitlements MARKETING_VERSION: "0.1" - CURRENT_PROJECT_VERSION: 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 @@ -137,14 +130,14 @@ targets: {{PROJECT_NAME}}UnitTests: type: bundle.unit-test - platform: macOS + 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 + 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: @@ -156,7 +149,7 @@ targets: {{PROJECT_NAME}}ClientViewModelsTests: type: bundle.unit-test - platform: macOS + 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 @@ -166,7 +159,7 @@ targets: settings: base: PRODUCT_BUNDLE_IDENTIFIER: {{BUNDLE_ID_ROOT}}-client-vm-tests - GENERATE_INFOPLIST_FILE: YES + 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: @@ -183,7 +176,7 @@ targets: {{PROJECT_NAME}}UITests: type: bundle.ui-testing - platform: macOS + 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 @@ -201,7 +194,7 @@ targets: settings: base: PRODUCT_BUNDLE_IDENTIFIER: {{BUNDLE_ID_ROOT}}-ui-tests - GENERATE_INFOPLIST_FILE: YES + GENERATE_INFOPLIST_FILE: YES{{DEVICE_FAMILY_OVERRIDE}}{{XR_COMPAT_OVERRIDE}} dependencies: - target: {{PROJECT_NAME}} # AboutViewTests imports the client-hosted framework (its VM + StubOps); the @@ -215,7 +208,7 @@ targets: product: FOSTesting - package: FOSUtilities product: FOSTestingUI - +{{WATCH_TARGET}} schemes: {{PROJECT_NAME}}: build: @@ -234,3 +227,4 @@ schemes: - {{PROJECT_NAME}}UITests archive: config: Release +{{WATCH_SCHEME}} diff --git a/Sources/FOSMVVMBootstrap/Templates/local-only/project.yml.tmpl b/Sources/FOSMVVMBootstrap/Templates/local-only/project.yml.tmpl index 82e7456..3947093 100644 --- a/Sources/FOSMVVMBootstrap/Templates/local-only/project.yml.tmpl +++ b/Sources/FOSMVVMBootstrap/Templates/local-only/project.yml.tmpl @@ -9,18 +9,11 @@ options: # 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: - macOS: "{{MACOS_DEPLOYMENT}}" + deploymentTarget:{{DEPLOYMENT_TARGETS}} groupSortPosition: top settings: base: - # macOS is the only supported destination today (matches the witness - # projects). Without this pin, xcodegen ≥2.46 stamps - # "iphoneos iphonesimulator macosx", and running tests on the advertised - # iOS simulator hits the FOS type-identity trap (duplicate FOS copies via - # SPMLibraries + directly-linked FOSTesting) that macOS tolerates. - SUPPORTED_PLATFORMS: macosx SWIFT_VERSION: "6.0" SWIFT_STRICT_CONCURRENCY: complete BUILD_LIBRARY_FOR_DISTRIBUTION: NO @@ -39,14 +32,14 @@ targets: # memory/spm-libraries-settled.md). SPMLibraries: type: framework - platform: macOS + supportedDestinations: {{FRAMEWORK_DESTINATIONS}} sources: - path: Sources/SPMLibraries type: syncedFolder settings: base: PRODUCT_BUNDLE_IDENTIFIER: {{BUNDLE_ID_ROOT}}.SPMLibraries - GENERATE_INFOPLIST_FILE: YES + GENERATE_INFOPLIST_FILE: YES{{DEVICE_FAMILY_OVERRIDE}}{{XR_COMPAT_OVERRIDE}} dependencies: - package: FOSUtilities product: FOSFoundation @@ -56,21 +49,21 @@ targets: # Shared contract module — client-hosted ViewModels + their YAML. ViewModels: type: framework - platform: macOS + supportedDestinations: {{FRAMEWORK_DESTINATIONS}} sources: - path: Sources/ViewModels type: syncedFolder settings: base: PRODUCT_BUNDLE_IDENTIFIER: {{BUNDLE_ID_ROOT}}.ViewModels - GENERATE_INFOPLIST_FILE: YES + 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 - platform: macOS + 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 @@ -87,7 +80,7 @@ targets: INFOPLIST_FILE: Sources/{{PROJECT_NAME}}/Info.plist CODE_SIGN_ENTITLEMENTS: Sources/{{PROJECT_NAME}}/{{PROJECT_NAME}}.entitlements MARKETING_VERSION: "0.1" - CURRENT_PROJECT_VERSION: 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 @@ -108,14 +101,14 @@ targets: {{PROJECT_NAME}}UnitTests: type: bundle.unit-test - platform: macOS + 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 + 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: @@ -129,14 +122,14 @@ targets: {{PROJECT_NAME}}UITests: type: bundle.ui-testing - platform: macOS + 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 + 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 @@ -150,7 +143,7 @@ targets: product: FOSTesting - package: FOSUtilities product: FOSTestingUI - +{{WATCH_TARGET}} schemes: {{PROJECT_NAME}}: build: @@ -167,3 +160,4 @@ schemes: - {{PROJECT_NAME}}UITests archive: config: Release +{{WATCH_SCHEME}} diff --git a/Sources/FOSMVVMBootstrap/TokenSet+Watch.swift b/Sources/FOSMVVMBootstrap/TokenSet+Watch.swift new file mode 100644 index 0000000..3850843 --- /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 index d1fb309..155e59d 100644 --- a/Sources/FOSMVVMBootstrap/TokenSet.swift +++ b/Sources/FOSMVVMBootstrap/TokenSet.swift @@ -25,6 +25,76 @@ public enum TokenSet { 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 diff --git a/Sources/FOSMVVMBootstrap/Verifier.swift b/Sources/FOSMVVMBootstrap/Verifier.swift index 40c5832..3781282 100644 --- a/Sources/FOSMVVMBootstrap/Verifier.swift +++ b/Sources/FOSMVVMBootstrap/Verifier.swift @@ -11,7 +11,7 @@ public enum VerifyStep: String, Sendable, CaseIterable { /// 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?) -> [String] { + func command(projectName: String?, destination: String = "platform=macOS") -> [String] { switch self { case .swiftBuild: return ["swift", "build"] case .swiftTest: return ["swift", "test"] @@ -22,7 +22,7 @@ public enum VerifyStep: String, Sendable, CaseIterable { "xcodebuild", "-project", "\(name).xcodeproj", "-scheme", name, - "-destination", "platform=macOS", + "-destination", destination, "build", "CODE_SIGNING_ALLOWED=NO", ] @@ -62,16 +62,40 @@ extension VerifierError: CustomStringConvertible { /// A failure is fatal and carries the tool output — the scaffolder /// never hands over a broken skeleton. public enum Verifier { - /// Steps for a shape. shared-library: build + test is the entire - /// finish line. local-only builds the generated Xcode project. - /// client-server runs both doors: the package (server boots + serves the - /// route headlessly under `swift test`) and the app (xcodegen + xcodebuild). - /// hybrid keeps the placeholder until Plan 4. + /// 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: [.xcodegenGenerate, .xcodebuildBuild] - case .clientServer: [.swiftBuild, .swiftTest, .xcodegenGenerate, .xcodebuildBuild] + case .localOnly: [.xcodebuildBuild] + case .clientServer: [.swiftBuild, .swiftTest, .xcodebuildBuild] case .hybrid: [.swiftBuild, .swiftTest] // extended in Plan 4 } } @@ -93,6 +117,7 @@ public enum Verifier { projectDir: URL, steps: [VerifyStep], projectName: String? = nil, + destination: String = "platform=macOS", environment: [String: String]? = nil ) throws { // Overlay the caller's variables onto the inherited environment. @@ -126,7 +151,7 @@ public enum Verifier { } let result = run( - command: step.command(projectName: projectName), + command: step.command(projectName: projectName, destination: destination), in: projectDir, environment: resolvedEnv ) diff --git a/Sources/FOSMVVMBootstrapCLI/Interview.swift b/Sources/FOSMVVMBootstrapCLI/Interview.swift new file mode 100644 index 0000000..ff8e742 --- /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/NewCommand.swift b/Sources/FOSMVVMBootstrapCLI/NewCommand.swift index bc24460..6b7c65b 100644 --- a/Sources/FOSMVVMBootstrapCLI/NewCommand.swift +++ b/Sources/FOSMVVMBootstrapCLI/NewCommand.swift @@ -8,35 +8,63 @@ struct New: ParsableCommand { abstract: "Scaffold a new FOSMVVM project from a config file." ) - @Option(name: .shortAndLong, help: "Path to a BootstrapConfig JSON file.") - var config: String + @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: "Skip the build/test verification phase (CI of this repo only — never for real use).") - var skipVerify = false + @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 configURL = URL(fileURLWithPath: config) let outputURL = URL(fileURLWithPath: output) - let bootstrapConfig = try JSONDecoder().decode( - BootstrapConfig.self, - from: Data(contentsOf: configURL) - ) + 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.") - if !skipVerify { + // 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 + projectName: bootstrapConfig.projectName, + destination: Verifier.buildDestination(for: bootstrapConfig) ) print("✅ Walking skeleton verified green.") } diff --git a/Tests/FOSMVVMBootstrapTests/BootstrapConfigTests.swift b/Tests/FOSMVVMBootstrapTests/BootstrapConfigTests.swift index 6c1460e..a823cb8 100644 --- a/Tests/FOSMVVMBootstrapTests/BootstrapConfigTests.swift +++ b/Tests/FOSMVVMBootstrapTests/BootstrapConfigTests.swift @@ -72,15 +72,15 @@ import Testing } } - @Test func clientServerRequiresMacOSPlatform() throws { + @Test func appShapesRequireAtLeastOneAppDestination() throws { let config = BootstrapConfig( projectName: "PalettePress", shape: .clientServer, - platforms: [.iOS: "17.0"], + platforms: [.watchOS: "10.0"], bundleIdRoot: "com.example.palettepress", teamId: "ABCDE12345" ) - #expect(throws: BootstrapConfigError.missingPlatform(.macOS)) { + #expect(throws: BootstrapConfigError.noAppDestinations) { try config.validate() } } diff --git a/Tests/FOSMVVMBootstrapTests/IntegrationTests.swift b/Tests/FOSMVVMBootstrapTests/IntegrationTests.swift index d6bc0b3..eb8c9b5 100644 --- a/Tests/FOSMVVMBootstrapTests/IntegrationTests.swift +++ b/Tests/FOSMVVMBootstrapTests/IntegrationTests.swift @@ -52,7 +52,7 @@ extension Tag { try Emitter.emit(config: config, into: out) try Verifier.verify( projectDir: out, - steps: Verifier.steps(for: .localOnly), + steps: Verifier.generationSteps(for: .localOnly) + Verifier.steps(for: .localOnly), projectName: "PalettePress" ) } @@ -78,7 +78,7 @@ extension Tag { try Emitter.emit(config: config, into: out) try Verifier.verify( projectDir: out, - steps: Verifier.steps(for: .clientServer), + steps: Verifier.generationSteps(for: .clientServer) + Verifier.steps(for: .clientServer), projectName: "PalettePress" ) } diff --git a/Tests/FOSMVVMBootstrapTests/TokenSetTests.swift b/Tests/FOSMVVMBootstrapTests/TokenSetTests.swift index 18e0996..ee0b9d3 100644 --- a/Tests/FOSMVVMBootstrapTests/TokenSetTests.swift +++ b/Tests/FOSMVVMBootstrapTests/TokenSetTests.swift @@ -39,6 +39,88 @@ import Testing #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", diff --git a/Tests/FOSMVVMBootstrapTests/VerifierTests.swift b/Tests/FOSMVVMBootstrapTests/VerifierTests.swift index 1927954..3572ab3 100644 --- a/Tests/FOSMVVMBootstrapTests/VerifierTests.swift +++ b/Tests/FOSMVVMBootstrapTests/VerifierTests.swift @@ -46,12 +46,14 @@ import Testing } @Test func localOnlyStepsAreXcodeSteps() { - #expect(Verifier.steps(for: .localOnly) == [.xcodegenGenerate, .xcodebuildBuild]) + #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, .xcodegenGenerate, .xcodebuildBuild]) + == [.swiftBuild, .swiftTest, .xcodebuildBuild]) } @Test func missingToolThrowsToolMissing() throws { 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 index c20c811..7a9d03b 100644 --- a/docs/superpowers/plans/2026-08-22-fosmvvm-bootstrap-migration-design.md +++ b/docs/superpowers/plans/2026-08-22-fosmvvm-bootstrap-migration-design.md @@ -25,7 +25,7 @@ The decision under review is David's (2026-08-22): distribute the scaffolder thr ## 3. What the user sees -**(a) Create a project** — README and DocC show one block, and it works from a bare clone: +**(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 @@ -38,7 +38,7 @@ 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). A prebuilt-binary path (GitHub release artifact → optional Homebrew) is scoped to Plan 5, not this migration. +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: From 3b2d0ef89c0cfbb2902a4522e2df14a2547eedb9 Mon Sep 17 00:00:00 2001 From: David Hunt Date: Sat, 22 Aug 2026 18:31:33 +0200 Subject: [PATCH 05/11] ci: pre-boot the simulator and retry generated UI tests once CI simulators are always the cold case, which is the measured first-run tap-flake condition; pre-boot plus -retry-tests-on-failure keeps a settling flake visible without redding the PR. The durable fix is FOSTestingUI's first-interaction settle (follow-on). --- .github/workflows/ci.yml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c5ca2cb..95b29c2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -207,12 +207,25 @@ jobs: "$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 SUPPORTED_PLATFORMS="iphonesimulator macosx" test done @@ -222,5 +235,6 @@ jobs: 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 From da2e9af2139eefd59d45737eae2e70b7dac720b7 Mon Sep 17 00:00:00 2001 From: David Hunt Date: Sun, 23 Aug 2026 13:25:30 +0200 Subject: [PATCH 06/11] ci: disable Xcode's macro fingerprint gate for the scaffolder job The generated-app build is an inner xcodebuild spawned by the Verifier, so the job's -skipMacroValidation flag never reaches it. Set IDESkipMacroFingerprintValidation runner-wide so the generated project can build against FOSMacros on a fresh runner. --- .github/workflows/ci.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 95b29c2..fba8db5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -158,6 +158,12 @@ jobs: - 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 From 8b5487ae378dd570a3d103cd5e98817974e3e31b Mon Sep 17 00:00:00 2001 From: David Hunt Date: Sun, 23 Aug 2026 14:18:49 +0200 Subject: [PATCH 07/11] test(FOSMVVMBootstrap): serialize the walking-skeleton suite Each skeleton resolves and builds the full FOSUtilities dependency graph; in parallel they starve a hosted runner past the per-test time limits, and the resulting kill also discards the not-yet-run tests. --- Tests/FOSMVVMBootstrapTests/IntegrationTests.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Tests/FOSMVVMBootstrapTests/IntegrationTests.swift b/Tests/FOSMVVMBootstrapTests/IntegrationTests.swift index eb8c9b5..aab9ad2 100644 --- a/Tests/FOSMVVMBootstrapTests/IntegrationTests.swift +++ b/Tests/FOSMVVMBootstrapTests/IntegrationTests.swift @@ -10,8 +10,12 @@ extension Tag { // 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; run in parallel they starve a 3–4 core hosted runner +// past the time limits (sharedLibrary is ~40s alone, >600s contended). @Suite( .tags(.integration), + .serialized, .enabled(if: ProcessInfo.processInfo.environment["FOSMVVM_BOOTSTRAP_SKELETONS"] == "1") ) struct IntegrationTests { /// Full walking-skeleton proof for the shared-library shape: From 6d06208669c8b947be7d382038caa5ae2b3323c2 Mon Sep 17 00:00:00 2001 From: David Hunt Date: Sun, 23 Aug 2026 15:11:13 +0200 Subject: [PATCH 08/11] fix(FOSMVVMBootstrap): verification children run in a sanitized environment When the scaffolder itself runs under xcodebuild test, the harness's XCTest session variables leak into the Verifier's child processes; the generated project's own test runner then tries to attach to the harness's IDE session and hangs on a handshake that never comes, or exits non-zero with every generated test green. Verifier.verify now strips XCTest*/DYLD_* from the inherited environment before spawning steps (caller overlay still wins), so verification behaves identically from a shell and inside a test runner. All three walking skeletons verified green locally under the CI-identical xcodebuild invocation (sharedLibrary 64s, localOnly 58s, clientServer 113s). --- Sources/FOSMVVMBootstrap/Verifier.swift | 89 ++++++++++++++----- .../IntegrationTests.swift | 21 ++++- .../VerifierEnvironmentTests.swift | 51 +++++++++++ 3 files changed, 138 insertions(+), 23 deletions(-) create mode 100644 Tests/FOSMVVMBootstrapTests/VerifierEnvironmentTests.swift diff --git a/Sources/FOSMVVMBootstrap/Verifier.swift b/Sources/FOSMVVMBootstrap/Verifier.swift index 3781282..9ccd662 100644 --- a/Sources/FOSMVVMBootstrap/Verifier.swift +++ b/Sources/FOSMVVMBootstrap/Verifier.swift @@ -1,4 +1,19 @@ // 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 { @@ -24,7 +39,7 @@ public enum VerifyStep: String, Sendable, CaseIterable { "-scheme", name, "-destination", destination, "build", - "CODE_SIGNING_ALLOWED=NO", + "CODE_SIGNING_ALLOWED=NO" ] } } @@ -34,8 +49,8 @@ public enum VerifyStep: String, Sendable, CaseIterable { /// tool before running the step and throws `.toolMissing` on absence. var requiredTool: (tool: String, installHint: String)? { switch self { - case .xcodegenGenerate: return ("xcodegen", "brew install xcodegen") - case .swiftBuild, .swiftTest, .xcodebuildBuild: return nil + case .xcodegenGenerate: ("xcodegen", "brew install xcodegen") + case .swiftBuild, .swiftTest, .xcodebuildBuild: nil } } } @@ -48,12 +63,12 @@ public enum VerifierError: Error { extension VerifierError: CustomStringConvertible { public var description: String { switch self { - case let .stepFailed(step, output): + 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. - return "verification step '\(step.command(projectName: nil).joined(separator: " "))' failed:\n\(output)" - case let .toolMissing(tool, installHint): - return "required tool '\(tool)' not found on PATH — install it with: \(installHint)" + "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)" } } } @@ -65,10 +80,18 @@ 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" } + 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" } @@ -109,6 +132,11 @@ public enum Verifier { /// 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 @@ -120,12 +148,13 @@ public enum Verifier { destination: String = "platform=macOS", environment: [String: String]? = nil ) throws { - // Overlay the caller's variables onto the inherited environment. - // Overlaying PATH REPLACES the inherited PATH for the children, - // which the tool-missing test relies on. nil means inherit as-is. - let resolvedEnv = environment.map { overlay in - ProcessInfo.processInfo.environment.merging(overlay) { _, new in new } - } + // 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 { @@ -161,20 +190,38 @@ public enum Verifier { } } + /// 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]? + environment: [String: String] ) -> (status: Int32, output: String) { let process = Process() process.executableURL = URL(fileURLWithPath: "/usr/bin/env") process.arguments = command process.currentDirectoryURL = dir - if let environment { - process.environment = environment - } + process.environment = environment let pipe = Pipe() process.standardOutput = pipe diff --git a/Tests/FOSMVVMBootstrapTests/IntegrationTests.swift b/Tests/FOSMVVMBootstrapTests/IntegrationTests.swift index aab9ad2..283c841 100644 --- a/Tests/FOSMVVMBootstrapTests/IntegrationTests.swift +++ b/Tests/FOSMVVMBootstrapTests/IntegrationTests.swift @@ -1,3 +1,19 @@ +// 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 @@ -11,8 +27,9 @@ extension Tag { // 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; run in parallel they starve a 3–4 core hosted runner -// past the time limits (sharedLibrary is ~40s alone, >600s contended). +// 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, diff --git a/Tests/FOSMVVMBootstrapTests/VerifierEnvironmentTests.swift b/Tests/FOSMVVMBootstrapTests/VerifierEnvironmentTests.swift new file mode 100644 index 0000000..f5116bd --- /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"]) + } +} From eb74f5c1c5546caf48d19b1007cef2490083b972 Mon Sep 17 00:00:00 2001 From: David Hunt Date: Sun, 23 Aug 2026 16:02:47 +0200 Subject: [PATCH 09/11] ci: the matrix demo configs request iOS, so the iOS leg has a real destination MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generation follows the config's platforms exactly, so the macOS-only demo configs produced projects with no iOS Simulator destination and the iOS UI-test leg could not match one. Both configs now request iOS 17.0 alongside macOS, and the SUPPORTED_PLATFORMS command-line override — which papered over the missing platform — is gone. All four legs (LocalDemo/ServerDemo x iPhone 17 simulator/macOS) verified green locally with the CI-identical invocations. --- .github/workflows/ci.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fba8db5..6594353 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -202,12 +202,12 @@ jobs: mkdir -p "$RUNNER_TEMP/matrix" cat > "$RUNNER_TEMP/local.json" <<'CONFIG' { "projectName": "LocalDemo", "shape": "localOnly", - "platforms": { "macOS": "14.0" }, + "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" }, + "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" @@ -232,7 +232,7 @@ jobs: 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 SUPPORTED_PLATFORMS="iphonesimulator macosx" test + CODE_SIGNING_ALLOWED=NO test done - name: Generated UI tests (macOS) From 28404cdbb7ea1280547bfe03a3a9e1864642aeb6 Mon Sep 17 00:00:00 2001 From: David Hunt Date: Sun, 23 Aug 2026 17:10:58 +0200 Subject: [PATCH 10/11] fix(FOSMVVMBootstrap): generated UI-test taps are witnessed, not assumed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI showed a freshly launched app can discard its first synthesized tap (the event synthesizes, the app idles, and the expected UI change never comes — a per-launch window, so simulator pre-boot and test retries don't close it). The generated test-support base gains tap(_:until:timeout:): tap, poll for the UI change the tap must cause, re-tap once at half timeout. All seven generated tap sites use it, ops reads follow the witness instead of racing the transporter repaint, and BoardViewTests' claim that no waiting is needed is corrected. The primitive lives in the generated support layer because generated projects pin released FOSUtilities; migrating it into FOSTestingUI is the durable follow-on. All four generated-app legs verified green locally (LocalDemo/ServerDemo x iPhone 17 simulator/macOS, CI-identical invocations). --- ...ECT_NAME}}ViewModelViewTestCase.swift.tmpl | 30 +++++++++++++++++++ .../Views/AboutViewTests.swift.tmpl | 24 +++++++++------ .../Views/BoardViewTests.swift.tmpl | 11 +++---- ...ECT_NAME}}ViewModelViewTestCase.swift.tmpl | 30 +++++++++++++++++++ .../Views/WelcomeViewTests.swift.tmpl | 24 +++++++++------ 5 files changed, 96 insertions(+), 23 deletions(-) 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 index 0569195..fbed8ea 100644 --- 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 @@ -30,6 +30,36 @@ class {{PROJECT_NAME}}ViewModelViewTestCase< ) } + /// 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 } 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 index 2cf7579..e8dea7b 100644 --- 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 @@ -25,28 +25,34 @@ final class AboutViewTestsUITests: {{PROJECT_NAME}}ViewModelViewTestCase, @unchecked Sendable { @@ -26,7 +24,10 @@ final class BoardViewTestsUITests: {{PROJECT_NAME}}ViewModelViewTestCase 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 } 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 index 4a299c2..edbdb21 100644 --- 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 @@ -22,28 +22,34 @@ final class WelcomeViewTestsUITests: {{PROJECT_NAME}}ViewModelViewTestCase Date: Sun, 23 Aug 2026 17:51:40 +0200 Subject: [PATCH 11/11] fix(FOSMVVMBootstrap): generated apps declare UILaunchScreen so iOS runs them full-screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Without the declaration iOS letterboxes the app (compatibility sizing: a 603pt window centered on an 874pt iPhone 17 screen), and the reported element frames disagree with the tap-dispatch coordinate space — every synthesized tap misses, deterministically. The 26.5 runtime letterboxes where 27.0 does not, which is why the generated UI tests failed on CI's simulator and passed locally. Reproduced and verified on the 26.5 runtime locally: the failing alert tests go green (and drop from 16-28s of futile polling to 5-7s). All four generated-app legs green locally. --- .../client-server/Sources/{{PROJECT_NAME}}/Info.plist | 5 +++++ .../Templates/local-only/Sources/{{PROJECT_NAME}}/Info.plist | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/Sources/FOSMVVMBootstrap/Templates/client-server/Sources/{{PROJECT_NAME}}/Info.plist b/Sources/FOSMVVMBootstrap/Templates/client-server/Sources/{{PROJECT_NAME}}/Info.plist index 8d70380..8c7eadd 100644 --- a/Sources/FOSMVVMBootstrap/Templates/client-server/Sources/{{PROJECT_NAME}}/Info.plist +++ b/Sources/FOSMVVMBootstrap/Templates/client-server/Sources/{{PROJECT_NAME}}/Info.plist @@ -18,5 +18,10 @@ $(MACOSX_DEPLOYMENT_TARGET) NSPrincipalClass NSApplication + + UILaunchScreen + diff --git a/Sources/FOSMVVMBootstrap/Templates/local-only/Sources/{{PROJECT_NAME}}/Info.plist b/Sources/FOSMVVMBootstrap/Templates/local-only/Sources/{{PROJECT_NAME}}/Info.plist index 8d70380..8c7eadd 100644 --- a/Sources/FOSMVVMBootstrap/Templates/local-only/Sources/{{PROJECT_NAME}}/Info.plist +++ b/Sources/FOSMVVMBootstrap/Templates/local-only/Sources/{{PROJECT_NAME}}/Info.plist @@ -18,5 +18,10 @@ $(MACOSX_DEPLOYMENT_TARGET) NSPrincipalClass NSApplication + + UILaunchScreen +