@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
package/src/init.ts ADDED
@@ -0,0 +1,63 @@
1
+ import { readdir } from 'node:fs/promises'
2
+ import { basename, dirname } from 'node:path'
3
+ import { applies, ROUTINE_STACKS, RULE_STACKS } from '~/catalogue'
4
+ import { CONFIG_FILE, type Stack, StackConfig } from '~/config'
5
+ import { placeholdersIn } from '~/render'
6
+ import { templatePlaceholders } from '~/templates'
7
+
8
+ /** Every placeholder the selected rules and routines use, so init can ask for exactly those. */
9
+ export const requiredPlaceholders = async (
10
+ packageDir: string,
11
+ stacks: readonly Stack[],
12
+ ): Promise<string[]> => {
13
+ const names = new Set<string>()
14
+ for (const [dir, catalogue] of [
15
+ ['rules', RULE_STACKS],
16
+ ['routines', ROUTINE_STACKS],
17
+ ] as const) {
18
+ for (const file of (await readdir(`${packageDir}/${dir}`).catch(() => [])).sort()) {
19
+ if (!file.endsWith('.md') || !applies(catalogue[file], stacks)) continue
20
+ for (const name of placeholdersIn(await Bun.file(`${packageDir}/${dir}/${file}`).text()))
21
+ names.add(name)
22
+ }
23
+ }
24
+ for (const name of await templatePlaceholders(packageDir, stacks)) names.add(name)
25
+ names.delete('STACK_VERSION')
26
+ names.delete('TODAY')
27
+ return [...names].sort()
28
+ }
29
+
30
+ /** Values init can settle without asking: derived from the folder, or a convention. */
31
+ export const defaultPlaceholders = (projectDir: string): Record<string, string> => {
32
+ const repoName = basename(projectDir)
33
+ return {
34
+ CODE_DIR: dirname(projectDir),
35
+ REPO_NAME: repoName,
36
+ APP_SLUG: repoName,
37
+ APP_NAME: repoName.charAt(0).toUpperCase() + repoName.slice(1),
38
+ SOURCE_LANGUAGE: 'French',
39
+ SERVED_LANGUAGES: 'French and English',
40
+ REVIEW_LANGUAGE: 'French',
41
+ AI_DAILY_ALERT: '3 euros',
42
+ BILLING_TABLE: 'polyforms-billing.billing_export.gcp_billing_export_v1',
43
+ GITHUB_OWNER: 'moifort',
44
+ GITHUB_REPO: `moifort/${repoName}`,
45
+ BUNDLE_ID: `com.polyforms.${repoName}.app`,
46
+ // Unknown until the App Store Connect record exists: filled in project-context.md
47
+ // and here by the bootstrap's last step.
48
+ APP_STORE_ID: '0',
49
+ // The Apple developer team: from the environment (the keychain at bootstrap).
50
+ APPLE_TEAM_ID: process.env.APPLE_TEAM_ID ?? 'TEAMID0000',
51
+ // Until the first deploy prints the function URL: the local server.
52
+ SERVER_URL: 'http://127.0.0.1:3000',
53
+ APP_TAGLINE: "Ce que l'application fait pour vous, en une phrase.",
54
+ APP_TAGLINE_EN: 'What the app does for you, in one sentence.',
55
+ }
56
+ }
57
+
58
+ export const writeConfig = async (projectDir: string, config: StackConfig): Promise<void> => {
59
+ await Bun.write(
60
+ `${projectDir}/${CONFIG_FILE}`,
61
+ `${JSON.stringify(StackConfig.parse(config), null, 2)}\n`,
62
+ )
63
+ }
@@ -0,0 +1,72 @@
1
+ import { readdir } from 'node:fs/promises'
2
+ import semver from 'semver'
3
+ import { z } from 'zod'
4
+ import { applies } from '~/catalogue'
5
+ import type { Stack } from '~/config'
6
+ import { STACKS } from '~/config'
7
+
8
+ export const Migration = z.object({
9
+ id: z.string().regex(/^[a-z0-9-]+$/),
10
+ risk: z.enum(['safe', 'review']),
11
+ stacks: z.array(z.enum([...STACKS, 'any'])),
12
+ rule: z.string(),
13
+ check: z.string().optional(),
14
+ })
15
+ export type Migration = z.infer<typeof Migration> & { version: string; path: string; body: string }
16
+
17
+ const FRONTMATTER = /^---\n([\s\S]*?)\n---\n([\s\S]*)$/
18
+
19
+ /** Reads the minimal YAML the frontmatter uses: `key: value` and `key: [a, b]`. */
20
+ export const parseFrontmatter = (text: string): { data: Record<string, unknown>; body: string } => {
21
+ const match = FRONTMATTER.exec(text)
22
+ if (!match) throw new Error('missing frontmatter')
23
+ const data: Record<string, unknown> = {}
24
+ for (const line of (match[1] as string).split('\n')) {
25
+ const [key, ...rest] = line.split(':')
26
+ if (!key || rest.length === 0) continue
27
+ const raw = rest.join(':').trim()
28
+ if (raw.startsWith('[') && raw.endsWith(']')) {
29
+ data[key.trim()] = raw
30
+ .slice(1, -1)
31
+ .split(',')
32
+ .map((s) => s.trim())
33
+ .filter(Boolean)
34
+ } else {
35
+ data[key.trim()] = raw.replace(/^"(.*)"$/, '$1')
36
+ }
37
+ }
38
+ return { data, body: match[2] as string }
39
+ }
40
+
41
+ export const readMigrations = async (packageDir: string): Promise<Migration[]> => {
42
+ const root = `${packageDir}/migrations`
43
+ const versions = await readdir(root, { withFileTypes: true }).catch(() => [])
44
+ const found: Migration[] = []
45
+ for (const dir of versions) {
46
+ if (!dir.isDirectory() || !semver.valid(dir.name)) continue
47
+ for (const file of await readdir(`${root}/${dir.name}`)) {
48
+ if (!file.endsWith('.md')) continue
49
+ const path = `${root}/${dir.name}/${file}`
50
+ const { data, body } = parseFrontmatter(await Bun.file(path).text())
51
+ const parsed = Migration.parse(data)
52
+ if (parsed.risk === 'safe' && !parsed.check)
53
+ throw new Error(`${path}: a safe migration needs a check`)
54
+ found.push({ ...parsed, version: dir.name, path, body })
55
+ }
56
+ }
57
+ return found.sort((a, b) => semver.compare(a.version, b.version) || a.id.localeCompare(b.id))
58
+ }
59
+
60
+ /** Migrations with from < version <= to, for the project's stacks. */
61
+ export const migrationsBetween = (
62
+ all: Migration[],
63
+ from: string,
64
+ to: string,
65
+ stacks: readonly Stack[],
66
+ ): Migration[] =>
67
+ all.filter(
68
+ (m) =>
69
+ semver.gt(m.version, from) &&
70
+ semver.lte(m.version, to) &&
71
+ (m.stacks.includes('any') || applies(m.stacks as Stack[], stacks)),
72
+ )
@@ -0,0 +1,44 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { type Migration, migrationsBetween, parseFrontmatter } from '~/migrations'
3
+
4
+ const m = (version: string, id: string, stacks: Migration['stacks'] = ['any']): Migration => ({
5
+ id,
6
+ version,
7
+ stacks,
8
+ risk: 'review',
9
+ rule: 'rules/10-ios.md#x',
10
+ path: '',
11
+ body: '',
12
+ })
13
+
14
+ describe('migrationsBetween', () => {
15
+ const all = [
16
+ m('1.0.0', 'a'),
17
+ m('1.1.0', 'b', ['ios']),
18
+ m('1.2.0', 'c', ['backend']),
19
+ m('2.0.0', 'd'),
20
+ ]
21
+ test('excludes from, includes to', () => {
22
+ expect(migrationsBetween(all, '1.0.0', '1.2.0', ['backend', 'ios']).map((x) => x.id)).toEqual([
23
+ 'b',
24
+ 'c',
25
+ ])
26
+ })
27
+ test('filters on the project stacks, any always applies', () => {
28
+ expect(migrationsBetween(all, '0.0.0', '2.0.0', ['backend']).map((x) => x.id)).toEqual([
29
+ 'a',
30
+ 'c',
31
+ 'd',
32
+ ])
33
+ })
34
+ })
35
+
36
+ describe('parseFrontmatter', () => {
37
+ test('reads scalars, quoted scalars and lists', () => {
38
+ const { data, body } = parseFrontmatter(
39
+ '---\nid: x-y\nrisk: safe\nstacks: [ios, any]\ncheck: "grep -r x"\n---\nBody\n',
40
+ )
41
+ expect(data).toEqual({ id: 'x-y', risk: 'safe', stacks: ['ios', 'any'], check: 'grep -r x' })
42
+ expect(body).toBe('Body\n')
43
+ })
44
+ })
package/src/package.ts ADDED
@@ -0,0 +1,8 @@
1
+ import { dirname, resolve } from 'node:path'
2
+ import { fileURLToPath } from 'node:url'
3
+
4
+ /** The root of this package, wherever it is installed. */
5
+ export const packageDir = (): string => resolve(dirname(fileURLToPath(import.meta.url)), '..')
6
+
7
+ export const packageVersion = async (): Promise<string> =>
8
+ ((await Bun.file(`${packageDir()}/package.json`).json()) as { version: string }).version
package/src/render.ts ADDED
@@ -0,0 +1,13 @@
1
+ const PLACEHOLDER = /\{\{([A-Z][A-Z0-9_]*)\}\}/g
2
+
3
+ /** The placeholder names a text uses, in order of first appearance, without duplicates. */
4
+ export const placeholdersIn = (text: string): string[] => [
5
+ ...new Set([...text.matchAll(PLACEHOLDER)].map((m) => m[1] as string)),
6
+ ]
7
+
8
+ /** Replaces every placeholder; throws naming the first value that is missing. */
9
+ export const render = (text: string, values: Record<string, string>): string => {
10
+ const missing = placeholdersIn(text).filter((name) => values[name] === undefined)
11
+ if (missing.length > 0) throw new Error(`missing placeholder value: ${missing.join(', ')}`)
12
+ return text.replace(PLACEHOLDER, (_, name: string) => values[name] as string)
13
+ }
@@ -0,0 +1,17 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { placeholdersIn, render } from '~/render'
3
+
4
+ describe('render', () => {
5
+ test('replaces every placeholder, each as many times as it appears', () => {
6
+ expect(render('{{A}} and {{B}} and {{A}}', { A: 'x', B: 'y' })).toBe('x and y and x')
7
+ })
8
+ test('lists the placeholders once each, in order', () => {
9
+ expect(placeholdersIn('{{B}} {{A}} {{B}}')).toEqual(['B', 'A'])
10
+ })
11
+ test('fails naming the missing values, before replacing anything', () => {
12
+ expect(() => render('{{A}} {{B}}', { A: 'x' })).toThrow('missing placeholder value: B')
13
+ })
14
+ test('ignores lowercase braces and template syntax that is not a placeholder', () => {
15
+ expect(render('{{ not }} {{a}}', {})).toBe('{{ not }} {{a}}')
16
+ })
17
+ })
@@ -0,0 +1,103 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
2
+ import { mkdtemp, rm } from 'node:fs/promises'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import { requiredPlaceholders } from '~/init'
6
+ import { packageDir } from '~/package'
7
+ import { applyPlan, checkPlan, planSync, withHooks, withRulesBlock } from '~/sync'
8
+
9
+ let project: string
10
+ beforeEach(async () => {
11
+ project = await mkdtemp(join(tmpdir(), 'stack-'))
12
+ })
13
+ afterEach(async () => {
14
+ await rm(project, { recursive: true, force: true })
15
+ })
16
+
17
+ const values = async (stacks: ('backend' | 'ios' | 'ai' | 'site')[]) => {
18
+ const placeholders: Record<string, string> = {}
19
+ for (const name of await requiredPlaceholders(packageDir(), stacks))
20
+ placeholders[name] = `<${name}>`
21
+ return placeholders
22
+ }
23
+
24
+ describe('sync', () => {
25
+ test('a project without the ios stack gets no ios rule, skill or hook', async () => {
26
+ const plan = await planSync(
27
+ packageDir(),
28
+ { stacks: ['backend'], placeholders: await values(['backend']), exclude: [] },
29
+ '1.0.0',
30
+ )
31
+ const paths = [...plan.files.keys()]
32
+ expect(paths).toContain('stack/rules/00-collaboration.md')
33
+ expect(paths).toContain('stack/rules/09-backend.md')
34
+ expect(paths).not.toContain('stack/rules/10-ios.md')
35
+ expect(paths.some((p) => p.includes('shutdown-simulators'))).toBe(false)
36
+ expect(paths).toContain('stack/VERSION')
37
+ })
38
+
39
+ test('fails before writing when a placeholder has no value', async () => {
40
+ await expect(
41
+ planSync(packageDir(), { stacks: ['backend'], placeholders: {}, exclude: [] }, '1.0.0'),
42
+ ).rejects.toThrow('missing placeholder value')
43
+ })
44
+
45
+ test('applies, then checks clean; a hand edit and a stale block are reported', async () => {
46
+ const config = {
47
+ stacks: ['backend', 'ios'] as const,
48
+ placeholders: await values(['backend', 'ios']),
49
+ exclude: [],
50
+ }
51
+ const plan = await planSync(packageDir(), { ...config, stacks: [...config.stacks] }, '1.0.0')
52
+ await applyPlan(project, plan)
53
+ expect(await checkPlan(project, plan)).toEqual([])
54
+ const rendered = await Bun.file(join(project, 'stack/rules/00-collaboration.md')).text()
55
+ expect(rendered).not.toMatch(/\{\{[A-Z_]+\}\}/)
56
+ const claude = await Bun.file(join(project, 'CLAUDE.md')).text()
57
+ expect(claude).toContain('@stack/rules/10-ios.md')
58
+ const settings = await Bun.file(join(project, '.claude/settings.json')).json()
59
+ expect(JSON.stringify(settings.hooks.PostToolUse)).toContain(
60
+ 'shutdown-simulators-after-push.sh',
61
+ )
62
+
63
+ await Bun.write(join(project, 'stack/rules/00-collaboration.md'), 'edited by hand\n')
64
+ await Bun.write(join(project, 'CLAUDE.md'), '# P\n')
65
+ const problems = await checkPlan(project, plan)
66
+ expect(problems).toContain('stack/rules/00-collaboration.md: differs from sync')
67
+ expect(problems).toContain('CLAUDE.md: rules block out of date')
68
+ })
69
+
70
+ test('running twice gives the same files', async () => {
71
+ const config = {
72
+ stacks: ['backend'] as 'backend'[],
73
+ placeholders: await values(['backend']),
74
+ exclude: [],
75
+ }
76
+ const plan = await planSync(packageDir(), config, '1.0.0')
77
+ await applyPlan(project, plan)
78
+ await applyPlan(project, plan)
79
+ expect(await checkPlan(project, plan)).toEqual([])
80
+ })
81
+ })
82
+
83
+ describe('withRulesBlock', () => {
84
+ test('inserts the block under the title, then replaces it in place', () => {
85
+ const once = withRulesBlock('# App\n\nText\n', ['@stack/rules/a.md'])
86
+ expect(once).toBe(
87
+ '# App\n\n<!-- stack:rules -->\n@stack/rules/a.md\n<!-- /stack:rules -->\n\nText\n',
88
+ )
89
+ const twice = withRulesBlock(once, ['@stack/rules/b.md'])
90
+ expect(twice).toBe(
91
+ '# App\n\n<!-- stack:rules -->\n@stack/rules/b.md\n<!-- /stack:rules -->\n\nText\n',
92
+ )
93
+ })
94
+ })
95
+
96
+ describe('withHooks', () => {
97
+ test('registers a hook once and keeps what was there', () => {
98
+ const once = withHooks({ permissions: { allow: [] } }, ['x.sh'])
99
+ const twice = withHooks(once, ['x.sh'])
100
+ expect((twice.hooks as { PostToolUse: unknown[] }).PostToolUse).toHaveLength(1)
101
+ expect(twice.permissions).toEqual({ allow: [] })
102
+ })
103
+ })
package/src/sync.ts ADDED
@@ -0,0 +1,161 @@
1
+ import { chmod, mkdir, readdir, rm } from 'node:fs/promises'
2
+ import { dirname, join } from 'node:path'
3
+ import { applies, HOOK_STACKS, ROUTINE_STACKS, RULE_STACKS, SKILL_STACKS } from '~/catalogue'
4
+ import type { StackConfig } from '~/config'
5
+ import { render } from '~/render'
6
+ import { planTemplates } from '~/templates'
7
+
8
+ export type Plan = {
9
+ /** Files to write in the project, path relative to the project root, with their content. */
10
+ files: Map<string, string>
11
+ /** The lines the CLAUDE.md rules block must hold. */
12
+ ruleImports: string[]
13
+ }
14
+
15
+ const RULES_BLOCK_START = '<!-- stack:rules -->'
16
+ const RULES_BLOCK_END = '<!-- /stack:rules -->'
17
+
18
+ const listFiles = async (dir: string, filter: (name: string) => boolean): Promise<string[]> =>
19
+ (await readdir(dir).catch(() => [])).filter(filter).sort()
20
+
21
+ /** Everything sync would write, computed before anything is written: a missing placeholder fails here. */
22
+ export const planSync = async (
23
+ packageDir: string,
24
+ config: StackConfig,
25
+ version: string,
26
+ ): Promise<Plan> => {
27
+ const files = new Map<string, string>()
28
+ const values = { ...config.placeholders, STACK_VERSION: version }
29
+ const { stacks, exclude } = config
30
+
31
+ const rules = await listFiles(`${packageDir}/rules`, (f) => f.endsWith('.md'))
32
+ const ruleImports: string[] = []
33
+ for (const name of rules) {
34
+ if (!applies(RULE_STACKS[name], stacks) || exclude.includes(name)) continue
35
+ const text = await Bun.file(`${packageDir}/rules/${name}`).text()
36
+ files.set(`stack/rules/${name}`, render(text, values))
37
+ ruleImports.push(`@stack/rules/${name}`)
38
+ }
39
+
40
+ const routines = await listFiles(`${packageDir}/routines`, (f) => f.endsWith('.md'))
41
+ for (const name of routines) {
42
+ if (!applies(ROUTINE_STACKS[name], stacks)) continue
43
+ const text = await Bun.file(`${packageDir}/routines/${name}`).text()
44
+ files.set(`stack/routines/${name}`, render(text, values))
45
+ }
46
+
47
+ const skills = await readdir(`${packageDir}/skills`, { withFileTypes: true }).catch(() => [])
48
+ for (const skill of skills) {
49
+ if (!skill.isDirectory() || !applies(SKILL_STACKS[skill.name], stacks)) continue
50
+ for (const file of await listFiles(`${packageDir}/skills/${skill.name}`, () => true)) {
51
+ const text = await Bun.file(`${packageDir}/skills/${skill.name}/${file}`).text()
52
+ files.set(`.claude/skills/${skill.name}/${file}`, text)
53
+ }
54
+ }
55
+
56
+ const hooks = await listFiles(`${packageDir}/hooks`, (f) => f.endsWith('.sh'))
57
+ for (const name of hooks) {
58
+ if (!applies(HOOK_STACKS[name], stacks)) continue
59
+ files.set(`.claude/hooks/${name}`, await Bun.file(`${packageDir}/hooks/${name}`).text())
60
+ }
61
+
62
+ files.set('stack/VERSION', `${version}\n`)
63
+ return { files, ruleImports }
64
+ }
65
+
66
+ /** Rewrites the rules block of a CLAUDE.md; adds it at the top when absent. */
67
+ export const withRulesBlock = (claudeMd: string, imports: string[]): string => {
68
+ const block = [RULES_BLOCK_START, ...imports, RULES_BLOCK_END].join('\n')
69
+ const start = claudeMd.indexOf(RULES_BLOCK_START)
70
+ const end = claudeMd.indexOf(RULES_BLOCK_END)
71
+ if (start >= 0 && end > start) {
72
+ return claudeMd.slice(0, start) + block + claudeMd.slice(end + RULES_BLOCK_END.length)
73
+ }
74
+ const lines = claudeMd.split('\n')
75
+ const title = lines.findIndex((l) => l.startsWith('# '))
76
+ if (title >= 0) {
77
+ lines.splice(title + 1, 0, '', block)
78
+ return lines.join('\n')
79
+ }
80
+ return `${block}\n\n${claudeMd}`
81
+ }
82
+
83
+ /** The hook registrations sync adds to .claude/settings.json. */
84
+ export const withHooks = (
85
+ settings: Record<string, unknown>,
86
+ hookFiles: string[],
87
+ ): Record<string, unknown> => {
88
+ const hooks = (settings.hooks ?? {}) as Record<string, unknown[]>
89
+ const post = [...((hooks.PostToolUse as unknown[] | undefined) ?? [])]
90
+ for (const file of hookFiles) {
91
+ const command = `.claude/hooks/${file}`
92
+ const already = post.some((entry) => JSON.stringify(entry).includes(command))
93
+ if (!already) post.push({ matcher: 'Bash', hooks: [{ type: 'command', command }] })
94
+ }
95
+ return { ...settings, hooks: { ...hooks, PostToolUse: post } }
96
+ }
97
+
98
+ /** Writes the templates into a project that does not have them yet: a file that
99
+ * already exists is left alone, so init on an existing project adds what is
100
+ * missing and overwrites nothing. */
101
+ export const applyTemplates = async (
102
+ packageDir: string,
103
+ projectDir: string,
104
+ config: StackConfig,
105
+ values: Record<string, string>,
106
+ ): Promise<{ written: string[]; kept: string[] }> => {
107
+ const written: string[] = []
108
+ const kept: string[] = []
109
+ for (const file of await planTemplates(packageDir, config, values)) {
110
+ const target = join(projectDir, file.path)
111
+ if (await Bun.file(target).exists()) {
112
+ kept.push(file.path)
113
+ continue
114
+ }
115
+ await mkdir(dirname(target), { recursive: true })
116
+ await Bun.write(target, file.content)
117
+ if (file.executable) await chmod(target, 0o755)
118
+ written.push(file.path)
119
+ }
120
+ return { written, kept }
121
+ }
122
+
123
+ export const applyPlan = async (projectDir: string, plan: Plan): Promise<void> => {
124
+ await rm(join(projectDir, 'stack/rules'), { recursive: true, force: true })
125
+ await rm(join(projectDir, 'stack/routines'), { recursive: true, force: true })
126
+ for (const [path, content] of plan.files) {
127
+ const target = join(projectDir, path)
128
+ await mkdir(dirname(target), { recursive: true })
129
+ await Bun.write(target, content)
130
+ }
131
+ const claudePath = join(projectDir, 'CLAUDE.md')
132
+ const claudeFile = Bun.file(claudePath)
133
+ const claude = (await claudeFile.exists()) ? await claudeFile.text() : '# Project\n'
134
+ await Bun.write(claudePath, withRulesBlock(claude, plan.ruleImports))
135
+ const hookFiles = [...plan.files.keys()]
136
+ .filter((p) => p.startsWith('.claude/hooks/'))
137
+ .map((p) => p.slice('.claude/hooks/'.length))
138
+ if (hookFiles.length > 0) {
139
+ const settingsPath = join(projectDir, '.claude/settings.json')
140
+ const settingsFile = Bun.file(settingsPath)
141
+ const settings = (await settingsFile.exists()) ? await settingsFile.json() : {}
142
+ await mkdir(dirname(settingsPath), { recursive: true })
143
+ await Bun.write(settingsPath, `${JSON.stringify(withHooks(settings, hookFiles), null, 2)}\n`)
144
+ }
145
+ }
146
+
147
+ /** The differences between the project and what sync would produce; empty when in sync. */
148
+ export const checkPlan = async (projectDir: string, plan: Plan): Promise<string[]> => {
149
+ const problems: string[] = []
150
+ for (const [path, content] of plan.files) {
151
+ const file = Bun.file(join(projectDir, path))
152
+ if (!(await file.exists())) problems.push(`${path}: missing`)
153
+ else if ((await file.text()) !== content) problems.push(`${path}: differs from sync`)
154
+ if (/\{\{[A-Z][A-Z0-9_]*\}\}/.test(content)) problems.push(`${path}: raw placeholder left`)
155
+ }
156
+ const claudeFile = Bun.file(join(projectDir, 'CLAUDE.md'))
157
+ const claude = (await claudeFile.exists()) ? await claudeFile.text() : ''
158
+ if (withRulesBlock(claude, plan.ruleImports) !== claude)
159
+ problems.push('CLAUDE.md: rules block out of date')
160
+ return problems
161
+ }
@@ -0,0 +1,121 @@
1
+ import { readdir } from 'node:fs/promises'
2
+ import { extname, join, relative } from 'node:path'
3
+ import type { Stack, StackConfig } from '~/config'
4
+ import { placeholdersIn, render } from '~/render'
5
+
6
+ /** The folders of templates/ that a project with these stacks receives, in order:
7
+ * base first, then one per selected stack. A later folder may add files or
8
+ * merge into a JSON file an earlier one wrote (`<name>.merge.json`). */
9
+ export const templateFolders = (stacks: readonly Stack[]): string[] => ['base', ...stacks]
10
+
11
+ const TEXT = new Set([
12
+ '.md',
13
+ '.ts',
14
+ '.tsx',
15
+ '.js',
16
+ '.json',
17
+ '.yml',
18
+ '.yaml',
19
+ '.toml',
20
+ '.tf',
21
+ '.example',
22
+ '.sh',
23
+ '.swift',
24
+ '.graphql',
25
+ '.html',
26
+ '.css',
27
+ '.txt',
28
+ '.plist',
29
+ '.xcstrings',
30
+ '.rules',
31
+ '.gitignore',
32
+ '.entitlements',
33
+ '.xcprivacy',
34
+ '.strings',
35
+ '.xctestplan',
36
+ '.pbxproj',
37
+ '.resolved',
38
+ ])
39
+ export const isText = (path: string): boolean => {
40
+ const ext = extname(path) || path.slice(path.lastIndexOf('/') + 1)
41
+ return TEXT.has(ext) || ext === '' || path.endsWith('/tf')
42
+ }
43
+
44
+ const walk = async (dir: string): Promise<string[]> => {
45
+ const out: string[] = []
46
+ for (const entry of await readdir(dir, { withFileTypes: true }).catch(() => [])) {
47
+ const path = join(dir, entry.name)
48
+ if (entry.isDirectory()) out.push(...(await walk(path)))
49
+ else out.push(path)
50
+ }
51
+ return out.sort()
52
+ }
53
+
54
+ const deepMerge = (into: Record<string, unknown>, from: Record<string, unknown>) => {
55
+ for (const [key, value] of Object.entries(from)) {
56
+ const current = into[key]
57
+ if (
58
+ value &&
59
+ typeof value === 'object' &&
60
+ !Array.isArray(value) &&
61
+ current &&
62
+ typeof current === 'object' &&
63
+ !Array.isArray(current)
64
+ ) {
65
+ deepMerge(current as Record<string, unknown>, value as Record<string, unknown>)
66
+ } else into[key] = value
67
+ }
68
+ return into
69
+ }
70
+
71
+ export type TemplateFile = { path: string; content: string | Uint8Array; executable: boolean }
72
+
73
+ /** Every file the templates produce for a project, rendered, in memory. */
74
+ export const planTemplates = async (
75
+ packageDir: string,
76
+ config: StackConfig,
77
+ values: Record<string, string>,
78
+ ): Promise<TemplateFile[]> => {
79
+ const files = new Map<string, TemplateFile>()
80
+ for (const folder of templateFolders(config.stacks)) {
81
+ const root = join(packageDir, 'templates', folder)
82
+ for (const path of await walk(root)) {
83
+ const rel = render(relative(root, path), values)
84
+ const file = Bun.file(path)
85
+ const executable = ((await file.stat()).mode & 0o111) !== 0
86
+ if (rel.endsWith('.merge.json')) {
87
+ const target = rel.replace(/\.merge\.json$/, '.json')
88
+ const existing = files.get(target)
89
+ const base =
90
+ existing && typeof existing.content === 'string' ? JSON.parse(existing.content) : {}
91
+ const merged = deepMerge(base, JSON.parse(render(await file.text(), values)))
92
+ files.set(target, {
93
+ path: target,
94
+ content: `${JSON.stringify(merged, null, 2)}\n`,
95
+ executable: false,
96
+ })
97
+ continue
98
+ }
99
+ if (isText(rel))
100
+ files.set(rel, { path: rel, content: render(await file.text(), values), executable })
101
+ else
102
+ files.set(rel, { path: rel, content: new Uint8Array(await file.arrayBuffer()), executable })
103
+ }
104
+ }
105
+ return [...files.values()]
106
+ }
107
+
108
+ /** The placeholders the templates of these stacks use. */
109
+ export const templatePlaceholders = async (
110
+ packageDir: string,
111
+ stacks: readonly Stack[],
112
+ ): Promise<string[]> => {
113
+ const names = new Set<string>()
114
+ for (const folder of templateFolders(stacks)) {
115
+ for (const path of await walk(join(packageDir, 'templates', folder))) {
116
+ if (!isText(path)) continue
117
+ for (const name of placeholdersIn(await Bun.file(path).text())) names.add(name)
118
+ }
119
+ }
120
+ return [...names].sort()
121
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Content validation, run in the package's CI: every migration and routine parses, no safe
3
+ * migration without a check, no em or en dash in what is shipped, no identifier that belongs
4
+ * to one project (the denylist lives outside the published files).
5
+ */
6
+ import { readdir } from 'node:fs/promises'
7
+ import { parseFrontmatter, readMigrations } from '~/migrations'
8
+ import { packageDir } from '~/package'
9
+
10
+ const root = packageDir()
11
+ const problems: string[] = []
12
+
13
+ const walk = async (dir: string): Promise<string[]> => {
14
+ const out: string[] = []
15
+ for (const entry of await readdir(dir, { withFileTypes: true }).catch(() => [])) {
16
+ const path = `${dir}/${entry.name}`
17
+ if (entry.isDirectory()) out.push(...(await walk(path)))
18
+ else out.push(path)
19
+ }
20
+ return out
21
+ }
22
+
23
+ const denylist = (
24
+ await Bun.file(`${root}/.denylist`)
25
+ .text()
26
+ .catch(() => '')
27
+ )
28
+ .split('\n')
29
+ .map((l) => l.trim())
30
+ .filter((l) => l && !l.startsWith('#'))
31
+
32
+ for (const dir of ['rules', 'routines', 'skills', 'migrations']) {
33
+ for (const path of await walk(`${root}/${dir}`)) {
34
+ if (!path.endsWith('.md')) continue
35
+ const text = await Bun.file(path).text()
36
+ const rel = path.slice(root.length + 1)
37
+ if (/[—–]/.test(text)) problems.push(`${rel}: em or en dash`)
38
+ for (const word of denylist) if (text.includes(word)) problems.push(`${rel}: contains ${word}`)
39
+ }
40
+ }
41
+
42
+ for (const path of await walk(`${root}/routines`)) {
43
+ const { data } = parseFrontmatter(await Bun.file(path).text())
44
+ for (const key of ['id', 'scope', 'runtime', 'cadence', 'title']) {
45
+ if (!data[key]) problems.push(`${path.slice(root.length + 1)}: frontmatter lacks ${key}`)
46
+ }
47
+ }
48
+
49
+ try {
50
+ for (const m of await readMigrations(root)) {
51
+ const [file, anchor] = m.rule.split('#')
52
+ const ruleText = await Bun.file(`${root}/${file}`)
53
+ .text()
54
+ .catch(() => '')
55
+ if (!ruleText) problems.push(`${m.path}: rule file ${file} not found`)
56
+ else if (anchor && !ruleText.toLowerCase().includes(`## ${anchor.replace(/-/g, ' ')}`))
57
+ problems.push(`${m.path}: heading ${anchor} not found in ${file}`)
58
+ }
59
+ } catch (error) {
60
+ problems.push(String(error))
61
+ }
62
+
63
+ for (const p of problems) console.error(p)
64
+ console.log(problems.length === 0 ? 'content valid' : `${problems.length} problem(s)`)
65
+ process.exit(problems.length === 0 ? 0 : 1)