anhedral 0.1.0 → 0.1.3

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,13 +1,14 @@
1
- import { existsSync, readFileSync, readdirSync } from 'node:fs';
1
+ import { readFileSync, readdirSync, rmSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { env } from 'node:process';
4
- import { appendGitignore, exec, execWithInput, liftNestedProject, writeFile } from './util.js';
5
- import { getDefaultWebDependencyCommands, getDefaultWebInitCommand, getHeadlessInstallCommands, getSkillCommands, } from './commands.js';
4
+ import { anhedralPrint } from './print.js';
5
+ import { appendGitignore, exec, execWithInput, writeFile } from './util.js';
6
+ import { getDefaultWebDependencyCommands, getDefaultWebInitCommand, getSkillCommands, } from './commands.js';
6
7
  import { resolveToolchain } from './toolchain.js';
7
8
  import { scaffoldBackend } from './templates/backend.js';
8
9
  import { writeDefaultWebEnvExample, writeDefaultWebFiles } from './templates/default-web.js';
10
+ import { scaffoldExtension } from './templates/extension.js';
9
11
  import { scaffoldFrontend } from './templates/frontend.js';
10
- export const STACK_IDS = ['next', 'next-fullstack', 'expo-fullstack', 'backend'];
11
12
  const PNPM_PACKAGE_MANAGER = 'pnpm@10.15.1';
12
13
  const SHARED_ENV_GITIGNORE_LINES = ['.env', '.env.*', '!.env.example'];
13
14
  const SHARED_TYPESCRIPT_GITIGNORE_LINES = ['*.tsbuildinfo'];
@@ -39,33 +40,37 @@ async function printAsciiLogo() {
39
40
  }
40
41
  console.log('');
41
42
  }
