@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,213 @@
1
+ /**
2
+ * The App Store Connect calls the iOS upload workflow makes, answered from a Linux
3
+ * runner with the team API key rather than through fastlane.
4
+ *
5
+ * bun scripts/testflight.ts check <marketing> <build>
6
+ * Prints `exists=true|false` and `previous=<build number of the last upload>` as
7
+ * GitHub step outputs. A build number is the commit count, so a build that already
8
+ * exists means this commit is already on App Store Connect and there is nothing to do.
9
+ *
10
+ * bun scripts/testflight.ts notes <marketing> <build> <file>
11
+ * Waits for the uploaded build to show up, sets the file as its TestFlight
12
+ * "What to Test", then waits for processing to finish so the run only turns green once
13
+ * testers can actually install it.
14
+ *
15
+ * bun scripts/testflight.ts share <marketing> <build>
16
+ * Adds the processed build to the external "Public" group and submits it to Beta App
17
+ * Review. An external group only ever sees the builds added to it, one by one; once a
18
+ * version has passed review, Apple usually approves its later builds within minutes.
19
+ * Safe to rerun: a build already submitted is left alone.
20
+ *
21
+ * bun scripts/testflight.ts submitted <marketing>
22
+ * Prints `submitted=true|false` and `state=<App Store version state>` as GitHub step
23
+ * outputs. A version is submitted once it left the states a developer can still edit:
24
+ * waiting for review, in review, pending release, on sale. The release workflow then
25
+ * records the tag without uploading or submitting anything, since the version went to
26
+ * App Review by hand (the first subscriptions can only be attached from the web).
27
+ *
28
+ * Reads ASC_KEY_ID, ASC_ISSUER_ID and ASC_KEY_P8 (the .p8 contents) from the environment.
29
+ */
30
+
31
+ import { createPrivateKey, sign } from 'node:crypto'
32
+ import { readFileSync } from 'node:fs'
33
+
34
+ const BUNDLE_ID = '{{BUNDLE_ID}}'
35
+ // Beta testers get "What to Test" in their own language when it exists, and the app ships
36
+ // in these two. Commit subjects are English, so both carry the same text.
37
+ const LOCALES = ['fr-FR', 'en-US']
38
+ const WHATS_NEW_MAX = 4000
39
+ const EXTERNAL_GROUP = 'Public'
40
+
41
+ const env = (name: string) => {
42
+ const value = process.env[name]
43
+ if (!value) throw new Error(`${name} is unset`)
44
+ return value
45
+ }
46
+
47
+ const apiToken = () => {
48
+ const kid = env('ASC_KEY_ID')
49
+ const encode = (value: object) => Buffer.from(JSON.stringify(value)).toString('base64url')
50
+ const now = Math.floor(Date.now() / 1000)
51
+ const unsigned = `${encode({ alg: 'ES256', kid, typ: 'JWT' })}.${encode({
52
+ iss: env('ASC_ISSUER_ID'),
53
+ iat: now,
54
+ exp: now + 15 * 60,
55
+ aud: 'appstoreconnect-v1',
56
+ })}`
57
+ const signature = sign('sha256', Buffer.from(unsigned), {
58
+ key: createPrivateKey(env('ASC_KEY_P8')),
59
+ dsaEncoding: 'ieee-p1363',
60
+ })
61
+ return `${unsigned}.${signature.toString('base64url')}`
62
+ }
63
+
64
+ type Resource = { id: string; attributes: Record<string, unknown> }
65
+
66
+ // A fresh token per call: `notes` polls for up to an hour, well past a token's lifetime.
67
+ const api = async (method: string, path: string, body?: object) => {
68
+ const response = await fetch(`https://api.appstoreconnect.apple.com${path}`, {
69
+ method,
70
+ headers: { authorization: `Bearer ${apiToken()}`, 'content-type': 'application/json' },
71
+ body: body && JSON.stringify(body),
72
+ })
73
+ if (!response.ok) {
74
+ throw new Error(`${method} ${path} answered ${response.status}: ${await response.text()}`)
75
+ }
76
+ // Linking a relationship (a build to a group) answers 204 with no body at all.
77
+ if (response.status === 204) return { data: [] as Resource[] }
78
+ return (await response.json()) as { data: Resource[] | Resource }
79
+ }
80
+
81
+ const list = async (path: string) => (await api('GET', path)).data as Resource[]
82
+
83
+ const appId = async () => {
84
+ const [app] = await list(`/v1/apps?filter[bundleId]=${BUNDLE_ID}`)
85
+ if (!app) throw new Error(`No App Store Connect app for ${BUNDLE_ID}`)
86
+ return app.id
87
+ }
88
+
89
+ const findBuild = async (app: string, marketing: string, build: string) => {
90
+ const [found] = await list(
91
+ `/v1/builds?filter[app]=${app}&filter[version]=${build}` +
92
+ `&filter[preReleaseVersion.version]=${marketing}&fields[builds]=version,processingState`,
93
+ )
94
+ return found
95
+ }
96
+
97
+ const output = (lines: string[]) => process.stdout.write(`${lines.join('\n')}\n`)
98
+
99
+ const check = async (marketing: string, build: string) => {
100
+ const app = await appId()
101
+ const existing = await findBuild(app, marketing, build)
102
+ const [latest] = await list(
103
+ `/v1/builds?filter[app]=${app}&sort=-uploadedDate&limit=1&fields[builds]=version`,
104
+ )
105
+ output([`exists=${existing !== undefined}`, `previous=${latest?.attributes.version ?? ''}`])
106
+ }
107
+
108
+ // The states App Store Connect still lets a developer edit and submit; anything else means
109
+ // the version is with Apple or on the store, and deliver would be refused on it.
110
+ const EDITABLE_STATES = [
111
+ 'PREPARE_FOR_SUBMISSION',
112
+ 'READY_FOR_REVIEW',
113
+ 'DEVELOPER_REJECTED',
114
+ 'REJECTED',
115
+ 'METADATA_REJECTED',
116
+ 'INVALID_BINARY',
117
+ ]
118
+
119
+ const submitted = async (marketing: string) => {
120
+ const app = await appId()
121
+ const [version] = await list(
122
+ `/v1/apps/${app}/appStoreVersions?filter[platform]=IOS&filter[versionString]=${marketing}` +
123
+ '&fields[appStoreVersions]=appVersionState',
124
+ )
125
+ const state = String(version?.attributes.appVersionState ?? 'NONE')
126
+ const done = version !== undefined && !EDITABLE_STATES.includes(state)
127
+ output([`submitted=${done}`, `state=${state}`])
128
+ }
129
+
130
+ const poll = async <T>(what: string, attempt: () => Promise<T | undefined>) => {
131
+ // App Store Connect usually lists a build within five minutes of the upload and finishes
132
+ // processing it within twenty; an hour means something is wrong on Apple's side.
133
+ for (let tries = 0; tries < 60; tries++) {
134
+ const result = await attempt()
135
+ if (result !== undefined) return result
136
+ process.stdout.write(`Waiting for ${what}…\n`)
137
+ await Bun.sleep(60_000)
138
+ }
139
+ throw new Error(`Gave up waiting for ${what}`)
140
+ }
141
+
142
+ const notes = async (marketing: string, build: string, file: string) => {
143
+ const whatsNew = readFileSync(file, 'utf8').trim().slice(0, WHATS_NEW_MAX)
144
+ const app = await appId()
145
+ const { id } = await poll(`build ${marketing} (${build}) to be listed`, () =>
146
+ findBuild(app, marketing, build),
147
+ )
148
+
149
+ const existing = await list(`/v1/builds/${id}/betaBuildLocalizations`)
150
+ for (const locale of LOCALES) {
151
+ const localization = existing.find((item) => item.attributes.locale === locale)
152
+ if (localization) {
153
+ await api('PATCH', `/v1/betaBuildLocalizations/${localization.id}`, {
154
+ data: { type: 'betaBuildLocalizations', id: localization.id, attributes: { whatsNew } },
155
+ })
156
+ } else {
157
+ await api('POST', '/v1/betaBuildLocalizations', {
158
+ data: {
159
+ type: 'betaBuildLocalizations',
160
+ attributes: { locale, whatsNew },
161
+ relationships: { build: { data: { type: 'builds', id } } },
162
+ },
163
+ })
164
+ }
165
+ }
166
+ process.stdout.write(`What to Test set on ${marketing} (${build}):\n${whatsNew}\n`)
167
+
168
+ const state = await poll(`build ${marketing} (${build}) to finish processing`, async () => {
169
+ const found = await findBuild(app, marketing, build)
170
+ const processing = found?.attributes.processingState
171
+ return processing === 'PROCESSING' ? undefined : processing
172
+ })
173
+ if (state !== 'VALID') throw new Error(`Build ${marketing} (${build}) ended ${state}`)
174
+ process.stdout.write(`Build ${marketing} (${build}) is available in TestFlight\n`)
175
+ }
176
+
177
+ const share = async (marketing: string, build: string) => {
178
+ const app = await appId()
179
+ const found = await findBuild(app, marketing, build)
180
+ if (!found) throw new Error(`No build ${marketing} (${build}) on App Store Connect`)
181
+ const groups = await list(`/v1/apps/${app}/betaGroups?fields[betaGroups]=name`)
182
+ const group = groups.find((item) => item.attributes.name === EXTERNAL_GROUP)
183
+ if (!group) throw new Error(`No TestFlight group named ${EXTERNAL_GROUP}`)
184
+
185
+ await api('POST', `/v1/betaGroups/${group.id}/relationships/builds`, {
186
+ data: [{ type: 'builds', id: found.id }],
187
+ })
188
+ const detail = (await api('GET', `/v1/builds/${found.id}/buildBetaDetail`)).data as Resource
189
+ const state = detail.attributes.externalBuildState
190
+ if (state === 'READY_FOR_BETA_SUBMISSION') {
191
+ await api('POST', '/v1/betaAppReviewSubmissions', {
192
+ data: {
193
+ type: 'betaAppReviewSubmissions',
194
+ relationships: { build: { data: { type: 'builds', id: found.id } } },
195
+ },
196
+ })
197
+ process.stdout.write(`Build ${marketing} (${build}) submitted to Beta App Review\n`)
198
+ } else {
199
+ process.stdout.write(`Build ${marketing} (${build}) is already ${state}\n`)
200
+ }
201
+ }
202
+
203
+ const [command, marketing, build, file] = process.argv.slice(2)
204
+ if (!marketing || (command !== 'submitted' && !build)) {
205
+ throw new Error(
206
+ 'Usage: testflight.ts check|notes|share <marketing> <build> | submitted <marketing>',
207
+ )
208
+ }
209
+ if (command === 'submitted') await submitted(marketing)
210
+ else if (command === 'check') await check(marketing, build)
211
+ else if (command === 'notes' && file) await notes(marketing, build, file)
212
+ else if (command === 'share') await share(marketing, build)
213
+ else throw new Error(`Unknown command: ${command}`)