anhedral 0.1.1 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/scaffold.js CHANGED
@@ -1,14 +1,14 @@
1
- import { existsSync, readFileSync, readdirSync } from 'node:fs';
1
+ import { readdirSync } 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, 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';
6
8
  import { resolveToolchain } from './toolchain.js';
7
9
  import { scaffoldBackend } from './templates/backend.js';
8
- 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
- const PNPM_PACKAGE_MANAGER = 'pnpm@10.15.1';
12
12
  const SHARED_ENV_GITIGNORE_LINES = ['.env', '.env.*', '!.env.example'];
13
13
  const SHARED_TYPESCRIPT_GITIGNORE_LINES = ['*.tsbuildinfo'];
14
14
  const ASCII_LOGO = [
@@ -39,56 +39,42 @@ async function printAsciiLogo() {
39
39
  }
40
40
  console.log('');
41
41
  }
42
+ function getUsedToolchain(options, toolchain) {
43
+ return {
44
+ verifiedAt: toolchain.verifiedAt,
45
+ shadcn: toolchain.shadcn,
46
+ reactNativeReusables: toolchain.reactNativeReusables,
47
+ wxt: toolchain.wxt,
48
+ };
49
+ }
42
50
  function normalizeStack(options, generatedPaths) {
43
51
  const toolchain = resolveToolchain(options.toolchainChannel);
44
- const frontend = options.mode === 'next'
45
- ? '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';
52
52
  return {
53
53
  schema_version: '2.0.0',
54
- mode: options.mode,
54
+ mode: 'fullstack',
55
55
  project_name: options.projectName,
56
56
  display_name: options.displayName,
57
- frontend,
58
- backend,
57
+ frontend: 'expo_react_native_reusables',
58
+ extension: 'wxt_chrome_extension',
59
+ backend: 'fastify',
59
60
  auth: options.auth,
60
61
  payments: options.payments,
61
62
  storage: 'cloudflare_r2_via_aws_s3_sdk',
62
63
  database: 'neon_plus_drizzle',
63
- skills: getSkillCommands(options.mode),
64
+ skills: getSkillCommands(),
64
65
  outputs: {
65
- monorepo: options.monorepo,
66
+ monorepo: true,
66
67
  package_manager: 'pnpm',
68
+ dependency_manifest: dependencyManifest(),
67
69
  toolchain_channel: options.toolchainChannel,
68
- toolchain,
70
+ toolchain: getUsedToolchain(options, toolchain),
69
71
  generated_paths: generatedPaths,
70
72
  },
71
73
  };
72
74
  }
73
- function readJsonFile(filePath) {
74
- return JSON.parse(readFileSync(filePath, 'utf-8'));
75
- }
76
75
  function writeJsonFile(filePath, payload) {
77
76
  writeFile(filePath, JSON.stringify(payload, null, 2) + '\n');
78
77
  }