43
+ function getUsedToolchain(options, toolchain) {
44
+ return {
45
+ 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 } : {}),
49
+ };
50
+ }
42
51
  function normalizeStack(options, generatedPaths) {
43
52
  const toolchain = resolveToolchain(options.toolchainChannel);
44
- const frontend = options.mode === 'next'
53
+ const frontend = options.frontend === 'next'
45
54
  ? 'nextjs_shadcn'
46
- : options.mode === 'next-fullstack'
47
- ? 'nextjs_shadcn'
48
- : options.mode === 'expo-fullstack'
49
- ? 'expo_react_native_reusables'
50
- : null;
51
- const backend = options.mode === 'next' ? null : 'fastify';
55
+ : 'expo_react_native_reusables';
52
56
  return {
53
57
  schema_version: '2.0.0',
54
- mode: options.mode,
58
+ mode: 'fullstack',
55
59
  project_name: options.projectName,
56
60
  display_name: options.displayName,
57
61
  frontend,
58
- backend,
62
+ extension: options.extension ? 'wxt_chrome_extension' : null,
63
+ backend: 'fastify',
59
64
  auth: options.auth,
60
65
  payments: options.payments,
61
66
  storage: 'cloudflare_r2_via_aws_s3_sdk',
62
67
  database: 'neon_plus_drizzle',
63
- skills: getSkillCommands(options.mode),
68
+ skills: getSkillCommands(options),
64
69
  outputs: {
65
70
  monorepo: options.monorepo,
66
71
  package_manager: 'pnpm',
67
72
  toolchain_channel: options.toolchainChannel,
68
- toolchain,
73
+ toolchain: getUsedToolchain(options, toolchain),
69
74
  generated_paths: generatedPaths,
70
75
  },
71
76
  };
@@ -77,9 +82,10 @@ function writeJsonFile(filePath, payload) {
77
82
  writeFile(filePath, JSON.stringify(payload, null, 2) + '\n');
78
83
  }
79
84
  function runScaffoldCommands(cwd, commands) {
85
+ let skippedCount = 0;
80
86
  for (const command of commands) {
81
87
  if (env.ANHEDRAL_SKIP_INSTALL === '1' && command.skippable) {
82
- console.log(` $ ${command.cmd} (skipped via ANHEDRAL_SKIP_INSTALL=1)`);
88
+ skippedCount += 1;
83
89
  continue;
84
90
  }
85
91
  if (command.stdinInput == null) {
@@ -88,6 +94,9 @@ function runScaffoldCommands(cwd, commands) {
88
94
  }
89
95
  execWithInput(command.cmd, cwd, command.stdinInput);
90
96
  }
97
+ if (skippedCount > 0) {
98
+ anhedralPrint.info(`Skipped ${skippedCount} install step${skippedCount === 1 ? '' : 's'} (ANHEDRAL_SKIP_INSTALL=1)`);
99
+ }
91
100
  }
92
101
  function ensureScaffoldRoot(root) {
93
102
  const allowedEntries = new Set(['.git', '.gitignore', '.DS_Store']);
@@ -127,18 +136,12 @@ function patchPackageJson(root, updates) {
127
136
  }
128
137
  writeJsonFile(filePath, packageJson);
129
138
  }
130
- function writeDrizzleConfig(root, schemaPath, migrationsPath) {
131
- writeFile(path.join(root, 'drizzle.config.ts'), `import type { Config } from 'drizzle-kit';
132
-
133
- export default {
134
- schema: '${schemaPath}',
135
- out: '${migrationsPath}',
136
- dialect: 'postgresql',
137
- dbCredentials: {
138
- url: process.env.DATABASE_URL!,
139
- },
140
- } satisfies Config;
141
- `);
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);
142
145
  }
143
146
  function writeSkillsGuide(root, commands) {
144
147
  const commandBlock = commands.map((command) => `${command}\n`).join('\n');
@@ -154,131 +157,235 @@ function writeSkillsGuide(root, commands) {
154
157
 
155
158
  ${commandBlock}`);
156
159
  }
157
- function writeCrossplatformEnvExample(root) {
158
- writeFile(path.join(root, '.env.example'), `# Shared services
159
- DATABASE_URL=postgresql://user:pass@ep-xxx.us-east-1.aws.neon.tech/dbname?sslmode=require
160
+ function writeRootDocs(root, options, stack) {
161
+ const toolchainLine = stack.outputs.toolchain.verifiedAt
162
+ ? `${options.toolchainChannel} (verified ${stack.outputs.toolchain.verifiedAt})`
163
+ : `${options.toolchainChannel} (floating latest)`;
164
+ 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
+ writeFile(path.join(root, 'README.md'), `# ${options.displayName}
160
183
 
161
- # Clerk
162
- NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_***
163
- CLERK_SECRET_KEY=sk_test_***
164
- EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_***
184
+ Generated by anhedral.
165
185
 
166
- # Cloudflare R2
167
- R2_ACCOUNT_ID=
168
- R2_ACCESS_KEY_ID=
169
- R2_SECRET_ACCESS_KEY=
170
- R2_BUCKET=
186
+ ## Stack
171
187
 
172
- # RevenueCat
173
- RC_SECRET_API_KEY=
174
- RC_WEBHOOK_SECRET=
175
- RC_ENTITLEMENT_ID=pro
176
- RC_OFFERING_ID=default
177
- EXPO_PUBLIC_RC_ENTITLEMENT_ID=pro
178
- EXPO_PUBLIC_RC_API_KEY_IOS=
179
- EXPO_PUBLIC_RC_API_KEY_ANDROID=
180
- EXPO_PUBLIC_RC_WEB_API_KEY=
188
+ - Mode: fullstack
189
+ - Frontend: ${frontendLabel}
190
+ - Backend: Fastify
191
+ ${extensionLine}- Shared packages: \`packages/*\`
192
+ - Auth: ${options.auth}
193
+ - Payments: ${options.payments}
194
+ - Database: ${options.db} + ${options.orm}
195
+ - Storage: ${options.storage}
196
+ - API: ${options.api ?? 'framework-native'}
197
+ - Toolchain: ${toolchainLine}
181
198
 
182
- # Stripe
183
- STRIPE_SECRET_KEY=sk_test_***
184
- STRIPE_WEBHOOK_SECRET=whsec_***
185
- `);
186
- }
187
- function writeNextFullstackRootEnvExample(root) {
188
- writeFile(path.join(root, '.env.example'), `# Frontend
189
- NEXT_PUBLIC_APP_URL=http://localhost:3000
190
- NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_***
191
- NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in
192
- NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up
193
- NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL=/dashboard
194
- NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL=/dashboard
199
+ ## Generated paths
195
200
 
196
- # Backend
197
- PORT=8787
198
- FRONTEND_URL=http://localhost:3000
199
- DATABASE_URL=postgresql://user:pass@ep-xxx.us-east-1.aws.neon.tech/dbname?sslmode=require
200
- CLERK_SECRET_KEY=sk_test_***
201
- CLERK_PUBLISHABLE_KEY=pk_test_***
201
+ ${generatedPaths}
202
202
 
203
- # Cloudflare R2
204
- R2_ACCOUNT_ID=
205
- R2_ACCESS_KEY_ID=
206
- R2_SECRET_ACCESS_KEY=
207
- R2_BUCKET=
203
+ ## First Run
208
204
 
209
- # Stripe
210
- STRIPE_SECRET_KEY=sk_test_***
211
- STRIPE_WEBHOOK_SECRET=whsec_***
212
- STRIPE_PRICE_STARTER=price_***
205
+ \`\`\`bash
206
+ pnpm install
207
+ cp .env.example .env
208
+ ${frontendEnvCommand}${extensionEnvCommand}
209
+ pnpm db:generate
210
+ pnpm db:migrate
211
+ pnpm dev:api
212
+ ${frontendDevCommand}${extensionDevCommand}
213
+ \`\`\`
214
+
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.
216
+
217
+ ## Provider Setup
218
+
219
+ - 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}
222
+ - 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}
224
+
225
+ ## Setup Notes
226
+
227
+ 1. Copy \`.env.example\` into the runtime env files your apps need and fill in provider values.
228
+ 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:
231
+
232
+ \`\`\`bash
233
+ pnpm db:generate
234
+ pnpm db:migrate
235
+ \`\`\`
236
+ ${extensionSetup}
213
237
  `);
214
238
  }
215
- function writeHeadlessPackageJson(root, projectName) {
216
- writeRootPackageJson(root, projectName, {
217
- dev: 'tsx --env-file=.env --watch src/index.ts',
218
- build: 'tsc',
219
- typecheck: 'tsc --noEmit',
220
- 'db:generate': 'drizzle-kit generate',
221
- 'db:migrate': 'pnpm tsx src/lib/db/migrate.ts',
222
- }, undefined, {
223
- type: 'module',
224
- });
239
+ function writeStackFile(root, stack) {
240
+ writeFile(path.join(root, 'stack.json'), JSON.stringify(stack, null, 2) + '\n');
225
241
  }
226
- function writeHeadlessTsConfig(root) {
227
- writeFile(path.join(root, 'tsconfig.json'), JSON.stringify({
242
+ function writeSharedPackages(root) {
243
+ const dbRoot = path.join(root, 'packages/db');
244
+ writeFile(path.join(dbRoot, 'package.json'), JSON.stringify({
245
+ name: '@anhedral/db',
246
+ version: '0.1.0',
247
+ private: true,
248
+ type: 'module',
249
+ exports: {
250
+ '.': './src/index.ts',
251
+ './schema': './src/schema.ts',
252
+ './queries': './src/queries/index.ts',
253
+ './migrate': './src/migrate.ts',
254
+ },
255
+ scripts: {
256
+ build: 'pnpm typecheck',
257
+ typecheck: 'tsc --noEmit',
258
+ 'db:generate': 'drizzle-kit generate',
259
+ 'db:migrate': 'tsx --env-file=../../.env src/migrate.ts',
260
+ },
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
+ },
272
+ }, null, 2) + '\n');
273
+ writeFile(path.join(dbRoot, 'tsconfig.json'), JSON.stringify({
228
274
  compilerOptions: {
229
275
  target: 'ESNext',
230
- module: 'NodeNext',
231
- moduleResolution: 'NodeNext',
276
+ module: 'ESNext',
277
+ moduleResolution: 'Bundler',
232
278
  strict: true,
233
- esModuleInterop: true,
234
279
  skipLibCheck: true,
235
- outDir: 'dist',
236
- rootDir: 'src',
280
+ noEmit: true,
237
281
  types: ['node'],
238
282
  },
239
- include: ['src/**/*'],
240
- exclude: ['node_modules', 'dist'],
283
+ include: ['src/**/*', 'drizzle.config.ts'],
241
284
  }, null, 2) + '\n');
242
- }
243
- function writeHeadlessEnvExample(root) {
244
- writeFile(path.join(root, '.env.example'), `PORT=8787
245
- CLIENT_ORIGIN=http://localhost:3000
246
- DATABASE_URL=postgresql://user:pass@ep-xxx.us-east-1.aws.neon.tech/dbname?sslmode=require
247
-
248
- # Better Auth
249
- BETTER_AUTH_SECRET=replace-me
250
- BETTER_AUTH_URL=http://localhost:8787
251
- BETTER_AUTH_TRUSTED_ORIGINS=http://localhost:3000
285
+ writeFile(path.join(dbRoot, 'drizzle.config.ts'), `import type { Config } from 'drizzle-kit';
252
286
 
253
- # Cloudflare R2
254
- R2_ACCOUNT_ID=
255
- R2_ACCESS_KEY_ID=
256
- R2_SECRET_ACCESS_KEY=
257
- R2_BUCKET=
287
+ export default {
288
+ schema: './src/schema.ts',
289
+ out: './migrations',
290
+ dialect: 'postgresql',
291
+ dbCredentials: {
292
+ url: process.env.DATABASE_URL!,
293
+ },
294
+ } satisfies Config;
258
295
  `);
259
- }
260
- function writeHeadlessFiles(root) {
261
- writeFile(path.join(root, 'src/lib/db/client.ts'), `import { drizzle } from 'drizzle-orm/neon-http';
296
+ writeFile(path.join(dbRoot, 'src/index.ts'), `import { drizzle } from 'drizzle-orm/neon-http';
262
297
  import { neon } from '@neondatabase/serverless';
263
- import * as schema from './schema.js';
298
+ import * as schema from './schema';
264
299
 
265
- if (!process.env.DATABASE_URL) {
266
- throw new Error('DATABASE_URL is not defined');
300
+ export function createDb(databaseUrl = process.env.DATABASE_URL) {
301
+ if (!databaseUrl) {
302
+ throw new Error('DATABASE_URL is not defined');
303
+ }
304
+
305
+ const sql = neon(databaseUrl);
306
+ return drizzle(sql, { schema });
307
+ }
308
+
309
+ export type Database = ReturnType<typeof createDb>;
310
+
311
+ let cachedDb: Database | undefined;
312
+
313
+ function getDb() {
314
+ cachedDb ??= createDb();
315
+ return cachedDb;
267
316
  }
268
317
 
269
- const sql = neon(process.env.DATABASE_URL);
270
- export const db = drizzle(sql, { schema });
318
+ export const db = new Proxy({} as Database, {
319
+ get(_target, prop, receiver) {
320
+ return Reflect.get(getDb(), prop, receiver);
321
+ },
322
+ });
323
+
324
+ export * from './schema';
271
325
  `);
272
- writeFile(path.join(root, 'src/lib/db/schema.ts'), `import { pgTable, text, timestamp, index } from 'drizzle-orm/pg-core';
326
+ writeFile(path.join(dbRoot, 'src/schema.ts'), `import { boolean, index, integer, jsonb, pgTable, text, timestamp } from 'drizzle-orm/pg-core';
327
+ import type { InferInsertModel, InferSelectModel } from 'drizzle-orm';
328
+
329
+ export const SUBSCRIPTION_TIERS = ['free', 'pro'] as const;
330
+ export type SubscriptionTier = (typeof SUBSCRIPTION_TIERS)[number];
331
+
332
+ export const SUBSCRIPTION_STATUSES = ['active', 'expired', 'canceled'] as const;
333
+ export type SubscriptionStatus = (typeof SUBSCRIPTION_STATUSES)[number];
334
+
335
+ export const SUBSCRIPTION_METHODS = ['trialing', 'redeemed', 'paid'] as const;
336
+ export type SubscriptionMethod = (typeof SUBSCRIPTION_METHODS)[number];
337
+
338
+ export const SUBSCRIPTION_ORIGINS = ['web', 'apple', 'google'] as const;
339
+ export type SubscriptionOrigin = (typeof SUBSCRIPTION_ORIGINS)[number];
340
+
341
+ export const SUBSCRIPTION_EVENT_TYPES = [
342
+ 'trial_started', 'trial_converted', 'trial_expired',
343
+ 'initial_purchase', 'renewal', 'product_change',
344
+ 'cancellation_scheduled', 'cancellation_unscheduled', 'subscription_expired', 'subscription_canceled',
345
+ 'promo_redeemed', 'billing_issue', 'billing_recovered',
346
+ ] as const;
347
+ export type SubscriptionEventType = (typeof SUBSCRIPTION_EVENT_TYPES)[number];
348
+
349
+ export type SubscriptionMetadata = {
350
+ revenueCatProductId?: string;
351
+ lastWebhookUpdate?: string;
352
+ cancelReason?: string;
353
+ redeemCode?: string;
354
+ redeemCodeRedeemedAt?: string;
355
+ };
356
+
357
+ export type SubscriptionEventMetadata = {
358
+ revenueCatEventType?: string;
359
+ revenueCatProductId?: string;
360
+ promoCode?: string;
361
+ billingPeriod?: string;
362
+ price?: { amount: number; currency: string };
363
+ store?: string;
364
+ transactionId?: string;
365
+ reason?: string;
366
+ };
367
+
368
+ export const PROMO_CODE_DURATIONS = [1, 6, 12] as const;
369
+ export type PromoCodeDuration = (typeof PROMO_CODE_DURATIONS)[number];
273
370
 
274
371
  export const users = pgTable('users', {
275
372
  id: text('id').primaryKey(),
373
+ clerkUserId: text('clerk_user_id').unique(),
276
374
  email: text('email').notNull().unique(),
277
- name: text('name'),
278
- createdAt: timestamp('created_at').defaultNow().notNull(),
279
- updatedAt: timestamp('updated_at').defaultNow().notNull(),
280
- }, (table) => [
281
- index('users_email_idx').on(table.email),
375
+ displayName: text('display_name'),
376
+ lastLoginAt: timestamp('last_login_at'),
377
+ profileImageUrl: text('profile_image_url'),
378
+ stripeCustomerId: text('stripe_customer_id').unique(),
379
+ subscriptionTier: text('subscription_tier').notNull().default('starter'),
380
+ subscriptionStatus: text('subscription_status').notNull().default('active'),
381
+ avatarObjectKey: text('avatar_object_key'),
382
+ avatarMimeType: text('avatar_mime_type'),
383
+ creditsBalance: integer('credits_balance').notNull().default(250),
384
+ createdAt: timestamp('created_at').$defaultFn(() => new Date()).notNull(),
385
+ updatedAt: timestamp('updated_at').$defaultFn(() => new Date()).notNull(),
386
+ }, (t) => [
387
+ index('users_clerk_user_id_idx').on(t.clerkUserId),
388
+ index('users_email_idx').on(t.email),
282
389
  ]);
283
390
 
284
391
  export const uploads = pgTable('uploads', {
@@ -286,220 +393,440 @@ export const uploads = pgTable('uploads', {
286
393
  userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
287
394
  objectKey: text('object_key').notNull().unique(),
288
395
  bucket: text('bucket').notNull(),
289
- createdAt: timestamp('created_at').defaultNow().notNull(),
396
+ contentType: text('content_type'),
397
+ createdAt: timestamp('created_at').$defaultFn(() => new Date()).notNull(),
290
398
  });
291
- `);
292
- writeFile(path.join(root, 'src/lib/db/queries/users.ts'), `import { eq } from 'drizzle-orm';
293
- import { db } from '../client.js';
294
- import { users } from '../schema.js';
295
399
 
296
- export async function findUserByEmail(email: string) {
297
- return db.query.users.findFirst({
298
- where: eq(users.email, email),
299
- });
300
- }
301
- `);
302
- writeFile(path.join(root, 'src/lib/db/queries/index.ts'), `export * from './users.js';
303
- `);
304
- writeFile(path.join(root, 'src/lib/db/migrate.ts'), `import { drizzle } from 'drizzle-orm/neon-http';
305
- import { migrate } from 'drizzle-orm/neon-http/migrator';
306
- import { neon } from '@neondatabase/serverless';
307
- import 'dotenv/config';
400
+ export const subscriptions = pgTable('subscriptions', {
401
+ id: text('id').primaryKey(),
402
+ userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }).unique(),
403
+ tier: text('tier').$type<SubscriptionTier>().notNull().default('free'),
404
+ status: text('status').$type<SubscriptionStatus>().notNull().default('active'),
405
+ method: text('method').$type<SubscriptionMethod>(),
406
+ origin: text('origin').$type<SubscriptionOrigin>(),
407
+ billingPeriod: text('billing_period'),
408
+ currentPeriodStart: timestamp('current_period_start'),
409
+ currentPeriodEnd: timestamp('current_period_end'),
410
+ canceledAt: timestamp('canceled_at'),
411
+ cancelAtPeriodEnd: boolean('cancel_at_period_end').notNull().default(false),
412
+ trialStart: timestamp('trial_start'),
413
+ trialEnd: timestamp('trial_end'),
414
+ dailyLimit: integer('daily_limit'),
415
+ metadata: jsonb('metadata').$type<SubscriptionMetadata>(),
416
+ createdAt: timestamp('created_at').$defaultFn(() => new Date()).notNull(),
417
+ updatedAt: timestamp('updated_at').$defaultFn(() => new Date()).notNull(),
418
+ }, (t) => [
419
+ index('subscriptions_user_idx').on(t.userId),
420
+ index('subscriptions_status_idx').on(t.status),
421
+ index('subscriptions_tier_idx').on(t.tier),
422
+ index('subscriptions_period_end_idx').on(t.currentPeriodEnd),
423
+ ]);
308
424
 
309
- if (!process.env.DATABASE_URL) {
310
- throw new Error('DATABASE_URL is not defined');
311
- }
425
+ export const subscriptionEvents = pgTable('subscription_events', {
426
+ id: text('id').primaryKey(),
427
+ userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
428
+ subscriptionId: text('subscription_id').references(() => subscriptions.id, { onDelete: 'set null' }),
429
+ eventType: text('event_type').$type<SubscriptionEventType>().notNull(),
430
+ previousTier: text('previous_tier').$type<SubscriptionTier>(),
431
+ previousStatus: text('previous_status').$type<SubscriptionStatus>(),
432
+ previousMethod: text('previous_method').$type<SubscriptionMethod>(),
433
+ newTier: text('new_tier').$type<SubscriptionTier>(),
434
+ newStatus: text('new_status').$type<SubscriptionStatus>(),
435
+ newMethod: text('new_method').$type<SubscriptionMethod>(),
436
+ revenueCatEventType: text('revenuecat_event_type'),
437
+ revenueCatProductId: text('revenuecat_product_id'),
438
+ origin: text('origin').$type<SubscriptionOrigin>(),
439
+ periodStart: timestamp('period_start'),
440
+ periodEnd: timestamp('period_end'),
441
+ metadata: jsonb('metadata').$type<SubscriptionEventMetadata>(),
442
+ createdAt: timestamp('created_at').$defaultFn(() => new Date()).notNull(),
443
+ }, (t) => [
444
+ index('sub_events_user_idx').on(t.userId),
445
+ index('sub_events_user_created_idx').on(t.userId, t.createdAt),
446
+ index('sub_events_type_idx').on(t.eventType),
447
+ ]);
312
448
 
313
- const sql = neon(process.env.DATABASE_URL);
314
- const db = drizzle(sql);
449
+ export const trialClaims = pgTable('trial_claims', {
450
+ id: text('id').primaryKey(),
451
+ userId: text('user_id').references(() => users.id, { onDelete: 'set null' }),
452
+ email: text('email').notNull().unique(),
453
+ claimedAt: timestamp('claimed_at').$defaultFn(() => new Date()).notNull(),
454
+ }, (t) => [index('trial_claims_email_idx').on(t.email)]);
315
455
 
316
- await migrate(db, { migrationsFolder: './src/lib/db/migrations' });
317
- `);
318
- writeFile(path.join(root, 'src/lib/db/migrations/.gitkeep'), '');
319
- writeFile(path.join(root, 'src/lib/storage/r2.ts'), `import { S3Client } from '@aws-sdk/client-s3';
320
-
321
- export const r2 = new S3Client({
322
- region: 'auto',
323
- endpoint: \`https://\${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com\`,
324
- credentials: {
325
- accessKeyId: process.env.R2_ACCESS_KEY_ID ?? '',
326
- secretAccessKey: process.env.R2_SECRET_ACCESS_KEY ?? '',
327
- },
328
- });
329
- `);
330
- writeFile(path.join(root, 'src/lib/auth/auth.ts'), `import { betterAuth } from 'better-auth';
331
- import { drizzleAdapter } from 'better-auth/adapters/drizzle';
332
- import { db } from '../db/client.js';
333
- import * as schema from '../db/schema.js';
334
-
335
- export const auth = betterAuth({
336
- database: drizzleAdapter(db, {
337
- provider: 'pg',
338
- usePlural: true,
339
- schema,
340
- }),
341
- emailAndPassword: {
342
- enabled: true,
343
- },
344
- baseURL: process.env.BETTER_AUTH_URL,
345
- trustedOrigins: (process.env.BETTER_AUTH_TRUSTED_ORIGINS ?? '')
346
- .split(',')
347
- .map((value) => value.trim())
348
- .filter(Boolean),
349
- });
456
+ export const promoCodes = pgTable('promo_codes', {
457
+ id: text('id').primaryKey(),
458
+ code: text('code').notNull().unique(),
459
+ months: integer('months').$type<PromoCodeDuration>().notNull(),
460
+ maxRedemptions: integer('max_redemptions').notNull().default(1),
461
+ redeemedCount: integer('redeemed_count').notNull().default(0),
462
+ expiresAt: timestamp('expires_at'),
463
+ createdAt: timestamp('created_at').$defaultFn(() => new Date()).notNull(),
464
+ }, (t) => [index('promo_codes_code_idx').on(t.code)]);
465
+
466
+ export const promoRedemptions = pgTable('promo_redemptions', {
467
+ id: text('id').primaryKey(),
468
+ promoCodeId: text('promo_code_id').notNull().references(() => promoCodes.id, { onDelete: 'cascade' }),
469
+ userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
470
+ redeemedAt: timestamp('redeemed_at').$defaultFn(() => new Date()).notNull(),
471
+ entitlementExpiresAt: timestamp('entitlement_expires_at').notNull(),
472
+ }, (t) => [
473
+ index('promo_redemptions_user_idx').on(t.userId),
474
+ index('promo_redemptions_code_idx').on(t.promoCodeId),
475
+ ]);
476
+
477
+ export type Users = InferSelectModel<typeof users>;
478
+ export type NewUsers = InferInsertModel<typeof users>;
479
+ export type Uploads = InferSelectModel<typeof uploads>;
480
+ export type NewUploads = InferInsertModel<typeof uploads>;
481
+ export type Subscriptions = InferSelectModel<typeof subscriptions>;
482
+ export type NewSubscriptions = InferInsertModel<typeof subscriptions>;
483
+ export type SubscriptionEvents = InferSelectModel<typeof subscriptionEvents>;
484
+ export type NewSubscriptionEvents = InferInsertModel<typeof subscriptionEvents>;
485
+ export type TrialClaims = InferSelectModel<typeof trialClaims>;
486
+ export type NewTrialClaims = InferInsertModel<typeof trialClaims>;
487
+ export type PromoCodes = InferSelectModel<typeof promoCodes>;
488
+ export type NewPromoCodes = InferInsertModel<typeof promoCodes>;
489
+ export type PromoRedemptions = InferSelectModel<typeof promoRedemptions>;
490
+ export type NewPromoRedemptions = InferInsertModel<typeof promoRedemptions>;
350
491
  `);
351
- writeFile(path.join(root, 'src/app.ts'), `import Fastify from 'fastify';
352
- import cors from '@fastify/cors';
353
- import { fromNodeHeaders } from 'better-auth/node';
354
- import { auth } from './lib/auth/auth.js';
492
+ writeFile(path.join(dbRoot, 'src/queries/users.ts'), `import { eq } from 'drizzle-orm';
493
+ import { db } from '../index';
494
+ import { uploads, users } from '../schema';
355
495
 
356
- export function buildApp() {
357
- const app = Fastify({ logger: true });
496
+ type SyncUserInput = {
497
+ clerkUserId: string;
498
+ email: string;
499
+ displayName: string | null;
500
+ };
358
501
 
359
- app.register(cors, {
360
- origin: process.env.CLIENT_ORIGIN ?? true,
361
- credentials: true,
502
+ export async function findUserByClerkId(clerkUserId: string) {
503
+ return db.query.users.findFirst({
504
+ where: eq(users.clerkUserId, clerkUserId),
362
505
  });
506
+ }
363
507
 
364
- app.get('/health', async () => ({ ok: true }));
365
-
366
- app.route({
367
- method: ['GET', 'POST'],
368
- url: '/api/auth/*',
369
- async handler(request, reply) {
370
- const url = new URL(request.url, \`http://\${request.headers.host}\`);
371
- const headers = new Headers();
508
+ export async function syncUserProfile(input: SyncUserInput) {
509
+ await db
510
+ .insert(users)
511
+ .values({
512
+ id: input.clerkUserId,
513
+ clerkUserId: input.clerkUserId,
514
+ email: input.email,
515
+ displayName: input.displayName,
516
+ })
517
+ .onConflictDoUpdate({
518
+ target: users.clerkUserId,
519
+ set: {
520
+ email: input.email,
521
+ displayName: input.displayName,
522
+ updatedAt: new Date(),
523
+ },
524
+ });
372
525
 
373
- Object.entries(request.headers).forEach(([key, value]) => {
374
- if (Array.isArray(value)) {
375
- for (const entry of value) headers.append(key, entry);
376
- return;
377
- }
526
+ return findUserByClerkId(input.clerkUserId);
527
+ }
378
528
 
379
- if (value) headers.append(key, value.toString());
380
- });
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
+ }
381
539
 
382
- const authRequest = new Request(url.toString(), {
383
- method: request.method,
384
- headers,
385
- body: request.body ? JSON.stringify(request.body) : undefined,
386
- });
540
+ export async function updateAvatarForUser(
541
+ clerkUserId: string,
542
+ input: { objectKey: string; contentType: string | null },
543
+ ) {
544
+ await db
545
+ .update(users)
546
+ .set({
547
+ avatarObjectKey: input.objectKey,
548
+ avatarMimeType: input.contentType,
549
+ updatedAt: new Date(),
550
+ })
551
+ .where(eq(users.clerkUserId, clerkUserId));
552
+ }
387
553
 
388
- const response = await auth.handler(authRequest);
389
- reply.status(response.status);
390
- response.headers.forEach((value, key) => reply.header(key, value));
391
- reply.send(response.body ? await response.text() : null);
392
- },
554
+ export async function createUploadRecord(
555
+ clerkUserId: string,
556
+ input: { objectKey: string; bucket: string; contentType: string | null },
557
+ ) {
558
+ await db.insert(uploads).values({
559
+ id: crypto.randomUUID(),
560
+ userId: clerkUserId,
561
+ objectKey: input.objectKey,
562
+ bucket: input.bucket,
563
+ contentType: input.contentType,
393
564
  });
565
+ }
566
+ `);
567
+ writeFile(path.join(dbRoot, 'src/queries/uploads.ts'), `import { eq } from 'drizzle-orm';
568
+ import { db } from '../index';
569
+ import { uploads } from '../schema';
394
570
 
395
- app.get('/api/me', async (request, reply) => {
396
- const session = await auth.api.getSession({
397
- headers: fromNodeHeaders(request.headers),
398
- });
399
-
400
- if (!session) {
401
- return reply.status(401).send({ error: 'Unauthorized' });
402
- }
403
-
404
- return reply.send(session);
571
+ export async function listUploadsForUser(userId: string) {
572
+ return db.query.uploads.findMany({
573
+ where: eq(uploads.userId, userId),
405
574
  });
406
-
407
- return app;
408
575
  }
409
576
  `);
410
- writeFile(path.join(root, 'src/index.ts'), `import 'dotenv/config';
411
- import { buildApp } from './app.js';
412
-
413
- const app = buildApp();
414
- const port = Number(process.env.PORT ?? 8787);
415
-
416
- app.listen({ port, host: '0.0.0.0' })
417
- .then(() => {
418
- app.log.info({ port }, 'server_started');
419
- })
420
- .catch((error) => {
421
- app.log.error(error);
422
- process.exit(1);
423
- });
577
+ writeFile(path.join(dbRoot, 'src/queries/index.ts'), `export * from './uploads';
578
+ export * from './users';
424
579
  `);
580
+ writeFile(path.join(dbRoot, 'src/migrate.ts'), `import { drizzle } from 'drizzle-orm/neon-http';
581
+ import { migrate } from 'drizzle-orm/neon-http/migrator';
582
+ import { neon } from '@neondatabase/serverless';
583
+ import dotenv from 'dotenv';
584
+
585
+ dotenv.config({ path: '../../.env', quiet: true });
586
+ dotenv.config({ quiet: true });
587
+
588
+ if (!process.env.DATABASE_URL) {
589
+ throw new Error('DATABASE_URL is not defined');
425
590
  }
426
- function writeRootDocs(root, options, stack) {
427
- const toolchainLine = stack.outputs.toolchain.verifiedAt
428
- ? `${options.toolchainChannel} (verified ${stack.outputs.toolchain.verifiedAt})`
429
- : `${options.toolchainChannel} (floating latest)`;
430
- const generatedPaths = stack.outputs.generated_paths.map((entry) => `- \`${entry}\``).join('\n') || '- `.`';
431
- const envCopyStep = options.mode === 'next'
432
- ? 'Copy `.env.example` to `.env.local` and fill in your provider values.'
433
- : options.mode === 'next-fullstack'
434
- ? 'Copy the root `.env.example` values into the `.env.local` and `.env` files your frontend and backend will read before starting the apps.'
435
- : options.mode === 'expo-fullstack'
436
- ? 'Copy the generated `.env.example` files into the runtime `.env` files each package expects before starting the apps.'
437
- : 'Copy `.env.example` to `.env` and fill in your provider values.';
438
- const authStep = options.mode === 'backend'
439
- ? 'Review `src/lib/auth/auth.ts` and align the Better Auth schema with your production auth tables before shipping.'
440
- : options.mode === 'expo-fullstack'
441
- ? 'Add the real provider configuration for Clerk, RevenueCat, and Stripe where the scaffolded helper files indicate.'
442
- : options.mode === 'next-fullstack'
443
- ? 'Add the real provider configuration for Clerk and Stripe across the frontend and backend where the scaffolded helper files indicate.'
444
- : 'Add the real provider configuration for Clerk and Stripe where the scaffolded helper files indicate.';
445
- const databaseCommands = options.mode === 'next-fullstack' || options.mode === 'expo-fullstack'
446
- ? '```bash\ncd backend\npnpm db:generate\npnpm db:migrate\n```'
447
- : '```bash\npnpm db:generate\npnpm db:migrate\n```';
448
- writeFile(path.join(root, 'README.md'), `# ${options.displayName}
449
591
 
450
- Generated by anhedral.
592
+ const client = neon(process.env.DATABASE_URL);
593
+ const database = drizzle(client);
451
594
 
452
- ## Stack
595
+ console.log('Running migrations...');
596
+ const start = Date.now();
597
+ await migrate(database, { migrationsFolder: './migrations' });
598
+ console.log('Migrations completed in', Date.now() - start, 'ms');
599
+ `);
600
+ writeFile(path.join(dbRoot, 'migrations/.gitkeep'), '');
601
+ const simplePackages = [
602
+ ['types', { exports: { '.': './src/index.ts' } }, `export type ApiEnvelope<T> = { data: T } | { error: string; message: string };
603
+ export type ClientPlatform = 'web' | 'mobile' | 'extension';
604
+ export type SubscriptionEntitlements = {
605
+ pro: boolean;
606
+ inTrial: boolean;
607
+ trialEndsAt?: string;
608
+ expiresAt?: string;
609
+ periodStart?: string;
610
+ periodEnd?: string;
611
+ method?: 'trialing' | 'redeemed' | 'paid' | null;
612
+ managementUrl?: string;
613
+ cancelAtPeriodEnd?: boolean;
614
+ };
615
+ export type AuthMeResponse = {
616
+ user: {
617
+ id: string;
618
+ email: string;
619
+ firstName?: string | null;
620
+ lastName?: string | null;
621
+ displayName?: string | null;
622
+ imageUrl?: string | null;
623
+ avatarUrl?: string | null;
624
+ creditsBalance: number;
625
+ subscriptionTier: string;
626
+ subscriptionStatus: string;
627
+ };
628
+ };
629
+ export type PricingResponse = {
630
+ plans: Array<{
631
+ id: string;
632
+ name: string;
633
+ description: string;
634
+ price: string;
635
+ features: string[];
636
+ }>;
637
+ };
638
+ `],
639
+ ['config', { exports: { '.': './src/index.ts' } }, `export const DEFAULT_API_PATH_PREFIX = '/api';
640
+ 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';
643
+ export const DEFAULT_EXTENSION_URL = 'chrome-extension://';
644
+
645
+ export function joinApiUrl(baseUrl: string, path: string) {
646
+ const base = baseUrl.replace(/\\/$/, '');
647
+ const suffix = path.startsWith('/') ? path : \`/\${path}\`;
648
+ return \`\${base}\${suffix}\`;
649
+ }
650
+ `],
651
+ ['api-client', {
652
+ exports: { '.': './src/index.ts' },
653
+ dependencies: {
654
+ '@anhedral/config': 'workspace:*',
655
+ '@anhedral/types': 'workspace:*',
656
+ },
657
+ }, `import { joinApiUrl } from '@anhedral/config';
658
+ import type { AuthMeResponse, ClientPlatform, PricingResponse, SubscriptionEntitlements } from '@anhedral/types';
659
+
660
+ export type ApiClientOptions = {
661
+ baseUrl: string;
662
+ platform: ClientPlatform;
663
+ getToken?: () => Promise<string | null>;
664
+ };
665
+
666
+ export class ApiClient {
667
+ constructor(private readonly options: ApiClientOptions) {}
668
+
669
+ async request<T>(path: string, init: RequestInit = {}): Promise<T> {
670
+ const token = await this.options.getToken?.();
671
+ const headers = new Headers(init.headers);
672
+ headers.set('Content-Type', headers.get('Content-Type') ?? 'application/json');
673
+ headers.set('X-Platform', this.options.platform);
674
+ if (token) headers.set('Authorization', \`Bearer \${token}\`);
675
+
676
+ const response = await fetch(joinApiUrl(this.options.baseUrl, path), {
677
+ ...init,
678
+ headers,
679
+ });
453
680
 
454
- - Mode: ${options.mode}
455
- - Auth: ${options.auth}
456
- - Payments: ${options.payments}
457
- - Database: ${options.db} + ${options.orm}
458
- - Storage: ${options.storage}
459
- - API: ${options.api ?? 'framework-native'}
460
- - Toolchain: ${toolchainLine}
681
+ if (!response.ok) {
682
+ throw new Error(\`API request failed: \${response.status}\`);
683
+ }
461
684
 
462
- ## Generated paths
685
+ return response.json() as Promise<T>;
686
+ }
463
687
 
464
- ${generatedPaths}
688
+ getMe() {
689
+ return this.request<AuthMeResponse>('/auth/me');
690
+ }
465
691
 
466
- ## Setup
692
+ getPricing() {
693
+ return this.request<PricingResponse>('/subscriptions/pricing');
694
+ }
467
695
 
468
- 1. ${envCopyStep}
469
- 2. Review \`install-skills.sh\` and run the listed skill commands manually so you can choose scope and agent targets.
470
- 3. ${authStep}
471
- 4. Run database generation and migrations:
696
+ getSubscriptionEntitlements(options?: { refresh?: boolean }) {
697
+ return this.request<SubscriptionEntitlements>(
698
+ options?.refresh ? '/subscriptions/entitlements/me?refresh=true' : '/subscriptions/entitlements/me',
699
+ );
700
+ }
472
701
 
473
- ${databaseCommands}
474
- `);
702
+ redeemCode(code: string) {
703
+ return this.request<{ ok: boolean; expiresAt: string }>('/subscriptions/redeem', {
704
+ method: 'POST',
705
+ body: JSON.stringify({ code }),
706
+ });
707
+ }
475
708
  }
476
- function writeStackFile(root, stack) {
477
- writeFile(path.join(root, 'stack.json'), JSON.stringify(stack, null, 2) + '\n');
709
+ `],
710
+ ];
711
+ for (const [name, packageFields, source] of simplePackages) {
712
+ const packageRoot = path.join(root, `packages/${name}`);
713
+ writeFile(path.join(packageRoot, 'package.json'), JSON.stringify({
714
+ name: `@anhedral/${name}`,
715
+ version: '0.1.0',
716
+ private: true,
717
+ type: 'module',
718
+ scripts: {
719
+ build: 'pnpm typecheck',
720
+ typecheck: 'tsc --noEmit',
721
+ },
722
+ devDependencies: {
723
+ typescript: '^5.9.3',
724
+ },
725
+ ...packageFields,
726
+ }, null, 2) + '\n');
727
+ writeFile(path.join(packageRoot, 'tsconfig.json'), JSON.stringify({
728
+ compilerOptions: {
729
+ target: 'ESNext',
730
+ module: 'ESNext',
731
+ moduleResolution: 'Bundler',
732
+ strict: true,
733
+ skipLibCheck: true,
734
+ noEmit: true,
735
+ },
736
+ include: ['src/**/*'],
737
+ }, null, 2) + '\n');
738
+ writeFile(path.join(packageRoot, 'src/index.ts'), source);
739
+ }
478
740
  }
479
- function scaffoldDefaultWeb(root, options) {
480
- const toolchain = resolveToolchain(options.toolchainChannel);
481
- runScaffoldCommands(root, [getDefaultWebInitCommand(toolchain, options.projectName)]);
482
- liftNestedProject(root, options.projectName);
483
- runScaffoldCommands(root, getDefaultWebDependencyCommands());
484
- appendGitignore(root, [
485
- ...SHARED_ENV_GITIGNORE_LINES,
486
- '.next',
487
- 'node_modules',
488
- 'lib/db/migrations',
489
- ...SHARED_TYPESCRIPT_GITIGNORE_LINES,
490
- ]);
491
- patchPackageJson(root, {
492
- packageManager: PNPM_PACKAGE_MANAGER,
493
- scripts: {
494
- typecheck: 'tsc --noEmit',
495
- 'db:generate': 'drizzle-kit generate',
496
- 'db:migrate': 'pnpm tsx lib/db/migrate.ts',
497
- },
498
- });
499
- writeDrizzleConfig(root, './lib/db/schema.ts', './lib/db/migrations');
500
- writeDefaultWebEnvExample(root);
501
- writeDefaultWebFiles(root, options.displayName);
502
- return ['.'];
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
755
+ EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_***
756
+ EXPO_PUBLIC_RC_ENTITLEMENT_ID=pro
757
+ EXPO_PUBLIC_RC_API_KEY_IOS=
758
+ EXPO_PUBLIC_RC_API_KEY_ANDROID=
759
+ EXPO_PUBLIC_RC_WEB_API_KEY=
760
+ `
761
+ : '';
762
+ const extensionEnv = options.extension
763
+ ? `VITE_API_URL=http://localhost:8787
764
+ VITE_WEBSITE_URL=${frontendUrl}
765
+ VITE_CLERK_PUBLISHABLE_KEY=pk_test_***
766
+ VITE_CRX_PUBLIC_KEY=
767
+ VITE_RC_BILLING_URL=
768
+ `
769
+ : '';
770
+ const revenueCatEnv = options.payments === 'revenuecat'
771
+ ? `# RevenueCat
772
+ RC_SECRET_API_KEY=
773
+ RC_WEBHOOK_SECRET=
774
+ RC_ENTITLEMENT_ID=pro
775
+ 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
+ : '';
785
+ writeFile(path.join(root, '.env.example'), `# Apps
786
+ FRONTEND_URL=${frontendUrl}
787
+ ANHEDRAL_DEMO=false
788
+ ${nextEnv}${expoEnv}${extensionEnv}
789
+
790
+ # Server
791
+ PORT=8787
792
+ DATABASE_URL=postgresql://user:pass@ep-xxx.us-east-1.aws.neon.tech/dbname?sslmode=require
793
+
794
+ # Clerk
795
+ CLERK_PUBLISHABLE_KEY=pk_test_***
796
+ CLERK_SECRET_KEY=sk_test_***
797
+
798
+ # Cloudflare R2
799
+ R2_ACCOUNT_ID=
800
+ R2_ACCESS_KEY_ID=
801
+ R2_SECRET_ACCESS_KEY=
802
+ R2_BUCKET=
803
+
804
+ ${revenueCatEnv}${stripeEnv}`);
805
+ }
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
829
+ `);
503
830
  }
504
831
  function patchGeneratedPackageName(root, name) {
505
832
  const filePath = path.join(root, 'package.json');
@@ -509,86 +836,136 @@ function patchGeneratedPackageName(root, name) {
509
836
  }
510
837
  function scaffoldNextFrontend(root, options, directoryName) {
511
838
  const toolchain = resolveToolchain(options.toolchainChannel);
512
- runScaffoldCommands(root, [getDefaultWebInitCommand(toolchain, directoryName)]);
513
- runScaffoldCommands(path.join(root, directoryName), getDefaultWebDependencyCommands());
514
- appendGitignore(path.join(root, directoryName), [
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, [
515
858
  ...SHARED_ENV_GITIGNORE_LINES,
516
859
  '.next',
517
860
  'node_modules',
518
861
  'lib/db/migrations',
519
862
  ...SHARED_TYPESCRIPT_GITIGNORE_LINES,
520
863
  ]);
521
- patchPackageJson(path.join(root, directoryName), {
864
+ patchPackageJson(appRoot, {
522
865
  packageManager: PNPM_PACKAGE_MANAGER,
523
866
  scripts: {
524
867
  typecheck: 'tsc --noEmit',
525
- 'db:generate': 'drizzle-kit generate',
526
- 'db:migrate': 'pnpm tsx lib/db/migrate.ts',
868
+ 'db:generate': 'pnpm --filter @anhedral/db db:generate',
869
+ 'db:migrate': 'pnpm --filter @anhedral/db db:migrate',
527
870
  },
528
871
  });
529
- patchGeneratedPackageName(path.join(root, directoryName), `${options.projectName}-${directoryName}`);
530
- writeDrizzleConfig(path.join(root, directoryName), './lib/db/schema.ts', './lib/db/migrations');
531
- writeDefaultWebEnvExample(path.join(root, directoryName));
532
- writeDefaultWebFiles(path.join(root, directoryName), options.displayName);
533
- }
534
- async function scaffoldNextFullstack(root, options) {
535
- scaffoldNextFrontend(root, options, 'frontend');
536
- writeRootPackageJson(root, options.projectName, {
537
- dev: 'pnpm -r --parallel --filter ./frontend --filter ./backend run dev',
538
- }, ['frontend', 'backend']);
539
- writePnpmWorkspace(root, ['frontend', 'backend']);
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 });
886
+ }
887
+ function writeFullstackRootFiles(root, options) {
888
+ const appFilters = [
889
+ options.frontend === 'next' ? './apps/web' : './apps/mobile',
890
+ './apps/api',
891
+ ...(options.extension ? ['./apps/extension'] : []),
892
+ ];
893
+ const parallelFilters = appFilters.map((entry) => `--filter ${entry}`).join(' ');
894
+ const buildFilters = ['./packages/db', ...appFilters].map((entry) => `--filter ${entry}`).join(' ');
895
+ 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',
900
+ 'db:generate': 'pnpm --filter @anhedral/db db:generate',
901
+ 'db:migrate': 'pnpm --filter @anhedral/db db:migrate',
902
+ };
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/*']);
540
915
  appendGitignore(root, [
541
916
  'node_modules',
542
- 'frontend/node_modules',
543
- 'backend/node_modules',
917
+ 'apps/*/node_modules',
918
+ 'packages/*/node_modules',
544
919
  ...SHARED_ENV_GITIGNORE_LINES,
920
+ ...SHARED_TYPESCRIPT_GITIGNORE_LINES,
545
921
  ]);
546
- writeNextFullstackRootEnvExample(root);
922
+ writeRootEnvExample(root, options);
923
+ writeRootVercelJson(root, options);
924
+ }
925
+ async function scaffoldFullstack(root, options) {
926
+ anhedralPrint.section('Workspace root');
927
+ anhedralPrint.step('Writing root config (package.json, pnpm workspace, env, vercel)');
928
+ writeFullstackRootFiles(root, options);
929
+ anhedralPrint.done('Root config written');
930
+ anhedralPrint.section('Shared packages');
931
+ anhedralPrint.step('Writing @anhedral/db, types, config, api-client');
932
+ writeSharedPackages(root);
933
+ anhedralPrint.done('Shared packages written');
934
+ const frontendUrl = options.frontend === 'next' ? 'http://localhost:3000' : 'http://localhost:8081';
547
935
  await scaffoldBackend(root, {
548
936
  projectName: options.projectName,
549
937
  displayName: options.displayName,
550
938
  githubOrg: null,
551
- frontendUrl: 'http://localhost:3000',
939
+ frontendUrl,
552
940
  });
553
- return ['frontend', 'backend'];
554
- }
555
- async function scaffoldCrossplatform(root, options) {
556
- writeRootPackageJson(root, options.projectName, {
557
- dev: 'pnpm -r --parallel --filter ./frontend --filter ./backend run dev',
558
- }, ['frontend', 'backend']);
559
- writePnpmWorkspace(root, ['frontend', 'backend']);
560
- appendGitignore(root, [
561
- 'node_modules',
562
- 'frontend/node_modules',
563
- 'backend/node_modules',
564
- ...SHARED_ENV_GITIGNORE_LINES,
565
- ]);
566
- writeCrossplatformEnvExample(root);
567
- const projectOptions = {
568
- projectName: options.projectName,
569
- displayName: options.displayName,
570
- githubOrg: null,
571
- frontendUrl: 'http://localhost:8081',
572
- };
573
- await scaffoldFrontend(root, projectOptions);
574
- await scaffoldBackend(root, projectOptions);
575
- return ['frontend', 'backend'];
576
- }
577
- function scaffoldHeadless(root, options) {
578
- writeHeadlessPackageJson(root, options.projectName);
579
- appendGitignore(root, [
580
- 'dist',
581
- 'node_modules',
582
- ...SHARED_ENV_GITIGNORE_LINES,
583
- '*.log',
584
- ...SHARED_TYPESCRIPT_GITIGNORE_LINES,
585
- ]);
586
- writeHeadlessTsConfig(root);
587
- writeDrizzleConfig(root, './src/lib/db/schema.ts', './src/lib/db/migrations');
588
- writeHeadlessEnvExample(root);
589
- writeHeadlessFiles(root);
590
- runScaffoldCommands(root, getHeadlessInstallCommands());
591
- return ['.'];
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
+ return [
961
+ options.frontend === 'next' ? 'apps/web' : 'apps/mobile',
962
+ 'apps/api',
963
+ ...(options.extension ? ['apps/extension'] : []),
964
+ 'packages/db',
965
+ 'packages/types',
966
+ 'packages/config',
967
+ 'packages/api-client',
968
+ ];
592
969
  }
593
970
  export async function scaffoldProject(options) {
594
971
  const root = path.resolve(process.cwd());
@@ -597,24 +974,36 @@ export async function scaffoldProject(options) {
597
974
  env.ANHEDRAL_TOOLCHAIN = options.toolchainChannel;
598
975
  try {
599
976
  await printAsciiLogo();
600
- console.log(`\nšŸ“ Initializing ${options.displayName} in ${root}\n`);
601
- const generatedPaths = options.mode === 'next'
602
- ? scaffoldDefaultWeb(root, options)
603
- : options.mode === 'next-fullstack'
604
- ? await scaffoldNextFullstack(root, options)
605
- : options.mode === 'expo-fullstack'
606
- ? await scaffoldCrossplatform(root, options)
607
- : scaffoldHeadless(root, options);
608
- const skillCommands = getSkillCommands(options.mode);
977
+ anhedralPrint.banner(`Initializing ${options.displayName} in ${root}`);
978
+ const generatedPaths = await scaffoldFullstack(root, options);
979
+ anhedralPrint.section('Project metadata');
980
+ anhedralPrint.step('Writing skills guide, README, and stack.json');
981
+ const skillCommands = getSkillCommands(options);
609
982
  writeSkillsGuide(root, skillCommands);
610
983
  const stack = normalizeStack(options, generatedPaths);
611
984
  writeRootDocs(root, options, stack);
612
985
  writeStackFile(root, stack);
613
- console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
614
- console.log(`\nšŸŽ‰ ${options.displayName} is ready.\n`);
615
- console.log('Generated:');
986
+ anhedralPrint.done('Project metadata written');
987
+ console.log('');
988
+ anhedralPrint.banner(`${options.displayName} is ready`);
616
989
  for (const generatedPath of generatedPaths) {
617
- console.log(` - ${generatedPath}`);
990
+ anhedralPrint.done(generatedPath);
991
+ }
992
+ console.log('');
993
+ anhedralPrint.info('Next commands:');
994
+ console.log(' pnpm install');
995
+ 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
+ }
1002
+ 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');
618
1007
  }
619
1008
  console.log('');
620
1009
  }