anhedral 0.4.1 → 0.6.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 (85) hide show
  1. package/README.md +29 -256
  2. package/assets/anhedral-cli-init-technical.svg +322 -0
  3. package/assets/anhedral-cli-init.svg +257 -173
  4. package/dist/bin.js +28 -162
  5. package/dist/dependencies.d.ts +7 -56
  6. package/dist/dependencies.js +96 -305
  7. package/dist/index.d.ts +2 -7
  8. package/dist/index.js +1 -4
  9. package/dist/{templates → platforms}/desktop.d.ts +1 -1
  10. package/dist/{templates → platforms}/desktop.js +28 -266
  11. package/dist/{templates → platforms}/extension.d.ts +1 -1
  12. package/dist/platforms/extension.js +401 -0
  13. package/dist/{templates → platforms}/mobile.d.ts +1 -1
  14. package/dist/platforms/mobile.js +232 -0
  15. package/dist/project.d.ts +4 -0
  16. package/dist/project.js +1 -0
  17. package/dist/standard-products.d.ts +2 -0
  18. package/dist/standard-products.js +6 -0
  19. package/dist/standard.d.ts +18 -0
  20. package/dist/standard.js +578 -0
  21. package/dist/version.d.ts +1 -1
  22. package/dist/version.js +1 -1
  23. package/docs/application-stack-standard.md +700 -0
  24. package/package.json +34 -49
  25. package/CHANGELOG.md +0 -132
  26. package/CONTRIBUTING.md +0 -73
  27. package/SECURITY.md +0 -37
  28. package/SKILL.md +0 -244
  29. package/assets/images/svg/ably-symbol-color.svg +0 -8
  30. package/bin/anhedral.js +0 -2
  31. package/dist/architecture/contributions.d.ts +0 -28
  32. package/dist/architecture/contributions.js +0 -125
  33. package/dist/architecture/index.d.ts +0 -12
  34. package/dist/architecture/index.js +0 -6
  35. package/dist/architecture/manifest.d.ts +0 -53
  36. package/dist/architecture/manifest.js +0 -283
  37. package/dist/architecture/modules.d.ts +0 -37
  38. package/dist/architecture/modules.js +0 -184
  39. package/dist/architecture/plan.d.ts +0 -58
  40. package/dist/architecture/plan.js +0 -145
  41. package/dist/architecture/templates.d.ts +0 -10
  42. package/dist/architecture/templates.js +0 -23
  43. package/dist/cli.d.ts +0 -32
  44. package/dist/cli.js +0 -235
  45. package/dist/composers/root.d.ts +0 -6
  46. package/dist/composers/root.js +0 -35
  47. package/dist/print.d.ts +0 -8
  48. package/dist/print.js +0 -48
  49. package/dist/prompts.d.ts +0 -5
  50. package/dist/prompts.js +0 -19
  51. package/dist/scaffold.d.ts +0 -76
  52. package/dist/scaffold.js +0 -1895
  53. package/dist/template-source.d.ts +0 -20
  54. package/dist/template-source.js +0 -180
  55. package/dist/templates/api.d.ts +0 -2
  56. package/dist/templates/api.js +0 -3122
  57. package/dist/templates/assets-private-proxy.d.ts +0 -3
  58. package/dist/templates/assets-private-proxy.js +0 -277
  59. package/dist/templates/electron-updater.d.ts +0 -3
  60. package/dist/templates/electron-updater.js +0 -358
  61. package/dist/templates/extension.js +0 -732
  62. package/dist/templates/mobile.js +0 -720
  63. package/dist/templates/shared.d.ts +0 -2
  64. package/dist/templates/shared.js +0 -520
  65. package/dist/templates/web.d.ts +0 -2
  66. package/dist/templates/web.js +0 -412
  67. package/dist/toolchain.d.ts +0 -3
  68. package/dist/toolchain.js +0 -8
  69. package/dist/ui.d.ts +0 -33
  70. package/dist/ui.js +0 -199
  71. package/dist/workspace-config.d.ts +0 -11
  72. package/dist/workspace-config.js +0 -372
  73. package/docs/conventions.md +0 -92
  74. package/docs/master-stack-map.md +0 -199
  75. package/docs/output-tree-contract.md +0 -482
  76. package/docs/references/manual-scaffolding.md +0 -403
  77. package/docs/references/provisioning.md +0 -220
  78. package/scripts/render-master-stack-map.mjs +0 -510
  79. package/templates/api-fastify/apps/api/tsconfig.json +0 -20
  80. package/templates/catalog.json +0 -23
  81. package/templates/db-drizzle/packages/db/tsconfig.json +0 -14
  82. package/templates/desktop-electron/apps/desktop/tsconfig.json +0 -37
  83. package/templates/extension-wxt/apps/extension/tsconfig.json +0 -41
  84. package/templates/mobile-expo/apps/mobile/expo-env.d.ts +0 -1
  85. package/templates/web-next/apps/web/next-env.d.ts +0 -6
