@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,206 @@
1
+ # iOS
2
+
3
+ SwiftUI, Swift 6 strict concurrency, Apollo iOS for typed operations generated into
4
+ `Generated/GraphQL/` (never edited), Firebase Auth with Sign in with Apple, Sentry. Xcode file
5
+ system synchronised groups: a new file is picked up without touching the project. Long form in the
6
+ `ios-feature` skill.
7
+
8
+ ## The one rule
9
+
10
+ **A view is either pure or connected, and its name says which.**
11
+
12
+ - **Pure**: receives values and actions, draws them. No API call, no fetching `.task`, no
13
+ `.shared` singleton, no notification centre. Renders in a `#Preview` from fixtures alone.
14
+ Everything under `components/` and `Shared/Components/` is pure, and
15
+ `bun run ios:components` fails the build when it is not.
16
+ - **Connected**: the `{Feature}Screen` at the root of a feature. It owns the ViewModel, the
17
+ tasks, the change notices, the navigation and the sheets, and hands values and actions to its
18
+ `Page`. It draws nothing of its own.
19
+
20
+ ## Layout
21
+
22
+ ```
23
+ Shared/Components/{atoms,molecules,organisms,styles}/ design system, pure
24
+ Shared/Platform/ UIKit wrappers: camera, mail composer, web view
25
+ Shared/Shell/ the app around the tabs: launch curtain, update-required screen
26
+ Features/{Domain}/
27
+ {Feature}Screen.swift connected
28
+ {Feature}ViewModel.swift state and actions, init(client:)
29
+ {Domain}Client.swift struct of async closures, .live and .preview
30
+ {Domain}API.swift GraphQL operations to app models, the only file that sees Apollo types
31
+ {Domain}Models.swift Sendable app models
32
+ {Domain}Fixtures.swift preview and test data
33
+ components/{organisms,pages}/
34
+ ```
35
+
36
+ - Domains follow the business, not the tabs. A component another domain needs is imported, not
37
+ copied.
38
+ - Atoms and molecules live only in `Shared/`; a domain holds organisms and pages. A component
39
+ small enough to be an atom carries no business meaning.
40
+ - Naming carries the category: `Screen` connected, `Page` pure whole screen, `Section`, `Card`,
41
+ `Strip`, `Row`, `Tile` for organisms and molecules. A file named `…View` says nothing; new
42
+ files never use it, except the UIKit wrappers named after what they wrap.
43
+ - One component per file, named after it. A private helper only that file uses may stay.
44
+
45
+ ## Screen, page, action
46
+
47
+ - **One `onAction` per page, over an `enum`.** The screen's `switch` is exhaustive: an action
48
+ added to the page and forgotten in the screen does not compile. A page with a dozen optional
49
+ closures compiles with one left unwired. An organism with one or two actions takes plain
50
+ closures.
51
+ - An action the page waits on is `async`, awaited through `AsyncButton` / `AsyncToolbarButton`,
52
+ which draw the progress and disable the control. A page whose actions only open or switch takes
53
+ a plain closure.
54
+ - Navigation state (which sheet, which destination) lives in the screen; the page only says what
55
+ was tapped.
56
+ - Leaves take primitives (`String`, `Int?`, `Date?`, `Bool`, a logic-free enum, closures), never a
57
+ domain struct; an organism may take a domain model and is the mapping boundary. Beyond about
58
+ five parameters, a nested `Item` struct.
59
+
60
+ ## ViewModel and client
61
+
62
+ - The client is a **struct of `@Sendable` closures** with `.live` and `.preview`, not a protocol:
63
+ a test builds the client it needs inline, overriding one closure.
64
+ - The ViewModel is `@MainActor @Observable`, state `private(set)`, errors through the shared
65
+ `reportError()`, never a generated type in sight.
66
+ - Business logic leaves the views: "look it up, then add it" exists once, as a client call or a
67
+ domain function; formatting a line is a pure function on the model.
68
+ - No singleton read in a component: app services are read by the screen and passed down, or
69
+ injected through the environment.
70
+
71
+ ## Loading and failures
72
+
73
+ Every network call shows that it runs, except a refresh over content already on screen. A list
74
+ with nothing yet shows a `ProgressView` in its body, never full screen over the chrome. A button
75
+ that triggers a mutation shows its progress and is disabled while it runs.
76
+
77
+ - Over content already on screen, a failure says nothing: the rows stay. Replacing a good list
78
+ with an empty one reads as data loss. The error still goes to Sentry.
79
+ - Over an empty screen, `EmptyState.failure(title, message, retry)`.
80
+ - Every screen that loads is pullable: refresh is the retry.
81
+ - A cancellation is not a failure: check it and return.
82
+
83
+ ## Snapshots
84
+
85
+ **Only for a screen that changes slowly and that the person recognises** (a library, a list of
86
+ series): it keeps what it showed last on disk and reopens on it. Never for a screen whose state
87
+ must be exact at opening (a quota, a subscription, an inventory count, a cart): a stale snapshot
88
+ there misleads. When it applies: read synchronously in the ViewModel's `init`, written after each
89
+ successful load off the main actor, one file per view and filter, **never more than the first
90
+ page**, versioned (bump when the type changes shape; an older file is ignored), cleared at
91
+ sign-out and account deletion. The refresh that follows animates rows into place. Images are
92
+ cached by what they are, not by their signed URL. No normalised client cache beyond this.
93
+
94
+ ## Pagination
95
+
96
+ Long lists load by **cursor, never offset**: an offset shifts under a list that changed between
97
+ two pages. Page size is sized to the screen, not the dataset; the server caps a page. Prefetch
98
+ before the end, well below the page size. A load-more row closes the list while there is more,
99
+ with a retry once a page failed. **A page asked before a reload is never appended to the list that
100
+ replaced it**, and an edit deep in a list never shrinks it. The mechanisms Shiori uses for that
101
+ (generation token, reload keeping depth, patching a named row) are in the `ios-feature` skill as
102
+ a pattern to copy, not a requirement.
103
+
104
+ ## Change notices
105
+
106
+ Every mutation goes through one helper that posts a single data-changed notice once the write
107
+ landed, carrying what changed when it is one row. Only screens listen; a page never subscribes.
108
+ Coming back to a tab asks nothing once the server has answered.
109
+
110
+ ## Previews and tests
111
+
112
+ Every view has a `#Preview`: atoms, molecules, organisms, pages, one per state that matters.
113
+ Fixtures sit beside their type as `static let preview` or in `{Domain}Fixtures.swift`; tests reuse
114
+ them. A view that resists previewing is a design smell: pull the pure part out. Previews check
115
+ how a view looks; unit tests check what the app decides: ViewModels with an inline fake client
116
+ (state after a load, after a failure over rows and over nothing, a stale page dropped, a row
117
+ patched), and pure functions on models. Never a test of a SwiftUI `body`. A cached ViewModel takes
118
+ a no-cache option in tests. The unit test target runs in CI on every push.
119
+
120
+ ## Design conventions
121
+
122
+ - Screens showing the same kind of object compose the same shared parts (the read-only header,
123
+ labelled info rows, the status banner); only known fields are drawn; never a bespoke layout.
124
+ - An upcoming or announced date is orange, an available one green. A destructive button's icon is
125
+ red too (a row label style keeps it accent blue unless coloured).
126
+ - Icons beside a title and a description sit at the **top** of the row, never centred.
127
+ - **No hint text under sections**: no footer explaining a picker. A message that matters in one
128
+ state is a row inside the section, shown only in that state.
129
+ - Toolbar actions are icon-only, titles kept for VoiceOver; cancel on the left, confirm on the
130
+ right.
131
+ - A tappable row answers on its whole width: a `.plain` button whose label carries
132
+ `.contentShape(.rect)`, set in the row view. Never `onTapGesture` in place of a button.
133
+ - Native components, no custom re-skins.
134
+ - **Accessibility**: every icon without text carries a label; every component is checked once
135
+ at the largest Dynamic Type size (text wraps, nothing is clipped, nothing overlaps); colours
136
+ never carry a meaning alone (orange and green dates also say it in words).
137
+
138
+ ## Screen patterns
139
+
140
+ The shapes every app repeats, so they are not re-explained per project. Code in the
141
+ `ios-feature` skill and in the shared component package.
142
+
143
+ **A list row.** Image or icon on the left; the text block **top-aligned** beside it: title, one
144
+ secondary line, at most one tag; a status tag in the top corner when the list is not already
145
+ sectioned by it. The whole row opens the detail page. The one frequent action is a swipe action;
146
+ the context menu mirrors the detail page's menu. A row never carries more than three pieces of
147
+ information below its title: the rest belongs to the detail page.
148
+
149
+ **A detail page (fiche).** Opened from a row as a sheet, or pushed when it is the tab's own
150
+ object. From top to bottom: a header section (image, title, one line under it, the state), the
151
+ facts as labelled rows with an icon (long press copies the value), the long text, the actions
152
+ that belong to the object. **The toolbar**: close (`xmark`) on the left; on the right, the one
153
+ action the person takes over and over as its own icon (a favourite heart, a check), then a `…`
154
+ menu with the rest, edit first, delete last and red. A single fact is edited **inline in its
155
+ row** (tap, type, done), never through a form; the full edit form is one sheet from the menu.
156
+ Only known fields are drawn: an absent fact has no row, not an empty one.
157
+
158
+ **A tab's main screen.** Large title, icon-only actions on the right (add, filter, settings),
159
+ search attached to the list when there is one, sections with plain headers, the empty state
160
+ drawn in the list body with the one action that fills it.
161
+
162
+ **A form or a sheet that asks something.** Cancel (`xmark`) left, confirm (`checkmark`) right as
163
+ an `AsyncToolbarButton`; the first field focused on appear; a picker shows its current value in
164
+ its row; destructive confirmation as a dialog, never a toggle.
165
+
166
+ ## Shared component package
167
+
168
+ The pure atoms, molecules and styles that carry no business meaning live in the shared Swift
169
+ package `PolyformsUI`, versioned by tag and tracked by Renovate like any dependency:
170
+ `AsyncButton`, `AsyncToolbarButton`, `ToolbarIconButton`, `EmptyState`, `LabeledInfoRow`,
171
+ `LoadMoreRow`, `MenuPicker`, `SegmentedBar`, `TagList`, `RowChip`, `WidgetCard`, star ratings,
172
+ the row and caption label styles, `Copyable`, the edge-to-edge separator, the AI glow. Each ships
173
+ with its preview, and the package carries a catalogue app showing every component in every
174
+ state. An app's `Shared/Components/` holds only what is specific to that app; a component that
175
+ turns out generic moves up to the package with a version bump. Organisms (a book row, a cover
176
+ tile) stay in their app: they carry the product.
177
+
178
+ ## Build and capture
179
+
180
+ ```bash
181
+ bun run ios:build
182
+ ```
183
+
184
+ - Never clear the build cache to unblock a broken build; diagnose the cause.
185
+ - A build into a fresh derived data path needs `SWIFT_ENABLE_EXPLICIT_MODULES=NO` when Sentry's
186
+ import fails on CoreTelephony. Use a derived data path unique to the session for screenshot
187
+ builds: parallel sessions share the simulators and `/tmp`.
188
+ - `DEVELOPER_DIR` is required when `xcode-select` points at the Command Line Tools.
189
+ - A fresh worktree lacks the gitignored generated client, secrets file, Firebase plist and
190
+ package resolution: copy them from the main checkout.
191
+ - Apollo codegen is the CLI bundled in the package checkout under derived data, not on PATH; after
192
+ a `.graphql` change, run it before any build or install. Loop until the namespace file exists.
193
+
194
+ ## Physical device
195
+
196
+ The install on the person's phone closes a task that touched the app, once the screenshot of the
197
+ change is approved, never before:
198
+
199
+ - **App only**: install right away, before any push. The phone talks to production, which has
200
+ not changed.
201
+ - **Backend and app**: install after the push, once the Deploy workflow is green. Installing
202
+ earlier ships an app against a server that does not have its changes yet.
203
+
204
+ Clean tree: run the install script in place. Dirty tree with another session's work: build HEAD
205
+ from a worktree. Run the API codegen first after a schema change. Relay the raw build and install
206
+ output; no success claim without it.
@@ -0,0 +1,114 @@
1
+ # Operations
2
+
3
+ ## Deployment goes through CI only
4
+
5
+ Never `terraform apply`, never a backend deploy, never a migration from the local machine. The
6
+ Deploy workflow on a push to `main` is the only path to production: build, `terraform
7
+ apply`, then `POST /admin/migrate`. If production looks stale, `gh run list --workflow deploy.yml`
8
+ first; fix the pipeline, never the machine.
9
+
10
+ ## Migrations
11
+
12
+ Forward-only, sequential, no rollback, in `server/system/migration/migrations/`, registered in
13
+ its index, tracked in a `migration-meta` collection, triggered by `POST /admin/migrate` from the
14
+ Deploy workflow right after `terraform apply`. After a push that adds one, read that step's log
15
+ line (`{"outcome":"migrated","from":7,"to":8,"applied":1}`) instead of telling anyone to run it.
16
+
17
+ Between the push and the end of the deploy, a local server runs new code against unmigrated data:
18
+ errors in that window are expected.
19
+
20
+ **Migrate when** renaming a field, changing its structure, changing enum values, removing stale
21
+ data. **No migration** for a new optional field, a new collection, or changed query logic. Every
22
+ migration has its `.int.test.ts`.
23
+
24
+ ## Indexes
25
+
26
+ Indexes follow queries: when a query changes, add the indexes it needs and drop the dead ones.
27
+ Only the necessary: a sort done in memory after the read needs none. The index file is the source
28
+ of truth read by Terraform, not a console action.
29
+
30
+ ## API evolution
31
+
32
+ The GraphQL schema is not versioned: installed builds keep asking for what they were built
33
+ against, for weeks after a release. So the schema only grows, and what the app stops using leaves
34
+ in two steps.
35
+
36
+ 1. **Deprecate, in the commit where the app stops asking.** The operations stop selecting the
37
+ field or argument; the server marks it with a `deprecationReason` that names the version and
38
+ the date it was deprecated and what replaces it (`"since 1.3 (2026-10-21): use coverSources"`).
39
+ The resolver keeps working. `bun run schema:check` in CI refuses any breaking change that
40
+ was not deprecated first.
41
+ 2. **Remove, once no build on sale asks for it.** `bun run deprecations` finds, for each
42
+ deprecated element, the first build whose operations no longer select it and compares it
43
+ with what the store sells: removable when a build without it has been on sale for **fourteen
44
+ days**, in grace until then, waiting while the store still sells a build that asks for it.
45
+ One commit removes the removable elements (schema, resolver, the code and tests only they
46
+ used) and raises the minimum supported build to the floor the script proposes, which sends
47
+ older builds to the update screen at their next launch. Several releases in one day change
48
+ nothing: the fourteen days count from the release that stopped asking, not from the next one.
49
+
50
+ The removal runs as a **scheduled routine** (monthly by default, see
51
+ [16-routines.md](16-routines.md)), and may be run by hand at a release. The comparison key is the
52
+ build number, never the marketing version. The gate fails open: if the configuration is
53
+ unreachable, the app stays usable. A deploy whose floor is above the build on sale fails: readers
54
+ must never be blocked with no update to install.
55
+
56
+ | Change | Breaks installed builds | What to do |
57
+ |---|---|---|
58
+ | Added field, query, mutation, optional argument, enum value | No | Nothing: an unknown enum value is read as a neutral one by the app |
59
+ | Field or argument the app stops using | No | Deprecate it in the same commit |
60
+ | Removal, rename, type change, argument made required, non-null made nullable | Yes | Deprecate first; the routine removes it fourteen days after a build without it is on sale |
61
+
62
+ ## Scale to zero
63
+
64
+ Never a warm instance (`min_instance_count`), whatever the p95 says. Scaling to zero is the point;
65
+ cold starts are absorbed by the launch screen, and when traffic grows the users keep instances
66
+ warm. A p95 driven by cold starts is a figure to report, not an action item.
67
+
68
+ ## Monitoring and Sentry
69
+
70
+ One Sentry project per side (`<app>-node`, `<app>-ios`), org `polyforms`. Triage of an issue:
71
+
72
+ 1. Load the token from the local environment, never print or commit it.
73
+ 2. Read the issue and its latest event through the API.
74
+ 3. Walk the stack to the domain code, fix the cause, not the symptom.
75
+ 4. For a serious functional bug, write the failing test before the fix.
76
+ 5. Verify locally, commit.
77
+ 6. Mark the issue `resolvedInNextRelease` (never plain `resolved`, so a regression is flagged)
78
+ only after the push **and** a green pipeline.
79
+
80
+ Source maps upload is part of the release workflow, never asked again.
81
+
82
+ ## Backups and recovery
83
+
84
+ The `(default)` database has delete protection and seven-day point-in-time recovery. Until the
85
+ app has paying users, there is no backup outside it; from the first paying user, a weekly
86
+ managed export of the database to a bucket of the project (`gcloud firestore export`, run by
87
+ Cloud Scheduler, 90-day lifecycle on the bucket) is provisioned by Terraform: a deletion found on
88
+ the eighth day is then still recoverable. When data is lost (deleted documents, a failed
89
+ migration, a deleted database or project) follow the project's `docs/data-recovery.md` before
90
+ acting, and never restore the whole database over live writes.
91
+
92
+ ## Secrets
93
+
94
+ A secret lives in the gitignored local `.env`, with `.env.example` beside it, and in the CI's
95
+ secret store. The chain to production: GitHub secret, Terraform variable, Secret Manager, function
96
+ environment. Every place a token is replicated is listed in `project-context.md`; a rotation that
97
+ forgets one is found by an outage. A local API key is distinct from production's; keys are
98
+ compared by fingerprint (`printf '%s' "$k" | shasum`), never displayed.
99
+
100
+ ## Dependencies
101
+
102
+ Renovate, self-hosted in a nightly workflow: `config:recommended`, non-major updates grouped in
103
+ one pull request automerged when CI is green, weekly lockfile maintenance. Every
104
+ `allowedVersions` bound carries its reason in `description` (a peer dependency the deploy's
105
+ stricter package manager rejects, a framework not yet on the new major). A strict install
106
+ replayed on the build artefact catches what the permissive local install let through.
107
+
108
+ **Majors are never automerged.** The upgrade routine ([16-routines.md](16-routines.md)) takes
109
+ every open major pull request each morning: it reads the dependency's changelog and migration
110
+ guide, applies the code changes the new major requires, deploys the branch to the project's
111
+ staging environment ([14-infra.md](14-infra.md#staging)) to prove the build and the deploy, and
112
+ merges when CI and the staging deploy are green; otherwise it leaves the pull request open with a
113
+ comment saying what blocks. The package of this stack follows the same path with its own label,
114
+ `stack`.
package/rules/12-ci.md ADDED
@@ -0,0 +1,32 @@
1
+ # Continuous integration
2
+
3
+ One workflow per concern, so a red run names its cause and a change touches one file. Templates
4
+ in `templates/.github/workflows/`.
5
+
6
+ | Workflow | Trigger | What it does |
7
+ |---|---|---|
8
+ | `test-unit`, `test-int`, `test-feat` | push, pull request | One test family each, run separately on purpose |
9
+ | `schema` | push, pull request | Schema compatibility against `main`: a removal without a prior deprecation fails |
10
+ | `build-ios` | push touching `ios/**` or the schema | Builds the app, runs the unit test target, the strings catalogue check and the pure-components check; on `main`, also produces the signed archive and keeps it as a short-lived artifact |
11
+ | `deploy` | push to `main`; pull request labelled `staging` | Build, `terraform apply`, migrations; to production from the branch, to staging from a labelled pull request; `paths-ignore` for prose |
12
+ | `testflight-ios` | nightly, manual | The nightly: takes the archive `build-ios` produced for the latest green commit of `main` (no second build), uploads it once a night when `main` moved since the last upload, to the internal group, then to the external group through Beta App Review, under the version under development read from the changelog |
13
+ | `release-ios` | tag `ios-v<version>` | Version guard against the changelog, panels upload, submission, source maps |
14
+ | `pages` | push touching `site/` | Publishes the site |
15
+ | `renovate` | nightly, manual | Self-hosted Renovate with its own token |
16
+
17
+ ## Rules
18
+
19
+ - The build number is the run number. The marketing version is read from the project.
20
+ - `paths-ignore` on deploy, schema and the tests for `site/**`, `docs/**`, `screenshots/**`,
21
+ `README.md`, `CLAUDE.md`; never for the served changelogs.
22
+ - A workflow authenticates to Google Cloud through Workload Identity Federation, never a key file.
23
+ - Secrets are repository secrets; configuration that is not secret (a billing table, an analytics
24
+ property) is a repository variable.
25
+ - The iOS archive signs manually with the certificate and profile stored as secrets; the store API
26
+ key uploads and submits.
27
+ - **One build per commit.** The app is compiled once per commit of `main`, by
28
+ `build-ios`, as the archive; the nightly uploads that artifact and runs no macOS job of its
29
+ own. Only the release workflow archives again, on its tag. (Shiori still builds twice today:
30
+ this is a migration of the package.)
31
+ - A failing lint or test in CI is a process failure: it should have been caught before the push
32
+ ([04-push.md](04-push.md)).
package/rules/13-ai.md ADDED
@@ -0,0 +1,71 @@
1
+ # AI and paid calls
2
+
3
+ ## State the cost before building
4
+
5
+ Any feature that calls a paid API states its cost in its design document and in its commit
6
+ message before it is built: how many calls the first run makes, how many per day or month after,
7
+ multiplied out (rows × formats × languages × searches per call) and priced. A background pass is
8
+ sized to what a reader is shown, never to a whole dataset: looking up every winner since 1953 to
9
+ show a dozen books cost 526 calls and 25 euros in four hours.
10
+
11
+ ## Gemini
12
+
13
+ - A grounded call bills about **four searches**, not one; the logged search count undercounts
14
+ them. Searches are billed from the first one on a project that has spent its monthly free
15
+ allowance (5,000 per GCP project).
16
+ - One API key per GCP project, on the project's own prepaid billing account, carried GitHub
17
+ secret → Terraform → Secret Manager. Never a key shared between apps: one app's burst cuts
18
+ every other.
19
+ - A local key, distinct from production's, set for every Claude session on the machine; before
20
+ blaming production for a bill, compare fingerprints.
21
+ - Two limits, two errors: 402 when the prepaid balance is spent, 429 when the monthly cap is
22
+ reached. The logged error says which.
23
+ - The invoice is the truth: read spend from the billing export in BigQuery, grouped by day and
24
+ SKU; the grounded-search SKU is the one that costs. Structured logs (`jsonPayload.message`)
25
+ with the step and the search count let a spike be attributed to a device and an hour.
26
+ - Past the free searches, grounded steps switch to Claude Haiku with web search capped at two
27
+ searches per call; a monthly counter decides.
28
+ - Image generation for store panels: prototype at 1K or 2K, 4K only for a kept scene; the model
29
+ draws dividing lines when asked for several panels in one image.
30
+
31
+ ## Choosing a model
32
+
33
+ The method is the rule; the choices are a dated table, revisited at every major version of this
34
+ package and whenever a provider changes its prices or models.
35
+
36
+ - **A quality test before any change of model**, upgrade included, and before adopting a new
37
+ one: the project keeps a bench script that runs the real task on about thirty real items
38
+ (`scripts/bench-*.ts`), compares the candidates on quality and on cost per item, and the result
39
+ goes into the commit message. A blind test by the person decides when quality is a matter of
40
+ taste (recommendations, copy).
41
+ - **The cheapest model that gives the same quality wins.** At every change, the next cheaper
42
+ model is in the bench; a more expensive one is adopted only when the bench shows the quality
43
+ the cheaper one does not reach.
44
+ - A source that is free (a national library's catalogue) is benched against the paid model
45
+ before the paid model is kept.
46
+
47
+ Choices as of 2026-10-21:
48
+
49
+ | Need | Model | Why |
50
+ |---|---|---|
51
+ | Structured extraction from a photo or a page | Gemini Flash-Lite | Cheapest with vision, good enough for fields |
52
+ | Edition data with web grounding | Gemini grounded, then Claude Haiku + web search past the free tier | Cost of searches |
53
+ | Editorial recommendation from the person's own data | Claude Sonnet | Blind test won against Haiku, Opus and Gemini Pro; about 0.035 euro per generation |
54
+ | Choosing a month's picks, text only | Claude Sonnet | Same |
55
+
56
+ Google Books and Open Library are not edition sources (503 on most calls, wrong ISBNs, no French
57
+ editions); BnF's SRU serves French named books for free.
58
+
59
+ ## A cap in production
60
+
61
+ Estimating before building does not protect from a loop or a burst. Every paid call, whether a
62
+ background pass or a user action triggers it, goes through a counter with a **daily cap per
63
+ project and per step** (Shiori's release watch: 30 a day); past the cap the step logs an `error`
64
+ and stops instead of continuing, and the daily health routine reports the counter. A user-facing
65
+ action is metered per user as well (a monthly quota on the free plan, an anti-abuse ceiling on the
66
+ paid one).
67
+
68
+ ## Keys in the app
69
+
70
+ The app never holds an AI key: every call goes through the backend, which meters it (a free
71
+ monthly quota per user, an anti-abuse ceiling on the paid plan the app never shows).
@@ -0,0 +1,90 @@
1
+ # Infrastructure
2
+
3
+ Terraform in `infra/`, one GCP project per app named `<app>-polyforms`, region `europe-west3`,
4
+ Firestore `eur3`. Templates in `templates/infra/`.
5
+
6
+ | File | What it provisions |
7
+ |---|---|
8
+ | `project.tf`, `versions.tf`, `state.tf`, `backend.tf` | The project, the providers, the remote state bucket |
9
+ | `firebase.tf`, `auth.tf` | The Firebase app, Sign in with Apple |
10
+ | `firestore.tf` | The database with delete protection and point-in-time recovery, the indexes |
11
+ | `function.tf` | The Cloud Function gen 2 (Node 22) serving the Nitro build, scale to zero |
12
+ | `secrets.tf`, `variables.tf` | Secret Manager entries fed by Terraform variables from GitHub secrets |
13
+ | `cicd.tf` | Workload Identity Federation for the GitHub workflows |
14
+ | `scheduler.tf` | Cloud Scheduler jobs calling the backend's scheduled routes |
15
+ | `storage.tf` | Buckets for uploads, signed URLs |
16
+ | `ios.tf` | Resources the app needs (APNs key, bundle configuration) |
17
+ | `observer.tf` | A read-only service account for unattended production reads |
18
+ | `outputs.tf` | What the workflows and the app read back |
19
+
20
+ ## Staging
21
+
22
+ Every app has a second GCP project, `<app>-staging-polyforms`, provisioned by the same Terraform
23
+ (a workspace, same files, smaller quotas), scale to zero so it costs nothing idle. It is the
24
+ production-like environment where anything is proven before production: a pull request labelled
25
+ `staging` (every major dependency bump, every stack upgrade, anything touching infrastructure) is
26
+ deployed there by the Deploy workflow; the end-to-end scenarios and any test that needs a real
27
+ stack run against it; a seeded dataset and its own Sentry environment; the app points at it
28
+ through its server-URL override. Nothing in staging is ever production data.
29
+
30
+ It exists from the moment an app has real users to protect, and in any case before the first
31
+ paid plan: with a handful of readers, production is the staging. Bazar is built with it from the
32
+ start.
33
+
34
+ ## Bootstrap: one line creates a project
35
+
36
+ `bunx @moifort/stack bootstrap <app>` runs once from the person's machine and leaves a project
37
+ that deploys itself. It is a command of the package's CLI (TypeScript on Bun, like the rest), not
38
+ a shell script: it reads the keychain, drives `terraform` (installed once on the machine, see
39
+ [15-tooling.md](15-tooling.md)), `gh` and `git`, and reports. Two Terraform roots, because two
40
+ kinds of credentials:
41
+
42
+ | Root | Runs | Credentials | Creates |
43
+ |---|---|---|---|
44
+ | `infra/org/` | From the machine, at bootstrap and when the organisation-level setup changes | The person's organisation tokens, read from the macOS keychain for the duration of the run | The GitHub repository (settings, Pages, labels, branch rules), its secrets and variables, the Sentry projects (`<app>-node`, `<app>-ios`) with their DSNs written straight into the GitHub secrets, the Renovate installation, the GCP project and the state bucket |
45
+ | `infra/` | By the Deploy workflow, through Workload Identity Federation | None on the machine | Everything in the table above |
46
+
47
+ - **Keychain, not files.** The organisation tokens (GitHub, Sentry, App Store Connect) live in
48
+ the macOS keychain under fixed item names (`polyforms.github`, `polyforms.sentry`,
49
+ `polyforms.asc`), read with `security find-generic-password -w` into the environment of the
50
+ bootstrap process only, never written to a file, never printed, never committed. Each token is
51
+ fine-grained and scoped to the organisation, with an expiry, and its creation date is in the
52
+ person's `project-context.md`.
53
+ - **The CI never holds a personal token.** What a workflow needs at run time is a repository
54
+ secret Terraform wrote (a DSN, a store API key) or a token it mints itself: Renovate runs
55
+ with a GitHub App installed on the organisation (the workflow mints a short-lived token from
56
+ the app's private key, stored once as an organisation secret), not with a personal token
57
+ that expires.
58
+ - **Replayable.** Every step of the bootstrap checks whether it is already done and skips it,
59
+ so a run that failed halfway is simply run again. It never deploys the backend itself: it
60
+ ends by pushing the first commit and letting the Deploy workflow do the first deploy.
61
+ - **What it cannot do, it lists.** The bootstrap ends with a numbered list of the steps that
62
+ need the person: the App Store Connect app record and the Sign in with Apple key (no API),
63
+ the Apple developer agreement, the DNS of the project's domain, the GA4 link when the API
64
+ refuses it. Each item says where to click and what value to paste back.
65
+ - `bunx @moifort/stack teardown <app>` destroys both roots, after a typed confirmation of the
66
+ app's name.
67
+
68
+ ## Rules
69
+
70
+ - The Terraform state is remote and never committed. `terraform.tfvars` is gitignored, with its
71
+ `.example`.
72
+ - Secrets reach the function through Secret Manager only; the workflow never writes a key into
73
+ an environment file.
74
+ - **One observer account for Claude, across every project** of the organisation
75
+ (`claude-observer`, in the neutral `polyforms-billing` project below), granted the read roles
76
+ (logging, monitoring, scheduler, functions, run, Firestore viewer, BigQuery job user and data
77
+ viewer) on each app project by that project's Terraform. Writes answer permission denied,
78
+ checked. It lives in its own `gcloud` configuration (`CLOUDSDK_CONFIG`) so the person's
79
+ account is untouched and routines do not depend on their login, which expires. Its key is on
80
+ the machine only, outside every repository.
81
+ - **A neutral `polyforms-billing` GCP project** holds the billing export dataset and the
82
+ observer account, so that deleting an app never takes the invoice or the reader of every
83
+ other app with it. Each project's runtime account gets `dataViewer` on the dataset through SQL
84
+ `GRANT`, the IAM binding command being allowlisted. (Today the export lives in Vinarium's
85
+ project: moving it is a migration of this package.)
86
+ - Analytics: the Firebase project is linked to a GA4 property at creation, or sessions go
87
+ nowhere; the property id is a repository variable.
88
+ - Never a warm instance, see [11-ops.md](11-ops.md#scale-to-zero).
89
+ - APNs keys and Sign in with Apple keys live in `infra/`, gitignored; they are not the store API
90
+ key.
@@ -0,0 +1,81 @@
1
+ # Tooling on the development machine
2
+
3
+ ## What a machine needs
4
+
5
+ Installed once; the bootstrap checks they are there and names what is missing.
6
+
7
+ | Tool | Install | Used for |
8
+ |---|---|---|
9
+ | Bun | `curl -fsSL https://bun.sh/install \| bash` | Backend, scripts, this package's CLI |
10
+ | Xcode, latest final | Mac App Store | The app; `sudo xcode-select -s /Applications/Xcode.app/Contents/Developer` once, the person's to run |
11
+ | `gh` | `brew install gh`, then `gh auth login` | GitHub: runs, pull requests, secrets |
12
+ | `gcloud` | `brew install --cask google-cloud-sdk` | Production reads through the observer configuration |
13
+ | Terraform | `brew install terraform` | The bootstrap and local plans |
14
+ | `apollo-ios-cli` | Bundled in the app's Swift package checkout, never a global install | GraphQL codegen for the app |
15
+ | JDK and Firebase CLI | Only with the `e2e` stack | The emulators |
16
+
17
+ ## Runtime
18
+
19
+ Always `bun` / `bunx`, never `npm` / `npx`. Biome for formatting and linting: spaces, single
20
+ quotes, no semicolons, line width 100.
21
+
22
+ ## Xcode and simulators
23
+
24
+ - Build with the latest final Xcode. `DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer`
25
+ is required for `xcodebuild`, `simctl` and `xcstringstool` while `xcode-select` points at the
26
+ Command Line Tools; switching it needs the person's password.
27
+ - **Always the simulator runtime already on the machine.** The reference device is the iPhone of
28
+ the runtime `xcrun simctl list runtimes` shows installed; a build or a capture targets that
29
+ one. Never download another runtime or platform without asking: each is several gigabytes.
30
+ When one is needed: only the version matching the installed SDK, from the command line
31
+ (`xcodebuild -downloadPlatform <name>`), never through Xcode's UI, one download at a time
32
+ (`pgrep -f downloadPlatform` first; several block each other on "Preparing to download").
33
+ Progress is the size of `/Library/Developer/CoreSimulator/Volumes`, not the program's messages.
34
+ - A download stuck on "Preparing to download" with no byte transferred is the network first:
35
+ macOS suspends system asset downloads on a connection it considers cellular, an iPhone
36
+ hotspot included, silently. Check the interface and the Wi-Fi name before anything else; the
37
+ daemon is not broken.
38
+ - **One simulator at a time on the machine.** Every session and every project uses the same
39
+ reference device, booted on demand if it is not, and never shut down by a session: the push
40
+ hook shuts it, with Simulator.app. A session installs its own build into it right before each
41
+ launch, from a derived data path unique to the session, so two sessions sharing the device
42
+ never launch each other's build. Two booted simulators is the exception that needs a reason
43
+ (a capture on a second size).
44
+ - `simctl io <udid> screenshot` works when the simulator tool's own capture fails.
45
+
46
+ ## Browser, and doing things end to end
47
+
48
+ Always the built-in browser of the Claude app, never Claude in Chrome, even when asked to "go to
49
+ Chrome". When the person says "go to the browser and click here", Claude goes and clicks: it
50
+ drives the browser, fills the forms, runs the commands, and goes to the end of the task. When a
51
+ page needs a sign-in, it asks the person to sign in there, then continues. Some console actions
52
+ have no API (a price schedule, privacy answers, a subscription submission): they are done in that
53
+ browser, signed in as the person.
54
+
55
+ The only things that wait for a go, said in one message with what will be done and why:
56
+ **reading production data** and **anything that changes production** (a deploy, a console
57
+ setting, a store submission, a purchase). Everything else (code, commands, builds, the person's
58
+ own sites and consoles in read mode, GitHub, CI) is done without asking.
59
+
60
+ ## Git and GitHub
61
+
62
+ - `gh` authenticates with a keyring token; a push changing workflow files may need SSH
63
+ ([04-push.md](04-push.md#workflow-files)).
64
+ - Worktrees for parallel sessions, with the gitignored files copied in
65
+ ([02-commits.md](02-commits.md#several-sessions-on-one-project)).
66
+ - The App Store Connect API key for scripts lives in `~/.appstoreconnect/private_keys/`, shared
67
+ by every app of the team; its id and issuer are in each project's `project-context.md`.
68
+
69
+ ## Remote control
70
+
71
+ Driving a project from the Claude app on a phone is a CLI process per project
72
+ (`claude remote-control --name <app> --permission-mode auto --spawn same-dir`), with its own
73
+ `claude auth login`, relaunched after a reboot. Check it with `pgrep -fl "claude remote-control"`.
74
+
75
+ ## Reading production
76
+
77
+ Unattended reads (logs, metrics, billing, Firestore) go through the **Claude observer account**,
78
+ one for every Polyforms project ([14-infra.md](14-infra.md)), in its own `gcloud` configuration;
79
+ `gcloud auth login` is the person's and expires. In an interactive session, a production read
80
+ is announced and waits for the go; a routine the person configured has its go in its prompt.
81
+ Firestore's REST API needs `Content-Type: application/json` or answers a misleading 400.