79
- function runScaffoldCommands(cwd, commands) {
80
- for (const command of commands) {
81
- if (env.ANHEDRAL_SKIP_INSTALL === '1' && command.skippable) {
82
- console.log(` $ ${command.cmd} (skipped via ANHEDRAL_SKIP_INSTALL=1)`);
83
- continue;
84
- }
85
- if (command.stdinInput == null) {
86
- exec(command.cmd, cwd);
87
- continue;
88
- }
89
- execWithInput(command.cmd, cwd, command.stdinInput);
90
- }
91
- }
92
78
  function ensureScaffoldRoot(root) {
93
79
  const allowedEntries = new Set(['.git', '.gitignore', '.DS_Store']);
94
80
  const unexpected = readdirSync(root).filter((entry) => !allowedEntries.has(entry));
@@ -101,7 +87,7 @@ function writeRootPackageJson(root, projectName, scripts, workspaces, extraField
101
87
  name: projectName,
102
88
  private: true,
103
89
  version: '0.1.0',
104
- packageManager: PNPM_PACKAGE_MANAGER,
90
+ packageManager: PACKAGE_MANAGER,
105
91
  scripts,
106
92
  ...extraFields,
107
93
  };
@@ -114,32 +100,6 @@ function writePnpmWorkspace(root, packages) {
114
100
  const serializedPackages = packages.map((entry) => ` - '${entry}'`).join('\n');
115
101
  writeFile(path.join(root, 'pnpm-workspace.yaml'), `packages:\n${serializedPackages}\n`);
116
102
  }
117
- function patchPackageJson(root, updates) {
118
- const filePath = path.join(root, 'package.json');
119
- const packageJson = readJsonFile(filePath);
120
- const scripts = (packageJson.scripts ?? {});
121
- if (updates.scripts) {
122
- Object.assign(scripts, updates.scripts);
123
- }
124
- packageJson.scripts = scripts;
125
- if (updates.packageManager) {
126
- packageJson.packageManager = updates.packageManager;
127
- }
128
- writeJsonFile(filePath, packageJson);
129
- }
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
- `);
142
- }
143
103
  function writeSkillsGuide(root, commands) {
144
104
  const commandBlock = commands.map((command) => `${command}\n`).join('\n');
145
105
  writeFile(path.join(root, 'install-skills.sh'), `#!/usr/bin/env bash
@@ -154,131 +114,220 @@ function writeSkillsGuide(root, commands) {
154
114
 
155
115
  ${commandBlock}`);
156
116
  }
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
117
+ function writeRootDocs(root, options, stack) {
118
+ const toolchainLine = stack.outputs.toolchain.verifiedAt
119
+ ? `${options.toolchainChannel} (verified ${stack.outputs.toolchain.verifiedAt})`
120
+ : `${options.toolchainChannel} (floating latest)`;
121
+ const generatedPaths = stack.outputs.generated_paths.map((entry) => `- \`${entry}\``).join('\n') || '- `.`';
122
+ writeFile(path.join(root, 'README.md'), `# ${options.displayName}
160
123
 
161
- # Clerk
162
- NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_***
163
- CLERK_SECRET_KEY=sk_test_***
164
- EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_***
124
+ Generated by anhedral.
165
125
 
166
- # Cloudflare R2
167
- R2_ACCOUNT_ID=
168
- R2_ACCESS_KEY_ID=
169
- R2_SECRET_ACCESS_KEY=
170
- R2_BUCKET=
126
+ ## Stack
171
127
 
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=
128
+ - Mode: fullstack
129
+ - Frontend: Expo + React Native Reusables
130
+ - Backend: Fastify
131
+ - Extension: WXT Chrome extension
132
+ - Shared packages: \`packages/*\`
133
+ - Auth: ${options.auth}
134
+ - Payments: ${options.payments}
135
+ - Database: ${options.db} + ${options.orm}
136
+ - Storage: ${options.storage}
137
+ - API: ${options.api ?? 'framework-native'}
138
+ - Toolchain: ${toolchainLine}
139
+ - Dependency manifest: recorded in \`stack.json\`
181
140
 
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
195
-
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_***
141
+ ## Generated paths
202
142
 
203
- # Cloudflare R2
204
- R2_ACCOUNT_ID=
205
- R2_ACCESS_KEY_ID=
206
- R2_SECRET_ACCESS_KEY=
207
- R2_BUCKET=
143
+ ${generatedPaths}
144
+
145
+ ## First Run
208
146
 
209
- # Stripe
210
- STRIPE_SECRET_KEY=sk_test_***
211
- STRIPE_WEBHOOK_SECRET=whsec_***
212
- STRIPE_PRICE_STARTER=price_***
147
+ \`\`\`bash
148
+ pnpm install
149
+ cp .env.example .env
150
+ # apps/frontend/.env is generated for local Expo development
151
+ cp apps/extension/.env.example apps/extension/.env
152
+ pnpm db:generate
153
+ pnpm db:migrate
154
+ pnpm dev:api
155
+ pnpm dev:frontend
156
+ pnpm dev:extension
157
+ \`\`\`
158
+
159
+ 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
+
161
+ ## Provider Setup
162
+
163
+ - Neon: create a Postgres database and set \`DATABASE_URL\` in the root and API env files.
164
+ - Clerk: create an application, configure allowed origins, and set \`CLERK_PUBLISHABLE_KEY\`, \`CLERK_SECRET_KEY\`, plus the Expo and extension publishable keys.
165
+ - 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.
166
+ - Cloudflare R2: create a bucket and API token, then set \`R2_ACCOUNT_ID\`, \`R2_ACCESS_KEY_ID\`, \`R2_SECRET_ACCESS_KEY\`, and \`R2_BUCKET\`.
167
+ - Vercel: import this same Git repository twice. Use \`apps/frontend\` as the root directory for the Expo web project, and \`apps/api\` 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.
168
+ - EAS: use \`apps/frontend\` as the Expo project root for iOS and Android builds. Vercel deploys the web export from the same source.
169
+
170
+ ## Setup Notes
171
+
172
+ 1. Copy \`.env.example\` into the runtime env files your apps need and fill in provider values.
173
+ 2. Review \`install-skills.sh\` and run the listed skill commands manually so you can choose scope and agent targets.
174
+ 3. Add the real provider configuration for Clerk, RevenueCat, Stripe web billing, Neon, and R2 where the scaffolded helper files indicate.
175
+ 4. Use \`stack.json\` when debugging generated projects; it records the verified dependency manifest used by this init run.
176
+ 5. Run database generation and migrations from the shared DB package:
177
+
178
+ \`\`\`bash
179
+ pnpm db:generate
180
+ pnpm db:migrate
181
+ \`\`\`
182
+
183
+ 6. Build or run the extension separately. It is not deployed by Vercel:
184
+
185
+ \`\`\`bash
186
+ pnpm dev:extension
187
+ pnpm extension:zip
188
+ \`\`\`
213
189
  `);
214
190
  }
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
- });
191
+ function writeStackFile(root, stack) {
192
+ writeFile(path.join(root, 'stack.json'), JSON.stringify(stack, null, 2) + '\n');
225
193
  }
