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.
- package/README.md +29 -269
- package/assets/anhedral-cli-init-technical.svg +322 -0
- package/assets/anhedral-cli-init.svg +257 -173
- package/dist/bin.js +28 -191
- package/dist/dependencies.d.ts +7 -57
- package/dist/dependencies.js +96 -308
- package/dist/index.d.ts +2 -7
- package/dist/index.js +1 -4
- package/dist/platforms/desktop.js +401 -0
- package/dist/platforms/extension.js +401 -0
- package/dist/platforms/mobile.js +232 -0
- package/dist/project.d.ts +0 -26
- package/dist/standard-products.d.ts +2 -0
- package/dist/standard-products.js +6 -0
- package/dist/standard.d.ts +18 -0
- package/dist/standard.js +578 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/docs/application-stack-standard.md +700 -0
- package/package.json +34 -51
- package/CHANGELOG.md +0 -178
- package/CONTRIBUTING.md +0 -77
- package/SECURITY.md +0 -37
- package/SKILL.md +0 -339
- package/assets/images/svg/ably-symbol-color.svg +0 -8
- package/bin/anhedral.js +0 -2
- package/dist/architecture/contributions.d.ts +0 -28
- package/dist/architecture/contributions.js +0 -125
- package/dist/architecture/index.d.ts +0 -12
- package/dist/architecture/index.js +0 -6
- package/dist/architecture/manifest.d.ts +0 -53
- package/dist/architecture/manifest.js +0 -283
- package/dist/architecture/modules.d.ts +0 -41
- package/dist/architecture/modules.js +0 -187
- package/dist/architecture/plan.d.ts +0 -58
- package/dist/architecture/plan.js +0 -145
- package/dist/architecture/templates.d.ts +0 -10
- package/dist/architecture/templates.js +0 -23
- package/dist/cli.d.ts +0 -32
- package/dist/cli.js +0 -281
- package/dist/composers/root.d.ts +0 -6
- package/dist/composers/root.js +0 -35
- package/dist/print.d.ts +0 -8
- package/dist/print.js +0 -48
- package/dist/prompts.d.ts +0 -6
- package/dist/prompts.js +0 -41
- package/dist/scaffold.d.ts +0 -64
- package/dist/scaffold.js +0 -2102
- package/dist/template-source.d.ts +0 -20
- package/dist/template-source.js +0 -180
- package/dist/templates/api.d.ts +0 -2
- package/dist/templates/api.js +0 -3187
- package/dist/templates/assets-private-proxy.d.ts +0 -3
- package/dist/templates/assets-private-proxy.js +0 -277
- package/dist/templates/desktop.js +0 -792
- package/dist/templates/electron-updater.d.ts +0 -3
- package/dist/templates/electron-updater.js +0 -358
- package/dist/templates/extension.js +0 -856
- package/dist/templates/first-run.d.ts +0 -2
- package/dist/templates/first-run.js +0 -167
- package/dist/templates/mobile.js +0 -861
- package/dist/templates/shared.d.ts +0 -2
- package/dist/templates/shared.js +0 -577
- package/dist/templates/web.d.ts +0 -2
- package/dist/templates/web.js +0 -569
- package/dist/toolchain.d.ts +0 -3
- package/dist/toolchain.js +0 -8
- package/dist/ui.d.ts +0 -33
- package/dist/ui.js +0 -199
- package/dist/workspace-config.d.ts +0 -11
- package/dist/workspace-config.js +0 -372
- package/docs/NORTHSTAR.md +0 -114
- package/docs/conventions.md +0 -94
- package/docs/engineering-principles.md +0 -88
- package/docs/master-stack-map.md +0 -199
- package/docs/output-tree-contract.md +0 -493
- package/docs/references/manual-scaffolding.md +0 -421
- package/docs/references/provisioning.md +0 -577
- package/scripts/render-master-stack-map.mjs +0 -510
- package/templates/api-fastify/apps/api/tsconfig.json +0 -20
- package/templates/catalog.json +0 -23
- package/templates/db-drizzle/packages/db/tsconfig.json +0 -14
- package/templates/desktop-electron/apps/desktop/tsconfig.json +0 -37
- package/templates/extension-wxt/apps/extension/tsconfig.json +0 -41
- package/templates/mobile-expo/apps/mobile/expo-env.d.ts +0 -1
- package/templates/web-next/apps/web/next-env.d.ts +0 -6
- /package/dist/{templates → platforms}/desktop.d.ts +0 -0
- /package/dist/{templates → platforms}/extension.d.ts +0 -0
- /package/dist/{templates → platforms}/mobile.d.ts +0 -0
package/dist/templates/mobile.js
DELETED
|
@@ -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
|
-
}
|