@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.
- package/CHANGELOG.md +18 -0
- package/README.md +26 -2
- package/bin/stack.ts +109 -0
- package/hooks/shutdown-simulators-after-push.sh +19 -0
- package/package.json +40 -4
- package/routines/daily-health.md +78 -0
- package/routines/deprecations.md +23 -0
- package/routines/sentry-autofix.md +76 -0
- package/routines/stack-upgrade.md +45 -0
- package/rules/00-collaboration.md +81 -0
- package/rules/01-workflow.md +81 -0
- package/rules/02-commits.md +71 -0
- package/rules/03-code-review.md +25 -0
- package/rules/04-push.md +75 -0
- package/rules/05-release.md +40 -0
- package/rules/06-changelog.md +48 -0
- package/rules/07-appstore.md +77 -0
- package/rules/08-copy.md +52 -0
- package/rules/09-backend.md +132 -0
- package/rules/10-ios.md +206 -0
- package/rules/11-ops.md +114 -0
- package/rules/12-ci.md +32 -0
- package/rules/13-ai.md +71 -0
- package/rules/14-infra.md +90 -0
- package/rules/15-tooling.md +81 -0
- package/rules/16-routines.md +69 -0
- package/rules/17-landing.md +76 -0
- package/skills/appstore/SKILL.md +53 -0
- package/skills/domain/SKILL.md +136 -0
- package/skills/graphql/SKILL.md +83 -0
- package/skills/ios-feature/SKILL.md +94 -0
- package/skills/stack-install/SKILL.md +35 -0
- package/skills/stack-upgrade/SKILL.md +27 -0
- package/src/catalogue.ts +33 -0
- package/src/config.ts +20 -0
- package/src/init.ts +63 -0
- package/src/migrations.ts +72 -0
- package/src/migrations.unit.test.ts +44 -0
- package/src/package.ts +8 -0
- package/src/render.ts +13 -0
- package/src/render.unit.test.ts +17 -0
- package/src/sync.int.test.ts +103 -0
- package/src/sync.ts +161 -0
- package/src/templates.ts +121 -0
- package/src/validate-content.ts +65 -0
- package/templates/backend/.env.example +10 -0
- package/templates/backend/.github/workflows/deploy.yml +151 -0
- package/templates/backend/.github/workflows/schema.yml +59 -0
- package/templates/backend/.github/workflows/test-feat.yml +40 -0
- package/templates/backend/.github/workflows/test-int.yml +40 -0
- package/templates/backend/.github/workflows/test-unit.yml +64 -0
- package/templates/backend/docs/api-evolution.md +67 -0
- package/templates/backend/firebase.json +21 -0
- package/templates/backend/firestore.indexes.json +4 -0
- package/templates/backend/firestore.rules +11 -0
- package/templates/backend/infra/auth.tf +54 -0
- package/templates/backend/infra/backend.tf +6 -0
- package/templates/backend/infra/cicd.tf +63 -0
- package/templates/backend/infra/firebase.tf +26 -0
- package/templates/backend/infra/firestore.tf +53 -0
- package/templates/backend/infra/function.tf +171 -0
- package/templates/backend/infra/main.tf +26 -0
- package/templates/backend/infra/observer.tf +18 -0
- package/templates/backend/infra/outputs.tf +30 -0
- package/templates/backend/infra/project.tf +64 -0
- package/templates/backend/infra/scheduler.tf +28 -0
- package/templates/backend/infra/secrets.tf +44 -0
- package/templates/backend/infra/state.tf +13 -0
- package/templates/backend/infra/terraform.tfvars.example +16 -0
- package/templates/backend/infra/variables.tf +104 -0
- package/templates/backend/infra/versions.tf +30 -0
- package/templates/backend/nitro.config.ts +40 -0
- package/templates/backend/package.json +51 -0
- package/templates/backend/scripts/deprecations/inspector-rule.ts +29 -0
- package/templates/backend/scripts/deprecations/status.ts +50 -0
- package/templates/backend/scripts/deprecations/status.unit.test.ts +57 -0
- package/templates/backend/scripts/deprecations/usage.ts +79 -0
- package/templates/backend/scripts/deprecations/usage.unit.test.ts +64 -0
- package/templates/backend/scripts/deprecations.ts +224 -0
- package/templates/backend/scripts/export-graphql-schema.ts +11 -0
- package/templates/backend/scripts/generate-changelog-asset.ts +27 -0
- package/templates/backend/scripts/install-terraform.sh +61 -0
- package/templates/backend/scripts/tf +11 -0
- package/templates/backend/server/domain/changelog/business-rules.ts +60 -0
- package/templates/backend/server/domain/changelog/business-rules.unit.test.ts +88 -0
- package/templates/backend/server/domain/changelog/infrastructure/graphql/queries.ts +17 -0
- package/templates/backend/server/domain/changelog/infrastructure/graphql/types.ts +23 -0
- package/templates/backend/server/domain/changelog/primitives.ts +19 -0
- package/templates/backend/server/domain/changelog/query.ts +13 -0
- package/templates/backend/server/domain/changelog/query.unit.test.ts +19 -0
- package/templates/backend/server/domain/changelog/types.ts +9 -0
- package/templates/backend/server/domain/shared/graphql/builder.ts +52 -0
- package/templates/backend/server/domain/shared/graphql/errors.ts +19 -0
- package/templates/backend/server/domain/shared/graphql/scalars.ts +51 -0
- package/templates/backend/server/domain/shared/graphql/schema.ts +21 -0
- package/templates/backend/server/domain/shared/language.ts +26 -0
- package/templates/backend/server/domain/shared/page.ts +25 -0
- package/templates/backend/server/domain/shared/page.unit.test.ts +51 -0
- package/templates/backend/server/domain/shared/primitives.ts +50 -0
- package/templates/backend/server/domain/shared/primitives.unit.test.ts +25 -0
- package/templates/backend/server/domain/shared/types.ts +14 -0
- package/templates/backend/server/domain/user/command.ts +16 -0
- package/templates/backend/server/domain/user/infrastructure/graphql/inputs.ts +12 -0
- package/templates/backend/server/domain/user/infrastructure/graphql/mutations.ts +40 -0
- package/templates/backend/server/domain/user/infrastructure/graphql/queries.ts +13 -0
- package/templates/backend/server/domain/user/infrastructure/graphql/types.ts +35 -0
- package/templates/backend/server/domain/user/infrastructure/repository.ts +59 -0
- package/templates/backend/server/domain/user/mutations.feat.test.ts +42 -0
- package/templates/backend/server/domain/user/query.int.test.ts +49 -0
- package/templates/backend/server/domain/user/query.ts +34 -0
- package/templates/backend/server/domain/user/types.ts +20 -0
- package/templates/backend/server/domain/user/use-case.ts +18 -0
- package/templates/backend/server/middleware/auth.ts +54 -0
- package/templates/backend/server/plugins/00-sentry.ts +74 -0
- package/templates/backend/server/plugins/01-request-log.ts +28 -0
- package/templates/backend/server/plugins/02-graphql.ts +69 -0
- package/templates/backend/server/plugins/03-boot-time.ts +21 -0
- package/templates/backend/server/routes/admin/migrate.post.ts +9 -0
- package/templates/backend/server/routes/app-config.get.ts +15 -0
- package/templates/backend/server/routes/graphql.ts +52 -0
- package/templates/backend/server/system/app-support.ts +17 -0
- package/templates/backend/server/system/config/index.ts +18 -0
- package/templates/backend/server/system/config/primitives.ts +20 -0
- package/templates/backend/server/system/config/primitives.unit.test.ts +13 -0
- package/templates/backend/server/system/config/types.ts +8 -0
- package/templates/backend/server/system/firebase.ts +19 -0
- package/templates/backend/server/system/identity.ts +18 -0
- package/templates/backend/server/system/logger.ts +119 -0
- package/templates/backend/server/system/logger.unit.test.ts +109 -0
- package/templates/backend/server/system/migration/migrations/index.ts +6 -0
- package/templates/backend/server/system/migration/primitives.ts +18 -0
- package/templates/backend/server/system/migration/runner.ts +52 -0
- package/templates/backend/server/system/migration/types.ts +16 -0
- package/templates/backend/server/system/read-meter.ts +100 -0
- package/templates/backend/server/system/read-meter.unit.test.ts +97 -0
- package/templates/backend/server/system/request-cache.ts +139 -0
- package/templates/backend/server/system/request-cache.unit.test.ts +166 -0
- package/templates/backend/server/system/secret-box.ts +49 -0
- package/templates/backend/server/system/secret-box.unit.test.ts +46 -0
- package/templates/backend/server/test/bdd.ts +12 -0
- package/templates/backend/server/test/fake-firestore.ts +483 -0
- package/templates/backend/server/utils/apollo.ts +13 -0
- package/templates/backend/server/utils/fetch.ts +5 -0
- package/templates/backend/server/utils/firestore.ts +91 -0
- package/templates/backend/server/utils/input.ts +36 -0
- package/templates/backend/server/utils/input.unit.test.ts +43 -0
- package/templates/backend/server/utils/slug.ts +21 -0
- package/templates/backend/tsconfig.json +16 -0
- package/templates/base/.github/workflows/renovate.yml +17 -0
- package/templates/base/CHANGELOG.fr.md +3 -0
- package/templates/base/CHANGELOG.md +7 -0
- package/templates/base/CLAUDE.md +34 -0
- package/templates/base/README.md +12 -0
- package/templates/base/biome.json +47 -0
- package/templates/base/bunfig.toml +2 -0
- package/templates/base/docs/adr/0001-built-on-the-polyforms-stack.md +21 -0
- package/templates/base/docs/data-recovery.md +116 -0
- package/templates/base/docs/ideas.md +4 -0
- package/templates/base/project-context.md +43 -0
- package/templates/base/renovate.json +37 -0
- package/templates/ios/.github/workflows/build-ios.yml +168 -0
- package/templates/ios/.github/workflows/ios-upload.yml +308 -0
- package/templates/ios/.github/workflows/release-ios.yml +178 -0
- package/templates/ios/.github/workflows/testflight-ios.yml +57 -0
- package/templates/ios/infra/Gemfile +5 -0
- package/templates/ios/infra/fastlane/Appfile +1 -0
- package/templates/ios/infra/fastlane/Fastfile +54 -0
- package/templates/ios/infra/fastlane/metadata/copyright.txt +1 -0
- package/templates/ios/infra/fastlane/metadata/en-US/description.txt +0 -0
- package/templates/ios/infra/fastlane/metadata/en-US/keywords.txt +0 -0
- package/templates/ios/infra/fastlane/metadata/en-US/name.txt +1 -0
- package/templates/ios/infra/fastlane/metadata/en-US/privacy_url.txt +1 -0
- package/templates/ios/infra/fastlane/metadata/en-US/promotional_text.txt +0 -0
- package/templates/ios/infra/fastlane/metadata/en-US/subtitle.txt +1 -0
- package/templates/ios/infra/fastlane/metadata/en-US/support_url.txt +1 -0
- package/templates/ios/infra/fastlane/metadata/fr-FR/description.txt +0 -0
- package/templates/ios/infra/fastlane/metadata/fr-FR/keywords.txt +0 -0
- package/templates/ios/infra/fastlane/metadata/fr-FR/name.txt +1 -0
- package/templates/ios/infra/fastlane/metadata/fr-FR/privacy_url.txt +1 -0
- package/templates/ios/infra/fastlane/metadata/fr-FR/promotional_text.txt +0 -0
- package/templates/ios/infra/fastlane/metadata/fr-FR/subtitle.txt +1 -0
- package/templates/ios/infra/fastlane/metadata/fr-FR/support_url.txt +1 -0
- package/templates/ios/infra/fastlane/metadata/primary_category.txt +1 -0
- package/templates/ios/infra/ios.tf +28 -0
- package/templates/ios/ios/ExportOptions.plist +26 -0
- package/templates/ios/ios/apollo-codegen-config.json +16 -0
- package/templates/ios/ios/project.yml +94 -0
- package/templates/ios/ios/{{APP_NAME}}/App.swift +46 -0
- package/templates/ios/ios/{{APP_NAME}}/Assets.xcassets/AppIcon.appiconset/AppIcon.png +0 -0
- package/templates/ios/ios/{{APP_NAME}}/Assets.xcassets/AppIcon.appiconset/Contents.json +14 -0
- package/templates/ios/ios/{{APP_NAME}}/Assets.xcassets/Contents.json +6 -0
- package/templates/ios/ios/{{APP_NAME}}/Assets.xcassets/LaunchBackground.colorset/Contents.json +38 -0
- package/templates/ios/ios/{{APP_NAME}}/ContentView.swift +17 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Auth/AppleNonce.swift +33 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Auth/AuthRoot.swift +74 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Auth/AuthSession.swift +86 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Auth/LoginScreen.swift +83 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Home/HomeScreen.swift +21 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Home/components/pages/HomePage.swift +40 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Onboarding/AccountEnvironment.swift +7 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Onboarding/GraphQL/Onboarding.graphql +16 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Onboarding/OnboardingAPI.swift +28 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Onboarding/OnboardingClient.swift +26 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Onboarding/OnboardingGate.swift +50 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Onboarding/OnboardingScreen.swift +69 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Onboarding/components/pages/FirstNamePage.swift +66 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Onboarding/components/pages/WelcomePage.swift +37 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Settings/ChangelogScreen.swift +25 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Settings/ChangelogViewModel.swift +27 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Settings/GraphQL/Settings.graphql +11 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Settings/ProfileSettingsScreen.swift +30 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Settings/ProfileSettingsViewModel.swift +51 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Settings/SettingsAPI.swift +37 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Settings/SettingsClient.swift +40 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Settings/SettingsHomeScreen.swift +39 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Settings/components/organisms/DeleteAccountButton.swift +30 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Settings/components/organisms/SettingsRow.swift +56 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Settings/components/organisms/SignOutButton.swift +23 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Settings/components/pages/ChangelogPage.swift +59 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Settings/components/pages/FeedbackSheet.swift +118 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Settings/components/pages/ProfileSettingsPage.swift +90 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Settings/components/pages/SettingsHomePage.swift +70 -0
- package/templates/ios/ios/{{APP_NAME}}/Info.plist +14 -0
- package/templates/ios/ios/{{APP_NAME}}/Localizable.xcstrings +1030 -0
- package/templates/ios/ios/{{APP_NAME}}/PrivacyInfo.xcprivacy +122 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/APIClient.swift +23 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/AcceptLanguageInterceptor.swift +20 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/Components/atoms/AsyncButton.swift +73 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/Components/atoms/AsyncToolbarButton.swift +69 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/Components/atoms/ToolbarIconButton.swift +46 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/Components/molecules/EmptyState.swift +132 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/Components/molecules/LabeledInfoRow.swift +29 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/Components/styles/CaptionLabelStyle.swift +45 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/Components/styles/Copyable.swift +32 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/Components/styles/EdgeToEdgeSeparator.swift +14 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/Components/styles/RowLabelStyle.swift +58 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/CursorPage.swift +26 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/DataChanges.swift +19 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/ErrorPresenter.swift +62 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/ErrorReporting.swift +35 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/FeedbackReporting.swift +42 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/FirebaseTokenInterceptor.swift +49 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/GraphQLClient.swift +54 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/GraphQLHelpers.swift +128 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/GraphQLLoggingInterceptor.swift +28 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/LoadGate.swift +30 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/Secrets.swift.example +9 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/Services/AppSupportGate.swift +50 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/Shell/StartupLoadingView.swift +23 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/Shell/UpdateRequiredView.swift +24 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/Showcase.swift +13 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/SnapshotCache.swift +78 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/UITestEnvironment.swift +70 -0
- package/templates/ios/ios/{{APP_NAME}}/{{APP_NAME}}.entitlements +10 -0
- package/templates/ios/ios/{{APP_NAME}}Tests/Onboarding/OnboardingGateTests.swift +38 -0
- package/templates/ios/ios/{{APP_NAME}}Tests/Settings/SettingsViewModelTests.swift +63 -0
- package/templates/ios/ios/{{APP_NAME}}Tests/Support/TestDoubles.swift +35 -0
- package/templates/ios/package.merge.json +10 -0
- package/templates/ios/scripts/apollo-codegen.ts +31 -0
- package/templates/ios/scripts/check-ios-components.ts +72 -0
- package/templates/ios/scripts/check-strings.ts +103 -0
- package/templates/ios/scripts/install-device.sh +29 -0
- package/templates/ios/scripts/testflight.ts +213 -0
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# Scheduled routines
|
|
2
|
+
|
|
3
|
+
Claude tasks that run on a schedule. Each one states its cadence, where it runs, its
|
|
4
|
+
repositories, what it may do, and what it never does. A routine's push is the one exception to
|
|
5
|
+
"never push unasked" ([02-commits.md](02-commits.md#push)).
|
|
6
|
+
|
|
7
|
+
## Where a routine runs
|
|
8
|
+
|
|
9
|
+
**In the cloud by default**, as a cloud scheduled task on the person's claude.ai account: it runs
|
|
10
|
+
when the machine is off, from a fresh clone of the repositories, with the connectors and the
|
|
11
|
+
environment variables the task is given, and pushes as the person's GitHub identity. Minimum
|
|
12
|
+
interval one hour. What a cloud run does not have: the machine's files (no keychain, no local
|
|
13
|
+
`.env`, no observer `gcloud` configuration), Xcode and the simulators, the person's browser
|
|
14
|
+
sessions. So:
|
|
15
|
+
|
|
16
|
+
- a cloud routine gets its credentials as environment variables of its cloud environment (the
|
|
17
|
+
Sentry token, the observer service account key, the store API key), never from a file in a
|
|
18
|
+
repository;
|
|
19
|
+
- a change a cloud routine cannot verify itself (an app fix needs Xcode) is pushed as a branch
|
|
20
|
+
with a pull request, and merged by the next pass once CI built it, instead of going to
|
|
21
|
+
`main` directly;
|
|
22
|
+
- a routine that needs the machine (a simulator, the keychain, a capture) is a **desktop**
|
|
23
|
+
scheduled task, which runs only while the app is open and the Mac awake, and catches up one
|
|
24
|
+
missed run on wake.
|
|
25
|
+
|
|
26
|
+
Each routine's prompt names its runtime in its frontmatter (`runtime: cloud` or `desktop`); the
|
|
27
|
+
install and upgrade skills create a cloud task through the `/schedule` tooling or a desktop task
|
|
28
|
+
through the app, and update it on a version change.
|
|
29
|
+
|
|
30
|
+
## When a routine cannot decide
|
|
31
|
+
|
|
32
|
+
A routine never asks the person in its own session: nobody is there. It **opens a GitHub issue**
|
|
33
|
+
in the project, labelled `decision`, with the question, the options and what it will do by
|
|
34
|
+
default if nobody answers, and references it from the pull request or the report. GitHub
|
|
35
|
+
notifies the person on their phone and desktop; the answer is written in the issue; the next pass
|
|
36
|
+
reads the issues labelled `decision` that got an answer and acts on them. A push notification to
|
|
37
|
+
the phone (`PushNotification`) is sent in addition when the session can, best effort: it needs
|
|
38
|
+
Remote Control connected and is not reliable from a scheduled run.
|
|
39
|
+
|
|
40
|
+
| Routine | Cadence, runtime | Does | Never |
|
|
41
|
+
|---|---|---|---|
|
|
42
|
+
| Sentry triage | Every six hours, cloud, every app | Reads unresolved issues, fixes the cause with a verified commit, pushes to `main`, watches CI, marks `resolvedInNextRelease` | Marks plain `resolved`; touches an issue it did not fix |
|
|
43
|
+
| Daily health | Noon, cloud, per app | Through the observer account: spend from the billing export, performance, monitoring; fixes and pushes what is code; reports cold starts as a figure | Proposes a warm instance; reads errors (Sentry's job) |
|
|
44
|
+
| Upgrades | Every morning after Renovate, cloud | Stack pull requests (label `stack`): the `stack-upgrade` skill, sync, migrations with their checks, merge when every migration is safe and CI is green. Major dependency pull requests: reads the changelog and migration guide, applies the code changes, deploys to staging, merges when CI and staging are green, otherwise comments and leaves open | Force-merges; edits rules; touches a pull request without one of those labels |
|
|
45
|
+
| Deprecations | Monthly, cloud, per app | Runs the deprecations script, removes what is removable, raises the build floor, pushes, watches CI and Deploy | Removes anything in grace or still asked; raises the floor by hand |
|
|
46
|
+
|
|
47
|
+
## Shipped by the package, installed on the machine
|
|
48
|
+
|
|
49
|
+
The routines' prompts live in `routines/` of this package, with placeholders for the project's
|
|
50
|
+
facts. A routine is `scope: machine` (one instance for every project: Sentry triage, stack
|
|
51
|
+
upgrades) or `scope: project` (one per project: daily health). `sync` renders them into
|
|
52
|
+
`~/.claude/scheduled-tasks/<id>/SKILL.md` (`<id>-<app>` for a project routine); the
|
|
53
|
+
`stack-install` and `stack-upgrade` skills then create the scheduled task when it does not exist
|
|
54
|
+
and update its prompt and cadence when it does, through the app's scheduled-tasks tools. A routine
|
|
55
|
+
is never edited by hand on the machine: the change goes into the package and reaches every machine
|
|
56
|
+
and project at the next version.
|
|
57
|
+
|
|
58
|
+
## Rules
|
|
59
|
+
|
|
60
|
+
- A routine verifies before committing and watches CI after pushing like any session; what it
|
|
61
|
+
cannot verify where it runs goes through a pull request and CI.
|
|
62
|
+
- A routine pushes at most three commits per pass (Sentry) or one (the others), and a report
|
|
63
|
+
that says "pushed" always carries the CI result per workflow.
|
|
64
|
+
- A routine never asks the person in its session; a decision becomes a `decision` issue.
|
|
65
|
+
- A routine reads production through the observer account, never through the person's login.
|
|
66
|
+
- The daily health routine checks the last run of every other routine and reports one that
|
|
67
|
+
missed two cadences: nothing else watches the routines.
|
|
68
|
+
- The routine's prompt lives in the machine's scheduled tasks; the skills it follows are shipped
|
|
69
|
+
by this package.
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# Landing page
|
|
2
|
+
|
|
3
|
+
Every product gets a page that sells it. It lives in the project's repository (`site/`), static,
|
|
4
|
+
no build step, published by the Pages workflow on the project's domain
|
|
5
|
+
(`<app>.polyforms.co`), next to the privacy policy and the support page the store requires.
|
|
6
|
+
Shiori's page is the reference.
|
|
7
|
+
|
|
8
|
+
## What it is for
|
|
9
|
+
|
|
10
|
+
One job: a person who does not know the product understands in ten seconds what it does for
|
|
11
|
+
them, and in two minutes wants to install it. Everything on the page serves that; nothing explains
|
|
12
|
+
how the product is built.
|
|
13
|
+
|
|
14
|
+
## Structure
|
|
15
|
+
|
|
16
|
+
In this order, each section one screen tall at most:
|
|
17
|
+
|
|
18
|
+
1. **Hero.** A title of a few words that says the benefit, not the feature ("Your library in your
|
|
19
|
+
pocket."), a subtitle of two or three short sentences, the store badge, the platform line. The
|
|
20
|
+
product's own home screen beside it.
|
|
21
|
+
2. **Tour.** The product screen by screen: for each, a title that is a promise ("One photo, the
|
|
22
|
+
rest follows."), two sentences of what happens, the real screen. The essentials only, in the
|
|
23
|
+
order a new user meets them.
|
|
24
|
+
3. **Features.** Everything else the product does, as cards with the app's own icons and one
|
|
25
|
+
sentence each. The section lead does not repeat the hero.
|
|
26
|
+
4. **Compare.** Free against paid, as a feature table, the way the store's paywall presents it.
|
|
27
|
+
The paid plan's name and prices are the store's.
|
|
28
|
+
5. **FAQ.** The questions a careful buyer asks: price, data, privacy, deletion, what happens
|
|
29
|
+
offline, what the AI does with a photo. Short answers.
|
|
30
|
+
6. **Download.** The free plan set beside the paid one, the store badge again.
|
|
31
|
+
|
|
32
|
+
Then a footer: privacy policy, support, the company.
|
|
33
|
+
|
|
34
|
+
## Copy
|
|
35
|
+
|
|
36
|
+
The tone of [08-copy.md](08-copy.md) applies in full, plus:
|
|
37
|
+
|
|
38
|
+
- **Benefit first.** Every title says what the person gets; the feature is in the sentence
|
|
39
|
+
under it.
|
|
40
|
+
- **Short.** A title under eight words, a paragraph under three sentences. The whole page reads
|
|
41
|
+
in two minutes.
|
|
42
|
+
- **The product's name is not in the copy.** It is in the logo and the title tag; the sentences
|
|
43
|
+
talk to the reader about their life, not about the app.
|
|
44
|
+
- **Concrete.** Numbers and examples over adjectives: "thirty books without pulling out a single
|
|
45
|
+
one", not "fast and powerful".
|
|
46
|
+
- **Honest.** Nothing the shipped product does not do. A feature that is coming is not on the
|
|
47
|
+
page.
|
|
48
|
+
|
|
49
|
+
## Screens and images
|
|
50
|
+
|
|
51
|
+
- **Real screens only**, from the showcase captures ([07-appstore.md](07-appstore.md)), never a
|
|
52
|
+
mock-up drawn by hand or by a model. Each image has an `alt` that says what the screen shows.
|
|
53
|
+
- **The app's own icons** (its SF Symbols) for every card, so the page and the app share a
|
|
54
|
+
visual language.
|
|
55
|
+
- **Real people's data** on the screens is shown with their agreement, and reads as real (a
|
|
56
|
+
friend's actual reading), never as lorem ipsum.
|
|
57
|
+
- An Open Graph image and a favicon derived from the app icon.
|
|
58
|
+
|
|
59
|
+
## Languages
|
|
60
|
+
|
|
61
|
+
The page is written in the source language in the HTML; `i18n.js` carries the translation for
|
|
62
|
+
every element marked `data-i18n` (text), `data-i18n-alt` (image alt), `data-i18n-aria`,
|
|
63
|
+
`data-i18n-src` (a screen or a cover swapped for its edition in that language), applied before the
|
|
64
|
+
first paint from the browser's language. Every screen and mock is translated, down to the spines
|
|
65
|
+
on a shelf: a French screen on an English page breaks the illusion. A language switch is visible
|
|
66
|
+
in the nav.
|
|
67
|
+
|
|
68
|
+
## Publishing and checks
|
|
69
|
+
|
|
70
|
+
- `site/**` triggers the Pages workflow and nothing else; never `[skip ci]` on a site commit
|
|
71
|
+
([04-push.md](04-push.md)).
|
|
72
|
+
- Before pushing, open the page in the built-in browser in both languages, at phone and desktop
|
|
73
|
+
widths, and send a screenshot: the page is a UI change like any other
|
|
74
|
+
([01-workflow.md](01-workflow.md#screenshot-after-every-ui-change)).
|
|
75
|
+
- The privacy policy page states what is collected, by whom, for how long, and how to delete the
|
|
76
|
+
account, in the source language and in English; the store listing links to it.
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: appstore
|
|
3
|
+
description: Use when preparing an App Store or TestFlight step of a Polyforms app (listing, captures and panels, first subscription submission, release notes, signing secrets) or when App Store Connect refuses something. The long form of stack rule 07-appstore.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# App Store Connect
|
|
7
|
+
|
|
8
|
+
## The release, in order
|
|
9
|
+
|
|
10
|
+
1. `bun run deprecations`, remove what is removable, floor raised by the script's value.
|
|
11
|
+
2. Notes in `CHANGELOG.md` and each `CHANGELOG.<lang>.md` (rule 06), French shown and
|
|
12
|
+
approved in the conversation.
|
|
13
|
+
3. Push `main`, watch Deploy.
|
|
14
|
+
4. Captures and panels only if the person asked.
|
|
15
|
+
5. `git tag ios-v<version> && git push origin ios-v<version>`; watch Release to the end.
|
|
16
|
+
|
|
17
|
+
## Secrets the iOS workflows read
|
|
18
|
+
|
|
19
|
+
| Secret | What |
|
|
20
|
+
|---|---|
|
|
21
|
+
| `ASC_KEY_ID`, `ASC_ISSUER_ID`, `ASC_KEY_P8` | The team's App Store Connect API key |
|
|
22
|
+
| `IOS_DIST_CERT_P12`, `IOS_DIST_CERT_PASSWORD` | The Apple Distribution certificate, base64 |
|
|
23
|
+
| `IOS_PROVISION_PROFILE` | The App Store profile named `<App> App Store`, base64 |
|
|
24
|
+
| `IOS_GOOGLE_SERVICE_INFO_PLIST` | The Firebase plist, base64 |
|
|
25
|
+
| `IOS_SENTRY_DSN`, `SENTRY_AUTH_TOKEN` | Reporting and dSYM upload |
|
|
26
|
+
|
|
27
|
+
## What has no API
|
|
28
|
+
|
|
29
|
+
Done once, in the built-in browser, signed in as the person: the app record, the price
|
|
30
|
+
schedule, the App Privacy answers, accepting a new agreement (account holder only).
|
|
31
|
+
|
|
32
|
+
## First subscription
|
|
33
|
+
|
|
34
|
+
The API refuses every direct route (`FIRST_SUBSCRIPTION_MUST_BE_SUBMITTED_ON_VERSION`,
|
|
35
|
+
`SUBSCRIPTION_GROUP_SUBMISSION_NOT_ALLOWED`). The sequence that works:
|
|
36
|
+
1. API: create a draft `reviewSubmission` (platform IOS), add the `appStoreVersion` as an item.
|
|
37
|
+
2. Browser: the subscription group's page, "Add for review" is a dropdown, pick the draft;
|
|
38
|
+
then each subscription's page, same dropdown, same draft.
|
|
39
|
+
3. The draft's "Submit for review" becomes active; submit.
|
|
40
|
+
Cancelling takes a minute; a draft without items cannot be cancelled, delete its items.
|
|
41
|
+
|
|
42
|
+
## Listing traps
|
|
43
|
+
|
|
44
|
+
- A name held by another account in a locale answers 409: "Name: What it does".
|
|
45
|
+
- Keywords: 100 characters, checked by App Store Connect.
|
|
46
|
+
- A locale with release notes but no listing is created empty and the submission is refused:
|
|
47
|
+
add a locale's `infra/fastlane/metadata/<locale>/` before its notes.
|
|
48
|
+
- The product page header (16:9) is dragged in by hand: there is no API for it.
|
|
49
|
+
|
|
50
|
+
## TestFlight
|
|
51
|
+
|
|
52
|
+
Adding a tester who belongs to another app's group answers 409: create the tester with
|
|
53
|
+
`POST /v1/betaTesters` carrying the group relationship.
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: domain
|
|
3
|
+
description: Use when creating or changing a backend domain (types, primitives, command, query, repository, business rules, use case, migration) in a Polyforms Nitro + Firestore project. The long form of stack rule 09-backend.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Create or change a domain
|
|
7
|
+
|
|
8
|
+
The `user` domain under `server/domain/user/` is the reference: copy its shape.
|
|
9
|
+
|
|
10
|
+
## 1. Files
|
|
11
|
+
|
|
12
|
+
```
|
|
13
|
+
server/domain/<domain>/
|
|
14
|
+
types.ts branded types and the aggregate; the file IS the spec of the model
|
|
15
|
+
primitives.ts one Zod constructor per brand
|
|
16
|
+
command.ts writes, bare exported functions
|
|
17
|
+
query.ts reads, bare exported functions
|
|
18
|
+
business-rules.ts optional: pure functions, no I/O, full coverage
|
|
19
|
+
use-case.ts optional: orchestration across domains, no business logic of its own
|
|
20
|
+
infrastructure/
|
|
21
|
+
repository.ts the only file that touches Firestore
|
|
22
|
+
graphql/{enums,types,inputs,queries,mutations}.ts
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Callers import a module under the domain's name: `import * as OrderCommand from
|
|
26
|
+
'~/domain/order/command'`, then `OrderCommand.place(...)`. Never a TypeScript `namespace`.
|
|
27
|
+
|
|
28
|
+
## 2. Types and primitives
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
// types.ts
|
|
32
|
+
import type { Brand } from 'ts-brand'
|
|
33
|
+
import type { UserId } from '~/domain/shared/types'
|
|
34
|
+
|
|
35
|
+
export type OrderId = Brand<string, 'OrderId'>
|
|
36
|
+
export type Quantity = Brand<number, 'Quantity'>
|
|
37
|
+
|
|
38
|
+
export type Order = {
|
|
39
|
+
id: OrderId
|
|
40
|
+
userId: UserId // every private document names its owner
|
|
41
|
+
quantity: Quantity
|
|
42
|
+
note?: string // absence is an optional field, never null
|
|
43
|
+
createdAt: Date
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// primitives.ts
|
|
47
|
+
export const Quantity = (value: unknown) =>
|
|
48
|
+
make<QuantityType>()(z.number().int().min(1).max(999).parse(value))
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Never `as Quantity` on untrusted input: the constructor. Generic brands (`UserId`, `Count`,
|
|
52
|
+
`Eur`, `Year`, `Day`) come from `~/domain/shared`.
|
|
53
|
+
|
|
54
|
+
## 3. Repository
|
|
55
|
+
|
|
56
|
+
```ts
|
|
57
|
+
const orders = () => db().collection('orders').withConverter(genericDataConverter<Order>())
|
|
58
|
+
|
|
59
|
+
export const findAllByUser = (userId: UserId): Promise<Order[]> =>
|
|
60
|
+
memoizedPerRequest(`order:all:${userId}`, async () =>
|
|
61
|
+
(await orders().where('userId', '==', userId).get()).docs.map((doc) => doc.data()),
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
export const save = async (order: Order) => {
|
|
65
|
+
await orders().doc(order.id).set(withoutAbsentFields(order))
|
|
66
|
+
evictFromRequestCache(`order:all:${order.userId}`)
|
|
67
|
+
return order
|
|
68
|
+
}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
- Flat collections, `userId` on every private document, every query filters on it.
|
|
72
|
+
- A memoised read is evicted or revised by every write under its key.
|
|
73
|
+
- Several writes: `atomically(batch => ...)`; read-modify-write: `transactionally(tx => ...)`;
|
|
74
|
+
mass delete: `deleteInBatches`.
|
|
75
|
+
- Filter, sort and page in Firestore. A new composite query adds its index to
|
|
76
|
+
`firestore.indexes.json` in the same commit.
|
|
77
|
+
|
|
78
|
+
## 4. Query and command
|
|
79
|
+
|
|
80
|
+
```ts
|
|
81
|
+
// query.ts
|
|
82
|
+
export const byId = async (userId: UserId, id: OrderId) => {
|
|
83
|
+
const order = await repository.findBy(userId, id)
|
|
84
|
+
if (!order) return 'not-found' as const
|
|
85
|
+
return order
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// command.ts
|
|
89
|
+
export const place = async (userId: UserId, quantity: Quantity) => {
|
|
90
|
+
if (await isOverLimit(userId)) return 'limit-reached' as const
|
|
91
|
+
return repository.save({ id: OrderId(randomUUID()), userId, quantity, createdAt: new Date() })
|
|
92
|
+
}
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
One literal per expected failure, `as const`; `throw` only for an impossible state. A
|
|
96
|
+
function's name is the business concept (`place`, not `createOrder`, never `handleX`).
|
|
97
|
+
|
|
98
|
+
## 5. Account deletion
|
|
99
|
+
|
|
100
|
+
A domain with private documents exports `deleteAllForUser(userId)` from its command and adds
|
|
101
|
+
it to the parallel block of `server/domain/user/use-case.ts`.
|
|
102
|
+
|
|
103
|
+
## 6. Tests
|
|
104
|
+
|
|
105
|
+
| File | What it proves |
|
|
106
|
+
|---|---|
|
|
107
|
+
| `primitives.unit.test.ts`, `business-rules.unit.test.ts` | Every rule, every boundary |
|
|
108
|
+
| `command.int.test.ts`, `query.int.test.ts` | Through the real repository against the fake Firestore; read budgets with `fake.docReads` and `fake.queryReads` |
|
|
109
|
+
| `infrastructure/graphql/*.feat.test.ts` | The API contract: answer shape and values, error codes, read budget |
|
|
110
|
+
|
|
111
|
+
```ts
|
|
112
|
+
mock.module('~/system/firebase', () => ({ db: fakeDb }))
|
|
113
|
+
const OrderCommand = await import('~/domain/order/command')
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Dates are asserted with `toEqual` on the field, never inside `toMatchObject`. Run the three
|
|
117
|
+
families separately.
|
|
118
|
+
|
|
119
|
+
## 7. Migration
|
|
120
|
+
|
|
121
|
+
Renaming, restructuring, changing enum values or removing data needs a migration:
|
|
122
|
+
`server/system/migration/migrations/NNN-<what-it-does>.ts` exporting a `Migration`
|
|
123
|
+
(`version`, `name`, `migrate({ db })` returning `{ ok: true, transformed }`), its
|
|
124
|
+
`.int.test.ts` against the fake Firestore, and its line in `migrations/index.ts`. Idempotent:
|
|
125
|
+
run twice, it changes nothing the second time. The Deploy workflow runs it; never by hand.
|
|
126
|
+
|
|
127
|
+
## Checklist
|
|
128
|
+
|
|
129
|
+
- [ ] Types branded, absence optional, `userId` on private documents
|
|
130
|
+
- [ ] One constructor per brand, one GraphQL scalar per brand (graphql skill)
|
|
131
|
+
- [ ] Repository private, memoised reads evicted on write
|
|
132
|
+
- [ ] Outcomes as literals, mapped exhaustively in the resolver
|
|
133
|
+
- [ ] `deleteAllForUser` wired into account deletion
|
|
134
|
+
- [ ] Indexes added for new queries
|
|
135
|
+
- [ ] Unit, int and feat tests, read budgets asserted
|
|
136
|
+
- [ ] `bun run generate:graphql`, `bun run schema:check`, preflight green
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: graphql
|
|
3
|
+
description: Use when adding or changing a GraphQL type, field, query, mutation, input, enum or scalar in a Polyforms Pothos schema, or deprecating one. The long form of stack rules 09-backend and 11-ops (API evolution).
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Change the GraphQL API
|
|
7
|
+
|
|
8
|
+
Domain first, schema second: the domain is the source of truth.
|
|
9
|
+
|
|
10
|
+
## Where things go
|
|
11
|
+
|
|
12
|
+
`server/domain/<domain>/infrastructure/graphql/` holds `enums.ts`, `types.ts`, `inputs.ts`,
|
|
13
|
+
`queries.ts`, `mutations.ts`, each imported in order by `server/domain/shared/graphql/schema.ts`
|
|
14
|
+
(a lender before a borrower). One endpoint, `POST /graphql`.
|
|
15
|
+
|
|
16
|
+
## A scalar per brand
|
|
17
|
+
|
|
18
|
+
```ts
|
|
19
|
+
// builder.ts: Scalars: { Quantity: { Input: Quantity; Output: Quantity } }
|
|
20
|
+
// scalars.ts
|
|
21
|
+
scalar('Quantity', 'How many, 1 to 999. Example: 3.', Quantity)
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
The scalar reuses the brand's constructor: an invalid value is a `BAD_USER_INPUT` before the
|
|
25
|
+
resolver runs, and resolvers never call a constructor themselves.
|
|
26
|
+
|
|
27
|
+
## Types, queries, mutations
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
export const OrderType = builder.objectRef<Order>('Order').implement({
|
|
31
|
+
description: 'One order of the signed-in user.',
|
|
32
|
+
fields: (t) => ({
|
|
33
|
+
id: t.expose('id', { type: 'OrderId', description: 'Opaque identifier.' }),
|
|
34
|
+
note: t.exposeString('note', { nullable: true, description: 'Absent when none was written.' }),
|
|
35
|
+
}),
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
builder.mutationField('placeOrder', (t) =>
|
|
39
|
+
t.field({
|
|
40
|
+
type: OrderType,
|
|
41
|
+
description: 'Place an order. Fails with LIMIT_REACHED past the monthly limit.',
|
|
42
|
+
args: { quantity: t.arg({ type: 'Quantity', required: true, description: 'How many.' }) },
|
|
43
|
+
resolve: async (_root, { quantity }, { userId }) =>
|
|
44
|
+
match(await OrderCommand.place(userId, quantity))
|
|
45
|
+
.with('limit-reached', () => domainError('LIMIT_REACHED', 'Monthly limit reached'))
|
|
46
|
+
.with(P.not(P.string), (order) => order)
|
|
47
|
+
.exhaustive(),
|
|
48
|
+
}),
|
|
49
|
+
)
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
- Every type, field, argument and enum value has a description: it is the documentation
|
|
53
|
+
the app developer reads.
|
|
54
|
+
- Nullability is off by default: a field that may be absent says `nullable: true`.
|
|
55
|
+
- Outcomes map with `match().exhaustive()`; `notFound`, `badUserInput`, `forbidden`,
|
|
56
|
+
`domainError(code, message)` from `shared/graphql/errors.ts`. A consultation that finds
|
|
57
|
+
nothing answers `null`.
|
|
58
|
+
- Optional inputs pass through `stripNulls` before the domain.
|
|
59
|
+
- A nested field that needs another document: denormalise it on the parent, or add a
|
|
60
|
+
per-request loader to the context before shipping it. Never one read per parent row.
|
|
61
|
+
|
|
62
|
+
## After a change
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
bun run generate:graphql # shared/schema.graphql, committed whole, never hunk-split
|
|
66
|
+
bun run schema:check # refuses a breaking change that was not deprecated
|
|
67
|
+
bun run ios:codegen # when the app uses it
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Deprecate, never remove
|
|
71
|
+
|
|
72
|
+
When the app stops using a field or an argument, in the same commit:
|
|
73
|
+
|
|
74
|
+
```ts
|
|
75
|
+
oldField: t.exposeString('old', {
|
|
76
|
+
deprecationReason: 'since 1.3 (2026-10-21): use newField',
|
|
77
|
+
description: '...',
|
|
78
|
+
})
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
The monthly deprecations routine removes it once a build without it has been on sale for
|
|
82
|
+
fourteen days and raises the build floor. Never remove by hand, never raise the floor by hand.
|
|
83
|
+
Adding a field, a query, a mutation, an optional argument or an enum value needs nothing.
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: ios-feature
|
|
3
|
+
description: Use when adding or changing an iOS screen or feature in a Polyforms SwiftUI app (screen, page, organism, ViewModel, client, API, fixtures, previews, tests), or taking its screenshot. The long form of stack rule 10-ios.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Add an iOS feature
|
|
7
|
+
|
|
8
|
+
`Features/Settings/` is the reference: copy its shape.
|
|
9
|
+
|
|
10
|
+
## Files
|
|
11
|
+
|
|
12
|
+
```
|
|
13
|
+
Features/<Domain>/
|
|
14
|
+
<Feature>Screen.swift connected: ViewModel, tasks, change notices, navigation, sheets
|
|
15
|
+
<Feature>ViewModel.swift @MainActor @Observable, init(client:), state private(set)
|
|
16
|
+
<Domain>Client.swift struct of @Sendable closures, .live and .preview
|
|
17
|
+
<Domain>API.swift the only file that sees <App>GraphQL types; returns app models
|
|
18
|
+
<Domain>Models.swift Sendable app models
|
|
19
|
+
<Domain>Fixtures.swift static let preview data, shared by previews and tests
|
|
20
|
+
GraphQL/<Domain>.graphql operations
|
|
21
|
+
components/organisms/ sections, rows, cards: may take a model, map to primitives
|
|
22
|
+
components/pages/ whole screens, pure, one onAction over an enum
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## The page and its screen
|
|
26
|
+
|
|
27
|
+
```swift
|
|
28
|
+
enum OrdersAction { case open(Order.ID); case delete(Order.ID); case refresh }
|
|
29
|
+
|
|
30
|
+
struct OrdersPage: View {
|
|
31
|
+
let orders: [Order]
|
|
32
|
+
let isLoading: Bool
|
|
33
|
+
let errorMessage: String?
|
|
34
|
+
let onAction: (OrdersAction) async -> Void
|
|
35
|
+
var body: some View { /* list, EmptyState, EmptyState.failure, .refreshable */ }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
struct OrdersScreen: View {
|
|
39
|
+
@State private var viewModel = OrdersViewModel(client: .live)
|
|
40
|
+
@State private var opened: Order.ID?
|
|
41
|
+
var body: some View {
|
|
42
|
+
OrdersPage(orders: viewModel.orders, isLoading: viewModel.isLoading,
|
|
43
|
+
errorMessage: viewModel.errorMessage) { action in
|
|
44
|
+
switch action {
|
|
45
|
+
case let .open(id): opened = id
|
|
46
|
+
case let .delete(id): await viewModel.delete(id)
|
|
47
|
+
case .refresh: await viewModel.load()
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
.task { await viewModel.load() }
|
|
51
|
+
.onReceive(NotificationCenter.default.publisher(for: .dataDidChange)) { _ in
|
|
52
|
+
Task { await viewModel.load() }
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Loading rules in code
|
|
59
|
+
|
|
60
|
+
- Nothing yet: `ProgressView()` in the body. Failure over nothing: `EmptyState.failure`.
|
|
61
|
+
Failure over rows: keep the rows, `reportError` only.
|
|
62
|
+
- Every mutation through `GraphQLHelpers.perform` (it posts `.dataDidChange`); a mutation
|
|
63
|
+
that changes nothing a list shows passes `changesData: false`.
|
|
64
|
+
- A button that waits: `AsyncButton` / `AsyncToolbarButton`.
|
|
65
|
+
- Lists: `CursorPage`, a `LoadMoreRow`-style closer, a generation token in the ViewModel.
|
|
66
|
+
|
|
67
|
+
## Patterns
|
|
68
|
+
|
|
69
|
+
A list row, a detail page (fiche), a tab's main screen, a form: as described in rule 10-ios,
|
|
70
|
+
"Screen patterns". The toolbar of a detail page: `xmark` left; on the right the one repeated
|
|
71
|
+
action as an icon, then a `…` menu, edit first, delete last and red.
|
|
72
|
+
|
|
73
|
+
## Strings
|
|
74
|
+
|
|
75
|
+
Written in French in the code. After the build: `bun run ios:strings -- --write`, fill the
|
|
76
|
+
English of each new key in `Localizable.xcstrings`, rerun without `--write` until it reports
|
|
77
|
+
every string translated, commit the catalogue with the code.
|
|
78
|
+
|
|
79
|
+
## Tests
|
|
80
|
+
|
|
81
|
+
`<App>Tests/<Domain>/<Feature>ViewModelTests.swift` with Swift Testing: the client built
|
|
82
|
+
inline from `.preview` with one closure overridden; `Boom`, `Flag`, `Calls` from
|
|
83
|
+
`Support/TestDoubles.swift`. Never a test of a `body`.
|
|
84
|
+
|
|
85
|
+
## Screenshot
|
|
86
|
+
|
|
87
|
+
1. A temporary launch argument in `App.swift` that shows the page with its fixtures
|
|
88
|
+
(`if CommandLine.arguments.contains("-shot-orders") { OrdersPage(orders: Order.previews, ...) }`).
|
|
89
|
+
2. Build into `/tmp/<app>-shot-<topic>` with `SWIFT_ENABLE_EXPLICIT_MODULES=NO`, install on
|
|
90
|
+
the reference simulator, launch with the argument, `xcrun simctl io <udid> screenshot`.
|
|
91
|
+
3. Revert the harness before the commit; keep fixtures and previews.
|
|
92
|
+
4. Check the design conventions, send the screenshot, wait for approval.
|
|
93
|
+
|
|
94
|
+
Then `bun run ios:components` (pure and previewed) and `bun run ios:test`.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: stack-install
|
|
3
|
+
description: Use to start a new Polyforms project, or to bring an existing project onto the @moifort/stack package (init, templates, Renovate, CI check, routines).
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Install the stack
|
|
7
|
+
|
|
8
|
+
## New project
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
mkdir <app> && cd <app> && git init
|
|
12
|
+
bunx @moifort/stack init --stacks backend,ios --set APP_TAGLINE="..."
|
|
13
|
+
bun install
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Stacks: `backend`, `ios`, `ai`, `paid`, `admin`, `e2e`, `site`. `init` asks for every
|
|
17
|
+
placeholder the chosen stacks use that it cannot derive; derived ones (name, slug, bundle id,
|
|
18
|
+
repository) come from the folder name and can be overridden with `--set NAME=value`.
|
|
19
|
+
|
|
20
|
+
Then, in order, verifying each:
|
|
21
|
+
1. `bun run preflight` green.
|
|
22
|
+
2. `bun run ios:build` and `bun run ios:test` green (ios stack).
|
|
23
|
+
3. `bunx @moifort/stack bootstrap <app>` creates GitHub, Sentry, Renovate, GCP, and pushes
|
|
24
|
+
the first commit; the Deploy workflow does the first deploy. It ends with the list of
|
|
25
|
+
what needs the person.
|
|
26
|
+
4. Write the deployed function URL into `ios/<App>/Shared/APIClient.swift`.
|
|
27
|
+
5. Create or update the scheduled routines from `stack/routines/` (rule 16).
|
|
28
|
+
6. Fill `project-context.md`.
|
|
29
|
+
|
|
30
|
+
## Existing project
|
|
31
|
+
|
|
32
|
+
`init` never overwrites: it writes what is missing and lists what it kept. Then write a
|
|
33
|
+
batched plan, one batch per domain (CI, Terraform, Sentry, iOS shell, scripts), comparing each
|
|
34
|
+
kept file with its template and bringing the project in line batch by batch, each batch
|
|
35
|
+
verified and committed on its own.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: stack-upgrade
|
|
3
|
+
description: Use when a Renovate pull request bumps @moifort/stack in a project, to sync the rules and apply the version's migrations.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Upgrade the stack in a project
|
|
7
|
+
|
|
8
|
+
1. Confirm the pull request bumps `@moifort/stack`. Otherwise this skill does not apply.
|
|
9
|
+
2. In the pull request's branch: `bun install`, `bunx stack sync`, commit
|
|
10
|
+
`chore(stack): sync to <version>`.
|
|
11
|
+
3. `bunx stack migrations <old> <new> --json`: the migrations between the two versions, for
|
|
12
|
+
this project's stacks, in order.
|
|
13
|
+
4. For each migration, in order:
|
|
14
|
+
- read the rule it points to and its body (what changes, how to find it, how to fix it,
|
|
15
|
+
when it does not apply);
|
|
16
|
+
- find the affected code, fix it, run its `check` command until it exits 0;
|
|
17
|
+
- commit `refactor(<scope>): <migration id>`;
|
|
18
|
+
- when it does not apply, note why and commit nothing.
|
|
19
|
+
5. `bun run preflight` (and `bun run ios:build` with the ios stack). A failure caused by a
|
|
20
|
+
migration: revert that migration's commit and mark it `needs review`.
|
|
21
|
+
6. If the version changed a routine (`stack/routines/`), update the scheduled task.
|
|
22
|
+
7. Comment on the pull request: one line per migration, its outcome (applied, not applicable,
|
|
23
|
+
needs review) and the files touched.
|
|
24
|
+
8. Merge only when every migration is `safe`, each applied or not applicable, every `check`
|
|
25
|
+
passes and CI is green. Otherwise label `stack:review` and leave it open.
|
|
26
|
+
|
|
27
|
+
Never edit `stack/` by hand, never edit a rule from a project: a wrong rule is reported.
|
package/src/catalogue.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { Stack } from '~/config'
|
|
2
|
+
|
|
3
|
+
/** Which stacks a shipped file needs; absent means it always applies. */
|
|
4
|
+
export const RULE_STACKS: Record<string, Stack[]> = {
|
|
5
|
+
'07-appstore.md': ['ios'],
|
|
6
|
+
'09-backend.md': ['backend'],
|
|
7
|
+
'10-ios.md': ['ios'],
|
|
8
|
+
'11-ops.md': ['backend'],
|
|
9
|
+
'12-ci.md': ['backend', 'ios'],
|
|
10
|
+
'13-ai.md': ['ai'],
|
|
11
|
+
'14-infra.md': ['backend'],
|
|
12
|
+
'17-landing.md': ['site'],
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const ROUTINE_STACKS: Record<string, Stack[]> = {
|
|
16
|
+
'daily-health.md': ['backend'],
|
|
17
|
+
'deprecations.md': ['backend', 'ios'],
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const SKILL_STACKS: Record<string, Stack[]> = {
|
|
21
|
+
domain: ['backend'],
|
|
22
|
+
graphql: ['backend'],
|
|
23
|
+
'ios-feature': ['ios'],
|
|
24
|
+
appstore: ['ios'],
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const HOOK_STACKS: Record<string, Stack[]> = {
|
|
28
|
+
'shutdown-simulators-after-push.sh': ['ios'],
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** A file applies when it needs no stack, or when the project has at least one of the stacks it names. */
|
|
32
|
+
export const applies = (needs: Stack[] | undefined, stacks: readonly Stack[]): boolean =>
|
|
33
|
+
needs === undefined || needs.some((stack) => stacks.includes(stack))
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
|
|
3
|
+
export const STACKS = ['backend', 'ios', 'ai', 'paid', 'admin', 'e2e', 'site'] as const
|
|
4
|
+
export type Stack = (typeof STACKS)[number]
|
|
5
|
+
|
|
6
|
+
export const StackConfig = z.object({
|
|
7
|
+
stacks: z.array(z.enum(STACKS)).default([]),
|
|
8
|
+
placeholders: z.record(z.string(), z.string()).default({}),
|
|
9
|
+
exclude: z.array(z.string()).default([]),
|
|
10
|
+
})
|
|
11
|
+
export type StackConfig = z.infer<typeof StackConfig>
|
|
12
|
+
|
|
13
|
+
export const CONFIG_FILE = 'stack.config.json'
|
|
14
|
+
|
|
15
|
+
export const readConfig = async (projectDir: string): Promise<StackConfig> => {
|
|
16
|
+
const file = Bun.file(`${projectDir}/${CONFIG_FILE}`)
|
|
17
|
+
if (!(await file.exists()))
|
|
18
|
+
throw new Error(`${CONFIG_FILE} not found in ${projectDir}: run init first`)
|
|
19
|
+
return StackConfig.parse(await file.json())
|
|
20
|
+
}
|