@moifort/stack 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (263) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +26 -2
  3. package/bin/stack.ts +109 -0
  4. package/hooks/shutdown-simulators-after-push.sh +19 -0
  5. package/package.json +40 -4
  6. package/routines/daily-health.md +78 -0
  7. package/routines/deprecations.md +23 -0
  8. package/routines/sentry-autofix.md +76 -0
  9. package/routines/stack-upgrade.md +45 -0
  10. package/rules/00-collaboration.md +81 -0
  11. package/rules/01-workflow.md +81 -0
  12. package/rules/02-commits.md +71 -0
  13. package/rules/03-code-review.md +25 -0
  14. package/rules/04-push.md +75 -0
  15. package/rules/05-release.md +40 -0
  16. package/rules/06-changelog.md +48 -0
  17. package/rules/07-appstore.md +77 -0
  18. package/rules/08-copy.md +52 -0
  19. package/rules/09-backend.md +132 -0
  20. package/rules/10-ios.md +206 -0
  21. package/rules/11-ops.md +114 -0
  22. package/rules/12-ci.md +32 -0
  23. package/rules/13-ai.md +71 -0
  24. package/rules/14-infra.md +90 -0
  25. package/rules/15-tooling.md +81 -0
  26. package/rules/16-routines.md +69 -0
  27. package/rules/17-landing.md +76 -0
  28. package/skills/appstore/SKILL.md +53 -0
  29. package/skills/domain/SKILL.md +136 -0
  30. package/skills/graphql/SKILL.md +83 -0
  31. package/skills/ios-feature/SKILL.md +94 -0
  32. package/skills/stack-install/SKILL.md +35 -0
  33. package/skills/stack-upgrade/SKILL.md +27 -0
  34. package/src/catalogue.ts +33 -0
  35. package/src/config.ts +20 -0
  36. package/src/init.ts +63 -0
  37. package/src/migrations.ts +72 -0
  38. package/src/migrations.unit.test.ts +44 -0
  39. package/src/package.ts +8 -0
  40. package/src/render.ts +13 -0
  41. package/src/render.unit.test.ts +17 -0
  42. package/src/sync.int.test.ts +103 -0
  43. package/src/sync.ts +161 -0
  44. package/src/templates.ts +121 -0
  45. package/src/validate-content.ts +65 -0
  46. package/templates/backend/.env.example +10 -0
  47. package/templates/backend/.github/workflows/deploy.yml +151 -0
  48. package/templates/backend/.github/workflows/schema.yml +59 -0
  49. package/templates/backend/.github/workflows/test-feat.yml +40 -0
  50. package/templates/backend/.github/workflows/test-int.yml +40 -0
  51. package/templates/backend/.github/workflows/test-unit.yml +64 -0
  52. package/templates/backend/docs/api-evolution.md +67 -0
  53. package/templates/backend/firebase.json +21 -0
  54. package/templates/backend/firestore.indexes.json +4 -0
  55. package/templates/backend/firestore.rules +11 -0
  56. package/templates/backend/infra/auth.tf +54 -0
  57. package/templates/backend/infra/backend.tf +6 -0
  58. package/templates/backend/infra/cicd.tf +63 -0
  59. package/templates/backend/infra/firebase.tf +26 -0
  60. package/templates/backend/infra/firestore.tf +53 -0
  61. package/templates/backend/infra/function.tf +171 -0
  62. package/templates/backend/infra/main.tf +26 -0
  63. package/templates/backend/infra/observer.tf +18 -0
  64. package/templates/backend/infra/outputs.tf +30 -0
  65. package/templates/backend/infra/project.tf +64 -0
  66. package/templates/backend/infra/scheduler.tf +28 -0
  67. package/templates/backend/infra/secrets.tf +44 -0
  68. package/templates/backend/infra/state.tf +13 -0
  69. package/templates/backend/infra/terraform.tfvars.example +16 -0
  70. package/templates/backend/infra/variables.tf +104 -0
  71. package/templates/backend/infra/versions.tf +30 -0
  72. package/templates/backend/nitro.config.ts +40 -0
  73. package/templates/backend/package.json +51 -0
  74. package/templates/backend/scripts/deprecations/inspector-rule.ts +29 -0
  75. package/templates/backend/scripts/deprecations/status.ts +50 -0
  76. package/templates/backend/scripts/deprecations/status.unit.test.ts +57 -0
  77. package/templates/backend/scripts/deprecations/usage.ts +79 -0
  78. package/templates/backend/scripts/deprecations/usage.unit.test.ts +64 -0
  79. package/templates/backend/scripts/deprecations.ts +224 -0
  80. package/templates/backend/scripts/export-graphql-schema.ts +11 -0
  81. package/templates/backend/scripts/generate-changelog-asset.ts +27 -0
  82. package/templates/backend/scripts/install-terraform.sh +61 -0
  83. package/templates/backend/scripts/tf +11 -0
  84. package/templates/backend/server/domain/changelog/business-rules.ts +60 -0
  85. package/templates/backend/server/domain/changelog/business-rules.unit.test.ts +88 -0
  86. package/templates/backend/server/domain/changelog/infrastructure/graphql/queries.ts +17 -0
  87. package/templates/backend/server/domain/changelog/infrastructure/graphql/types.ts +23 -0
  88. package/templates/backend/server/domain/changelog/primitives.ts +19 -0
  89. package/templates/backend/server/domain/changelog/query.ts +13 -0
  90. package/templates/backend/server/domain/changelog/query.unit.test.ts +19 -0
  91. package/templates/backend/server/domain/changelog/types.ts +9 -0
  92. package/templates/backend/server/domain/shared/graphql/builder.ts +52 -0
  93. package/templates/backend/server/domain/shared/graphql/errors.ts +19 -0
  94. package/templates/backend/server/domain/shared/graphql/scalars.ts +51 -0
  95. package/templates/backend/server/domain/shared/graphql/schema.ts +21 -0
  96. package/templates/backend/server/domain/shared/language.ts +26 -0
  97. package/templates/backend/server/domain/shared/page.ts +25 -0
  98. package/templates/backend/server/domain/shared/page.unit.test.ts +51 -0
  99. package/templates/backend/server/domain/shared/primitives.ts +50 -0
  100. package/templates/backend/server/domain/shared/primitives.unit.test.ts +25 -0
  101. package/templates/backend/server/domain/shared/types.ts +14 -0
  102. package/templates/backend/server/domain/user/command.ts +16 -0
  103. package/templates/backend/server/domain/user/infrastructure/graphql/inputs.ts +12 -0
  104. package/templates/backend/server/domain/user/infrastructure/graphql/mutations.ts +40 -0
  105. package/templates/backend/server/domain/user/infrastructure/graphql/queries.ts +13 -0
  106. package/templates/backend/server/domain/user/infrastructure/graphql/types.ts +35 -0
  107. package/templates/backend/server/domain/user/infrastructure/repository.ts +59 -0
  108. package/templates/backend/server/domain/user/mutations.feat.test.ts +42 -0
  109. package/templates/backend/server/domain/user/query.int.test.ts +49 -0
  110. package/templates/backend/server/domain/user/query.ts +34 -0
  111. package/templates/backend/server/domain/user/types.ts +20 -0
  112. package/templates/backend/server/domain/user/use-case.ts +18 -0
  113. package/templates/backend/server/middleware/auth.ts +54 -0
  114. package/templates/backend/server/plugins/00-sentry.ts +74 -0
  115. package/templates/backend/server/plugins/01-request-log.ts +28 -0
  116. package/templates/backend/server/plugins/02-graphql.ts +69 -0
  117. package/templates/backend/server/plugins/03-boot-time.ts +21 -0
  118. package/templates/backend/server/routes/admin/migrate.post.ts +9 -0
  119. package/templates/backend/server/routes/app-config.get.ts +15 -0
  120. package/templates/backend/server/routes/graphql.ts +52 -0
  121. package/templates/backend/server/system/app-support.ts +17 -0
  122. package/templates/backend/server/system/config/index.ts +18 -0
  123. package/templates/backend/server/system/config/primitives.ts +20 -0
  124. package/templates/backend/server/system/config/primitives.unit.test.ts +13 -0
  125. package/templates/backend/server/system/config/types.ts +8 -0
  126. package/templates/backend/server/system/firebase.ts +19 -0
  127. package/templates/backend/server/system/identity.ts +18 -0
  128. package/templates/backend/server/system/logger.ts +119 -0
  129. package/templates/backend/server/system/logger.unit.test.ts +109 -0
  130. package/templates/backend/server/system/migration/migrations/index.ts +6 -0
  131. package/templates/backend/server/system/migration/primitives.ts +18 -0
  132. package/templates/backend/server/system/migration/runner.ts +52 -0
  133. package/templates/backend/server/system/migration/types.ts +16 -0
  134. package/templates/backend/server/system/read-meter.ts +100 -0
  135. package/templates/backend/server/system/read-meter.unit.test.ts +97 -0
  136. package/templates/backend/server/system/request-cache.ts +139 -0
  137. package/templates/backend/server/system/request-cache.unit.test.ts +166 -0
  138. package/templates/backend/server/system/secret-box.ts +49 -0
  139. package/templates/backend/server/system/secret-box.unit.test.ts +46 -0
  140. package/templates/backend/server/test/bdd.ts +12 -0
  141. package/templates/backend/server/test/fake-firestore.ts +483 -0
  142. package/templates/backend/server/utils/apollo.ts +13 -0
  143. package/templates/backend/server/utils/fetch.ts +5 -0
  144. package/templates/backend/server/utils/firestore.ts +91 -0
  145. package/templates/backend/server/utils/input.ts +36 -0
  146. package/templates/backend/server/utils/input.unit.test.ts +43 -0
  147. package/templates/backend/server/utils/slug.ts +21 -0
  148. package/templates/backend/tsconfig.json +16 -0
  149. package/templates/base/.github/workflows/renovate.yml +17 -0
  150. package/templates/base/CHANGELOG.fr.md +3 -0
  151. package/templates/base/CHANGELOG.md +7 -0
  152. package/templates/base/CLAUDE.md +34 -0
  153. package/templates/base/README.md +12 -0
  154. package/templates/base/biome.json +47 -0
  155. package/templates/base/bunfig.toml +2 -0
  156. package/templates/base/docs/adr/0001-built-on-the-polyforms-stack.md +21 -0
  157. package/templates/base/docs/data-recovery.md +116 -0
  158. package/templates/base/docs/ideas.md +4 -0
  159. package/templates/base/project-context.md +43 -0
  160. package/templates/base/renovate.json +37 -0
  161. package/templates/ios/.github/workflows/build-ios.yml +168 -0
  162. package/templates/ios/.github/workflows/ios-upload.yml +308 -0
  163. package/templates/ios/.github/workflows/release-ios.yml +178 -0
  164. package/templates/ios/.github/workflows/testflight-ios.yml +57 -0
  165. package/templates/ios/infra/Gemfile +5 -0
  166. package/templates/ios/infra/fastlane/Appfile +1 -0
  167. package/templates/ios/infra/fastlane/Fastfile +54 -0
  168. package/templates/ios/infra/fastlane/metadata/copyright.txt +1 -0
  169. package/templates/ios/infra/fastlane/metadata/en-US/description.txt +0 -0
  170. package/templates/ios/infra/fastlane/metadata/en-US/keywords.txt +0 -0
  171. package/templates/ios/infra/fastlane/metadata/en-US/name.txt +1 -0
  172. package/templates/ios/infra/fastlane/metadata/en-US/privacy_url.txt +1 -0
  173. package/templates/ios/infra/fastlane/metadata/en-US/promotional_text.txt +0 -0
  174. package/templates/ios/infra/fastlane/metadata/en-US/subtitle.txt +1 -0
  175. package/templates/ios/infra/fastlane/metadata/en-US/support_url.txt +1 -0
  176. package/templates/ios/infra/fastlane/metadata/fr-FR/description.txt +0 -0
  177. package/templates/ios/infra/fastlane/metadata/fr-FR/keywords.txt +0 -0
  178. package/templates/ios/infra/fastlane/metadata/fr-FR/name.txt +1 -0
  179. package/templates/ios/infra/fastlane/metadata/fr-FR/privacy_url.txt +1 -0
  180. package/templates/ios/infra/fastlane/metadata/fr-FR/promotional_text.txt +0 -0
  181. package/templates/ios/infra/fastlane/metadata/fr-FR/subtitle.txt +1 -0
  182. package/templates/ios/infra/fastlane/metadata/fr-FR/support_url.txt +1 -0
  183. package/templates/ios/infra/fastlane/metadata/primary_category.txt +1 -0
  184. package/templates/ios/infra/ios.tf +28 -0
  185. package/templates/ios/ios/ExportOptions.plist +26 -0
  186. package/templates/ios/ios/apollo-codegen-config.json +16 -0
  187. package/templates/ios/ios/project.yml +94 -0
  188. package/templates/ios/ios/{{APP_NAME}}/App.swift +46 -0
  189. package/templates/ios/ios/{{APP_NAME}}/Assets.xcassets/AppIcon.appiconset/AppIcon.png +0 -0
  190. package/templates/ios/ios/{{APP_NAME}}/Assets.xcassets/AppIcon.appiconset/Contents.json +14 -0
  191. package/templates/ios/ios/{{APP_NAME}}/Assets.xcassets/Contents.json +6 -0
  192. package/templates/ios/ios/{{APP_NAME}}/Assets.xcassets/LaunchBackground.colorset/Contents.json +38 -0
  193. package/templates/ios/ios/{{APP_NAME}}/ContentView.swift +17 -0
  194. package/templates/ios/ios/{{APP_NAME}}/Features/Auth/AppleNonce.swift +33 -0
  195. package/templates/ios/ios/{{APP_NAME}}/Features/Auth/AuthRoot.swift +74 -0
  196. package/templates/ios/ios/{{APP_NAME}}/Features/Auth/AuthSession.swift +86 -0
  197. package/templates/ios/ios/{{APP_NAME}}/Features/Auth/LoginScreen.swift +83 -0
  198. package/templates/ios/ios/{{APP_NAME}}/Features/Home/HomeScreen.swift +21 -0
  199. package/templates/ios/ios/{{APP_NAME}}/Features/Home/components/pages/HomePage.swift +40 -0
  200. package/templates/ios/ios/{{APP_NAME}}/Features/Onboarding/AccountEnvironment.swift +7 -0
  201. package/templates/ios/ios/{{APP_NAME}}/Features/Onboarding/GraphQL/Onboarding.graphql +16 -0
  202. package/templates/ios/ios/{{APP_NAME}}/Features/Onboarding/OnboardingAPI.swift +28 -0
  203. package/templates/ios/ios/{{APP_NAME}}/Features/Onboarding/OnboardingClient.swift +26 -0
  204. package/templates/ios/ios/{{APP_NAME}}/Features/Onboarding/OnboardingGate.swift +50 -0
  205. package/templates/ios/ios/{{APP_NAME}}/Features/Onboarding/OnboardingScreen.swift +69 -0
  206. package/templates/ios/ios/{{APP_NAME}}/Features/Onboarding/components/pages/FirstNamePage.swift +66 -0
  207. package/templates/ios/ios/{{APP_NAME}}/Features/Onboarding/components/pages/WelcomePage.swift +37 -0
  208. package/templates/ios/ios/{{APP_NAME}}/Features/Settings/ChangelogScreen.swift +25 -0
  209. package/templates/ios/ios/{{APP_NAME}}/Features/Settings/ChangelogViewModel.swift +27 -0
  210. package/templates/ios/ios/{{APP_NAME}}/Features/Settings/GraphQL/Settings.graphql +11 -0
  211. package/templates/ios/ios/{{APP_NAME}}/Features/Settings/ProfileSettingsScreen.swift +30 -0
  212. package/templates/ios/ios/{{APP_NAME}}/Features/Settings/ProfileSettingsViewModel.swift +51 -0
  213. package/templates/ios/ios/{{APP_NAME}}/Features/Settings/SettingsAPI.swift +37 -0
  214. package/templates/ios/ios/{{APP_NAME}}/Features/Settings/SettingsClient.swift +40 -0
  215. package/templates/ios/ios/{{APP_NAME}}/Features/Settings/SettingsHomeScreen.swift +39 -0
  216. package/templates/ios/ios/{{APP_NAME}}/Features/Settings/components/organisms/DeleteAccountButton.swift +30 -0
  217. package/templates/ios/ios/{{APP_NAME}}/Features/Settings/components/organisms/SettingsRow.swift +56 -0
  218. package/templates/ios/ios/{{APP_NAME}}/Features/Settings/components/organisms/SignOutButton.swift +23 -0
  219. package/templates/ios/ios/{{APP_NAME}}/Features/Settings/components/pages/ChangelogPage.swift +59 -0
  220. package/templates/ios/ios/{{APP_NAME}}/Features/Settings/components/pages/FeedbackSheet.swift +118 -0
  221. package/templates/ios/ios/{{APP_NAME}}/Features/Settings/components/pages/ProfileSettingsPage.swift +90 -0
  222. package/templates/ios/ios/{{APP_NAME}}/Features/Settings/components/pages/SettingsHomePage.swift +70 -0
  223. package/templates/ios/ios/{{APP_NAME}}/Info.plist +14 -0
  224. package/templates/ios/ios/{{APP_NAME}}/Localizable.xcstrings +1030 -0
  225. package/templates/ios/ios/{{APP_NAME}}/PrivacyInfo.xcprivacy +122 -0
  226. package/templates/ios/ios/{{APP_NAME}}/Shared/APIClient.swift +23 -0
  227. package/templates/ios/ios/{{APP_NAME}}/Shared/AcceptLanguageInterceptor.swift +20 -0
  228. package/templates/ios/ios/{{APP_NAME}}/Shared/Components/atoms/AsyncButton.swift +73 -0
  229. package/templates/ios/ios/{{APP_NAME}}/Shared/Components/atoms/AsyncToolbarButton.swift +69 -0
  230. package/templates/ios/ios/{{APP_NAME}}/Shared/Components/atoms/ToolbarIconButton.swift +46 -0
  231. package/templates/ios/ios/{{APP_NAME}}/Shared/Components/molecules/EmptyState.swift +132 -0
  232. package/templates/ios/ios/{{APP_NAME}}/Shared/Components/molecules/LabeledInfoRow.swift +29 -0
  233. package/templates/ios/ios/{{APP_NAME}}/Shared/Components/styles/CaptionLabelStyle.swift +45 -0
  234. package/templates/ios/ios/{{APP_NAME}}/Shared/Components/styles/Copyable.swift +32 -0
  235. package/templates/ios/ios/{{APP_NAME}}/Shared/Components/styles/EdgeToEdgeSeparator.swift +14 -0
  236. package/templates/ios/ios/{{APP_NAME}}/Shared/Components/styles/RowLabelStyle.swift +58 -0
  237. package/templates/ios/ios/{{APP_NAME}}/Shared/CursorPage.swift +26 -0
  238. package/templates/ios/ios/{{APP_NAME}}/Shared/DataChanges.swift +19 -0
  239. package/templates/ios/ios/{{APP_NAME}}/Shared/ErrorPresenter.swift +62 -0
  240. package/templates/ios/ios/{{APP_NAME}}/Shared/ErrorReporting.swift +35 -0
  241. package/templates/ios/ios/{{APP_NAME}}/Shared/FeedbackReporting.swift +42 -0
  242. package/templates/ios/ios/{{APP_NAME}}/Shared/FirebaseTokenInterceptor.swift +49 -0
  243. package/templates/ios/ios/{{APP_NAME}}/Shared/GraphQLClient.swift +54 -0
  244. package/templates/ios/ios/{{APP_NAME}}/Shared/GraphQLHelpers.swift +128 -0
  245. package/templates/ios/ios/{{APP_NAME}}/Shared/GraphQLLoggingInterceptor.swift +28 -0
  246. package/templates/ios/ios/{{APP_NAME}}/Shared/LoadGate.swift +30 -0
  247. package/templates/ios/ios/{{APP_NAME}}/Shared/Secrets.swift.example +9 -0
  248. package/templates/ios/ios/{{APP_NAME}}/Shared/Services/AppSupportGate.swift +50 -0
  249. package/templates/ios/ios/{{APP_NAME}}/Shared/Shell/StartupLoadingView.swift +23 -0
  250. package/templates/ios/ios/{{APP_NAME}}/Shared/Shell/UpdateRequiredView.swift +24 -0
  251. package/templates/ios/ios/{{APP_NAME}}/Shared/Showcase.swift +13 -0
  252. package/templates/ios/ios/{{APP_NAME}}/Shared/SnapshotCache.swift +78 -0
  253. package/templates/ios/ios/{{APP_NAME}}/Shared/UITestEnvironment.swift +70 -0
  254. package/templates/ios/ios/{{APP_NAME}}/{{APP_NAME}}.entitlements +10 -0
  255. package/templates/ios/ios/{{APP_NAME}}Tests/Onboarding/OnboardingGateTests.swift +38 -0
  256. package/templates/ios/ios/{{APP_NAME}}Tests/Settings/SettingsViewModelTests.swift +63 -0
  257. package/templates/ios/ios/{{APP_NAME}}Tests/Support/TestDoubles.swift +35 -0
  258. package/templates/ios/package.merge.json +10 -0
  259. package/templates/ios/scripts/apollo-codegen.ts +31 -0
  260. package/templates/ios/scripts/check-ios-components.ts +72 -0
  261. package/templates/ios/scripts/check-strings.ts +103 -0
  262. package/templates/ios/scripts/install-device.sh +29 -0
  263. package/templates/ios/scripts/testflight.ts +213 -0