226
- function writeHeadlessTsConfig(root) {
227
- writeFile(path.join(root, 'tsconfig.json'), JSON.stringify({
194
+ function writeSharedPackages(root) {
195
+ const dbRoot = path.join(root, 'packages/db');
196
+ writeFile(path.join(dbRoot, 'package.json'), JSON.stringify({
197
+ name: '@anhedral/db',
198
+ version: '0.1.0',
199
+ private: true,
200
+ type: 'module',
201
+ exports: {
202
+ '.': './src/index.ts',
203
+ './schema': './src/schema.ts',
204
+ './queries': './src/queries/index.ts',
205
+ './migrate': './src/migrate.ts',
206
+ },
207
+ scripts: {
208
+ build: 'pnpm typecheck',
209
+ typecheck: 'tsc --noEmit',
210
+ 'db:generate': 'drizzle-kit generate',
211
+ 'db:migrate': 'tsx --env-file=../../.env src/migrate.ts',
212
+ },
213
+ dependencies: SHARED_DB_DEPENDENCIES.dependencies,
214
+ devDependencies: SHARED_DB_DEPENDENCIES.devDependencies,
215
+ }, null, 2) + '\n');
216
+ writeFile(path.join(dbRoot, 'tsconfig.json'), JSON.stringify({
228
217
  compilerOptions: {
229
218
  target: 'ESNext',
230
- module: 'NodeNext',
231
- moduleResolution: 'NodeNext',
219
+ module: 'ESNext',
220
+ moduleResolution: 'Bundler',
232
221
  strict: true,
233
- esModuleInterop: true,
234
222
  skipLibCheck: true,
235
- outDir: 'dist',
236
- rootDir: 'src',
223
+ noEmit: true,
237
224
  types: ['node'],
238
225
  },
239
- include: ['src/**/*'],
240
- exclude: ['node_modules', 'dist'],
226
+ include: ['src/**/*', 'drizzle.config.ts'],
241
227
  }, 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
228
+ writeFile(path.join(dbRoot, 'drizzle.config.ts'), `import type { Config } from 'drizzle-kit';
252
229
 
253
- # Cloudflare R2
254
- R2_ACCOUNT_ID=
255
- R2_ACCESS_KEY_ID=
256
- R2_SECRET_ACCESS_KEY=
257
- R2_BUCKET=
230
+ export default {
231
+ schema: './src/schema.ts',
232
+ out: './migrations',
233
+ dialect: 'postgresql',
234
+ dbCredentials: {
235
+ url: process.env.DATABASE_URL!,
236
+ },
237
+ } satisfies Config;
258
238
  `);
259
- }
260
- function writeHeadlessFiles(root) {
261
- writeFile(path.join(root, 'src/lib/db/client.ts'), `import { drizzle } from 'drizzle-orm/neon-http';
239
+ writeFile(path.join(dbRoot, 'src/index.ts'), `import { drizzle } from 'drizzle-orm/neon-http';
262
240
  import { neon } from '@neondatabase/serverless';
263
- import * as schema from './schema.js';
241
+ import * as schema from './schema';
264
242
 
265
- if (!process.env.DATABASE_URL) {
266
- throw new Error('DATABASE_URL is not defined');
243
+ export function createDb(databaseUrl = process.env.DATABASE_URL) {
244
+ if (!databaseUrl) {
245
+ throw new Error('DATABASE_URL is not defined');
246
+ }
247
+
248
+ const sql = neon(databaseUrl);
249
+ return drizzle(sql, { schema });
250
+ }
251
+
252
+ export type Database = ReturnType<typeof createDb>;
253
+
254
+ let cachedDb: Database | undefined;
255
+
256
+ function getDb() {
257
+ cachedDb ??= createDb();
258
+ return cachedDb;
267
259
  }
268
260
 
269
- const sql = neon(process.env.DATABASE_URL);
270
- export const db = drizzle(sql, { schema });
261
+ export const db = new Proxy({} as Database, {
262
+ get(_target, prop, receiver) {
263
+ return Reflect.get(getDb(), prop, receiver);
264
+ },
265
+ });
266
+
267
+ export * from './schema';
271
268
  `);
272
- writeFile(path.join(root, 'src/lib/db/schema.ts'), `import { pgTable, text, timestamp, index } from 'drizzle-orm/pg-core';
269
+ writeFile(path.join(dbRoot, 'src/schema.ts'), `import { boolean, index, integer, jsonb, pgTable, text, timestamp } from 'drizzle-orm/pg-core';
270
+ import type { InferInsertModel, InferSelectModel } from 'drizzle-orm';
271
+
272
+ export const SUBSCRIPTION_TIERS = ['free', 'pro'] as const;
273
+ export type SubscriptionTier = (typeof SUBSCRIPTION_TIERS)[number];
274
+
275
+ export const SUBSCRIPTION_STATUSES = ['active', 'expired', 'canceled'] as const;
276
+ export type SubscriptionStatus = (typeof SUBSCRIPTION_STATUSES)[number];
277
+
278
+ export const SUBSCRIPTION_METHODS = ['trialing', 'redeemed', 'paid'] as const;
279
+ export type SubscriptionMethod = (typeof SUBSCRIPTION_METHODS)[number];
280
+
281
+ export const SUBSCRIPTION_ORIGINS = ['web', 'apple', 'google'] as const;
282
+ export type SubscriptionOrigin = (typeof SUBSCRIPTION_ORIGINS)[number];
283
+
284
+ export const SUBSCRIPTION_EVENT_TYPES = [
285
+ 'trial_started', 'trial_converted', 'trial_expired',
286
+ 'initial_purchase', 'renewal', 'product_change',
287
+ 'cancellation_scheduled', 'cancellation_unscheduled', 'subscription_expired', 'subscription_canceled',
288
+ 'promo_redeemed', 'billing_issue', 'billing_recovered',
289
+ ] as const;
290
+ export type SubscriptionEventType = (typeof SUBSCRIPTION_EVENT_TYPES)[number];
291
+
292
+ export type SubscriptionMetadata = {
293
+ revenueCatProductId?: string;
294
+ lastWebhookUpdate?: string;
295
+ cancelReason?: string;
296
+ redeemCode?: string;
297
+ redeemCodeRedeemedAt?: string;
298
+ };
299
+
300
+ export type SubscriptionEventMetadata = {
301
+ revenueCatEventType?: string;
302
+ revenueCatProductId?: string;
303
+ promoCode?: string;
304
+ billingPeriod?: string;
305
+ price?: { amount: number; currency: string };
306
+ store?: string;
307
+ transactionId?: string;
308
+ reason?: string;
309
+ };
310
+
311
+ export const PROMO_CODE_DURATIONS = [1, 6, 12] as const;
312
+ export type PromoCodeDuration = (typeof PROMO_CODE_DURATIONS)[number];
273
313
 
274
314
  export const users = pgTable('users', {
275
315
  id: text('id').primaryKey(),
316
+ clerkUserId: text('clerk_user_id').unique(),
276
317
  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),
318
+ displayName: text('display_name'),
319
+ lastLoginAt: timestamp('last_login_at'),
320
+ profileImageUrl: text('profile_image_url'),
321
+ subscriptionTier: text('subscription_tier').notNull().default('free'),
322
+ subscriptionStatus: text('subscription_status').notNull().default('active'),
323
+ avatarObjectKey: text('avatar_object_key'),
324
+ avatarMimeType: text('avatar_mime_type'),
325
+ creditsBalance: integer('credits_balance').notNull().default(250),
326
+ createdAt: timestamp('created_at').$defaultFn(() => new Date()).notNull(),
327
+ updatedAt: timestamp('updated_at').$defaultFn(() => new Date()).notNull(),
328
+ }, (t) => [
329
+ index('users_clerk_user_id_idx').on(t.clerkUserId),
330
+ index('users_email_idx').on(t.email),
282
331
  ]);
283
332
 
284
333
  export const uploads = pgTable('uploads', {
@@ -286,309 +335,582 @@ export const uploads = pgTable('uploads', {
286
335
  userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
287
336
  objectKey: text('object_key').notNull().unique(),
288
337
  bucket: text('bucket').notNull(),
289
- createdAt: timestamp('created_at').defaultNow().notNull(),
338
+ contentType: text('content_type'),
339
+ createdAt: timestamp('created_at').$defaultFn(() => new Date()).notNull(),
290
340
  });
341
+
342
+ export const subscriptions = pgTable('subscriptions', {
343
+ id: text('id').primaryKey(),
344
+ userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }).unique(),
345
+ tier: text('tier').$type<SubscriptionTier>().notNull().default('free'),
346
+ status: text('status').$type<SubscriptionStatus>().notNull().default('active'),
347
+ method: text('method').$type<SubscriptionMethod>(),
348
+ origin: text('origin').$type<SubscriptionOrigin>(),
349
+ billingPeriod: text('billing_period'),
350
+ currentPeriodStart: timestamp('current_period_start'),
351
+ currentPeriodEnd: timestamp('current_period_end'),
352
+ canceledAt: timestamp('canceled_at'),
353
+ cancelAtPeriodEnd: boolean('cancel_at_period_end').notNull().default(false),
354
+ trialStart: timestamp('trial_start'),
355
+ trialEnd: timestamp('trial_end'),
356
+ dailyLimit: integer('daily_limit'),
357
+ metadata: jsonb('metadata').$type<SubscriptionMetadata>(),
358
+ createdAt: timestamp('created_at').$defaultFn(() => new Date()).notNull(),
359
+ updatedAt: timestamp('updated_at').$defaultFn(() => new Date()).notNull(),
360
+ }, (t) => [
361
+ index('subscriptions_user_idx').on(t.userId),
362
+ index('subscriptions_status_idx').on(t.status),
363
+ index('subscriptions_tier_idx').on(t.tier),
364
+ index('subscriptions_period_end_idx').on(t.currentPeriodEnd),
365
+ ]);
366
+
367
+ export const subscriptionEvents = pgTable('subscription_events', {
368
+ id: text('id').primaryKey(),
369
+ userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
370
+ subscriptionId: text('subscription_id').references(() => subscriptions.id, { onDelete: 'set null' }),
371
+ eventType: text('event_type').$type<SubscriptionEventType>().notNull(),
372
+ previousTier: text('previous_tier').$type<SubscriptionTier>(),
373
+ previousStatus: text('previous_status').$type<SubscriptionStatus>(),
374
+ previousMethod: text('previous_method').$type<SubscriptionMethod>(),
375
+ newTier: text('new_tier').$type<SubscriptionTier>(),
376
+ newStatus: text('new_status').$type<SubscriptionStatus>(),
377
+ newMethod: text('new_method').$type<SubscriptionMethod>(),
378
+ revenueCatEventType: text('revenuecat_event_type'),
379
+ revenueCatProductId: text('revenuecat_product_id'),
380
+ origin: text('origin').$type<SubscriptionOrigin>(),
381
+ periodStart: timestamp('period_start'),
382
+ periodEnd: timestamp('period_end'),
383
+ metadata: jsonb('metadata').$type<SubscriptionEventMetadata>(),
384
+ createdAt: timestamp('created_at').$defaultFn(() => new Date()).notNull(),
385
+ }, (t) => [
386
+ index('sub_events_user_idx').on(t.userId),
387
+ index('sub_events_user_created_idx').on(t.userId, t.createdAt),
388
+ index('sub_events_type_idx').on(t.eventType),
389
+ ]);
390
+
391
+ export const trialClaims = pgTable('trial_claims', {
392
+ id: text('id').primaryKey(),
393
+ userId: text('user_id').references(() => users.id, { onDelete: 'set null' }),
394
+ email: text('email').notNull().unique(),
395
+ claimedAt: timestamp('claimed_at').$defaultFn(() => new Date()).notNull(),
396
+ }, (t) => [index('trial_claims_email_idx').on(t.email)]);
397
+
398
+ export const promoCodes = pgTable('promo_codes', {
399
+ id: text('id').primaryKey(),
400
+ code: text('code').notNull().unique(),
401
+ months: integer('months').$type<PromoCodeDuration>().notNull(),
402
+ maxRedemptions: integer('max_redemptions').notNull().default(1),
403
+ redeemedCount: integer('redeemed_count').notNull().default(0),
404
+ expiresAt: timestamp('expires_at'),
405
+ createdAt: timestamp('created_at').$defaultFn(() => new Date()).notNull(),
406
+ }, (t) => [index('promo_codes_code_idx').on(t.code)]);
407
+
408
+ export const promoRedemptions = pgTable('promo_redemptions', {
409
+ id: text('id').primaryKey(),
410
+ promoCodeId: text('promo_code_id').notNull().references(() => promoCodes.id, { onDelete: 'cascade' }),
411
+ userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
412
+ redeemedAt: timestamp('redeemed_at').$defaultFn(() => new Date()).notNull(),
413
+ entitlementExpiresAt: timestamp('entitlement_expires_at').notNull(),
414
+ }, (t) => [
415
+ index('promo_redemptions_user_idx').on(t.userId),
416
+ index('promo_redemptions_code_idx').on(t.promoCodeId),
417
+ ]);
418
+
419
+ export type Users = InferSelectModel<typeof users>;
420
+ export type NewUsers = InferInsertModel<typeof users>;
421
+ export type Uploads = InferSelectModel<typeof uploads>;
422
+ export type NewUploads = InferInsertModel<typeof uploads>;
423
+ export type Subscriptions = InferSelectModel<typeof subscriptions>;
424
+ export type NewSubscriptions = InferInsertModel<typeof subscriptions>;
425
+ export type SubscriptionEvents = InferSelectModel<typeof subscriptionEvents>;
426
+ export type NewSubscriptionEvents = InferInsertModel<typeof subscriptionEvents>;
427
+ export type TrialClaims = InferSelectModel<typeof trialClaims>;
428
+ export type NewTrialClaims = InferInsertModel<typeof trialClaims>;
429
+ export type PromoCodes = InferSelectModel<typeof promoCodes>;
430
+ export type NewPromoCodes = InferInsertModel<typeof promoCodes>;
431
+ export type PromoRedemptions = InferSelectModel<typeof promoRedemptions>;
432
+ export type NewPromoRedemptions = InferInsertModel<typeof promoRedemptions>;
291
433
  `);
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';
434
+ writeFile(path.join(dbRoot, 'src/queries/users.ts'), `import { eq } from 'drizzle-orm';
435
+ import { db } from '../index';
436
+ import { uploads, users } from '../schema';
295
437
 
296
- export async function findUserByEmail(email: string) {
438
+ type SyncUserInput = {
439
+ clerkUserId: string;
440
+ email: string;
441
+ displayName: string | null;
442
+ };
443
+
444
+ export async function findUserByClerkId(clerkUserId: string) {
297
445
  return db.query.users.findFirst({
298
- where: eq(users.email, email),
446
+ where: eq(users.clerkUserId, clerkUserId),
447
+ });
448
+ }
449
+
450
+ export async function syncUserProfile(input: SyncUserInput) {
451
+ await db
452
+ .insert(users)
453
+ .values({
454
+ id: input.clerkUserId,
455
+ clerkUserId: input.clerkUserId,
456
+ email: input.email,
457
+ displayName: input.displayName,
458
+ })
459
+ .onConflictDoUpdate({
460
+ target: users.clerkUserId,
461
+ set: {
462
+ email: input.email,
463
+ displayName: input.displayName,
464
+ updatedAt: new Date(),
465
+ },
466
+ });
467
+
468
+ return findUserByClerkId(input.clerkUserId);
469
+ }
470
+
471
+ export async function updateAvatarForUser(
472
+ clerkUserId: string,
473
+ input: { objectKey: string; contentType: string | null },
474
+ ) {
475
+ await db
476
+ .update(users)
477
+ .set({
478
+ avatarObjectKey: input.objectKey,
479
+ avatarMimeType: input.contentType,
480
+ updatedAt: new Date(),
481
+ })
482
+ .where(eq(users.clerkUserId, clerkUserId));
483
+ }
484
+
485
+ export async function createUploadRecord(
486
+ clerkUserId: string,
487
+ input: { objectKey: string; bucket: string; contentType: string | null },
488
+ ) {
489
+ await db.insert(uploads).values({
490
+ id: crypto.randomUUID(),
491
+ userId: clerkUserId,
492
+ objectKey: input.objectKey,
493
+ bucket: input.bucket,
494
+ contentType: input.contentType,
299
495
  });
300
496
  }
301
497
  `);
302
- writeFile(path.join(root, 'src/lib/db/queries/index.ts'), `export * from './users.js';
498
+ writeFile(path.join(dbRoot, 'src/queries/uploads.ts'), `import { eq } from 'drizzle-orm';
499
+ import { db } from '../index';
500
+ import { uploads } from '../schema';
501
+
502
+ export async function listUploadsForUser(userId: string) {
503
+ return db.query.uploads.findMany({
504
+ where: eq(uploads.userId, userId),
505
+ });
506
+ }
303
507
  `);
304
- writeFile(path.join(root, 'src/lib/db/migrate.ts'), `import { drizzle } from 'drizzle-orm/neon-http';
508
+ writeFile(path.join(dbRoot, 'src/queries/index.ts'), `export * from './uploads';
509
+ export * from './users';
510
+ `);
511
+ writeFile(path.join(dbRoot, 'src/migrate.ts'), `import { drizzle } from 'drizzle-orm/neon-http';
305
512
  import { migrate } from 'drizzle-orm/neon-http/migrator';
306
513
  import { neon } from '@neondatabase/serverless';
307
- import 'dotenv/config';
514
+ import dotenv from 'dotenv';
515
+
516
+ dotenv.config({ path: '../../.env', quiet: true });
517
+ dotenv.config({ quiet: true });
308
518
 
309
519
  if (!process.env.DATABASE_URL) {
310
520
  throw new Error('DATABASE_URL is not defined');
311
521
  }
312
522
 
313
- const sql = neon(process.env.DATABASE_URL);
314
- const db = drizzle(sql);
523
+ const client = neon(process.env.DATABASE_URL);
524
+ const database = drizzle(client);
315
525
 
316
- await migrate(db, { migrationsFolder: './src/lib/db/migrations' });
526
+ console.log('Running migrations...');
527
+ const start = Date.now();
528
+ await migrate(database, { migrationsFolder: './migrations' });
529
+ console.log('Migrations completed in', Date.now() - start, 'ms');
317
530
  `);
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
- });
350
- `);
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';
531
+ writeFile(path.join(dbRoot, 'migrations/.gitkeep'), '');
532
+ const simplePackages = [
533
+ ['types', { exports: { '.': './src/index.ts' } }, `export type ApiEnvelope<T> = { data: T } | { error: string; message: string };
534
+ export type ClientPlatform = 'frontend' | 'extension';
535
+ export type SubscriptionEntitlements = {
536
+ pro: boolean;
537
+ inTrial: boolean;
538
+ trialEndsAt?: string;
539
+ expiresAt?: string;
540
+ periodStart?: string;
541
+ periodEnd?: string;
542
+ method?: 'trialing' | 'redeemed' | 'paid' | null;
543
+ managementUrl?: string;
544
+ cancelAtPeriodEnd?: boolean;
545
+ };
546
+ export type AuthMeResponse = {
547
+ user: {
548
+ id: string;
549
+ email: string;
550
+ firstName?: string | null;
551
+ lastName?: string | null;
552
+ displayName: string;
553
+ imageUrl?: string | null;
554
+ avatarUrl?: string | null;
555
+ creditsBalance: number;
556
+ subscriptionTier: string;
557
+ subscriptionStatus: string;
558
+ };
559
+ };
560
+ export type PricingResponse = {
561
+ tiers: Array<{
562
+ tier: 'free' | 'pro';
563
+ displayName: string;
564
+ description: string;
565
+ priceMonthly: number | null;
566
+ priceYearly: number | null;
567
+ currency: string;
568
+ limits: {
569
+ dailyLimit: number | null;
570
+ };
571
+ paymentInfo?: {
572
+ revenueCatEntitlementId: string;
573
+ revenueCatOfferingId: string;
574
+ };
575
+ }>;
576
+ };
577
+ `],
578
+ ['config', { exports: { '.': './src/index.ts' } }, `export const DEFAULT_API_PATH_PREFIX = '/api';
579
+ export const DEFAULT_LOCAL_API_URL = 'http://localhost:8787';
580
+ export const DEFAULT_FRONTEND_URL = 'http://localhost:8081';
581
+ export const DEFAULT_EXTENSION_URL = 'chrome-extension://';
582
+
583
+ export function joinApiUrl(baseUrl: string, path: string) {
584
+ const base = baseUrl.replace(/\\/$/, '');
585
+ const suffix = path.startsWith('/') ? path : \`/\${path}\`;
586
+ return \`\${base}\${suffix}\`;
587
+ }
588
+ `],
589
+ ['api-client', {
590
+ exports: { '.': './src/index.ts' },
591
+ dependencies: API_CLIENT_DEPENDENCIES.dependencies,
592
+ }, `import { joinApiUrl } from '@anhedral/config';
593
+ import type { AuthMeResponse, ClientPlatform, PricingResponse, SubscriptionEntitlements } from '@anhedral/types';
594
+
595
+ export class APIRequestError extends Error {
596
+ constructor(
597
+ public statusCode: number,
598
+ message: string,
599
+ public errorCode?: string,
600
+ ) {
601
+ super(message);
602
+ this.name = 'APIRequestError';
603
+ }
604
+ }
355
605
 
356
- export function buildApp() {
357
- const app = Fastify({ logger: true });
606
+ export type ApiClientOptions = {
607
+ baseUrl: string;
608
+ platform: ClientPlatform;
609
+ getToken?: () => Promise<string | null>;
610
+ };
611
+
612
+ export class ApiClient {
613
+ constructor(private readonly options: ApiClientOptions) {}
614
+
615
+ async request<T>(path: string, init: RequestInit = {}): Promise<T> {
616
+ const token = await this.options.getToken?.();
617
+ const headers = new Headers(init.headers);
618
+ headers.set('Content-Type', headers.get('Content-Type') ?? 'application/json');
619
+ headers.set('X-Platform', this.options.platform);
620
+ if (token) headers.set('Authorization', \`Bearer \${token}\`);
621
+
622
+ const response = await fetch(joinApiUrl(this.options.baseUrl, path), {
623
+ ...init,
624
+ headers,
625
+ });
358
626
 
359
- app.register(cors, {
360
- origin: process.env.CLIENT_ORIGIN ?? true,
361
- credentials: true,
362
- });
627
+ if (!response.ok) {
628
+ let error: { error?: string; message?: string } = {};
629
+ try { error = await response.json(); } catch {}
630
+ throw new APIRequestError(
631
+ response.status,
632
+ error.message || \`API request failed: \${response.status}\`,
633
+ error.error,
634
+ );
635
+ }
363
636
 
364
- app.get('/health', async () => ({ ok: true }));
637
+ if (response.status === 204) return {} as T;
638
+ return response.json() as Promise<T>;
639
+ }
365
640
 
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();
641
+ getMe() {
642
+ return this.request<AuthMeResponse>('/auth/me');
643
+ }
372
644
 
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
- }
645
+ getPricing() {
646
+ return this.request<PricingResponse>('/subscriptions/pricing');
647
+ }
378
648
 
379
- if (value) headers.append(key, value.toString());
380
- });
649
+ getSubscriptionPricing() {
650
+ return this.getPricing();
651
+ }
381
652
 
382
- const authRequest = new Request(url.toString(), {
383
- method: request.method,
384
- headers,
385
- body: request.body ? JSON.stringify(request.body) : undefined,
386
- });
653
+ getSubscriptionEntitlements(options?: { refresh?: boolean }) {
654
+ return this.request<SubscriptionEntitlements>(
655
+ options?.refresh ? '/subscriptions/entitlements/me?refresh=true' : '/subscriptions/entitlements/me',
656
+ );
657
+ }
387
658
 
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
- },
393
- });
659
+ redeemCode(code: string) {
660
+ return this.request<{ ok: boolean; expiresAt: string }>('/subscriptions/redeem', {
661
+ method: 'POST',
662
+ body: JSON.stringify({ code }),
663
+ });
664
+ }
394
665
 
395
- app.get('/api/me', async (request, reply) => {
396
- const session = await auth.api.getSession({
397
- headers: fromNodeHeaders(request.headers),
666
+ signOut() {
667
+ return this.request<{ success: boolean }>('/auth/signout', {
668
+ method: 'POST',
398
669
  });
670
+ }
399
671
 
400
- if (!session) {
401
- return reply.status(401).send({ error: 'Unauthorized' });
672
+ uploadAvatar(input: { base64: string; mimeType: string; fileName?: string }) {
673
+ return this.request<{ ok: boolean; avatarUrl: string }>('/auth/avatar', {
674
+ method: 'POST',
675
+ body: JSON.stringify(input),
676
+ });
677
+ }
678
+
679
+ deleteAccount() {
680
+ return this.request<void>('/auth/account', {
681
+ method: 'DELETE',
682
+ });
683
+ }
684
+ }
685
+ `],
686
+ ];
687
+ for (const [name, packageFields, source] of simplePackages) {
688
+ const packageRoot = path.join(root, `packages/${name}`);
689
+ writeFile(path.join(packageRoot, 'package.json'), JSON.stringify({
690
+ name: `@anhedral/${name}`,
691
+ version: '0.1.0',
692
+ private: true,
693
+ type: 'module',
694
+ scripts: {
695
+ build: 'pnpm typecheck',
696
+ typecheck: 'tsc --noEmit',
697
+ },
698
+ devDependencies: SHARED_PACKAGE_DEPENDENCIES.devDependencies,
699
+ ...packageFields,
700
+ }, null, 2) + '\n');
701
+ writeFile(path.join(packageRoot, 'tsconfig.json'), JSON.stringify({
702
+ compilerOptions: {
703
+ target: 'ESNext',
704
+ module: 'ESNext',
705
+ moduleResolution: 'Bundler',
706
+ strict: true,
707
+ skipLibCheck: true,
708
+ noEmit: true,
709
+ },
710
+ include: ['src/**/*'],
711
+ }, null, 2) + '\n');
712
+ writeFile(path.join(packageRoot, 'src/index.ts'), source);
402
713
  }
714
+ }
715
+ function writeRootEnvExample(root) {
716
+ const frontendUrl = 'http://localhost:8081';
717
+ const expoEnv = `EXPO_PUBLIC_API_URL=http://localhost:8787
718
+ EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_***
719
+ EXPO_PUBLIC_RC_ENTITLEMENT_ID=pro
720
+ EXPO_PUBLIC_RC_API_KEY_IOS=
721
+ EXPO_PUBLIC_RC_API_KEY_ANDROID=
722
+ EXPO_PUBLIC_RC_WEB_API_KEY=
723
+ `;
724
+ const extensionEnv = `VITE_API_URL=http://localhost:8787
725
+ VITE_WEBSITE_URL=${frontendUrl}
726
+ VITE_CLERK_PUBLISHABLE_KEY=pk_test_***
727
+ VITE_CRX_PUBLIC_KEY=
728
+ VITE_RC_BILLING_URL=
729
+ `;
730
+ const paymentsEnv = `# RevenueCat
731
+ RC_SECRET_API_KEY=
732
+ RC_WEBHOOK_SECRET=
733
+ RC_ENTITLEMENT_ID=pro
734
+ RC_OFFERING_ID=default
735
+ `;
736
+ writeFile(path.join(root, '.env.example'), `# Apps
737
+ FRONTEND_URL=${frontendUrl}
738
+ ANHEDRAL_DEMO=false
739
+ ${expoEnv}${extensionEnv}
403
740
 
404
- return reply.send(session);
405
- });
741
+ # Server
742
+ PORT=8787
743
+ DATABASE_URL=postgresql://user:pass@ep-xxx.us-east-1.aws.neon.tech/dbname?sslmode=require
744
+
745
+ # Clerk
746
+ CLERK_PUBLISHABLE_KEY=pk_test_***
747
+ CLERK_SECRET_KEY=sk_test_***
748
+
749
+ # Cloudflare R2
750
+ R2_ACCOUNT_ID=
751
+ R2_ACCESS_KEY_ID=
752
+ R2_SECRET_ACCESS_KEY=
753
+ R2_BUCKET=
406
754
 
407
- return app;
755
+ ${paymentsEnv}`);
408
756
  }
409
- `);
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
- });
757
+ function writeRootVercelFiles(root) {
758
+ writeFile(path.join(root, '.vercelignore'), `apps/extension/.output
759
+ apps/extension/.wxt
760
+ apps/extension/dist
424
761
  `);
425
762
  }
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}
763
+ function writeGeneratedCiWorkflow(root) {
764
+ writeFile(path.join(root, '.github/workflows/ci.yml'), `name: CI
449
765
 
450
- Generated by anhedral.
766
+ on:
767
+ pull_request:
768
+ push:
769
+ branches:
770
+ - main
451
771
 
452
- ## Stack
772
+ permissions:
773
+ contents: read
453
774
 
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}
775
+ concurrency:
776
+ group: ci-\${{ github.workflow }}-\${{ github.ref }}
777
+ cancel-in-progress: true
461
778
 
462
- ## Generated paths
779
+ jobs:
780
+ verify:
781
+ runs-on: ubuntu-latest
782
+ timeout-minutes: 45
783
+ steps:
784
+ - name: Checkout
785
+ uses: actions/checkout@v4
463
786
 
464
- ${generatedPaths}
787
+ - name: Setup pnpm
788
+ uses: pnpm/action-setup@v4
789
+ with:
790
+ version: 10.15.1
465
791
 
466
- ## Setup
792
+ - name: Setup Node.js
793
+ uses: actions/setup-node@v4
794
+ with:
795
+ node-version: 20
796
+ cache: pnpm
467
797
 
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:
798
+ - name: Install dependencies
799
+ run: pnpm install --frozen-lockfile
800
+
801
+ - name: Check Expo dependency alignment
802
+ run: pnpm --filter ./apps/frontend exec expo install --check
803
+
804
+ - name: Build Expo web
805
+ run: pnpm --filter ./apps/frontend build:web
806
+
807
+ - name: Test API
808
+ run: pnpm --filter ./apps/api test
809
+
810
+ - name: Build API
811
+ run: pnpm --filter ./apps/api build
812
+
813
+ - name: Build extension
814
+ run: pnpm --filter ./apps/extension build
815
+
816
+ - name: Zip extension
817
+ run: pnpm --filter ./apps/extension zip
472
818
 
473
- ${databaseCommands}
819
+ - name: Build workspace
820
+ run: pnpm build
474
821
  `);
475
822
  }
476
- function writeStackFile(root, stack) {
477
- writeFile(path.join(root, 'stack.json'), JSON.stringify(stack, null, 2) + '\n');
478
- }
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
- },
823
+ function writeFullstackRootFiles(root, options) {
824
+ const appFilters = [
825
+ './apps/frontend',
826
+ './apps/api',
827
+ './apps/extension',
828
+ ];
829
+ const parallelFilters = appFilters.map((entry) => `--filter=${entry}`).join(' ');
830
+ const scripts = {
831
+ dev: `turbo dev --parallel ${parallelFilters}`,
832
+ 'dev:api': 'pnpm --filter ./apps/api dev',
833
+ build: 'turbo build',
834
+ typecheck: 'turbo typecheck',
835
+ 'db:generate': 'pnpm --filter @anhedral/db db:generate',
836
+ 'db:migrate': 'pnpm --filter @anhedral/db db:migrate',
837
+ };
838
+ scripts['dev:frontend'] = 'pnpm --filter ./apps/frontend dev';
839
+ scripts['dev:extension'] = 'pnpm --filter ./apps/extension dev';
840
+ scripts['extension:zip'] = 'pnpm --filter ./apps/extension zip';
841
+ writeRootPackageJson(root, options.projectName, scripts, ['apps/*', 'packages/*'], {
842
+ devDependencies: ROOT_DEPENDENCIES.devDependencies,
498
843
  });
499
- writeDrizzleConfig(root, './lib/db/schema.ts', './lib/db/migrations');
500
- writeDefaultWebEnvExample(root);
501
- writeDefaultWebFiles(root, options.displayName);
502
- return ['.'];
503
- }
504
- function patchGeneratedPackageName(root, name) {
505
- const filePath = path.join(root, 'package.json');
506
- const packageJson = readJsonFile(filePath);
507
- packageJson.name = name;
508
- writeJsonFile(filePath, packageJson);
509
- }
510
- function scaffoldNextFrontend(root, options, directoryName) {
511
- const toolchain = resolveToolchain(options.toolchainChannel);
512
- runScaffoldCommands(root, [getDefaultWebInitCommand(toolchain, directoryName)]);
513
- runScaffoldCommands(path.join(root, directoryName), getDefaultWebDependencyCommands());
514
- appendGitignore(path.join(root, directoryName), [
515
- ...SHARED_ENV_GITIGNORE_LINES,
516
- '.next',
517
- 'node_modules',
518
- 'lib/db/migrations',
519
- ...SHARED_TYPESCRIPT_GITIGNORE_LINES,
520
- ]);
521
- patchPackageJson(path.join(root, directoryName), {
522
- packageManager: PNPM_PACKAGE_MANAGER,
523
- scripts: {
524
- typecheck: 'tsc --noEmit',
525
- 'db:generate': 'drizzle-kit generate',
526
- 'db:migrate': 'pnpm tsx lib/db/migrate.ts',
844
+ writePnpmWorkspace(root, ['apps/*', 'packages/*']);
845
+ writeFile(path.join(root, 'turbo.json'), JSON.stringify({
846
+ $schema: 'https://turborepo.dev/schema.json',
847
+ tasks: {
848
+ build: {
849
+ dependsOn: ['^build'],
850
+ outputs: ['dist/**', '.output/**'],
851
+ },
852
+ typecheck: {
853
+ dependsOn: ['^build'],
854
+ },
855
+ dev: {
856
+ cache: false,
857
+ persistent: true,
858
+ },
527
859
  },
528
- });
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']);
860
+ }, null, 2) + '\n');
540
861
  appendGitignore(root, [
541
862
  'node_modules',
542
- 'frontend/node_modules',
543
- 'backend/node_modules',
863
+ '.turbo',
864
+ 'apps/*/node_modules',
865
+ 'packages/*/node_modules',
544
866
  ...SHARED_ENV_GITIGNORE_LINES,
867
+ ...SHARED_TYPESCRIPT_GITIGNORE_LINES,
545
868
  ]);
546
- writeNextFullstackRootEnvExample(root);
869
+ writeRootEnvExample(root);
870
+ writeRootVercelFiles(root);
871
+ writeGeneratedCiWorkflow(root);
872
+ }
873
+ async function scaffoldFullstack(root, options) {
874
+ anhedralPrint.section('Workspace root');
875
+ anhedralPrint.step('Writing root config (package.json, pnpm workspace, env, vercel)');
876
+ writeFullstackRootFiles(root, options);
877
+ anhedralPrint.done('Root config written');
878
+ anhedralPrint.section('Shared packages');
879
+ anhedralPrint.step('Writing @anhedral/db, types, config, api-client');
880
+ writeSharedPackages(root);
881
+ anhedralPrint.done('Shared packages written');
882
+ const frontendUrl = 'http://localhost:8081';
547
883
  await scaffoldBackend(root, {
548
884
  projectName: options.projectName,
549
885
  displayName: options.displayName,
550
886
  githubOrg: null,
551
- frontendUrl: 'http://localhost:3000',
887
+ frontendUrl,
888
+ skipInstall: options.skipInstall,
552
889
  });
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 = {
890
+ await scaffoldFrontend(root, {
568
891
  projectName: options.projectName,
569
892
  displayName: options.displayName,
570
893
  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 ['.'];
894
+ frontendUrl,
895
+ skipInstall: options.skipInstall,
896
+ });
897
+ await scaffoldExtension(root, {
898
+ projectName: options.projectName,
899
+ displayName: options.displayName,
900
+ githubOrg: null,
901
+ frontendUrl,
902
+ skipInstall: options.skipInstall,
903
+ });
904
+ return [
905
+ '.github/workflows/ci.yml',
906
+ 'apps/frontend',
907
+ 'apps/api',
908
+ 'apps/extension',
909
+ 'packages/db',
910
+ 'packages/types',
911
+ 'packages/config',
912
+ 'packages/api-client',
913
+ ];
592
914
  }
593
915
  export async function scaffoldProject(options) {
594
916
  const root = path.resolve(process.cwd());
@@ -597,26 +919,31 @@ export async function scaffoldProject(options) {
597
919
  env.ANHEDRAL_TOOLCHAIN = options.toolchainChannel;
598
920
  try {
599
921
  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);
922
+ anhedralPrint.banner(`Initializing ${options.displayName} in ${root}`);
923
+ const generatedPaths = await scaffoldFullstack(root, options);
924
+ anhedralPrint.section('Project metadata');
925
+ anhedralPrint.step('Writing skills guide, README, and stack.json');
926
+ const skillCommands = getSkillCommands();
609
927
  writeSkillsGuide(root, skillCommands);
610
928
  const stack = normalizeStack(options, generatedPaths);
611
929
  writeRootDocs(root, options, stack);
612
930
  writeStackFile(root, stack);
613
- console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
614
- console.log(`\nšŸŽ‰ ${options.displayName} is ready.\n`);
615
- console.log('Generated:');
931
+ anhedralPrint.done('Project metadata written');
932
+ console.log('');
933
+ anhedralPrint.banner(`${options.displayName} is ready`);
616
934
  for (const generatedPath of generatedPaths) {
617
- console.log(` - ${generatedPath}`);
935
+ anhedralPrint.done(generatedPath);
618
936
  }
619
937
  console.log('');
938
+ anhedralPrint.info('Next commands:');
939
+ console.log(' pnpm install');
940
+ console.log(' cp .env.example .env');
941
+ console.log(' cp apps/extension/.env.example apps/extension/.env');
942
+ console.log(' pnpm db:generate && pnpm db:migrate');
943
+ console.log(' pnpm dev:api');
944
+ console.log(' pnpm dev:frontend');
945
+ console.log(' pnpm dev:extension');
946
+ console.log('');
620
947
  }
621
948
  finally {
622
949
  if (previousToolchain == null) {