anhedral 0.1.3 → 0.1.5

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/dist/scaffold.js CHANGED
@@ -1,15 +1,14 @@
1
- import { readFileSync, readdirSync, rmSync } from 'node:fs';
1
+ import { readdirSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { env } from 'node:process';
4
4
  import { anhedralPrint } from './print.js';
5
- import { appendGitignore, exec, execWithInput, writeFile } from './util.js';
6
- import { getDefaultWebDependencyCommands, getDefaultWebInitCommand, getSkillCommands, } from './commands.js';
5
+ import { appendGitignore, writeFile } from './util.js';
6
+ import { getSkillCommands } from './commands.js';
7
+ import { API_CLIENT_DEPENDENCIES, PACKAGE_MANAGER, ROOT_DEPENDENCIES, SHARED_DB_DEPENDENCIES, SHARED_PACKAGE_DEPENDENCIES, dependencyManifest, } from './dependencies.js';
7
8
  import { resolveToolchain } from './toolchain.js';
8
9
  import { scaffoldBackend } from './templates/backend.js';
9
- import { writeDefaultWebEnvExample, writeDefaultWebFiles } from './templates/default-web.js';
10
10
  import { scaffoldExtension } from './templates/extension.js';
11
11
  import { scaffoldFrontend } from './templates/frontend.js';
12
- const PNPM_PACKAGE_MANAGER = 'pnpm@10.15.1';
13
12
  const SHARED_ENV_GITIGNORE_LINES = ['.env', '.env.*', '!.env.example'];
14
13
  const SHARED_TYPESCRIPT_GITIGNORE_LINES = ['*.tsbuildinfo'];
15
14
  const ASCII_LOGO = [
@@ -43,61 +42,39 @@ async function printAsciiLogo() {
43
42
  function getUsedToolchain(options, toolchain) {
44
43
  return {
45
44
  verifiedAt: toolchain.verifiedAt,
46
- ...(options.frontend === 'next' || options.extension ? { shadcn: toolchain.shadcn } : {}),
47
- ...(options.frontend === 'expo' ? { reactNativeReusables: toolchain.reactNativeReusables } : {}),
48
- ...(options.extension ? { wxt: toolchain.wxt } : {}),
45
+ shadcn: toolchain.shadcn,
46
+ reactNativeReusables: toolchain.reactNativeReusables,
47
+ wxt: toolchain.wxt,
49
48
  };
50
49
  }
51
50
  function normalizeStack(options, generatedPaths) {
52
51
  const toolchain = resolveToolchain(options.toolchainChannel);
53
- const frontend = options.frontend === 'next'
54
- ? 'nextjs_shadcn'
55
- : 'expo_react_native_reusables';
56
52
  return {
57
53
  schema_version: '2.0.0',
58
54
  mode: 'fullstack',
59
55
  project_name: options.projectName,
60
56
  display_name: options.displayName,
61
- frontend,
62
- extension: options.extension ? 'wxt_chrome_extension' : null,
57
+ frontend: 'expo_react_native_reusables',
58
+ extension: 'wxt_chrome_extension',
63
59
  backend: 'fastify',
64
60
  auth: options.auth,
65
61
  payments: options.payments,
66
62
  storage: 'cloudflare_r2_via_aws_s3_sdk',
67
63
  database: 'neon_plus_drizzle',
68
- skills: getSkillCommands(options),
64
+ skills: getSkillCommands(),
69
65
  outputs: {
70
- monorepo: options.monorepo,
66
+ monorepo: true,
71
67
  package_manager: 'pnpm',
68
+ dependency_manifest: dependencyManifest(),
72
69
  toolchain_channel: options.toolchainChannel,
73
70
  toolchain: getUsedToolchain(options, toolchain),
74
71
  generated_paths: generatedPaths,
75
72
  },
76
73
  };
77
74
  }
78
- function readJsonFile(filePath) {
79
- return JSON.parse(readFileSync(filePath, 'utf-8'));
80
- }
81
75
  function writeJsonFile(filePath, payload) {
82
76
  writeFile(filePath, JSON.stringify(payload, null, 2) + '\n');
83
77
  }
84
- function runScaffoldCommands(cwd, commands) {
85
- let skippedCount = 0;
86
- for (const command of commands) {
87
- if (env.ANHEDRAL_SKIP_INSTALL === '1' && command.skippable) {
88
- skippedCount += 1;
89
- continue;
90
- }
91
- if (command.stdinInput == null) {
92
- exec(command.cmd, cwd);
93
- continue;
94
- }
95
- execWithInput(command.cmd, cwd, command.stdinInput);
96
- }
97
- if (skippedCount > 0) {
98
- anhedralPrint.info(`Skipped ${skippedCount} install step${skippedCount === 1 ? '' : 's'} (ANHEDRAL_SKIP_INSTALL=1)`);
99
- }
100
- }
101
78
  function ensureScaffoldRoot(root) {
102
79
  const allowedEntries = new Set(['.git', '.gitignore', '.DS_Store']);
103
80
  const unexpected = readdirSync(root).filter((entry) => !allowedEntries.has(entry));
@@ -110,7 +87,7 @@ function writeRootPackageJson(root, projectName, scripts, workspaces, extraField
110
87
  name: projectName,
111
88
  private: true,
112
89
  version: '0.1.0',
113
- packageManager: PNPM_PACKAGE_MANAGER,
90
+ packageManager: PACKAGE_MANAGER,
114
91
  scripts,
115
92
  ...extraFields,
116
93
  };
@@ -123,26 +100,6 @@ function writePnpmWorkspace(root, packages) {
123
100
  const serializedPackages = packages.map((entry) => ` - '${entry}'`).join('\n');
124
101
  writeFile(path.join(root, 'pnpm-workspace.yaml'), `packages:\n${serializedPackages}\n`);
125
102
  }
126
- function patchPackageJson(root, updates) {
127
- const filePath = path.join(root, 'package.json');
128
- const packageJson = readJsonFile(filePath);
129
- const scripts = (packageJson.scripts ?? {});
130
- if (updates.scripts) {
131
- Object.assign(scripts, updates.scripts);
132
- }
133
- packageJson.scripts = scripts;
134
- if (updates.packageManager) {
135
- packageJson.packageManager = updates.packageManager;
136
- }
137
- writeJsonFile(filePath, packageJson);
138
- }
139
- function replaceInFile(filePath, replacements) {
140
- let contents = readFileSync(filePath, 'utf-8');
141
- for (const [search, replacement] of replacements) {
142
- contents = contents.split(search).join(replacement);
143
- }
144
- writeFile(filePath, contents);
145
- }
146
103
  function writeSkillsGuide(root, commands) {
147
104
  const commandBlock = commands.map((command) => `${command}\n`).join('\n');
148
105
  writeFile(path.join(root, 'install-skills.sh'), `#!/usr/bin/env bash
@@ -162,23 +119,6 @@ function writeRootDocs(root, options, stack) {
162
119
  ? `${options.toolchainChannel} (verified ${stack.outputs.toolchain.verifiedAt})`
163
120
  : `${options.toolchainChannel} (floating latest)`;
164
121
  const generatedPaths = stack.outputs.generated_paths.map((entry) => `- \`${entry}\``).join('\n') || '- `.`';
165
- const frontendLabel = options.frontend === 'next' ? 'Next.js' : 'Expo';
166
- const extensionLine = options.extension ? '- Extension: WXT Chrome extension\n' : '';
167
- const extensionSetup = options.extension
168
- ? '\n5. Build or run the extension separately. It is not deployed by Vercel:\n\n```bash\npnpm dev:extension\npnpm extension:zip\n```\n'
169
- : '';
170
- const frontendDevCommand = options.frontend === 'next' ? 'pnpm dev:web' : 'pnpm dev:mobile';
171
- const frontendEnvCommand = options.frontend === 'next'
172
- ? 'cp apps/web/.env.example apps/web/.env.local'
173
- : '# apps/mobile/.env is generated for local Expo development';
174
- const extensionEnvCommand = options.extension ? '\ncp apps/extension/.env.example apps/extension/.env' : '';
175
- const extensionDevCommand = options.extension ? '\npnpm dev:extension' : '';
176
- const paymentSetup = options.payments === 'stripe'
177
- ? `- Stripe: create a Checkout price, then set \`STRIPE_SECRET_KEY\`, \`STRIPE_WEBHOOK_SECRET\`, and \`STRIPE_PRICE_STARTER\`.`
178
- : `- RevenueCat: create an entitlement named \`pro\`, configure app keys, then set \`RC_SECRET_API_KEY\`, \`RC_WEBHOOK_SECRET\`, and the \`EXPO_PUBLIC_RC_*\` values.`;
179
- const deploySetup = options.frontend === 'next'
180
- ? '- Vercel: deploy this repo from the root. The root `vercel.json` builds `apps/web` and `apps/api`; Fastify is routed under `/backend/*`.'
181
- : '- Vercel: deploy this repo from the root when you want the Fastify API hosted. Expo is built and shipped outside Vercel.';
182
122
  writeFile(path.join(root, 'README.md'), `# ${options.displayName}
183
123
 
184
124
  Generated by anhedral.
@@ -186,15 +126,17 @@ Generated by anhedral.
186
126
  ## Stack
187
127
 
188
128
  - Mode: fullstack
189
- - Frontend: ${frontendLabel}
129
+ - Frontend: Expo + React Native Reusables
190
130
  - Backend: Fastify
191
- ${extensionLine}- Shared packages: \`packages/*\`
131
+ - Extension: WXT Chrome extension
132
+ - Shared packages: \`packages/*\`
192
133
  - Auth: ${options.auth}
193
134
  - Payments: ${options.payments}
194
135
  - Database: ${options.db} + ${options.orm}
195
136
  - Storage: ${options.storage}
196
137
  - API: ${options.api ?? 'framework-native'}
197
138
  - Toolchain: ${toolchainLine}
139
+ - Dependency manifest: recorded in \`stack.json\`
198
140
 
199
141
  ## Generated paths
200
142
 
@@ -205,35 +147,211 @@ ${generatedPaths}
205
147
  \`\`\`bash
206
148
  pnpm install
207
149
  cp .env.example .env
208
- ${frontendEnvCommand}${extensionEnvCommand}
150
+ # Frontend/.env is generated for local Expo development
151
+ cp Extension/.env.example Extension/.env
209
152
  pnpm db:generate
210
153
  pnpm db:migrate
211
- pnpm dev:api
212
- ${frontendDevCommand}${extensionDevCommand}
154
+ pnpm verify
155
+ pnpm dev:backend
156
+ pnpm dev:frontend
157
+ pnpm dev:extension
213
158
  \`\`\`
214
159
 
215
- For a provider-free smoke test, keep \`ANHEDRAL_DEMO=true\` in \`apps/api/.env\`. Demo mode returns a signed-in sample user and active subscription responses without Clerk, RevenueCat, or Stripe credentials. It is for local development only.
160
+ For a provider-free smoke test, keep \`ANHEDRAL_DEMO=true\` in \`Backend/.env\`. Demo mode returns a signed-in sample user and active subscription responses without Clerk, RevenueCat, or Stripe credentials. It is for local development only.
216
161
 
217
162
  ## Provider Setup
218
163
 
219
164
  - Neon: create a Postgres database and set \`DATABASE_URL\` in the root and API env files.
220
- - Clerk: create an application, configure allowed origins, and set \`CLERK_PUBLISHABLE_KEY\`, \`CLERK_SECRET_KEY\`, plus the frontend publishable key for your selected client.
221
- ${paymentSetup}
165
+ - Clerk: create an application, configure allowed origins, and set \`CLERK_PUBLISHABLE_KEY\`, \`CLERK_SECRET_KEY\`, plus the Expo and extension publishable keys.
166
+ - RevenueCat + Stripe: create an entitlement named \`pro\`, configure Stripe as a RevenueCat web billing source, configure app keys, then set \`RC_SECRET_API_KEY\`, \`RC_WEBHOOK_SECRET\`, and the \`EXPO_PUBLIC_RC_*\` values.
222
167
  - Cloudflare R2: create a bucket and API token, then set \`R2_ACCOUNT_ID\`, \`R2_ACCESS_KEY_ID\`, \`R2_SECRET_ACCESS_KEY\`, and \`R2_BUCKET\`.
223
- ${deploySetup}
168
+ - Vercel: import this same Git repository twice. Use \`Frontend\` as the root directory for the Expo web project, and \`Backend\` as the root directory for the Fastify API project. Enable access to source files outside each root directory so workspace packages under \`packages/*\` are available during builds. The Backend project uses Vercel Fastify entrypoint detection from \`Backend/src/index.ts\`.
169
+ - EAS: use \`Frontend\` as the Expo project root for iOS and Android builds. Vercel deploys the web export from the same source.
224
170
 
225
171
  ## Setup Notes
226
172
 
227
173
  1. Copy \`.env.example\` into the runtime env files your apps need and fill in provider values.
228
174
  2. Review \`install-skills.sh\` and run the listed skill commands manually so you can choose scope and agent targets.
229
- 3. Add the real provider configuration for Clerk, Stripe, RevenueCat, Neon, and R2 where the scaffolded helper files indicate.
230
- 4. Run database generation and migrations from the shared DB package:
175
+ 3. Add the real provider configuration for Clerk, RevenueCat, Stripe web billing, Neon, and R2 where the scaffolded helper files indicate.
176
+ 4. Use \`stack.json\` when debugging generated projects; it records the verified dependency manifest used by this init run.
177
+ 5. Review \`PRODUCTION.md\` before connecting live provider projects.
178
+ 6. Run database generation and migrations from the shared DB package:
231
179
 
232
180
  \`\`\`bash
233
181
  pnpm db:generate
234
182
  pnpm db:migrate
235
183
  \`\`\`
236
- ${extensionSetup}
184
+
185
+ Run deployment readiness checks before pushing:
186
+
187
+ \`\`\`bash
188
+ pnpm verify
189
+ pnpm verify:frontend
190
+ pnpm verify:backend
191
+ pnpm verify:extension
192
+ \`\`\`
193
+
194
+ 7. Build or run the extension separately. It is not deployed by Vercel:
195
+
196
+ \`\`\`bash
197
+ pnpm dev:extension
198
+ pnpm extension:zip
199
+ \`\`\`
200
+ `);
201
+ }
202
+ function writeProductionGuide(root, options) {
203
+ writeFile(path.join(root, 'PRODUCTION.md'), `# ${options.displayName} Production Guide
204
+
205
+ ## Project Structure
206
+
207
+ The generated app is one pnpm monorepo:
208
+
209
+ \`\`\`txt
210
+ .
211
+ ├─ Frontend/ Expo + React Native Reusables app
212
+ ├─ Backend/ Fastify API
213
+ ├─ Extension/ WXT Chrome extension
214
+ ├─ packages/
215
+ │ ├─ api-client/ shared typed API client
216
+ │ ├─ config/ shared config constants/helpers
217
+ │ ├─ db/ Drizzle schema, Neon client, migrations
218
+ │ └─ types/ shared TypeScript types
219
+ ├─ PRODUCTION.md development and deployment guide
220
+ ├─ turbo.json workspace build graph
221
+ └─ package.json root scripts
222
+ \`\`\`
223
+
224
+ ## Frontend
225
+
226
+ \`Frontend\` is the single Expo source for:
227
+
228
+ - Web on Vercel
229
+ - iOS through EAS
230
+ - Android through EAS
231
+
232
+ Important files:
233
+
234
+ - \`Frontend/package.json\`: Expo scripts
235
+ - \`Frontend/vercel.json\`: Vercel web export config
236
+ - \`Frontend/eas.json\`: native build profiles
237
+ - \`Frontend/app\`: Expo Router screens
238
+
239
+ Build output goes to \`Frontend/dist\`.
240
+
241
+ ## Backend
242
+
243
+ \`Backend\` is the Fastify API.
244
+
245
+ Important files:
246
+
247
+ - \`Backend/src/index.ts\`: Vercel Fastify entrypoint
248
+ - \`Backend/src/app.ts\`: Fastify app construction
249
+ - \`Backend/vercel.json\`: minimal Vercel config
250
+ - \`Backend/src/routes\`: health/auth/subscription routes
251
+
252
+ ## Extension
253
+
254
+ \`Extension\` is the WXT Chrome extension using the Side Panel API.
255
+
256
+ Important files:
257
+
258
+ - \`Extension/wxt.config.ts\`: manifest, side panel, permissions
259
+ - \`Extension/src/entrypoints/sidepanel\`: side panel UI
260
+ - \`Extension/src/entrypoints/background.ts\`: side panel behavior
261
+
262
+ ZIP output goes to \`Extension/.output\`.
263
+
264
+ ## Develop
265
+
266
+ From the repository root:
267
+
268
+ \`\`\`sh
269
+ pnpm install
270
+ pnpm dev
271
+ \`\`\`
272
+
273
+ Or run one surface at a time:
274
+
275
+ \`\`\`sh
276
+ pnpm dev:frontend
277
+ pnpm dev:backend
278
+ pnpm dev:extension
279
+ \`\`\`
280
+
281
+ Use these before deploy:
282
+
283
+ \`\`\`sh
284
+ pnpm verify
285
+ pnpm verify:frontend
286
+ pnpm verify:backend
287
+ pnpm verify:extension
288
+ \`\`\`
289
+
290
+ Database workflow:
291
+
292
+ \`\`\`sh
293
+ pnpm db:generate
294
+ pnpm db:migrate
295
+ \`\`\`
296
+
297
+ Local demo mode is enabled through generated \`.env\` files. Real provider behavior needs Clerk, RevenueCat/Stripe, Neon, and R2 credentials.
298
+
299
+ ## Environment Variables
300
+
301
+ - Root/local: \`DATABASE_URL\` for shared Drizzle commands.
302
+ - Frontend/Vercel Frontend: only \`EXPO_PUBLIC_*\` values.
303
+ - Backend/Vercel Backend: server secrets such as \`DATABASE_URL\`, \`CLERK_SECRET_KEY\`, \`RC_SECRET_API_KEY\`, \`RC_WEBHOOK_SECRET\`, and R2 credentials.
304
+ - Extension: \`VITE_API_URL\`, \`VITE_CLERK_PUBLISHABLE_KEY\`, and optionally \`VITE_CRX_PUBLIC_KEY\`.
305
+ - EAS: add native app public keys with \`eas secret:create\` or the EAS dashboard.
306
+
307
+ ## Deploy
308
+
309
+ Vercel deployment is two Vercel projects from the same Git repo:
310
+
311
+ 1. Frontend Vercel project
312
+ - Root Directory: \`Frontend\`
313
+ - Build command: \`pnpm build:web\`
314
+ - Output directory: \`dist\`
315
+
316
+ 2. Backend Vercel project
317
+ - Root Directory: \`Backend\`
318
+ - Build command: \`pnpm build\`
319
+ - Fastify entrypoint: \`src/index.ts\`
320
+
321
+ Enable Vercel access to source files outside each project root so \`packages/*\` workspace packages resolve.
322
+
323
+ Native app deployment:
324
+
325
+ \`\`\`sh
326
+ cd Frontend
327
+ pnpm dlx eas-cli@latest login
328
+ pnpm dlx eas-cli@latest init
329
+ pnpm dlx eas-cli@latest build --platform all --profile production
330
+ pnpm dlx eas-cli@latest submit --platform all --latest --profile production
331
+ \`\`\`
332
+
333
+ Chrome extension deployment:
334
+
335
+ \`\`\`sh
336
+ pnpm extension:zip
337
+ \`\`\`
338
+
339
+ Upload \`Extension/.output/*-chrome.zip\` to the Chrome Web Store Developer Dashboard.
340
+
341
+ ## Provider Checklist
342
+
343
+ - Clerk: configure allowed origins for the Expo web Vercel domain, native redirect/deep-link settings for the Expo scheme in \`Frontend/app.json\`, and the Chrome extension origin after publishing or after assigning a stable extension key.
344
+ - RevenueCat + Stripe: create a \`pro\` entitlement, configure iOS/Android/Web app API keys separately, connect Stripe as the web billing source, set the RevenueCat webhook URL to \`https://<backend-domain>/webhooks/revenuecat\`, and keep \`RC_WEBHOOK_SECRET\` only in Backend.
345
+ - Neon + Drizzle: create the Neon database, set \`DATABASE_URL\` locally and in the Backend Vercel project, then run \`pnpm db:generate\` and \`pnpm db:migrate\` before production traffic.
346
+ - Cloudflare R2: create an R2 bucket and least-privilege API token, then set \`R2_ACCOUNT_ID\`, \`R2_ACCESS_KEY_ID\`, \`R2_SECRET_ACCESS_KEY\`, and \`R2_BUCKET\` in Backend only.
347
+
348
+ ## Relevant Docs
349
+
350
+ - [Vercel monorepos](https://vercel.com/docs/monorepos)
351
+ - [Expo web publishing](https://docs.expo.dev/guides/publishing-websites/)
352
+ - [Expo EAS](https://docs.expo.dev/eas/)
353
+ - [WXT publishing](https://wxt.dev/guide/essentials/publishing.html)
354
+ - [Chrome Web Store publishing](https://developer.chrome.com/docs/webstore/publish)
237
355
  `);
238
356
  }
239
357
  function writeStackFile(root, stack) {
@@ -258,17 +376,8 @@ function writeSharedPackages(root) {
258
376
  'db:generate': 'drizzle-kit generate',
259
377
  'db:migrate': 'tsx --env-file=../../.env src/migrate.ts',
260
378
  },
261
- dependencies: {
262
- '@neondatabase/serverless': '^1.0.0',
263
- 'drizzle-orm': '^0.44.0',
264
- dotenv: '^17.2.3',
265
- },
266
- devDependencies: {
267
- 'drizzle-kit': '^0.31.0',
268
- tsx: '^4.20.5',
269
- typescript: '^5.9.3',
270
- '@types/node': '25.5.0',
271
- },
379
+ dependencies: SHARED_DB_DEPENDENCIES.dependencies,
380
+ devDependencies: SHARED_DB_DEPENDENCIES.devDependencies,
272
381
  }, null, 2) + '\n');
273
382
  writeFile(path.join(dbRoot, 'tsconfig.json'), JSON.stringify({
274
383
  compilerOptions: {
@@ -375,8 +484,7 @@ export const users = pgTable('users', {
375
484
  displayName: text('display_name'),
376
485
  lastLoginAt: timestamp('last_login_at'),
377
486
  profileImageUrl: text('profile_image_url'),
378
- stripeCustomerId: text('stripe_customer_id').unique(),
379
- subscriptionTier: text('subscription_tier').notNull().default('starter'),
487
+ subscriptionTier: text('subscription_tier').notNull().default('free'),
380
488
  subscriptionStatus: text('subscription_status').notNull().default('active'),
381
489
  avatarObjectKey: text('avatar_object_key'),
382
490
  avatarMimeType: text('avatar_mime_type'),
@@ -526,17 +634,6 @@ export async function syncUserProfile(input: SyncUserInput) {
526
634
  return findUserByClerkId(input.clerkUserId);
527
635
  }
528
636
 
529
- export async function updateStripeCustomerForUser(clerkUserId: string, stripeCustomerId: string) {
530
- await db
531
- .update(users)
532
- .set({
533
- stripeCustomerId,
534
- subscriptionStatus: 'checkout_started',
535
- updatedAt: new Date(),
536
- })
537
- .where(eq(users.clerkUserId, clerkUserId));
538
- }
539
-
540
637
  export async function updateAvatarForUser(
541
638
  clerkUserId: string,
542
639
  input: { objectKey: string; contentType: string | null },
@@ -600,7 +697,7 @@ console.log('Migrations completed in', Date.now() - start, 'ms');
600
697
  writeFile(path.join(dbRoot, 'migrations/.gitkeep'), '');
601
698
  const simplePackages = [
602
699
  ['types', { exports: { '.': './src/index.ts' } }, `export type ApiEnvelope<T> = { data: T } | { error: string; message: string };
603
- export type ClientPlatform = 'web' | 'mobile' | 'extension';
700
+ export type ClientPlatform = 'frontend' | 'extension';
604
701
  export type SubscriptionEntitlements = {
605
702
  pro: boolean;
606
703
  inTrial: boolean;
@@ -618,7 +715,7 @@ export type AuthMeResponse = {
618
715
  email: string;
619
716
  firstName?: string | null;
620
717
  lastName?: string | null;
621
- displayName?: string | null;
718
+ displayName: string;
622
719
  imageUrl?: string | null;
623
720
  avatarUrl?: string | null;
624
721
  creditsBalance: number;
@@ -627,19 +724,26 @@ export type AuthMeResponse = {
627
724
  };
628
725
  };
629
726
  export type PricingResponse = {
630
- plans: Array<{
631
- id: string;
632
- name: string;
727
+ tiers: Array<{
728
+ tier: 'free' | 'pro';
729
+ displayName: string;
633
730
  description: string;
634
- price: string;
635
- features: string[];
731
+ priceMonthly: number | null;
732
+ priceYearly: number | null;
733
+ currency: string;
734
+ limits: {
735
+ dailyLimit: number | null;
736
+ };
737
+ paymentInfo?: {
738
+ revenueCatEntitlementId: string;
739
+ revenueCatOfferingId: string;
740
+ };
636
741
  }>;
637
742
  };
638
743
  `],
639
744
  ['config', { exports: { '.': './src/index.ts' } }, `export const DEFAULT_API_PATH_PREFIX = '/api';
640
745
  export const DEFAULT_LOCAL_API_URL = 'http://localhost:8787';
641
- export const DEFAULT_WEB_URL = 'http://localhost:3000';
642
- export const DEFAULT_MOBILE_URL = 'http://localhost:8081';
746
+ export const DEFAULT_FRONTEND_URL = 'http://localhost:8081';
643
747
  export const DEFAULT_EXTENSION_URL = 'chrome-extension://';
644
748
 
645
749
  export function joinApiUrl(baseUrl: string, path: string) {
@@ -650,13 +754,21 @@ export function joinApiUrl(baseUrl: string, path: string) {
650
754
  `],
651
755
  ['api-client', {
652
756
  exports: { '.': './src/index.ts' },
653
- dependencies: {
654
- '@anhedral/config': 'workspace:*',
655
- '@anhedral/types': 'workspace:*',
656
- },
757
+ dependencies: API_CLIENT_DEPENDENCIES.dependencies,
657
758
  }, `import { joinApiUrl } from '@anhedral/config';
658
759
  import type { AuthMeResponse, ClientPlatform, PricingResponse, SubscriptionEntitlements } from '@anhedral/types';
659
760
 
761
+ export class APIRequestError extends Error {
762
+ constructor(
763
+ public statusCode: number,
764
+ message: string,
765
+ public errorCode?: string,
766
+ ) {
767
+ super(message);
768
+ this.name = 'APIRequestError';
769
+ }
770
+ }
771
+
660
772
  export type ApiClientOptions = {
661
773
  baseUrl: string;
662
774
  platform: ClientPlatform;
@@ -679,9 +791,16 @@ export class ApiClient {
679
791
  });
680
792
 
681
793
  if (!response.ok) {
682
- throw new Error(\`API request failed: \${response.status}\`);
794
+ let error: { error?: string; message?: string } = {};
795
+ try { error = await response.json(); } catch {}
796
+ throw new APIRequestError(
797
+ response.status,
798
+ error.message || \`API request failed: \${response.status}\`,
799
+ error.error,
800
+ );
683
801
  }
684
802
 
803
+ if (response.status === 204) return {} as T;
685
804
  return response.json() as Promise<T>;
686
805
  }
687
806
 
@@ -693,6 +812,10 @@ export class ApiClient {
693
812
  return this.request<PricingResponse>('/subscriptions/pricing');
694
813
  }
695
814
 
815
+ getSubscriptionPricing() {
816
+ return this.getPricing();
817
+ }
818
+
696
819
  getSubscriptionEntitlements(options?: { refresh?: boolean }) {
697
820
  return this.request<SubscriptionEntitlements>(
698
821
  options?.refresh ? '/subscriptions/entitlements/me?refresh=true' : '/subscriptions/entitlements/me',
@@ -705,6 +828,25 @@ export class ApiClient {
705
828
  body: JSON.stringify({ code }),
706
829
  });
707
830
  }
831
+
832
+ signOut() {
833
+ return this.request<{ success: boolean }>('/auth/signout', {
834
+ method: 'POST',
835
+ });
836
+ }
837
+
838
+ uploadAvatar(input: { base64: string; mimeType: string; fileName?: string }) {
839
+ return this.request<{ ok: boolean; avatarUrl: string }>('/auth/avatar', {
840
+ method: 'POST',
841
+ body: JSON.stringify(input),
842
+ });
843
+ }
844
+
845
+ deleteAccount() {
846
+ return this.request<void>('/auth/account', {
847
+ method: 'DELETE',
848
+ });
849
+ }
708
850
  }
709
851
  `],
710
852
  ];
@@ -719,9 +861,7 @@ export class ApiClient {
719
861
  build: 'pnpm typecheck',
720
862
  typecheck: 'tsc --noEmit',
721
863
  },
722
- devDependencies: {
723
- typescript: '^5.9.3',
724
- },
864
+ devDependencies: SHARED_PACKAGE_DEPENDENCIES.devDependencies,
725
865
  ...packageFields,
726
866
  }, null, 2) + '\n');
727
867
  writeFile(path.join(packageRoot, 'tsconfig.json'), JSON.stringify({
@@ -738,54 +878,31 @@ export class ApiClient {
738
878
  writeFile(path.join(packageRoot, 'src/index.ts'), source);
739
879
  }
740
880
  }
741
- function writeRootEnvExample(root, options) {
742
- const frontendUrl = options.frontend === 'next' ? 'http://localhost:3000' : 'http://localhost:8081';
743
- const nextEnv = options.frontend === 'next'
744
- ? `NEXT_PUBLIC_APP_URL=http://localhost:3000
745
- NEXT_PUBLIC_API_URL=http://localhost:8787
746
- NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_***
747
- NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in
748
- NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up
749
- NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL=/dashboard
750
- NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL=/dashboard
751
- `
752
- : '';
753
- const expoEnv = options.frontend === 'expo'
754
- ? `EXPO_PUBLIC_API_URL=http://localhost:8787
881
+ function writeRootEnvExample(root) {
882
+ const frontendUrl = 'http://localhost:8081';
883
+ const expoEnv = `EXPO_PUBLIC_API_URL=http://localhost:8787
755
884
  EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_***
756
885
  EXPO_PUBLIC_RC_ENTITLEMENT_ID=pro
757
886
  EXPO_PUBLIC_RC_API_KEY_IOS=
758
887
  EXPO_PUBLIC_RC_API_KEY_ANDROID=
759
888
  EXPO_PUBLIC_RC_WEB_API_KEY=
760
- `
761
- : '';
762
- const extensionEnv = options.extension
763
- ? `VITE_API_URL=http://localhost:8787
889
+ `;
890
+ const extensionEnv = `VITE_API_URL=http://localhost:8787
764
891
  VITE_WEBSITE_URL=${frontendUrl}
765
892
  VITE_CLERK_PUBLISHABLE_KEY=pk_test_***
766
893
  VITE_CRX_PUBLIC_KEY=
767
894
  VITE_RC_BILLING_URL=
768
- `
769
- : '';
770
- const revenueCatEnv = options.payments === 'revenuecat'
771
- ? `# RevenueCat
895
+ `;
896
+ const paymentsEnv = `# RevenueCat
772
897
  RC_SECRET_API_KEY=
773
898
  RC_WEBHOOK_SECRET=
774
899
  RC_ENTITLEMENT_ID=pro
775
900
  RC_OFFERING_ID=default
776
- `
777
- : '';
778
- const stripeEnv = options.payments === 'stripe'
779
- ? `# Stripe
780
- STRIPE_SECRET_KEY=sk_test_***
781
- STRIPE_WEBHOOK_SECRET=whsec_***
782
- STRIPE_PRICE_STARTER=price_***
783
- `
784
- : '';
901
+ `;
785
902
  writeFile(path.join(root, '.env.example'), `# Apps
786
903
  FRONTEND_URL=${frontendUrl}
787
904
  ANHEDRAL_DEMO=false
788
- ${nextEnv}${expoEnv}${extensionEnv}
905
+ ${expoEnv}${extensionEnv}
789
906
 
790
907
  # Server
791
908
  PORT=8787
@@ -801,126 +918,129 @@ R2_ACCESS_KEY_ID=
801
918
  R2_SECRET_ACCESS_KEY=
802
919
  R2_BUCKET=
803
920
 
804
- ${revenueCatEnv}${stripeEnv}`);
921
+ ${paymentsEnv}`);
805
922
  }
806
- function writeRootVercelJson(root, options) {
807
- const builds = options.frontend === 'next'
808
- ? [
809
- { src: 'apps/web/package.json', use: '@vercel/next' },
810
- { src: 'apps/api/api/index.ts', use: '@vercel/node' },
811
- ]
812
- : [
813
- { src: 'apps/api/api/index.ts', use: '@vercel/node' },
814
- ];
815
- const routes = options.frontend === 'next'
816
- ? [
817
- { src: '/backend/(.*)', dest: 'apps/api/api/index.ts' },
818
- { src: '/(.*)', dest: 'apps/web/$1' },
819
- ]
820
- : [
821
- { src: '/api/(.*)', dest: 'apps/api/api/index.ts' },
822
- { src: '/(.*)', dest: 'apps/api/api/index.ts' },
823
- ];
824
- writeFile(path.join(root, 'vercel.json'), JSON.stringify({ version: 2, builds, routes }, null, 2) + '\n');
825
- writeFile(path.join(root, '.vercelignore'), `apps/extension/.output
826
- apps/extension/.wxt
827
- apps/extension/dist
828
- apps/mobile/.expo
923
+ function writeRootVercelFiles(root) {
924
+ writeFile(path.join(root, '.vercelignore'), `Extension/.output
925
+ Extension/.wxt
926
+ Extension/dist
829
927
  `);
830
928
  }
831
- function patchGeneratedPackageName(root, name) {
832
- const filePath = path.join(root, 'package.json');
833
- const packageJson = readJsonFile(filePath);
834
- packageJson.name = name;
835
- writeJsonFile(filePath, packageJson);
836
- }
837
- function scaffoldNextFrontend(root, options, directoryName) {
838
- const toolchain = resolveToolchain(options.toolchainChannel);
839
- const appsRoot = path.join(root, 'apps');
840
- const appRoot = path.join(appsRoot, directoryName);
841
- anhedralPrint.section('Web (Next.js + shadcn/ui)');
842
- anhedralPrint.step('Scaffolding Next.js app');
843
- writeFile(path.join(appsRoot, '.gitkeep'), '');
844
- runScaffoldCommands(appsRoot, [getDefaultWebInitCommand(toolchain, directoryName)]);
845
- anhedralPrint.done('Next.js app scaffolded');
846
- const packageJsonPath = path.join(appRoot, 'package.json');
847
- const packageJson = readJsonFile(packageJsonPath);
848
- packageJson.dependencies = {
849
- ...(packageJson.dependencies ?? {}),
850
- '@anhedral/db': 'workspace:*',
851
- };
852
- writeJsonFile(packageJsonPath, packageJson);
853
- anhedralPrint.step('Installing web dependencies');
854
- runScaffoldCommands(appRoot, getDefaultWebDependencyCommands());
855
- anhedralPrint.done('Web dependencies installed');
856
- rmSync(path.join(appRoot, '.git'), { recursive: true, force: true });
857
- appendGitignore(appRoot, [
858
- ...SHARED_ENV_GITIGNORE_LINES,
859
- '.next',
860
- 'node_modules',
861
- 'lib/db/migrations',
862
- ...SHARED_TYPESCRIPT_GITIGNORE_LINES,
863
- ]);
864
- patchPackageJson(appRoot, {
865
- packageManager: PNPM_PACKAGE_MANAGER,
866
- scripts: {
867
- typecheck: 'tsc --noEmit',
868
- 'db:generate': 'pnpm --filter @anhedral/db db:generate',
869
- 'db:migrate': 'pnpm --filter @anhedral/db db:migrate',
870
- },
871
- });
872
- patchGeneratedPackageName(appRoot, `${options.projectName}-${directoryName}`);
873
- writeDefaultWebEnvExample(appRoot);
874
- writeDefaultWebFiles(appRoot, options.displayName);
875
- for (const relativePath of [
876
- 'lib/app/dashboard.ts',
877
- 'app/api/account/avatar/route.ts',
878
- 'app/api/stripe/checkout/route.ts',
879
- 'app/api/stripe/portal/route.ts',
880
- ]) {
881
- replaceInFile(path.join(appRoot, relativePath), [
882
- ["@/lib/db/queries", '@anhedral/db/queries'],
883
- ]);
884
- }
885
- rmSync(path.join(appRoot, 'lib/db'), { recursive: true, force: true });
929
+ function writeGeneratedCiWorkflow(root) {
930
+ writeFile(path.join(root, '.github/workflows/ci.yml'), `name: CI
931
+
932
+ on:
933
+ pull_request:
934
+ push:
935
+ branches:
936
+ - main
937
+
938
+ permissions:
939
+ contents: read
940
+
941
+ concurrency:
942
+ group: ci-\${{ github.workflow }}-\${{ github.ref }}
943
+ cancel-in-progress: true
944
+
945
+ jobs:
946
+ verify:
947
+ runs-on: ubuntu-latest
948
+ timeout-minutes: 45
949
+ steps:
950
+ - name: Checkout
951
+ uses: actions/checkout@v4
952
+
953
+ - name: Setup pnpm
954
+ uses: pnpm/action-setup@v4
955
+ with:
956
+ version: 10.15.1
957
+
958
+ - name: Setup Node.js
959
+ uses: actions/setup-node@v4
960
+ with:
961
+ node-version: 20
962
+ cache: pnpm
963
+
964
+ - name: Install dependencies
965
+ run: pnpm install --frozen-lockfile
966
+
967
+ - name: Check Expo dependency alignment
968
+ run: pnpm --filter ./Frontend exec expo install --check
969
+
970
+ - name: Build Expo web
971
+ run: pnpm --filter ./Frontend build:web
972
+
973
+ - name: Test API
974
+ run: pnpm --filter ./Backend test
975
+
976
+ - name: Build API
977
+ run: pnpm --filter ./Backend build
978
+
979
+ - name: Build extension
980
+ run: pnpm --filter ./Extension build
981
+
982
+ - name: Zip extension
983
+ run: pnpm --filter ./Extension zip
984
+
985
+ - name: Build workspace
986
+ run: pnpm build
987
+ `);
886
988
  }
887
989
  function writeFullstackRootFiles(root, options) {
888
990
  const appFilters = [
889
- options.frontend === 'next' ? './apps/web' : './apps/mobile',
890
- './apps/api',
891
- ...(options.extension ? ['./apps/extension'] : []),
991
+ './Frontend',
992
+ './Backend',
993
+ './Extension',
892
994
  ];
893
- const parallelFilters = appFilters.map((entry) => `--filter ${entry}`).join(' ');
894
- const buildFilters = ['./packages/db', ...appFilters].map((entry) => `--filter ${entry}`).join(' ');
995
+ const parallelFilters = appFilters.map((entry) => `--filter=${entry}`).join(' ');
895
996
  const scripts = {
896
- dev: `pnpm -r --parallel ${parallelFilters} run dev`,
897
- 'dev:api': 'pnpm --filter ./apps/api dev',
898
- build: `pnpm -r ${buildFilters} build`,
899
- typecheck: 'pnpm -r typecheck',
997
+ dev: `turbo dev --parallel ${parallelFilters}`,
998
+ 'dev:backend': 'pnpm --filter ./Backend dev',
999
+ build: 'turbo build',
1000
+ typecheck: 'turbo typecheck',
1001
+ verify: 'pnpm verify:frontend && pnpm verify:backend && pnpm verify:extension',
1002
+ 'verify:frontend': 'pnpm --filter ./Frontend exec expo install --check && pnpm --filter ./Frontend build:web',
1003
+ 'verify:backend': 'pnpm --filter ./Backend test && pnpm --filter ./Backend build',
1004
+ 'verify:extension': 'pnpm --filter ./Extension typecheck && pnpm --filter ./Extension zip',
900
1005
  'db:generate': 'pnpm --filter @anhedral/db db:generate',
901
1006
  'db:migrate': 'pnpm --filter @anhedral/db db:migrate',
902
1007
  };
903
- if (options.frontend === 'next') {
904
- scripts['dev:web'] = 'pnpm --filter ./apps/web dev';
905
- }
906
- else {
907
- scripts['dev:mobile'] = 'pnpm --filter ./apps/mobile dev';
908
- }
909
- if (options.extension) {
910
- scripts['dev:extension'] = 'pnpm --filter ./apps/extension dev';
911
- scripts['extension:zip'] = 'pnpm --filter ./apps/extension zip';
912
- }
913
- writeRootPackageJson(root, options.projectName, scripts, ['apps/*', 'packages/*']);
914
- writePnpmWorkspace(root, ['apps/*', 'packages/*']);
1008
+ scripts['dev:frontend'] = 'pnpm --filter ./Frontend dev';
1009
+ scripts['dev:extension'] = 'pnpm --filter ./Extension dev';
1010
+ scripts['extension:zip'] = 'pnpm --filter ./Extension zip';
1011
+ writeRootPackageJson(root, options.projectName, scripts, ['Frontend', 'Backend', 'Extension', 'packages/*'], {
1012
+ devDependencies: ROOT_DEPENDENCIES.devDependencies,
1013
+ });
1014
+ writePnpmWorkspace(root, ['Frontend', 'Backend', 'Extension', 'packages/*']);
1015
+ writeFile(path.join(root, 'turbo.json'), JSON.stringify({
1016
+ $schema: 'https://turborepo.dev/schema.json',
1017
+ tasks: {
1018
+ build: {
1019
+ dependsOn: ['^build'],
1020
+ outputs: [],
1021
+ },
1022
+ typecheck: {
1023
+ dependsOn: ['^build'],
1024
+ },
1025
+ dev: {
1026
+ cache: false,
1027
+ persistent: true,
1028
+ },
1029
+ },
1030
+ }, null, 2) + '\n');
915
1031
  appendGitignore(root, [
916
1032
  'node_modules',
917
- 'apps/*/node_modules',
1033
+ '.turbo',
1034
+ 'Frontend/node_modules',
1035
+ 'Backend/node_modules',
1036
+ 'Extension/node_modules',
918
1037
  'packages/*/node_modules',
919
1038
  ...SHARED_ENV_GITIGNORE_LINES,
920
1039
  ...SHARED_TYPESCRIPT_GITIGNORE_LINES,
921
1040
  ]);
922
- writeRootEnvExample(root, options);
923
- writeRootVercelJson(root, options);
1041
+ writeRootEnvExample(root);
1042
+ writeRootVercelFiles(root);
1043
+ writeGeneratedCiWorkflow(root);
924
1044
  }
925
1045
  async function scaffoldFullstack(root, options) {
926
1046
  anhedralPrint.section('Workspace root');
@@ -931,36 +1051,34 @@ async function scaffoldFullstack(root, options) {
931
1051
  anhedralPrint.step('Writing @anhedral/db, types, config, api-client');
932
1052
  writeSharedPackages(root);
933
1053
  anhedralPrint.done('Shared packages written');
934
- const frontendUrl = options.frontend === 'next' ? 'http://localhost:3000' : 'http://localhost:8081';
1054
+ const frontendUrl = 'http://localhost:8081';
935
1055
  await scaffoldBackend(root, {
936
1056
  projectName: options.projectName,
937
1057
  displayName: options.displayName,
938
1058
  githubOrg: null,
939
1059
  frontendUrl,
1060
+ skipInstall: options.skipInstall,
1061
+ });
1062
+ await scaffoldFrontend(root, {
1063
+ projectName: options.projectName,
1064
+ displayName: options.displayName,
1065
+ githubOrg: null,
1066
+ frontendUrl,
1067
+ skipInstall: options.skipInstall,
1068
+ });
1069
+ await scaffoldExtension(root, {
1070
+ projectName: options.projectName,
1071
+ displayName: options.displayName,
1072
+ githubOrg: null,
1073
+ frontendUrl,
1074
+ skipInstall: options.skipInstall,
940
1075
  });
941
- if (options.frontend === 'next') {
942
- scaffoldNextFrontend(root, options, 'web');
943
- }
944
- else {
945
- await scaffoldFrontend(root, {
946
- projectName: options.projectName,
947
- displayName: options.displayName,
948
- githubOrg: null,
949
- frontendUrl,
950
- });
951
- }
952
- if (options.extension) {
953
- await scaffoldExtension(root, {
954
- projectName: options.projectName,
955
- displayName: options.displayName,
956
- githubOrg: null,
957
- frontendUrl,
958
- });
959
- }
960
1076
  return [
961
- options.frontend === 'next' ? 'apps/web' : 'apps/mobile',
962
- 'apps/api',
963
- ...(options.extension ? ['apps/extension'] : []),
1077
+ '.github/workflows/ci.yml',
1078
+ 'PRODUCTION.md',
1079
+ 'Frontend',
1080
+ 'Backend',
1081
+ 'Extension',
964
1082
  'packages/db',
965
1083
  'packages/types',
966
1084
  'packages/config',
@@ -978,10 +1096,11 @@ export async function scaffoldProject(options) {
978
1096
  const generatedPaths = await scaffoldFullstack(root, options);
979
1097
  anhedralPrint.section('Project metadata');
980
1098
  anhedralPrint.step('Writing skills guide, README, and stack.json');
981
- const skillCommands = getSkillCommands(options);
1099
+ const skillCommands = getSkillCommands();
982
1100
  writeSkillsGuide(root, skillCommands);
983
1101
  const stack = normalizeStack(options, generatedPaths);
984
1102
  writeRootDocs(root, options, stack);
1103
+ writeProductionGuide(root, options);
985
1104
  writeStackFile(root, stack);
986
1105
  anhedralPrint.done('Project metadata written');
987
1106
  console.log('');
@@ -993,18 +1112,12 @@ export async function scaffoldProject(options) {
993
1112
  anhedralPrint.info('Next commands:');
994
1113
  console.log(' pnpm install');
995
1114
  console.log(' cp .env.example .env');
996
- if (options.frontend === 'next') {
997
- console.log(' cp apps/web/.env.example apps/web/.env.local');
998
- }
999
- if (options.extension) {
1000
- console.log(' cp apps/extension/.env.example apps/extension/.env');
1001
- }
1115
+ console.log(' cp Extension/.env.example Extension/.env');
1002
1116
  console.log(' pnpm db:generate && pnpm db:migrate');
1003
- console.log(' pnpm dev:api');
1004
- console.log(options.frontend === 'next' ? ' pnpm dev:web' : ' pnpm dev:mobile');
1005
- if (options.extension) {
1006
- console.log(' pnpm dev:extension');
1007
- }
1117
+ console.log(' pnpm verify');
1118
+ console.log(' pnpm dev:backend');
1119
+ console.log(' pnpm dev:frontend');
1120
+ console.log(' pnpm dev:extension');
1008
1121
  console.log('');
1009
1122
  }
1010
1123
  finally {