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