anhedral 0.1.3 → 0.1.4

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.
@@ -1,2 +1,2 @@
1
1
  import type { ProjectOptions } from '../scaffold.js';
2
- export declare function scaffoldBackend(root: string, { projectName, displayName, frontendUrl }: ProjectOptions): Promise<void>;
2
+ export declare function scaffoldBackend(root: string, { projectName, displayName, frontendUrl, skipInstall }: ProjectOptions): Promise<void>;
@@ -1,8 +1,9 @@
1
1
  import path from 'node:path';
2
- import { writeFile, exec } from '../util.js';
2
+ import { writeFile, execCommand } from '../util.js';
3
3
  import { anhedralPrint } from '../print.js';
4
4
  import { getBackendInstallCommands } from '../commands.js';
5
- export async function scaffoldBackend(root, { projectName, displayName, frontendUrl }) {
5
+ import { BACKEND_DEPENDENCIES } from '../dependencies.js';
6
+ export async function scaffoldBackend(root, { projectName, displayName, frontendUrl, skipInstall }) {
6
7
  const dir = path.join(root, 'apps/api');
7
8
  anhedralPrint.section('Backend (Fastify)');
8
9
  anhedralPrint.step('Writing backend source files');
@@ -24,15 +25,17 @@ export async function scaffoldBackend(root, { projectName, displayName, frontend
24
25
  writeRoutes(dir, displayName);
25
26
  writeAppAndIndex(dir, displayName);
26
27
  writeVercelEntry(dir);
28
+ writeVercelConfig(dir);
27
29
  writeTestFiles(dir);
28
30
  anhedralPrint.done('Backend source files written');
29
- if (process.env.ANHEDRAL_SKIP_INSTALL === '1') {
30
- anhedralPrint.info('Skipping backend dependency install (ANHEDRAL_SKIP_INSTALL=1)');
31
+ if (skipInstall) {
32
+ anhedralPrint.info('Skipping backend dependency install (--skip-install)');
33
+ anhedralPrint.info('Run after init: pnpm install');
31
34
  return;
32
35
  }
33
36
  anhedralPrint.step('Installing backend dependencies');
34
37
  for (const command of getBackendInstallCommands()) {
35
- exec(command.cmd, dir);
38
+ execCommand(command.command, command.args, dir);
36
39
  }
37
40
  anhedralPrint.done('Backend dependencies installed');
38
41
  }
@@ -59,9 +62,8 @@ function writePackageJson(dir, projectName) {
59
62
  },
60
63
  keywords: [],
61
64
  license: 'MIT',
62
- dependencies: {
63
- '@anhedral/db': 'workspace:*',
64
- },
65
+ dependencies: BACKEND_DEPENDENCIES.dependencies,
66
+ devDependencies: BACKEND_DEPENDENCIES.devDependencies,
65
67
  }, null, 2) + '\n');
66
68
  }
67
69
  function writeTsConfig(dir) {
@@ -1143,8 +1145,8 @@ const configPlugin: FastifyPluginAsync = async (fastify: FastifyInstance) => {
1143
1145
  R2_ACCESS_KEY_ID: { type: 'string', nullable: true },
1144
1146
  R2_SECRET_ACCESS_KEY: { type: 'string', nullable: true },
1145
1147
  R2_BUCKET: { type: 'string', nullable: true },
1146
- RC_SECRET_API_KEY: { type: 'string', default: '' },
1147
- RC_WEBHOOK_SECRET: { type: 'string', default: '' },
1148
+ RC_SECRET_API_KEY: { type: 'string', nullable: true, default: '' },
1149
+ RC_WEBHOOK_SECRET: { type: 'string', nullable: true, default: '' },
1148
1150
  RC_ENTITLEMENT_ID: { type: 'string', default: 'pro' },
1149
1151
  RC_OFFERING_ID: { type: 'string', default: 'default' },
1150
1152
  },
@@ -1155,6 +1157,23 @@ const configPlugin: FastifyPluginAsync = async (fastify: FastifyInstance) => {
1155
1157
  dotenv: !process.env.VERCEL,
1156
1158
  confKey: 'env',
1157
1159
  } as unknown as Record<string, unknown>);