@@ -1,720 +0,0 @@
1
- import path from 'node:path';
2
- import { anhedralPrint } from '../print.js';
3
- import { appendGitignore, writeFile } from '../util.js';
4
- import { childPackageName, jsString } from '../render.js';
5
- import { FRONTEND_ADDON_DEPENDENCIES, MOBILE_APP_DEPENDENCIES, MOBILE_NATIVEWIND_DEPENDENCIES, MOBILE_UNIWIND_DEPENDENCIES, TOOLCHAIN_DEPENDENCIES, } from '../dependencies.js';
6
- function selectedDependencies(options) {
7
- const dependencies = { ...(MOBILE_APP_DEPENDENCIES.dependencies ?? {}) };
8
- Object.assign(dependencies, (options.nativeStyling ?? 'nativewind') === 'uniwind'
9
- ? MOBILE_UNIWIND_DEPENDENCIES.dependencies
10
- : MOBILE_NATIVEWIND_DEPENDENCIES.dependencies);
11
- if (!options.apps.api)
12
- delete dependencies['@shared/api-client'];
13
- if (!options.features.billing)
14
- delete dependencies['@shared/realtime'];
15
- if (options.features.auth) {
16
- for (const name of [
17
- '@clerk/expo',
18
- 'expo-secure-store',
19
- ])
20
- dependencies[name] = FRONTEND_ADDON_DEPENDENCIES[name];
21
- }
22
- if (options.features.nativeSubscriptions) {
23
- dependencies['react-native-purchases'] = FRONTEND_ADDON_DEPENDENCIES['react-native-purchases'];
24
- dependencies['react-native-purchases-ui'] = FRONTEND_ADDON_DEPENDENCIES['react-native-purchases-ui'];
25
- }
26
- return dependencies;
27
- }
28
- function writeReactNativeReusablesConfig(dir, styling) {
29
- writeFile(path.join(dir, 'components.json'), JSON.stringify({
30
- $schema: 'https://ui.shadcn.com/schema.json',
31
- style: 'new-york',
32
- rsc: false,
33
- tsx: true,
34
- tailwind: {
35
- config: styling === 'nativewind' ? 'tailwind.config.js' : '',
36
- css: 'global.css',
37
- baseColor: 'neutral',
38
- cssVariables: true,
39
- },
40
- aliases: {
41
- components: '@/components',
42
- utils: '@/lib/utils',
43
- ui: '@/components/ui',
44
- lib: '@/lib',
45
- hooks: '@/hooks',
46
- },
47
- iconLibrary: 'lucide',
48
- }, null, 2) + '\n');
49
- writeFile(path.join(dir, 'lib/utils.ts'), `import { clsx, type ClassValue } from 'clsx';
50
- import { twMerge } from 'tailwind-merge';
51
-
52
- export function cn(...inputs: ClassValue[]) {
53
- return twMerge(clsx(inputs));
54
- }
55
- `);
56
- if (styling === 'uniwind') {
57
- writeFile(path.join(dir, 'metro.config.js'), `const { getDefaultConfig } = require('expo/metro-config');
58
- const { withUniwindConfig } = require('uniwind/metro');
59
-
60
- const config = getDefaultConfig(__dirname);
61
-
62
- module.exports = withUniwindConfig(config, {
63
- cssEntryFile: './global.css',
64
- dtsFile: './uniwind-types.d.ts',
65
- });
66
- `);
67
- writeFile(path.join(dir, 'uniwind-types.d.ts'), `/// <reference types="uniwind/types" />
68
-
69
- declare module 'uniwind' {
70
- export interface UniwindConfig {
71
- themes: readonly ['light', 'dark'];
72
- }
73
- }
74
-
75
- export {};
76
- `);
77
- writeFile(path.join(dir, 'global.css'), `@import "tailwindcss";
78
- @import "uniwind";
79
- @import "tw-animate-css";
80
-
81
- @theme {
82
- --radius: 10px;
83
- --radius-sm: calc(var(--radius) - 4px);
84
- --radius-md: calc(var(--radius) - 2px);
85
- --radius-lg: var(--radius);
86
- --radius-xl: calc(var(--radius) + 4px);
87
- --spacing-hairline: hairlineWidth();
88
- }
89
-
90
- @layer theme {
91
- :root {
92
- @variant light {
93
- --color-background: oklch(1 0 0); --color-foreground: oklch(0.145 0 0);
94
- --color-card: oklch(1 0 0); --color-card-foreground: oklch(0.145 0 0);
95
- --color-popover: oklch(1 0 0); --color-popover-foreground: oklch(0.145 0 0);
96
- --color-primary: oklch(0.205 0 0); --color-primary-foreground: oklch(0.985 0 0);
97
- --color-secondary: oklch(0.97 0 0); --color-secondary-foreground: oklch(0.205 0 0);
98
- --color-muted: oklch(0.97 0 0); --color-muted-foreground: oklch(0.556 0 0);
99
- --color-accent: oklch(0.97 0 0); --color-accent-foreground: oklch(0.205 0 0);
100
- --color-destructive: oklch(0.577 0.245 27.325); --color-border: oklch(0.922 0 0);
101
- --color-input: oklch(0.922 0 0); --color-ring: oklch(0.708 0 0);
102
- }
103
- @variant dark {
104
- --color-background: oklch(0.145 0 0); --color-foreground: oklch(0.985 0 0);
105
- --color-card: oklch(0.205 0 0); --color-card-foreground: oklch(0.985 0 0);
106
- --color-popover: oklch(0.205 0 0); --color-popover-foreground: oklch(0.985 0 0);
107
- --color-primary: oklch(0.922 0 0); --color-primary-foreground: oklch(0.205 0 0);
108
- --color-secondary: oklch(0.269 0 0); --color-secondary-foreground: oklch(0.985 0 0);
109
- --color-muted: oklch(0.269 0 0); --color-muted-foreground: oklch(0.708 0 0);
110
- --color-accent: oklch(0.269 0 0); --color-accent-foreground: oklch(0.985 0 0);
111
- --color-destructive: oklch(0.704 0.191 22.216); --color-border: oklch(1 0 0 / 10%);
112
- --color-input: oklch(1 0 0 / 15%); --color-ring: oklch(0.556 0 0);
113
- }
114
- }
115
- }
116
- `);
117
- return;
118
- }
119
- writeFile(path.join(dir, 'babel.config.js'), `module.exports = function (api) {
120
- api.cache(true);
121
- return {
122
- presets: [['babel-preset-expo', { jsxImportSource: 'nativewind' }], 'nativewind/babel'],
123
- };
124
- };
125
- `);
126
- writeFile(path.join(dir, 'metro.config.js'), `const { getDefaultConfig } = require('expo/metro-config');
127
- const { withNativeWind } = require('nativewind/metro');
128
-
129
- const config = getDefaultConfig(__dirname);
130
-
131
- module.exports = withNativeWind(config, { input: './global.css', inlineRem: 16 });
132
- `);
133
- writeFile(path.join(dir, 'nativewind-env.d.ts'), '/// <reference types="nativewind/types" />\n');
134
- writeFile(path.join(dir, 'tailwind.config.js'), `const { hairlineWidth } = require('nativewind/theme');
135
-
136
- /** @type {import('tailwindcss').Config} */
137
- module.exports = {
138
- darkMode: 'class',
139
- content: ['./app/**/*.{ts,tsx}', './components/**/*.{ts,tsx}'],
140
- presets: [require('nativewind/preset')],
141
- theme: {
142
- extend: {
143
- colors: {
144
- border: 'hsl(var(--border))', input: 'hsl(var(--input))', ring: 'hsl(var(--ring))',
145
- background: 'hsl(var(--background))', foreground: 'hsl(var(--foreground))',
146
- primary: { DEFAULT: 'hsl(var(--primary))', foreground: 'hsl(var(--primary-foreground))' },
147
- secondary: { DEFAULT: 'hsl(var(--secondary))', foreground: 'hsl(var(--secondary-foreground))' },
148
- destructive: { DEFAULT: 'hsl(var(--destructive))', foreground: 'hsl(var(--destructive-foreground))' },
149
- muted: { DEFAULT: 'hsl(var(--muted))', foreground: 'hsl(var(--muted-foreground))' },
150
- accent: { DEFAULT: 'hsl(var(--accent))', foreground: 'hsl(var(--accent-foreground))' },
151
- popover: { DEFAULT: 'hsl(var(--popover))', foreground: 'hsl(var(--popover-foreground))' },
152
- card: { DEFAULT: 'hsl(var(--card))', foreground: 'hsl(var(--card-foreground))' },
153
- },
154
- borderRadius: { lg: 'var(--radius)', md: 'calc(var(--radius) - 2px)', sm: 'calc(var(--radius) - 4px)' },
155
- borderWidth: { hairline: hairlineWidth() },
156
- },
157
- },
158
- future: { hoverOnlyWhenSupported: true },
159
- plugins: [require('tailwindcss-animate')],
160
- };
161
- `);
162
- writeFile(path.join(dir, 'global.css'), `@tailwind base;
163
- @tailwind components;
164
- @tailwind utilities;
165
-
166
- @layer base {
167
- :root {
168
- --background: 0 0% 100%; --foreground: 0 0% 3.9%;
169
- --card: 0 0% 100%; --card-foreground: 0 0% 3.9%;
170
- --popover: 0 0% 100%; --popover-foreground: 0 0% 3.9%;
171
- --primary: 0 0% 9%; --primary-foreground: 0 0% 98%;
172
- --secondary: 0 0% 96.1%; --secondary-foreground: 0 0% 9%;
173
- --muted: 0 0% 96.1%; --muted-foreground: 0 0% 45.1%;
174
- --accent: 0 0% 96.1%; --accent-foreground: 0 0% 9%;
175
- --destructive: 0 84.2% 60.2%; --destructive-foreground: 0 0% 98%;
176
- --border: 0 0% 89.8%; --input: 0 0% 89.8%; --ring: 0 0% 63%; --radius: 0.625rem;
177
- }
178
- .dark:root {
179
- --background: 0 0% 3.9%; --foreground: 0 0% 98%;
180
- --card: 0 0% 3.9%; --card-foreground: 0 0% 98%;
181
- --popover: 0 0% 3.9%; --popover-foreground: 0 0% 98%;
182
- --primary: 0 0% 98%; --primary-foreground: 0 0% 9%;
183
- --secondary: 0 0% 14.9%; --secondary-foreground: 0 0% 98%;
184
- --muted: 0 0% 14.9%; --muted-foreground: 0 0% 63.9%;
185
- --accent: 0 0% 14.9%; --accent-foreground: 0 0% 98%;
186
- --destructive: 0 70.9% 59.4%; --destructive-foreground: 0 0% 98%;
187
- --border: 0 0% 14.9%; --input: 0 0% 14.9%; --ring: 300 0% 45%;
188
- }
189
- }
190
- `);
191
- }
192
- function expoScheme(projectName) {
193
- const unscoped = projectName.replace(/^@[^/]+\//, '');
194
- const normalized = unscoped
195
- .toLowerCase()
196
- .replace(/[^a-z0-9+.-]+/g, '-')
197
- .replace(/^[+.-]+|[+.-]+$/g, '');
198
- const candidate = normalized || 'app';
199
- return /^[a-z]/.test(candidate) ? candidate : `app-${candidate}`;
200
- }
201
- export async function scaffoldMobile(root, options) {
202
- const dir = path.join(root, 'apps/mobile');
203
- const { projectName, displayName } = options;
204
- const nameLiteral = jsString(displayName);
205
- const hasSubscriptions = options.features.nativeSubscriptions;
206
- const nativeStyling = options.nativeStyling ?? 'nativewind';
207
- anhedralPrint.section('Mobile (Expo)');
208
- anhedralPrint.step('Writing deterministic Expo application');
209
- writeFile(path.join(dir, 'package.json'), JSON.stringify({
210
- name: childPackageName(projectName, 'mobile'),
211
- version: '0.1.0',
212
- private: true,
213
- main: 'expo-router/entry',
214
- scripts: {
215
- dev: 'expo start -c',
216
- android: 'expo start -c --android',
217
- ios: 'expo start -c --ios',
218
- web: 'expo start -c --web',
219
- build: 'pnpm typecheck && pnpm build:web',
220
- 'build:web': 'expo export --platform web',
221
- 'eas:login': `pnpm dlx eas-cli@${TOOLCHAIN_DEPENDENCIES['eas-cli']} login`,
222
- 'build:internal:ios': `pnpm dlx eas-cli@${TOOLCHAIN_DEPENDENCIES['eas-cli']} build --platform ios --profile preview`,
223
- 'build:internal:android': `pnpm dlx eas-cli@${TOOLCHAIN_DEPENDENCIES['eas-cli']} build --platform android --profile preview`,
224
- 'build:production:ios': `pnpm dlx eas-cli@${TOOLCHAIN_DEPENDENCIES['eas-cli']} build --platform ios --profile production`,
225
- 'build:production:android': `pnpm dlx eas-cli@${TOOLCHAIN_DEPENDENCIES['eas-cli']} build --platform android --profile production`,
226
- 'submit:ios': `pnpm dlx eas-cli@${TOOLCHAIN_DEPENDENCIES['eas-cli']} submit --platform ios --profile production --latest`,
227
- 'submit:android': `pnpm dlx eas-cli@${TOOLCHAIN_DEPENDENCIES['eas-cli']} submit --platform android --profile production --latest`,
228
- typecheck: 'tsc --noEmit',
229
- },
230
- dependencies: selectedDependencies(options),
231
- devDependencies: MOBILE_APP_DEPENDENCIES.devDependencies,
232
- }, null, 2) + '\n');
233
- writeFile(path.join(dir, 'app.json'), JSON.stringify({
234
- expo: {
235
- name: displayName,
236
- slug: projectName.replace(/^@[^/]+\//, ''),
237
- version: '1.0.0',
238
- orientation: 'portrait',
239
- scheme: expoScheme(projectName),
240
- userInterfaceStyle: 'automatic',
241
- web: { bundler: 'metro', output: 'static' },
242
- plugins: ['expo-router', ...(options.features.auth ? ['expo-secure-store'] : [])],
243
- experiments: { typedRoutes: true },
244
- },
245
- }, null, 2) + '\n');
246
- writeFile(path.join(dir, 'tsconfig.json'), JSON.stringify({
247
- extends: 'expo/tsconfig.base',
248
- compilerOptions: {
249
- strict: true,
250
- noUncheckedIndexedAccess: true,
251
- paths: { '@/*': ['./*'] },
252
- },
253
- include: [
254
- '**/*.ts',
255
- '**/*.tsx',
256
- '.expo/types/**/*.ts',
257
- 'expo-env.d.ts',
258
- nativeStyling === 'nativewind' ? 'nativewind-env.d.ts' : 'uniwind-types.d.ts',
259
- ],
260
- }, null, 2) + '\n');
261
- writeReactNativeReusablesConfig(dir, nativeStyling);
262
- const layoutImports = options.features.auth
263
- ? `import { ClerkProvider, useAuth } from '@clerk/expo';
264
- import { tokenCache } from '@clerk/expo/token-cache';
265
- import { ${hasSubscriptions ? 'Button, ' : ''}ScrollView, Text, View } from 'react-native';
266
- `
267
- : '';
268
- const subscriptionImports = hasSubscriptions
269
- ? `import { useEffect${options.features.auth ? ', useState' : ''} } from 'react';
270
- import { ${options.features.auth ? 'syncRevenueCatUser, type SubscriptionState' : 'initializeRevenueCat'} } from '../lib/subscriptions';
271
- `
272
- : '';
273
- const stackElement = hasSubscriptions ? '<AppStack />' : '<Stack />';
274
- const appStack = hasSubscriptions && options.features.auth ? `function AppStack() {
275
- const { isLoaded, userId } = useAuth();
276
- const [syncState, setSyncState] = useState<SubscriptionState | null>(null);
277
-
278
- useEffect(() => {
279
- if (!isLoaded) return;
280
- let cancelled = false;
281
- setSyncState({ status: 'syncing', message: 'Synchronizing the subscription account…' });
282
- void syncRevenueCatUser(userId ?? null).then((result) => {
283
- if (!cancelled) setSyncState(result);
284
- });
285
- return () => { cancelled = true; };
286
- }, [isLoaded, userId]);
287
-
288
- const retry = async () => {
289
- setSyncState({ status: 'syncing', message: 'Retrying subscription account synchronization…' });
290
- setSyncState(await syncRevenueCatUser(userId ?? null));
291
- };
292
-
293
- return (
294
- <>
295
- <Stack />
296
- {syncState?.status === 'error' ? (
297
- <View style={{ position: 'absolute', left: 16, right: 16, bottom: 16, gap: 8, padding: 16, borderRadius: 12, backgroundColor: '#fee2e2' }}>
298
- <Text selectable accessibilityLiveRegion="assertive" style={{ color: '#991b1b' }}>{syncState.message}</Text>
299
- <Button title="Retry subscription sync" onPress={() => void retry()} />
300
- </View>
301
- ) : null}
302
- </>
303
- );
304
- }
305
-
306
- ` : hasSubscriptions ? `function AppStack() {
307
- useEffect(() => {
308
- void initializeRevenueCat(null);
309
- }, []);
310
-
311
- return <Stack />;
312
- }
313
-
314
- ` : '';
315
- const layoutBody = options.features.auth
316
- ? ` const publishableKey = process.env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY;
317
- if (!publishableKey) {
318
- return (
319
- <ScrollView contentInsetAdjustmentBehavior="automatic" contentContainerStyle={{ flexGrow: 1, justifyContent: 'center', padding: 32 }}>
320
- <View style={{ gap: 8 }}>
321
- <Text accessibilityRole="header" style={{ fontSize: 24, fontWeight: '700' }}>Authentication configuration required</Text>
322
- <Text selectable accessibilityLiveRegion="assertive">
323
- Set EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY before starting this app.
324
- </Text>
325
- </View>
326
- </ScrollView>
327
- );
328
- }
329
- return <><ClerkProvider publishableKey={publishableKey} tokenCache={tokenCache}>${stackElement}</ClerkProvider><PortalHost /></>;`
330
- : ` return <>${stackElement}<PortalHost /></>;`;
331
- writeFile(path.join(dir, 'app/_layout.tsx'), `import '@/global.css';
332
-
333
- import { PortalHost } from '@rn-primitives/portal';
334
- import { Stack } from 'expo-router';
335
- ${layoutImports}${subscriptionImports}
336
- ${appStack}
337
- export default function RootLayout() {
338
- ${layoutBody}
339
- }
340
- `);
341
- const moduleLines = [
342
- options.apps.api ? 'Typed API client' : null,
343
- options.features.auth ? 'Clerk authentication' : null,
344
- options.features.nativeSubscriptions ? 'RevenueCat subscriptions' : null,
345
- ].filter((value) => value !== null);
346
- const homeImports = [
347
- options.features.nativeSubscriptions
348
- ? "import { useState } from 'react';\nimport { getRevenueCatAvailability, presentPaywallIfNeeded, type SubscriptionState } from '../lib/subscriptions';"
349
- : null,
350
- options.features.auth ? "import { AccountControls } from '../components/account-controls';" : null,
351
- options.features.billing ? "import { useEntitlement } from '../hooks/use-entitlement';" : null,
352
- ].filter((value) => value !== null).join('\n');
353
- const reactNativeImports = options.features.nativeSubscriptions
354
- ? 'Button, ScrollView, StyleSheet, Text, View'
355
- : 'ScrollView, StyleSheet, Text, View';
356
- const subscriptionComponent = options.features.nativeSubscriptions
357
- ? `function SubscriptionControls() {
358
- const [state, setState] = useState<SubscriptionState>(() => getRevenueCatAvailability());
359
- const [isBusy, setIsBusy] = useState(false);
360
- const { entitlement, error, refresh } = useEntitlement();
361
-
362
- const openPaywall = async () => {
363
- setIsBusy(true);
364
- setState({ status: 'ready', message: 'Opening subscription options…' });
365
- const result = await presentPaywallIfNeeded();
366
- setState(result);
367
- if (result.status === 'success') await refresh(true);
368
- setIsBusy(false);
369
- };
370
-
371
- return (
372
- <View style={styles.actions}>
373
- <Button title={isBusy ? 'Opening…' : 'View subscription options'} disabled={isBusy || state.status === 'unavailable'} onPress={() => void openPaywall()} />
374
- <Text
375
- selectable
376
- accessibilityLiveRegion={state.status === 'error' ? 'assertive' : 'polite'}
377
- style={state.status === 'error' ? styles.error : styles.status}
378
- >
379
- {state.message}
380
- </Text>
381
- {entitlement ? <Text selectable style={styles.status}>Plan: {entitlement.entitlement} ({entitlement.status})</Text> : null}
382
- {error ? <Text selectable accessibilityLiveRegion="assertive" style={styles.error}>{error}</Text> : null}
383
- </View>
384
- );
385
- }
386
-
387
- `
388
- : '';
389
- const nativeSubscriptionAction = options.features.nativeSubscriptions
390
- ? ' <SubscriptionControls />\n'
391
- : '';
392
- const accountControls = options.features.auth ? ' <AccountControls />\n' : '';
393
- writeFile(path.join(dir, 'app/index.tsx'), `import { StatusBar } from 'expo-status-bar';
394
- import { ${reactNativeImports} } from 'react-native';
395
- ${homeImports}
396
-
397
- const enabledModules = ${JSON.stringify(moduleLines)};
398
-
399
- ${subscriptionComponent}export default function HomeScreen() {
400
- return (
401
- <ScrollView style={styles.screen} contentInsetAdjustmentBehavior="automatic" contentContainerStyle={styles.container}>
402
- <StatusBar style="auto" />
403
- <View style={styles.content}>
404
- <Text accessibilityRole="header" style={styles.title}>{${nameLiteral}}</Text>
405
- <Text selectable style={styles.subtitle}>Deterministic Expo application generated by Anhedral.</Text>
406
- {enabledModules.map((moduleName) => <Text selectable key={moduleName} style={styles.module}>• {moduleName}</Text>)}
407
- ${accountControls}${nativeSubscriptionAction} </View>
408
- </ScrollView>
409
- );
410
- }
411
-
412
- const styles = StyleSheet.create({
413
- screen: { flex: 1, backgroundColor: '#f8fafc' },
414
- container: { flexGrow: 1, justifyContent: 'center', padding: 32 },
415
- content: { gap: 12 },
416
- actions: { gap: 8, paddingTop: 8 },
417
- title: { color: '#0f172a', fontSize: 34, fontWeight: '700' },
418
- subtitle: { color: '#475569', fontSize: 17, paddingBottom: 12 },
419
- module: { color: '#1e293b', fontSize: 15 },
420
- status: { color: '#475569', fontSize: 14 },
421
- error: { color: '#b91c1c', fontSize: 14 },
422
- });
423
- `);
424
- if (options.features.auth) {
425
- writeFile(path.join(dir, 'components/account-controls.tsx'), `import { Show, useClerk, useUser } from '@clerk/expo';
426
- import { useState } from 'react';
427
- import { Button, Text, View } from 'react-native';
428
-
429
- export function AccountControls() {
430
- const clerk = useClerk();
431
- const { user } = useUser();
432
- const [error, setError] = useState<string | null>(null);
433
-
434
- const signIn = async () => {
435
- setError(null);
436
- try {
437
- await Promise.resolve(clerk.openSignIn({}));
438
- } catch {
439
- setError('Unable to open sign in. Check the Clerk configuration and try again.');
440
- }
441
- };
442
-
443
- const signOut = async () => {
444
- setError(null);
445
- try {
446
- await clerk.signOut();
447
- } catch {
448
- setError('Unable to sign out. Check your connection and try again.');
449
- }
450
- };
451
-
452
- return (
453
- <View style={{ gap: 8, paddingTop: 8 }}>
454
- <Show when="signed-out">
455
- <Button title="Sign in" onPress={() => void signIn()} />
456
- </Show>
457
- <Show when="signed-in">
458
- <Text selectable>
459
- Account: {user?.primaryEmailAddress?.emailAddress ?? user?.id ?? 'Signed in'}
460
- </Text>
461
- <Button title="Sign out" onPress={() => void signOut()} />
462
- </Show>
463
- {error ? (
464
- <Text selectable accessibilityLiveRegion="assertive" style={{ color: '#b91c1c' }}>{error}</Text>
465
- ) : null}
466
- </View>
467
- );
468
- }
469
- `);
470
- }
471
- if (options.apps.api) {
472
- writeFile(path.join(dir, 'lib/api.ts'), `import { ApiClient, normalizeApiBaseUrl } from '@shared/api-client';
473
-
474
- function apiBaseUrl(): string {
475
- const configured = process.env.EXPO_PUBLIC_API_URL?.trim();
476
- const candidate = configured || (__DEV__ ? 'http://localhost:8787/api' : '');
477
- if (!candidate) throw new Error('EXPO_PUBLIC_API_URL is required in production builds');
478
- const normalized = normalizeApiBaseUrl(candidate, 'EXPO_PUBLIC_API_URL');
479
- const url = new URL(normalized);
480
- if (!__DEV__ && url.protocol !== 'https:') {
481
- throw new Error('EXPO_PUBLIC_API_URL must use https: in production');
482
- }
483
- return normalized;
484
- }
485
-
486
- export function createApiClient(getToken?: () => Promise<string | null>) {
487
- return new ApiClient({
488
- baseUrl: apiBaseUrl(),
489
- getToken,
490
- });
491
- }
492
- `);
493
- }
494
- if (options.apps.api && options.features.auth) {
495
- writeFile(path.join(dir, 'hooks/use-api-client.ts'), `import { useAuth } from '@clerk/expo';
496
- import { useMemo } from 'react';
497
- import { createApiClient } from '../lib/api';
498
-
499
- export function useApiClient() {
500
- const { getToken } = useAuth();
501
- return useMemo(() => createApiClient(() => getToken()), [getToken]);
502
- }
503
- `);
504
- }
505
- if (options.features.billing) {
506
- writeFile(path.join(dir, 'hooks/use-entitlement.ts'), `import { subscribeToSubscriptionChanges } from '@shared/realtime';
507
- import { useAuth } from '@clerk/expo';
508
- import * as React from 'react';
509
- import { AppState } from 'react-native';
510
- import { useApiClient } from './use-api-client';
511
- ${options.features.nativeSubscriptions ? "import { subscribeToRevenueCatUpdates } from '../lib/subscriptions';" : ''}
512
-
513
- export function useEntitlement() {
514
- const api = useApiClient();
515
- const { isLoaded, isSignedIn, userId } = useAuth();
516
- const [entitlement, setEntitlement] = React.useState<Awaited<ReturnType<typeof api.getEntitlement>> | null>(null);
517
- const [error, setError] = React.useState<string | null>(null);
518
- const revision = React.useRef(0);
519
- const refresh = React.useCallback(async (reconcile = false) => {
520
- if (!isLoaded || !isSignedIn) return;
521
- try {
522
- const next = reconcile ? await api.refreshEntitlement() : await api.getEntitlement();
523
- revision.current = Math.max(revision.current, next.revision);
524
- setEntitlement(next);
525
- setError(null);
526
- } catch (cause) {
527
- setError(cause instanceof Error ? cause.message : 'Unable to load subscription');
528
- }
529
- }, [api, isLoaded, isSignedIn]);
530
-
531
- React.useEffect(() => { void refresh(); }, [refresh]);
532
- ${options.features.nativeSubscriptions ? 'React.useEffect(() => subscribeToRevenueCatUpdates(() => { void refresh(true); }), [refresh]);' : ''}
533
- React.useEffect(() => {
534
- const subscription = AppState.addEventListener('change', (state) => {
535
- if (state === 'active') void refresh();
536
- });
537
- return () => subscription.remove();
538
- }, [refresh]);
539
- React.useEffect(() => {
540
- if (!userId || !isSignedIn) return;
541
- return subscribeToSubscriptionChanges({
542
- userId,
543
- getTokenRequest: () => api.getRealtimeToken(),
544
- onChange: (nextRevision) => {
545
- if (nextRevision > revision.current) void refresh();
546
- },
547
- onError: (cause) => setError(cause.message),
548
- });
549
- }, [api, isSignedIn, refresh, userId]);
550
- return { entitlement, error, refresh };
551
- }
552
- `);
553
- }
554
- if (hasSubscriptions) {
555
- writeFile(path.join(dir, 'lib/subscriptions.ts'), `import { Platform } from 'react-native';
556
- import Purchases from 'react-native-purchases';
557
-
558
- export const revenueCatConfig = {
559
- entitlementId: process.env.EXPO_PUBLIC_RC_ENTITLEMENT_ID || 'pro',
560
- iosApiKey: process.env.EXPO_PUBLIC_RC_API_KEY_IOS,
561
- androidApiKey: process.env.EXPO_PUBLIC_RC_API_KEY_ANDROID,
562
- };
563
-
564
- export type SubscriptionState = {
565
- status: 'ready' | 'syncing' | 'unavailable' | 'cancelled' | 'success' | 'error';
566
- message: string;
567
- };
568
-
569
- let configured = false;
570
- let currentAppUserId: string | null = null;
571
- let desiredAppUserId: string | null | undefined;
572
- let identitySynchronized = false;
573
- let syncQueue: Promise<void> = Promise.resolve();
574
- const customerInfoListeners = new Set<() => void>();
575
- let customerInfoListenerRegistered = false;
576
-
577
- function ensureCustomerInfoListener(): void {
578
- if (customerInfoListenerRegistered || Platform.OS === 'web') return;
579
- Purchases.addCustomerInfoUpdateListener(() => {
580
- for (const listener of customerInfoListeners) listener();
581
- });
582
- customerInfoListenerRegistered = true;
583
- }
584
-
585
- export function subscribeToRevenueCatUpdates(listener: () => void): () => void {
586
- customerInfoListeners.add(listener);
587
- return () => customerInfoListeners.delete(listener);
588
- }
589
-
590
- function apiKeyForPlatform(): string | undefined {
591
- return Platform.OS === 'ios'
592
- ? revenueCatConfig.iosApiKey
593
- : Platform.OS === 'android'
594
- ? revenueCatConfig.androidApiKey
595
- : undefined;
596
- }
597
-
598
- export function getRevenueCatAvailability(): SubscriptionState {
599
- if (Platform.OS === 'web') {
600
- return { status: 'unavailable', message: 'Native subscriptions are unavailable on web.' };
601
- }
602
- if (!apiKeyForPlatform()) {
603
- return { status: 'unavailable', message: 'Set the RevenueCat API key for this platform.' };
604
- }
605
- return { status: 'ready', message: 'Subscription options are ready.' };
606
- }
607
-
608
- export async function initializeRevenueCat(appUserId: string | null = null): Promise<SubscriptionState> {
609
- const availability = getRevenueCatAvailability();
610
- if (availability.status === 'unavailable') return availability;
611
- if (configured) return { status: 'ready', message: 'Subscriptions are ready.' };
612
-
613
- try {
614
- Purchases.configure({
615
- apiKey: apiKeyForPlatform()!,
616
- ...(appUserId ? { appUserID: appUserId } : {}),
617
- });
618
- ensureCustomerInfoListener();
619
- configured = true;
620
- currentAppUserId = appUserId;
621
- if (desiredAppUserId === undefined) desiredAppUserId = appUserId;
622
- identitySynchronized = desiredAppUserId === appUserId;
623
- return { status: 'ready', message: 'Subscriptions are ready.' };
624
- } catch {
625
- identitySynchronized = false;
626
- return { status: 'error', message: 'RevenueCat initialization failed.' };
627
- }
628
- }
629
-
630
- async function applyRevenueCatUser(appUserId: string | null): Promise<SubscriptionState> {
631
- const initialized = await initializeRevenueCat(appUserId);
632
- if (initialized.status !== 'ready') {
633
- if (desiredAppUserId === appUserId) identitySynchronized = false;
634
- return initialized;
635
- }
636
- if (currentAppUserId === appUserId) {
637
- if (desiredAppUserId === appUserId) identitySynchronized = true;
638
- return initialized;
639
- }
640
-
641
- try {
642
- if (appUserId) await Purchases.logIn(appUserId);
643
- else if (currentAppUserId) await Purchases.logOut();
644
- currentAppUserId = appUserId;
645
- identitySynchronized = desiredAppUserId === appUserId;
646
- return { status: 'ready', message: 'Subscription account is synchronized.' };
647
- } catch {
648
- if (desiredAppUserId === appUserId) identitySynchronized = false;
649
- return { status: 'error', message: 'Unable to synchronize the subscription account.' };
650
- }
651
- }
652
-
653
- export async function syncRevenueCatUser(appUserId: string | null): Promise<SubscriptionState> {
654
- desiredAppUserId = appUserId;
655
- identitySynchronized = configured && currentAppUserId === appUserId;
656
- const operation = syncQueue.then(() => applyRevenueCatUser(appUserId));
657
- syncQueue = operation.then(() => undefined, () => undefined);
658
- return operation;
659
- }
660
-
661
- export async function presentPaywallIfNeeded(): Promise<SubscriptionState> {
662
- const targetAppUserId = desiredAppUserId;
663
- if (targetAppUserId === undefined) {
664
- return { status: 'error', message: 'Wait for the subscription account to synchronize, then try again.' };
665
- }
666
- if (!identitySynchronized || currentAppUserId !== targetAppUserId) {
667
- const synchronized = await syncRevenueCatUser(targetAppUserId);
668
- if (synchronized.status !== 'ready'
669
- || !identitySynchronized
670
- || currentAppUserId !== desiredAppUserId
671
- || desiredAppUserId !== targetAppUserId) {
672
- return { status: 'error', message: 'Subscription account synchronization is required before opening the paywall.' };
673
- }
674
- }
675
-
676
- try {
677
- const { default: RevenueCatUI, PAYWALL_RESULT } = await import('react-native-purchases-ui');
678
- const result = await RevenueCatUI.presentPaywallIfNeeded({
679
- requiredEntitlementIdentifier: revenueCatConfig.entitlementId,
680
- });
681
- switch (result) {
682
- case PAYWALL_RESULT.PURCHASED:
683
- return { status: 'success', message: 'Subscription purchase completed.' };
684
- case PAYWALL_RESULT.RESTORED:
685
- return { status: 'success', message: 'Subscription restored.' };
686
- case PAYWALL_RESULT.NOT_PRESENTED:
687
- return { status: 'success', message: 'The required entitlement is already active.' };
688
- case PAYWALL_RESULT.CANCELLED:
689
- return { status: 'cancelled', message: 'Subscription options were closed without a purchase.' };
690
- case PAYWALL_RESULT.ERROR:
691
- return { status: 'error', message: 'RevenueCat could not complete the paywall request.' };
692
- default:
693
- return { status: 'error', message: 'RevenueCat returned an unknown paywall result.' };
694
- }
695
- } catch {
696
- return { status: 'error', message: 'Unable to open subscription options.' };
697
- }
698
- }
699
- `);
700
- }
701
- const envLines = [
702
- options.apps.api ? 'EXPO_PUBLIC_API_URL=http://localhost:8787/api' : null,
703
- options.features.auth ? 'EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_***' : null,
704
- options.features.nativeSubscriptions ? 'EXPO_PUBLIC_RC_ENTITLEMENT_ID=pro' : null,
705
- options.features.nativeSubscriptions ? 'EXPO_PUBLIC_RC_API_KEY_IOS=' : null,
706
- options.features.nativeSubscriptions ? 'EXPO_PUBLIC_RC_API_KEY_ANDROID=' : null,
707
- ].filter((value) => value !== null);
708
- writeFile(path.join(dir, '.env.example'), envLines.join('\n') + (envLines.length > 0 ? '\n' : ''));
709
- writeFile(path.join(dir, 'eas.json'), JSON.stringify({
710
- cli: { version: '>= 16.0.0' },
711
- build: {
712
- development: { developmentClient: true, distribution: 'internal' },
713
- preview: { distribution: 'internal' },
714
- production: { autoIncrement: true },
715
- },
716
- submit: { production: {} },
717
- }, null, 2) + '\n');
718
- appendGitignore(dir, ['.env', '.env.*', '!.env.example', '.expo', 'dist', 'node_modules']);
719
- anhedralPrint.done('Expo application written');
720
- }