@@ -0,0 +1,78 @@
1
+ import Foundation
2
+
3
+ /// What a screen showed last time, kept on disk so a relaunch opens on it
4
+ /// instead of on a loader. The screens it is used for change
5
+ /// slowly: the snapshot written last time is almost always what the server
6
+ /// is about to send, and a screen one session old is worth far more than an
7
+ /// empty one. The snapshot draws first, the server's answer replaces it a
8
+ /// moment later, under a spinner at the top of the screen.
9
+ ///
10
+ /// Every snapshot lives in one folder of the caches directory, which the
11
+ /// system may reclaim at will — that is what a cache is for, and losing it
12
+ /// costs one loader. The screen decides what goes in: only what it *opens*
13
+ /// on, never a filtered state the user asked for.
14
+ struct SnapshotCache<Value: Codable & Sendable>: Sendable {
15
+ /// The file's name, one per screen.
16
+ private let name: String
17
+ /// Bump whenever `Value` changes shape. An older file is then ignored
18
+ /// instead of decoded into something that no longer means the same thing.
19
+ private let version: Int
20
+
21
+ init(_ name: String, version: Int) {
22
+ self.name = name
23
+ self.version = version
24
+ }
25
+
26
+ private var file: URL {
27
+ SnapshotCaches.folder.appending(path: "\(name).json")
28
+ }
29
+
30
+ /// The last snapshot, or `nil` when there is no usable file: a first
31
+ /// launch, a cache the system reclaimed, or a file written by an older shape.
32
+ func read() -> Value? {
33
+ #if DEBUG
34
+ // The captures draw the showcase alone, never a session left on the
35
+ // simulator.
36
+ if Showcase.isOn { return nil }
37
+ #endif
38
+ guard let data = try? Data(contentsOf: file),
39
+ let stored = try? JSONDecoder().decode(Stored.self, from: data),
40
+ stored.version == version
41
+ else { return nil }
42
+ return stored.value
43
+ }
44
+
45
+ /// Overwrite the snapshot with what is on screen. A failure is swallowed
46
+ /// on purpose: a snapshot that cannot be written costs a loader on the
47
+ /// next launch, nothing more.
48
+ func write(_ value: Value) {
49
+ #if DEBUG
50
+ if Showcase.isOn { return }
51
+ #endif
52
+ guard let data = try? JSONEncoder().encode(Stored(version: version, value: value))
53
+ else { return }
54
+ try? FileManager.default.createDirectory(
55
+ at: SnapshotCaches.folder, withIntermediateDirectories: true
56
+ )
57
+ try? data.write(to: file, options: .atomic)
58
+ }
59
+
60
+ private struct Stored: Codable {
61
+ let version: Int
62
+ let value: Value
63
+ }
64
+ }
65
+
66
+ /// Forget every screen's snapshot. Called when the session ends: whoever opens
67
+ /// the app next must not read the previous account's data before the
68
+ /// server has said a word.
69
+ enum SnapshotCaches {
70
+ /// The one folder every snapshot lives in, so the session's end is a single delete.
71
+ static var folder: URL {
72
+ URL.cachesDirectory.appending(path: "snapshots", directoryHint: .isDirectory)
73
+ }
74
+
75
+ static func clear() {
76
+ try? FileManager.default.removeItem(at: folder)
77
+ }
78
+ }
@@ -0,0 +1,70 @@
1
+ #if DEBUG
2
+ import FirebaseAuth
3
+ import Foundation
4
+
5
+ /// Signs the app in without Sign in with Apple, so the UI tests can reach the
6
+ /// screens behind the login wall. Apple's sheet is a system process XCUITest
7
+ /// cannot drive, so an end-to-end run points Firebase Auth at the local
8
+ /// emulator instead and creates its own account there.
9
+ ///
10
+ /// Debug-only by construction: the whole file is compiled out of the Release
11
+ /// configuration the App Store archive is built with.
12
+ ///
13
+ /// Driven by two launch arguments, both required for anything to happen:
14
+ /// -uiTestAuthEmulator 127.0.0.1:9099
15
+ /// -uiTestAccount e2e-<uuid>@{{APP_SLUG}}.test
16
+ ///
17
+ /// The server URL rides the plain `-serverURL` argument, which `APIClient`
18
+ /// already reads through `UserDefaults`.
19
+ enum UITestEnvironment {
20
+ /// Any password satisfies the emulator; it verifies nothing.
21
+ private static let password = "e2e-password"
22
+
23
+ /// Called from `App.init()` right after `FirebaseApp.configure()`,
24
+ /// before anything touches `Auth.auth()`.
25
+ static func bootstrapIfNeeded() {
26
+ guard
27
+ let emulator = value(for: "-uiTestAuthEmulator"),
28
+ let email = value(for: "-uiTestAccount"),
29
+ let (host, port) = split(emulator)
30
+ else { return }
31
+
32
+ Auth.auth().useEmulator(withHost: host, port: port)
33
+ // A previous run leaves its session in the simulator's keychain. Signing
34
+ // out first means the app never shows the old account's library while the
35
+ // new sign-in is still in flight.
36
+ try? Auth.auth().signOut()
37
+
38
+ Task {
39
+ do {
40
+ // The emulator starts empty on every run, so the account is
41
+ // created here. Falling back to a sign-in covers the app being
42
+ // re-launched inside one test, where the account already exists.
43
+ do {
44
+ _ = try await Auth.auth().createUser(withEmail: email, password: password)
45
+ } catch {
46
+ _ = try await Auth.auth().signIn(withEmail: email, password: password)
47
+ }
48
+ } catch {
49
+ // No XCTFail available here: the test fails on its own when the
50
+ // login screen never goes away, and this line says why.
51
+ print("[UITestEnvironment] sign-in failed for \(email): \(error)")
52
+ }
53
+ }
54
+ }
55
+
56
+ /// Reads the value following `name` in the launch arguments.
57
+ private static func value(for name: String) -> String? {
58
+ let arguments = ProcessInfo.processInfo.arguments
59
+ guard let index = arguments.firstIndex(of: name), index + 1 < arguments.count
60
+ else { return nil }
61
+ return arguments[index + 1]
62
+ }
63
+
64
+ private static func split(_ hostPort: String) -> (String, Int)? {
65
+ let parts = hostPort.split(separator: ":")
66
+ guard parts.count == 2, let port = Int(parts[1]) else { return nil }
67
+ return (String(parts[0]), port)
68
+ }
69
+ }
70
+ #endif
@@ -0,0 +1,10 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
+ <plist version="1.0">
4
+ <dict>
5
+ <key>com.apple.developer.applesignin</key>
6
+ <array>
7
+ <string>Default</string>
8
+ </array>
9
+ </dict>
10
+ </plist>
@@ -0,0 +1,38 @@
1
+ import Foundation
2
+ import Testing
3
+ @testable import {{APP_NAME}}
4
+
5
+ @MainActor
6
+ struct OnboardingGateTests {
7
+ @Test func aFreshAccountIsSentToOnboarding() async {
8
+ var client = OnboardingClient.preview
9
+ client.launch = { LaunchState(firstName: nil, onboardingCompleted: false) }
10
+ let gate = OnboardingGate(client: client)
11
+
12
+ await gate.refresh()
13
+
14
+ #expect(gate.state == .required)
15
+ }
16
+
17
+ @Test func anOnboardedAccountOpensTheAppWithItsName() async {
18
+ let gate = OnboardingGate(client: .preview)
19
+
20
+ await gate.refresh()
21
+
22
+ #expect(gate.state == .ready)
23
+ #expect(gate.firstName == "Ada")
24
+ }
25
+
26
+ @Test func aFailedLaunchIsSaidAndCanBeRetried() async {
27
+ var client = OnboardingClient.preview
28
+ client.launch = { throw Boom() }
29
+ let gate = OnboardingGate(client: client)
30
+
31
+ await gate.refresh()
32
+
33
+ guard case .failed = gate.state else {
34
+ Issue.record("expected a failure, got \(gate.state)")
35
+ return
36
+ }
37
+ }
38
+ }
@@ -0,0 +1,63 @@
1
+ import Foundation
2
+ import Testing
3
+ @testable import {{APP_NAME}}
4
+
5
+ @MainActor
6
+ struct ProfileSettingsViewModelTests {
7
+ @Test func deletionEndsTheSession() async {
8
+ let ended = Flag()
9
+ let viewModel = ProfileSettingsViewModel(client: .preview)
10
+
11
+ await viewModel.deleteAccount { ended.isOn = true }
12
+
13
+ #expect(ended.isOn)
14
+ #expect(viewModel.errorMessage == nil)
15
+ }
16
+
17
+ @Test func lostAnswerOverAGoneAccountSaysNothing() async {
18
+ var client = SettingsClient.preview
19
+ client.deleteAccount = { throw Boom() }
20
+ client.accountIsGone = { true }
21
+ let viewModel = ProfileSettingsViewModel(client: client)
22
+
23
+ await viewModel.deleteAccount {}
24
+
25
+ #expect(viewModel.errorMessage == nil)
26
+ }
27
+
28
+ @Test func refusedDeletionSaysSoAndStops() async {
29
+ let ended = Flag()
30
+ var client = SettingsClient.preview
31
+ client.deleteAccount = { throw Boom() }
32
+ let viewModel = ProfileSettingsViewModel(client: client)
33
+
34
+ await viewModel.deleteAccount { ended.isOn = true }
35
+
36
+ #expect(ended.isOn == false)
37
+ #expect(viewModel.errorMessage != nil)
38
+ #expect(viewModel.isDeletingAccount == false)
39
+ }
40
+ }
41
+
42
+ @MainActor
43
+ struct ChangelogViewModelTests {
44
+ @Test func loadShowsTheEntries() async {
45
+ let viewModel = ChangelogViewModel(client: .preview)
46
+
47
+ await viewModel.load()
48
+
49
+ #expect(viewModel.entries.map(\.version) == ["1.1", "1.0"])
50
+ #expect(viewModel.isLoading == false)
51
+ }
52
+
53
+ @Test func failureIsSaid() async {
54
+ var client = SettingsClient.preview
55
+ client.changelog = { throw Boom() }
56
+ let viewModel = ChangelogViewModel(client: client)
57
+
58
+ await viewModel.load()
59
+
60
+ #expect(viewModel.entries.isEmpty)
61
+ #expect(viewModel.errorMessage != nil)
62
+ }
63
+ }
@@ -0,0 +1,35 @@
1
+ import Foundation
2
+
3
+ /// An error no client really throws, for a call that fails.
4
+ struct Boom: Error {}
5
+
6
+ /// A switch a test flips between two calls of the same client.
7
+ final class Flag: @unchecked Sendable {
8
+ private let lock = NSLock()
9
+ private var value = false
10
+
11
+ var isOn: Bool {
12
+ get { lock.withLock { value } }
13
+ set { lock.withLock { value = newValue } }
14
+ }
15
+ }
16
+
17
+ /// What a client was asked, in order.
18
+ final class Calls: @unchecked Sendable {
19
+ private let lock = NSLock()
20
+ private var items: [String] = []
21
+
22
+ func record(_ call: String) { lock.withLock { items.append(call) } }
23
+ var all: [String] { lock.withLock { items } }
24
+ }
25
+
26
+ /// Waits until `condition` holds, or `timeout` runs out: a state a timer or a
27
+ /// debounce brings about is checked once it is there, not after a fixed sleep a
28
+ /// slow CI machine overshoots.
29
+ @MainActor
30
+ func eventually(within timeout: Duration = .seconds(5), _ condition: () -> Bool) async {
31
+ let deadline = ContinuousClock.now + timeout
32
+ while !condition(), ContinuousClock.now < deadline {
33
+ try? await Task.sleep(for: .milliseconds(10))
34
+ }
35
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "scripts": {
3
+ "ios:project": "cd ios && xcodegen generate",
4
+ "ios:codegen": "bun scripts/apollo-codegen.ts",
5
+ "ios:build": "bun run ios:project && bun run ios:codegen && DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer xcodebuild -project ios/{{APP_NAME}}.xcodeproj -scheme {{APP_NAME}} -destination 'generic/platform=iOS Simulator' -derivedDataPath build/derived SWIFT_ENABLE_EXPLICIT_MODULES=NO build",
6
+ "ios:test": "DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer xcodebuild -project ios/{{APP_NAME}}.xcodeproj -scheme {{APP_NAME}} -destination \"platform=iOS Simulator,name=${IOS_SIMULATOR:-iPhone 17}\" -derivedDataPath build/derived SWIFT_ENABLE_EXPLICIT_MODULES=NO test",
7
+ "ios:strings": "DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer bun scripts/check-strings.ts build/derived",
8
+ "ios:components": "bun scripts/check-ios-components.ts"
9
+ }
10
+ }
@@ -0,0 +1,31 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Generates the app's GraphQL types from the committed schema, with the CLI the
4
+ * resolved apollo-ios package ships (never a global install: its version must
5
+ * match the library's). Resolves the packages first when they are not there.
6
+ *
7
+ * bun scripts/apollo-codegen.ts
8
+ */
9
+ import { existsSync } from 'node:fs'
10
+ import { join } from 'node:path'
11
+ import { $ } from 'bun'
12
+
13
+ const root = join(import.meta.dir, '..')
14
+ const derived = join(root, 'build/derived')
15
+ const cliDir = join(derived, 'SourcePackages/checkouts/apollo-ios/CLI')
16
+ const env = { ...process.env, DEVELOPER_DIR: '/Applications/Xcode.app/Contents/Developer' }
17
+
18
+ if (!existsSync(join(cliDir, 'apollo-ios-cli.tar.gz'))) {
19
+ await $`xcodebuild -resolvePackageDependencies -project ios/{{APP_NAME}}.xcodeproj -scheme {{APP_NAME}} -derivedDataPath ${derived}`
20
+ .cwd(root)
21
+ .env(env)
22
+ .quiet()
23
+ }
24
+ if (!existsSync(join(cliDir, 'apollo-ios-cli'))) {
25
+ await $`tar -xzf apollo-ios-cli.tar.gz`.cwd(cliDir)
26
+ }
27
+ await $`${join(cliDir, 'apollo-ios-cli')} generate`.cwd(join(root, 'ios')).env(env)
28
+ // A fresh checkout sometimes skips the namespace file on the first run.
29
+ const namespace = join(root, 'ios/{{APP_NAME}}/Generated/GraphQL/{{APP_NAME}}GraphQL.graphql.swift')
30
+ if (!existsSync(namespace)) await $`${join(cliDir, 'apollo-ios-cli')} generate`.cwd(join(root, 'ios')).env(env)
31
+ console.log('GraphQL types generated in ios/{{APP_NAME}}/Generated/GraphQL')
@@ -0,0 +1,72 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Keeps the iOS components pure, as the stack rule 10-ios.md asks: everything under a
4
+ * `components/` folder, and under `Shared/Components`, draws
5
+ * what it is given and asks nothing of the server.
6
+ *
7
+ * A component fails the run when it
8
+ * - calls an API enum (`ItemAPI.add`…) or a client's `.live`;
9
+ * - reads a singleton (`Something.shared`…), the system's own
10
+ * (`UIApplication.shared`, `UIPasteboard.general`) aside;
11
+ * - listens to `NotificationCenter`;
12
+ * - holds a ViewModel;
13
+ * - declares a view and no `#Preview`.
14
+ *
15
+ * Connected code — Screens, ViewModels, clients — lives beside the
16
+ * `components/` folder and is not checked.
17
+ *
18
+ * bun scripts/check-ios-components.ts
19
+ */
20
+ import { readFile } from 'node:fs/promises'
21
+ import { join, relative } from 'node:path'
22
+ import { Glob } from 'bun'
23
+
24
+ const root = join(import.meta.dir, '../ios/{{APP_NAME}}')
25
+
26
+ const RULES: { pattern: RegExp; says: string }[] = [
27
+ {
28
+ pattern: /\b[A-Z][A-Za-z]*API\.[a-z]/,
29
+ says: 'calls an API: hand it the value or an action instead',
30
+ },
31
+ { pattern: /\b[A-Z][A-Za-z]*Client\.live\b/, says: 'reaches a live client: the Screen owns it' },
32
+ {
33
+ pattern: /\b(?!UIApplication\b|UIPasteboard\b)[A-Z][A-Za-z]*\.shared\b/,
34
+ says: 'reads a singleton: pass the value down, or inject it through @Environment',
35
+ },
36
+ { pattern: /\bNotificationCenter\b/, says: 'listens to change notices: only Screens do' },
37
+ {
38
+ pattern: /\b[A-Z][A-Za-z]*ViewModel\b/,
39
+ says: 'holds a ViewModel: take its values and actions',
40
+ },
41
+ ]
42
+
43
+ const files = [
44
+ ...new Glob('Features/*/components/**/*.swift').scanSync(root),
45
+ ...new Glob('Shared/Components/**/*.swift').scanSync(root),
46
+ ].sort()
47
+
48
+ const failures: string[] = []
49
+ for (const file of files) {
50
+ const source = await readFile(join(root, file), 'utf8')
51
+ // Comments say what the code once did; only the code is checked.
52
+ const code = source
53
+ .split('\n')
54
+ .map((line) => line.replace(/\/\/.*$/, ''))
55
+ .join('\n')
56
+ code.split('\n').forEach((line, index) => {
57
+ for (const rule of RULES) {
58
+ if (rule.pattern.test(line)) failures.push(`${file}:${index + 1}: ${rule.says}`)
59
+ }
60
+ })
61
+ const declaresView = /\bstruct\s+\w+(<[^>]*>)?\s*:\s*[^{]*\bView\b/.test(code)
62
+ if (declaresView && !code.includes('#Preview')) {
63
+ failures.push(`${file}: declares a view and no #Preview`)
64
+ }
65
+ }
66
+
67
+ if (failures.length > 0) {
68
+ console.error(`${failures.length} component rule(s) broken (the stack rule 10-ios.md):`)
69
+ for (const failure of failures) console.error(` ${relative(process.cwd(), join(root, failure))}`)
70
+ process.exit(1)
71
+ }
72
+ console.log(`${files.length} components, all pure and previewed.`)
@@ -0,0 +1,103 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Keeps the iOS string catalogues whole: every string the app shows has its
4
+ * French and its English.
5
+ *
6
+ * The strings are written in French in the Swift code, and the catalogues say
7
+ * so (`sourceLanguage: fr`). English is the app's development language all the
8
+ * same, so that a user whose iPhone speaks neither gets English, as the
9
+ * server answers them. The price of that: a string missing from the French
10
+ * table falls back to the English one. So each string carries an explicit
11
+ * French entry, copied from its key, which this script writes; a string with
12
+ * no English is reported, and fails the run.
13
+ *
14
+ * Reads what the compiler extracted from the last build (`.stringsdata`, under
15
+ * the derived data given), so a string added to the code and absent from the
16
+ * catalogue is caught too.
17
+ *
18
+ * bun scripts/check-strings.ts <derived-data> # check, as CI does
19
+ * bun scripts/check-strings.ts <derived-data> --write # sync the catalogues in place
20
+ */
21
+ import { copyFile, mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises'
22
+ import { tmpdir } from 'node:os'
23
+ import { basename, dirname, join } from 'node:path'
24
+ import { $, Glob } from 'bun'
25
+
26
+ const ios = join(import.meta.dir, '../ios')
27
+
28
+ /** Each catalogue, and the build products its strings come from. */
29
+ const CATALOGUES = [
30
+ { path: join(ios, '{{APP_NAME}}/Localizable.xcstrings'), product: '{{APP_NAME}}.build' },
31
+ ]
32
+
33
+ type Unit = { stringUnit?: { state: string; value: string }; variations?: unknown }
34
+ type Entry = {
35
+ extractionState?: string
36
+ shouldTranslate?: boolean
37
+ localizations?: Record<string, Unit>
38
+ }
39
+ type Catalogue = { sourceLanguage: string; strings: Record<string, Entry>; version: string }
40
+
41
+ const [derivedData, ...flags] = process.argv.slice(2)
42
+ if (!derivedData) throw new Error('Usage: bun scripts/check-strings.ts <derived-data> [--write]')
43
+ const write = flags.includes('--write')
44
+
45
+ /** Xcode's own layout: two spaces, ` : ` between a key and its value. */
46
+ const serialize = (catalogue: Catalogue) =>
47
+ `${JSON.stringify(catalogue, Object.keys(flatten(catalogue)).sort(), 2).replaceAll('": ', '" : ')}\n`
48
+
49
+ /** Every key at any depth, for `JSON.stringify` to write them sorted. */
50
+ function flatten(value: unknown, keys: Record<string, true> = {}): Record<string, true> {
51
+ if (value && typeof value === 'object')
52
+ for (const [key, inner] of Object.entries(value)) {
53
+ keys[key] = true
54
+ flatten(inner, keys)
55
+ }
56
+ return keys
57
+ }
58
+
59
+ const work = await mkdtemp(join(tmpdir(), '{{APP_SLUG}}-strings-'))
60
+ const missing: string[] = []
61
+
62
+ for (const { path, product } of CATALOGUES) {
63
+ // Under the configuration's folder: the project's own folder is named
64
+ // {{APP_NAME}}.build too, and holds the extension's strings.
65
+ const extracted = await Array.fromAsync(
66
+ new Glob(`**/*-iphone*/${product}/**/*.stringsdata`).scan({ cwd: derivedData, absolute: true }),
67
+ )
68
+ if (extracted.length === 0)
69
+ throw new Error(`No ${product} strings under ${derivedData}: build the app first`)
70
+
71
+ // The sync runs on a copy: a check must not touch the working tree. The copy
72
+ // keeps the catalogue's name, which is the table its strings are filed under.
73
+ const synced = join(work, basename(product, '.build'), basename(path))
74
+ await mkdir(dirname(synced), { recursive: true })
75
+ await copyFile(path, synced)
76
+ await $`xcrun xcstringstool sync ${synced} --stringsdata ${extracted}`.quiet()
77
+
78
+ const catalogue: Catalogue = JSON.parse(await readFile(synced, 'utf8'))
79
+ for (const [key, entry] of Object.entries(catalogue.strings)) {
80
+ if (entry.extractionState === 'stale') {
81
+ delete catalogue.strings[key]
82
+ continue
83
+ }
84
+ entry.localizations ??= {}
85
+ const localizations = entry.localizations
86
+ localizations.fr ??= { stringUnit: { state: 'translated', value: key } }
87
+ if (!localizations.en) missing.push(`${basename(product, '.build')}: ${JSON.stringify(key)}`)
88
+ }
89
+
90
+ const before = await readFile(path, 'utf8')
91
+ const after = serialize(catalogue)
92
+ if (before === after) continue
93
+ if (write) {
94
+ await writeFile(path, after)
95
+ console.log(`${basename(product, '.build')}: catalogue synced`)
96
+ } else missing.push(`${basename(product, '.build')}: catalogue out of date, run with --write`)
97
+ }
98
+
99
+ if (missing.length > 0) {
100
+ console.error(`Strings without their English:\n${missing.map((line) => ` ${line}`).join('\n')}`)
101
+ process.exit(1)
102
+ }
103
+ console.log('Every string has its French and its English.')
@@ -0,0 +1,29 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # Build, install and launch the app on the physical iPhone named in
4
+ # project-context.md, whose CoreDevice id is IOS_DEVICE_ID (in .env or the shell).
5
+ # On request only: see the stack rule 10-ios.md, "Physical device".
6
+ set -euo pipefail
7
+ cd "$(dirname "$0")/.."
8
+ [ -f .env ] && set -a && . ./.env && set +a
9
+ : "${IOS_DEVICE_ID:?IOS_DEVICE_ID is unset: the CoreDevice id from xcrun devicectl list devices}"
10
+ export DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer
11
+
12
+ bun run ios:project >/dev/null
13
+ bun run ios:codegen
14
+
15
+ echo "==> Building for device ${IOS_DEVICE_ID}"
16
+ # -allowProvisioningUpdates lets automatic signing register the device and mint the
17
+ # profile on first run.
18
+ xcodebuild -project ios/{{APP_NAME}}.xcodeproj -scheme {{APP_NAME}} \
19
+ -destination "platform=iOS,id=${IOS_DEVICE_ID}" \
20
+ -derivedDataPath build/device \
21
+ -allowProvisioningUpdates \
22
+ build
23
+
24
+ APP="build/device/Build/Products/Debug-iphoneos/{{APP_NAME}}.app"
25
+ echo "==> Installing ${APP}"
26
+ xcrun devicectl device install app "$APP" --device "$IOS_DEVICE_ID"
27
+ BUNDLE_ID=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "$APP/Info.plist")
28
+ echo "==> Launching ${BUNDLE_ID}"
29
+ xcrun devicectl device process launch --device "$IOS_DEVICE_ID" "$BUNDLE_ID"