1160
+
1161
+ if (fastify.env.NODE_ENV === 'production') {
1162
+ if (fastify.env.ANHEDRAL_DEMO === 'true') {
1163
+ throw new Error('ANHEDRAL_DEMO must be false in production');
1164
+ }
1165
+
1166
+ const required = [
1167
+ 'CLERK_PUBLISHABLE_KEY',
1168
+ 'CLERK_SECRET_KEY',
1169
+ 'RC_SECRET_API_KEY',
1170
+ 'RC_WEBHOOK_SECRET',
1171
+ ] as const;
1172
+ const missing = required.filter((key) => !fastify.env[key]);
1173
+ if (missing.length > 0) {
1174
+ throw new Error(\`Missing production environment variables: \${missing.join(', ')}\`);
1175
+ }
1176
+ }
1158
1177
  };
1159
1178
 
1160
1179
  export default fp(configPlugin, { name: 'env-config', fastify: '5.x' });
@@ -2189,6 +2208,23 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
2189
2208
  }
2190
2209
  `);
2191
2210
  }
2211
+ function writeVercelConfig(dir) {
2212
+ writeFile(path.join(dir, 'vercel.json'), JSON.stringify({
2213
+ version: 2,
2214
+ builds: [
2215
+ {
2216
+ src: 'api/index.ts',
2217
+ use: '@vercel/node',
2218
+ },
2219
+ ],
2220
+ routes: [
2221
+ {
2222
+ src: '/(.*)',
2223
+ dest: 'api/index.ts',
2224
+ },
2225
+ ],
2226
+ }, null, 2) + '\n');
2227
+ }
2192
2228
  // ═══════════════════════════════════════════════════════════════════════════
2193
2229
  // Tests
2194
2230
  // ═══════════════════════════════════════════════════════════════════════════
@@ -1,2 +1,2 @@
1
1
  import type { ProjectOptions } from '../scaffold.js';
2
- export declare function scaffoldExtension(root: string, { projectName, displayName }: ProjectOptions): Promise<void>;
2
+ export declare function scaffoldExtension(root: string, { projectName, displayName, skipInstall }: ProjectOptions): Promise<void>;
@@ -2,8 +2,9 @@ import { rmSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { writeFile, exec } from '../util.js';
4
4
  import { anhedralPrint } from '../print.js';
5
+ import { EXTENSION_DEPENDENCIES } from '../dependencies.js';
5
6
  import { resolveToolchainChannel, resolveToolchain, toolPackageRef } from '../toolchain.js';
6
- export async function scaffoldExtension(root, { projectName, displayName }) {
7
+ export async function scaffoldExtension(root, { projectName, displayName, skipInstall }) {
7
8
  const appsRoot = path.join(root, 'apps');
8
9
  const dir = path.join(appsRoot, 'extension');
9
10
  const toolchain = resolveToolchain(resolveToolchainChannel(process.env.ANHEDRAL_TOOLCHAIN));
@@ -14,9 +15,14 @@ export async function scaffoldExtension(root, { projectName, displayName }) {
14
15
  writePackageJson(dir, projectName);
15
16
  anhedralPrint.done('WXT extension scaffolded');
16
17
  anhedralPrint.step('Installing Clerk + React + Tailwind dependencies');
17
- exec(`pnpm add -D @types/chrome @types/react @types/react-dom @wxt-dev/module-react autoprefixer postcss tailwindcss@3.4.19 typescript@5.9.3 ${toolPackageRef('wxt', toolchain.wxt)}`, dir);
18
- exec('pnpm add @clerk/chrome-extension react react-dom clsx tailwind-merge class-variance-authority lucide-react@0.468.0', dir);
19
- anhedralPrint.done('Extension dependencies installed');
18
+ if (skipInstall) {
19
+ anhedralPrint.info('Skipping extension dependency install (--skip-install)');
20
+ anhedralPrint.info('Run after init: pnpm install');
21
+ }
22
+ else {
23
+ exec('pnpm install --no-frozen-lockfile', dir);
24
+ }
25
+ anhedralPrint.done(skipInstall ? 'Extension dependency manifests written' : 'Extension dependencies installed');
20
26
  cleanWxtStarterFiles(dir);
21
27
  writeWxtConfig(dir, displayName);
22
28
  writeTsConfig(dir);
@@ -27,6 +33,7 @@ export async function scaffoldExtension(root, { projectName, displayName }) {
27
33
  writeShadcnConfig(dir);
28
34
  writeReadme(dir, displayName);
29
35
  writeCnUtil(dir);
36
+ writeButtonComponent(dir);
30
37
  writeAuthContext(dir);
31
38
  writeApiClient(dir);
32
39
  writeBackground(dir);
@@ -35,10 +42,7 @@ export async function scaffoldExtension(root, { projectName, displayName }) {
35
42
  writeSidepanelHtml(dir, displayName);
36
43
  writeSidepanelApp(dir);
37
44
  writeStyles(dir);
38
- anhedralPrint.step('Adding shadcn button component');
39
- exec(`pnpm dlx ${toolPackageRef('shadcn', toolchain.shadcn)} add button`, dir);
40
- writeStyles(dir);
41
- anhedralPrint.done('shadcn button installed');
45
+ anhedralPrint.done('Extension source files written');
42
46
  }
43
47
  function cleanWxtStarterFiles(dir) {
44
48
  for (const relativePath of [
@@ -65,6 +69,8 @@ function writePackageJson(dir, projectName) {
65
69
  'zip:firefox': 'wxt zip -b firefox',
66
70
  typecheck: 'tsc --noEmit',
67
71
  },
72
+ dependencies: EXTENSION_DEPENDENCIES.dependencies,
73
+ devDependencies: EXTENSION_DEPENDENCIES.devDependencies,
68
74
  }, null, 2) + '\n');
69
75
  }
70
76
  function writeWxtConfig(dir, displayName) {
@@ -80,11 +86,15 @@ export default defineConfig({
80
86
  description: '${displayName} Chrome Extension',
81
87
  version: '0.1.0',
82
88
  ...(crxPublicKey ? { key: crxPublicKey } : {}),
83
- permissions: ['activeTab', 'cookies', 'storage'],
89
+ minimum_chrome_version: '114',
90
+ permissions: ['activeTab', 'cookies', 'storage', 'sidePanel'],
84
91
  host_permissions: [],
85
92
  action: {
86
93
  default_title: 'Open ${displayName}',
87
94
  },
95
+ side_panel: {
96
+ default_path: 'sidepanel.html',
97
+ },
88
98
  };
89
99
  },
90
100
  modules: ['@wxt-dev/module-react'],
@@ -108,7 +118,6 @@ function writeTsConfig(dir) {
108
118
  jsx: 'react-jsx',
109
119
  lib: ['DOM', 'DOM.Iterable', 'ESNext'],
110
120
  types: ['wxt/browser', '@wxt-dev/module-react', 'chrome'],
111
- baseUrl: '.',
112
121
  paths: { '@/*': ['./src/*'] },
113
122
  },
114
123
  include: ['**/*.ts', '**/*.tsx', '.wxt/wxt.d.ts'],
@@ -190,6 +199,41 @@ export function cn(...inputs: ClassValue[]) {
190
199
  }
191
200
  `);
192
201
  }
202
+ function writeButtonComponent(dir) {
203
+ writeFile(path.join(dir, 'src/components/ui/button.tsx'), `import * as React from 'react';
204
+ import { cn } from '@/lib/utils';
205
+
206
+ type ButtonVariant = 'default' | 'outline';
207
+
208
+ export type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
209
+ variant?: ButtonVariant;
210
+ };
211
+
212
+ const variantClasses: Record<ButtonVariant, string> = {
213
+ default: 'bg-primary text-primary-foreground hover:bg-primary/90',
214
+ outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
215
+ };
216
+
217
+ export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
218
+ ({ className, variant = 'default', type = 'button', ...props }, ref) => {
219
+ return (
220
+ <button
221
+ ref={ref}
222
+ type={type}
223
+ className={cn(
224
+ 'inline-flex h-9 items-center justify-center whitespace-nowrap rounded-md px-4 py-2 text-sm font-medium transition-colors disabled:pointer-events-none disabled:opacity-50',
225
+ variantClasses[variant],
226
+ className,
227
+ )}
228
+ {...props}
229
+ />
230
+ );
231
+ },
232
+ );
233
+
234
+ Button.displayName = 'Button';
235
+ `);
236
+ }
193
237
  function writeTailwindConfig(dir) {
194
238
  writeFile(path.join(dir, 'tailwind.config.cjs'), `/** @type {import('tailwindcss').Config} */
195
239
  module.exports = {
@@ -261,7 +305,7 @@ Set \`VITE_CLERK_PUBLISHABLE_KEY\` and \`VITE_API_URL\` in \`.env\` before using
261
305
 
262
306
  Run \`pnpm build\`, then load \`.output/chrome-mv3\` as an unpacked extension from \`chrome://extensions\`.
263
307
 
264
- The browser action opens the side panel. The background script initializes Clerk, the side panel handles sign-in/subscription state, and the content script is ready for page-to-extension messages.
308
+ The extension uses Chrome's Side Panel API. The browser action opens \`sidepanel.html\`, \`wxt.config.ts\` declares the \`sidePanel\` permission and Chrome 114+ minimum version, the background script initializes Clerk, and the content script is ready for page-to-extension messages.
265
309
  `);
266
310
  }
267
311
  function writeAuthContext(dir) {
@@ -405,58 +449,25 @@ export { WEBSITE_URL };
405
449
  `);
406
450
  }
407
451
  function writeApiClient(dir) {
408
- writeFile(path.join(dir, 'src/lib/api.ts'), `const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8787';
409
-
410
- export type SubscriptionEntitlements = {
411
- pro: boolean;
412
- inTrial: boolean;
413
- trialEndsAt?: string;
414
- expiresAt?: string;
415
- periodStart?: string;
416
- periodEnd?: string;
417
- method?: 'trialing' | 'redeemed' | 'paid' | null;
418
- managementUrl?: string;
419
- cancelAtPeriodEnd?: boolean;
420
- };
452
+ writeFile(path.join(dir, 'src/lib/api.ts'), `import { ApiClient } from '@anhedral/api-client';
421
453
 
422
454
  export class APIClient {
423
455
  constructor(private getToken: () => Promise<string | null>) {}
424
456
 
425
- private async request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
426
- const token = await this.getToken();
427
- if (!token) throw new Error('Not authenticated');
428
-
429
- const headers: Record<string, string> = {
430
- Authorization: \`Bearer \${token}\`,
431
- 'Content-Type': 'application/json',
432
- 'X-Client-Type': 'chrome-extension',
433
- 'X-Platform': 'chrome',
434
- ...(options.headers as Record<string, string>),
435
- };
436
-
437
- const response = await fetch(\`\${API_BASE_URL}\${endpoint}\`, { ...options, headers });
438
-
439
- if (!response.ok) {
440
- let error: { message?: string } = {};
441
- try { error = await response.json(); } catch {}
442
- throw new Error(error.message || \`Request failed (\${response.status})\`);
443
- }
444
-
445
- if (response.status === 204) return {} as T;
446
- return response.json();
457
+ private client() {
458
+ return new ApiClient({
459
+ baseUrl: import.meta.env.VITE_API_URL || 'http://localhost:8787',
460
+ getToken: this.getToken,
461
+ platform: 'extension',
462
+ });
447
463
  }
448
464
 
449
- async getMe() {
450
- return this.request<{
451
- user: { id: string; email: string; subscriptionTier: string; subscriptionStatus: string };
452
- }>('/auth/me');
465
+ getMe() {
466
+ return this.client().getMe();
453
467
  }
454
468
 
455
- async getSubscriptionEntitlements(options?: { refresh?: boolean }): Promise<SubscriptionEntitlements> {
456
- const path = options?.refresh
457
- ? '/subscriptions/entitlements/me?refresh=true'
458
- : '/subscriptions/entitlements/me';
459
- return this.request<SubscriptionEntitlements>(path);
469
+ getSubscriptionEntitlements(options?: { refresh?: boolean }) {
470
+ return this.client().getSubscriptionEntitlements(options);
460
471
  }
461
472
  }
462
473
  `);
@@ -464,15 +475,20 @@ export class APIClient {
464
475
  function writeBackground(dir) {
465
476
  writeFile(path.join(dir, 'src/entrypoints/background.ts'), `import { createClerkClient } from '@clerk/chrome-extension/background';
466
477
 
478
+ type ChromeWithSidePanel = typeof chrome & {
479
+ sidePanel: {
480
+ setPanelBehavior: (behavior: { openPanelOnActionClick: boolean }) => Promise<void>;
481
+ };
482
+ };
483
+
467
484
  export default defineBackground(() => {
468
485
  // Initialize Clerk in the background script for cookie-based auth
469
486
  void createClerkClient({
470
487
  publishableKey: import.meta.env.VITE_CLERK_PUBLISHABLE_KEY || '',
471
488
  }).catch(() => {});
472
489
 
473
- // Open the side panel when the extension icon is clicked
474
- // The sidePanel permission is auto-added by WXT when the sidepanel entrypoint exists
475
- chrome.sidePanel
490
+ // Open the side panel when the extension icon is clicked.
491
+ (chrome as ChromeWithSidePanel).sidePanel
476
492
  .setPanelBehavior({ openPanelOnActionClick: true })
477
493
  .catch(() => {});
478
494
  });
@@ -1,2 +1,2 @@
1
1
  import type { ProjectOptions } from '../scaffold.js';
2
- export declare function scaffoldFrontend(root: string, { projectName, displayName }: ProjectOptions): Promise<void>;
2
+ export declare function scaffoldFrontend(root: string, { projectName, displayName, skipInstall }: ProjectOptions): Promise<void>;
@@ -3,11 +3,12 @@ import path from 'node:path';
3
3
  import { writeAnhedralLogo } from '../branding.js';
4
4
  import { anhedralPrint } from '../print.js';
5
5
  import { appendGitignore, writeFile, exec, execExpect, liftNestedProject } from '../util.js';
6
+ import { FRONTEND_ADDON_DEPENDENCIES, withVersions } from '../dependencies.js';
6
7
  import { resolveToolchainChannel, resolveToolchain, toolPackageRef } from '../toolchain.js';
7
- export async function scaffoldFrontend(root, { projectName, displayName }) {
8
- const dir = path.join(root, 'apps/mobile');
8
+ export async function scaffoldFrontend(root, { projectName, displayName, skipInstall }) {
9
+ const dir = path.join(root, 'apps/frontend');
9
10
  const toolchain = resolveToolchain(resolveToolchainChannel(process.env.ANHEDRAL_TOOLCHAIN));
10
- anhedralPrint.section('Mobile (Expo)');
11
+ anhedralPrint.section('Frontend (Expo)');
11
12
  anhedralPrint.step('Scaffolding Expo app with react-native-reusables');
12
13
  fs.mkdirSync(dir, { recursive: true });
13
14
  await execExpect(`pnpm dlx ${toolPackageRef('@react-native-reusables/cli', toolchain.reactNativeReusables)} init -t clerk-auth`, dir, [
@@ -17,11 +18,15 @@ export async function scaffoldFrontend(root, { projectName, displayName }) {
17
18
  ]);
18
19
  liftNestedProject(dir, projectName);
19
20
  anhedralPrint.done('Expo app scaffolded');
20
- anhedralPrint.step('Installing mobile dependencies');
21
- exec('pnpm install --no-frozen-lockfile', dir);
21
+ anhedralPrint.step('Installing frontend dependencies');
22
22
  patchFrontendPackageJson(dir);
23
+ patchExpoAppConfig(dir);
24
+ if (!skipInstall) {
25
+ exec('pnpm install --no-frozen-lockfile', dir);
26
+ exec('pnpm exec expo install --fix --pnpm', dir);
27
+ }
23
28
  appendGitignore(dir, ['.env', '.env.*', '!.env.example']);
24
- anhedralPrint.done('Mobile dependencies installed');
29
+ anhedralPrint.done(skipInstall ? 'Frontend dependency manifests written' : 'Frontend dependencies installed');
25
30
  writeApiClient(dir);
26
31
  writeConfigFile(dir);
27
32
  writeApiHook(dir);
@@ -30,190 +35,81 @@ export async function scaffoldFrontend(root, { projectName, displayName }) {
30
35
  writeAppShellFiles(dir, displayName);
31
36
  writeEnvExample(dir);
32
37
  writeEnvFile(dir);
38
+ writeVercelConfig(dir);
33
39
  writeAnhedralLogo(dir);
34
40
  writeSubscriptionProvider(dir);
35
41
  writeUseSubscription(dir);
36
- anhedralPrint.step('Installing image picker + RevenueCat SDKs');
37
- exec('pnpm exec expo install expo-image-picker', dir);
38
- exec('pnpm add react-native-purchases react-native-purchases-ui @revenuecat/purchases-js', dir);
39
- anhedralPrint.done('Additional mobile SDKs installed');
42
+ anhedralPrint.step('Installing Expo native packages');
43
+ if (skipInstall) {
44
+ anhedralPrint.info('Skipping Expo native package install (--skip-install)');
45
+ anhedralPrint.info('Run after init: pnpm --filter ./apps/frontend exec expo install --pnpm expo-image-picker');
46
+ }
47
+ else {
48
+ exec('pnpm exec expo install --pnpm expo-image-picker', dir);
49
+ exec('pnpm exec expo install --fix --pnpm', dir);
50
+ }
51
+ anhedralPrint.done(skipInstall ? 'Expo native package install skipped' : 'Expo native packages installed');
52
+ anhedralPrint.step('Installing RevenueCat SDKs');
53
+ const revenueCatDependencies = withVersions({
54
+ 'react-native-purchases': FRONTEND_ADDON_DEPENDENCIES['react-native-purchases'],
55
+ 'react-native-purchases-ui': FRONTEND_ADDON_DEPENDENCIES['react-native-purchases-ui'],
56
+ '@revenuecat/purchases-js': FRONTEND_ADDON_DEPENDENCIES['@revenuecat/purchases-js'],
57
+ });
58
+ if (skipInstall) {
59
+ anhedralPrint.info(`Skipping RevenueCat SDK install (--skip-install). Run after init: pnpm --filter ./apps/frontend add ${revenueCatDependencies.join(' ')}`);
60
+ }
61
+ else {
62
+ exec(`pnpm add ${revenueCatDependencies.join(' ')}`, dir);
63
+ }
64
+ anhedralPrint.done(skipInstall ? 'RevenueCat SDK install skipped' : 'Additional Expo SDKs installed');
40
65
  }
41
66
  function patchFrontendPackageJson(dir) {
42
67
  const filePath = path.join(dir, 'package.json');
43
68
  const packageJson = JSON.parse(fs.readFileSync(filePath, 'utf8'));
69
+ packageJson.dependencies = {
70
+ ...(packageJson.dependencies ?? {}),
71
+ '@anhedral/api-client': FRONTEND_ADDON_DEPENDENCIES['@anhedral/api-client'],
72
+ '@clerk/clerk-expo': FRONTEND_ADDON_DEPENDENCIES['@clerk/clerk-expo'],
73
+ 'react-native-purchases': FRONTEND_ADDON_DEPENDENCIES['react-native-purchases'],
74
+ 'react-native-purchases-ui': FRONTEND_ADDON_DEPENDENCIES['react-native-purchases-ui'],
75
+ '@revenuecat/purchases-js': FRONTEND_ADDON_DEPENDENCIES['@revenuecat/purchases-js'],
76
+ };
44
77
  packageJson.scripts = {
45
78
  ...(packageJson.scripts ?? {}),
46
- build: 'pnpm typecheck',
79
+ build: 'pnpm typecheck && pnpm build:web',
80
+ 'build:web': 'expo export --platform web',
47
81
  typecheck: 'tsc --noEmit',
48
82
  };
49
83
  writeFile(filePath, JSON.stringify(packageJson, null, 2) + '\n');
50
84
  }
51
- function writeApiClient(dir) {
52
- writeFile(path.join(dir, 'api/client.ts'), `/**
53
- * API Client with SWR caching
54
- */
55
-
56
- const SWR_MAX_SIZE = 200;
57
- const SWR_DEFAULT_STALE_MS = 15_000;
58
- const SWR_DEFAULT_MAX_AGE_MS = 300_000;
59
-
60
- type SWREntry<T> = { data: T; fetchedAt: number; staleMs: number; maxAgeMs: number };
61
- const SWR_CACHE = new Map<string, SWREntry<unknown>>();
62
- const SWR_INFLIGHT = new Map<string, Promise<unknown>>();
63
-
64
- function swrEvict() {
65
- if (SWR_CACHE.size <= SWR_MAX_SIZE) return;
66
- const entries = [...SWR_CACHE.entries()].sort((a, b) => a[1].fetchedAt - b[1].fetchedAt);
67
- const toRemove = entries.slice(0, entries.length - SWR_MAX_SIZE);
68
- for (const [key] of toRemove) SWR_CACHE.delete(key);
69
- }
70
-
71
- export class APIRequestError extends Error {
72
- constructor(
73
- public statusCode: number,
74
- message: string,
75
- public errorCode?: string,
76
- ) {
77
- super(message);
78
- this.name = 'APIRequestError';
79
- }
80
- }
81
-
82
- export class APIClient {
83
- constructor(
84
- private baseUrl: string,
85
- private getToken: () => Promise<string | null>,
86
- ) {}
87
-
88
- private async request<T>(method: string, path: string, body?: unknown): Promise<T> {
89
- const token = await this.getToken();
90
- const headers: Record<string, string> = {
91
- 'Content-Type': 'application/json',
92
- 'X-Platform': 'web',
93
- };
94
- if (token) headers['Authorization'] = \`Bearer \${token}\`;
95
-
96
- const res = await fetch(\`\${this.baseUrl}\${path}\`, {
97
- method,
98
- headers,
99
- body: body ? JSON.stringify(body) : undefined,
100
- });
101
-
102
- if (!res.ok) {
103
- let err: { error?: string; message?: string } = {};
104
- try { err = await res.json(); } catch {}
105
- throw new APIRequestError(res.status, err.message || \`Request failed (\${res.status})\`, err.error);
106
- }
107
-
108
- if (res.status === 204) return {} as T;
109
- return res.json();
110
- }
111
-
112
- async get<T>(path: string): Promise<T> { return this.request<T>('GET', path); }
113
- async post<T>(path: string, body?: unknown): Promise<T> { return this.request<T>('POST', path, body); }
114
- async delete<T>(path: string): Promise<T> { return this.request<T>('DELETE', path); }
115
-
116
- async getCached<T>(
117
- path: string,
118
- opts?: { staleMs?: number; maxAgeMs?: number },
119
- ): Promise<T> {
120
- const staleMs = opts?.staleMs ?? SWR_DEFAULT_STALE_MS;
121
- const maxAgeMs = opts?.maxAgeMs ?? SWR_DEFAULT_MAX_AGE_MS;
122
- const now = Date.now();
123
- const cached = SWR_CACHE.get(path) as SWREntry<T> | undefined;
124
-
125
- if (cached) {
126
- const age = now - cached.fetchedAt;
127
- if (age < staleMs) return cached.data;
128
- if (age < maxAgeMs) {
129
- if (!SWR_INFLIGHT.has(path)) {
130
- const p = this.get<T>(path).then((data) => {
131
- SWR_CACHE.set(path, { data, fetchedAt: Date.now(), staleMs, maxAgeMs });
132
- return data;
133
- }).finally(() => SWR_INFLIGHT.delete(path));
134
- SWR_INFLIGHT.set(path, p);
135
- }
136
- return cached.data;
137
- }
138
- }
139
-
140
- const inflight = SWR_INFLIGHT.get(path) as Promise<T> | undefined;
141
- if (inflight) return inflight;
142
-
143
- const p = this.get<T>(path).then((data) => {
144
- SWR_CACHE.set(path, { data, fetchedAt: Date.now(), staleMs, maxAgeMs });
145
- swrEvict();
146
- return data;
147
- }).finally(() => SWR_INFLIGHT.delete(path));
148
- SWR_INFLIGHT.set(path, p);
149
- return p;
150
- }
151
-
152
- invalidateCache(path: string): void { SWR_CACHE.delete(path); }
153
- invalidateCachePrefix(prefix: string): void {
154
- for (const key of SWR_CACHE.keys()) {
155
- if (key.startsWith(prefix)) SWR_CACHE.delete(key);
85
+ function patchExpoAppConfig(dir) {
86
+ const filePath = path.join(dir, 'app.json');
87
+ if (!fs.existsSync(filePath)) {
88
+ return;
156
89
  }
157
- }
158
-
159
- clearAllCaches(): void {
160
- SWR_CACHE.clear();
161
- SWR_INFLIGHT.clear();
162
- }
163
-
164
- // ── Auth endpoints ──────────────────────────────────────────────────
165
- async getMe() {
166
- return this.get<{
167
- user: {
168
- id: string;
169
- email: string;
170
- firstName?: string | null;
171
- lastName?: string | null;
172
- displayName: string;
173
- imageUrl?: string | null;
174
- avatarUrl?: string | null;
175
- creditsBalance: number;
176
- subscriptionTier: string;
177
- subscriptionStatus: string;
178
- };
179
- }>('/auth/me');
180
- }
181
-
182
- async signOut() {
183
- return this.post<{ success: boolean }>('/auth/signout');
184
- }
185
-
186
- async uploadAvatar(input: { base64: string; mimeType: string; fileName?: string }) {
187
- return this.post<{ ok: boolean; avatarUrl: string }>('/auth/avatar', input);
188
- }
189
-
190
- async deleteAccount() {
191
- return this.delete<void>('/auth/account');
192
- }
193
-
194
- // ── Subscription endpoints ───────────────────────────────────────────
195
- async getSubscriptionPricing() {
196
- return this.getCached<{
197
- tiers: { tier: string; displayName: string; description: string; priceMonthly: number | null; priceYearly: number | null; currency: string; limits: { dailyLimit: number | null }; paymentInfo?: { revenueCatEntitlementId: string; revenueCatOfferingId: string } }[];
198
- }>('/subscriptions/pricing', { staleMs: 300_000, maxAgeMs: 3_600_000 });
199
- }
90
+ const appJson = JSON.parse(fs.readFileSync(filePath, 'utf8'));
91
+ appJson.expo ??= {};
92
+ appJson.expo.web = {
93
+ ...(appJson.expo.web ?? {}),
94
+ bundler: 'metro',
95
+ output: 'static',
96
+ };
97
+ writeFile(filePath, JSON.stringify(appJson, null, 2) + '\n');
98
+ }
99
+ function writeApiClient(dir) {
100
+ writeFile(path.join(dir, 'api/client.ts'), `import { ApiClient, APIRequestError } from '@anhedral/api-client';
200
101
 
201
- async getSubscriptionEntitlements(options?: { refresh?: boolean }) {
202
- const path = options?.refresh ? '/subscriptions/entitlements/me?refresh=true' : '/subscriptions/entitlements/me';
203
- return this.get<{
204
- pro: boolean; inTrial: boolean; trialEndsAt?: string; expiresAt?: string;
205
- periodStart?: string; periodEnd?: string;
206
- method?: 'trialing' | 'redeemed' | 'paid' | null;
207
- managementUrl?: string; cancelAtPeriodEnd?: boolean;
208
- }>(path);
102
+ export class APIClient extends ApiClient {
103
+ constructor(baseUrl: string, getToken: () => Promise<string | null>) {
104
+ super({ baseUrl, getToken, platform: 'frontend' });
209
105
  }
210
106
 
211
- async redeemPromoCode(code: string) {
212
- return this.post<{ ok: boolean; expiresAt: string }>('/subscriptions/redeem', { code });
107
+ redeemPromoCode(code: string) {
108
+ return this.redeemCode(code);
213
109
  }
214
110
  }
215
111
 
216
- export { SWR_CACHE };
112
+ export { APIRequestError };
217
113
  `);
218
114
  writeFile(path.join(dir, 'api/index.ts'), `export { APIClient, APIRequestError } from './client';
219
115
  `);
@@ -284,6 +180,22 @@ export function useAPI() {
284
180
  }
285
181
  `);
286
182
  }
183
+ function writeVercelConfig(dir) {
184
+ writeFile(path.join(dir, 'vercel.json'), JSON.stringify({
185
+ $schema: 'https://openapi.vercel.sh/vercel.json',
186
+ buildCommand: 'pnpm build:web',
187
+ outputDirectory: 'dist',
188
+ devCommand: 'pnpm dev',
189
+ cleanUrls: true,
190
+ framework: null,
191
+ rewrites: [
192
+ {
193
+ source: '/:path*',
194
+ destination: '/index.html',
195
+ },
196
+ ],
197
+ }, null, 2) + '\n');
198
+ }
287
199
  function writeAccountHook(dir) {
288
200
  writeFile(path.join(dir, 'hooks/useAccount.ts'), `import { useAuth } from '@clerk/clerk-expo';
289
201
  import * as React from 'react';
@@ -859,7 +771,7 @@ import { ScrollView, View } from 'react-native';
859
771
  const FEATURES = [
860
772
  {
861
773
  title: 'Landing page here',
862
- description: 'Replace this placeholder copy with the actual acquisition story for your mobile and web app.',
774
+ description: 'Replace this placeholder copy with the actual acquisition story for your Expo app.',
863
775
  icon: CloudIcon,
864
776
  },
865
777
  {