anhedral 0.1.3 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/scaffold.js CHANGED
@@ -1,15 +1,14 @@
1
- import { readFileSync, readdirSync, rmSync } from 'node:fs';
1
+ import { readdirSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { env } from 'node:process';
4
4
  import { anhedralPrint } from './print.js';
5
- import { appendGitignore, exec, execWithInput, writeFile } from './util.js';
6
- import { getDefaultWebDependencyCommands, getDefaultWebInitCommand, getSkillCommands, } from './commands.js';
5
+ import { appendGitignore, writeFile } from './util.js';
6
+ import { getSkillCommands } from './commands.js';
7
+ import { API_CLIENT_DEPENDENCIES, PACKAGE_MANAGER, ROOT_DEPENDENCIES, SHARED_DB_DEPENDENCIES, SHARED_PACKAGE_DEPENDENCIES, dependencyManifest, } from './dependencies.js';
7
8
  import { resolveToolchain } from './toolchain.js';
8
9
  import { scaffoldBackend } from './templates/backend.js';
9
- import { writeDefaultWebEnvExample, writeDefaultWebFiles } from './templates/default-web.js';
10
10
  import { scaffoldExtension } from './templates/extension.js';
11
11
  import { scaffoldFrontend } from './templates/frontend.js';
12
- const PNPM_PACKAGE_MANAGER = 'pnpm@10.15.1';
13
12
  const SHARED_ENV_GITIGNORE_LINES = ['.env', '.env.*', '!.env.example'];
14
13
  const SHARED_TYPESCRIPT_GITIGNORE_LINES = ['*.tsbuildinfo'];
15
14
  const ASCII_LOGO = [
@@ -43,61 +42,39 @@ async function printAsciiLogo() {
43
42
  function getUsedToolchain(options, toolchain) {
44
43
  return {
45
44
  verifiedAt: toolchain.verifiedAt,
46
- ...(options.frontend === 'next' || options.extension ? { shadcn: toolchain.shadcn } : {}),
47
- ...(options.frontend === 'expo' ? { reactNativeReusables: toolchain.reactNativeReusables } : {}),
48
- ...(options.extension ? { wxt: toolchain.wxt } : {}),
45
+ shadcn: toolchain.shadcn,
46
+ reactNativeReusables: toolchain.reactNativeReusables,
47
+ wxt: toolchain.wxt,
49
48
  };
50
49
  }
51
50
  function normalizeStack(options, generatedPaths) {
52
51
  const toolchain = resolveToolchain(options.toolchainChannel);
53
- const frontend = options.frontend === 'next'
54
- ? 'nextjs_shadcn'
55
- : 'expo_react_native_reusables';
56
52
  return {
57
53
  schema_version: '2.0.0',
58
54
  mode: 'fullstack',
59
55
  project_name: options.projectName,
60
56
  display_name: options.displayName,
61
- frontend,
62
- extension: options.extension ? 'wxt_chrome_extension' : null,
57
+ frontend: 'expo_react_native_reusables',
58
+ extension: 'wxt_chrome_extension',
63
59
  backend: 'fastify',
64
60
  auth: options.auth,
65
61
  payments: options.payments,
66
62
  storage: 'cloudflare_r2_via_aws_s3_sdk',
67
63
  database: 'neon_plus_drizzle',
68
- skills: getSkillCommands(options),
64
+ skills: getSkillCommands(),
69
65
  outputs: {
70
- monorepo: options.monorepo,
66
+ monorepo: true,
71
67
  package_manager: 'pnpm',
68
+ dependency_manifest: dependencyManifest(),
72
69
  toolchain_channel: options.toolchainChannel,
73
70
  toolchain: getUsedToolchain(options, toolchain),
74
71
  generated_paths: generatedPaths,
75
72
  },
76
73
  };
77
74
  }
78
- function readJsonFile(filePath) {
79
- return JSON.parse(readFileSync(filePath, 'utf-8'));
80
- }
81
75
  function writeJsonFile(filePath, payload) {
82
76
  writeFile(filePath, JSON.stringify(payload, null, 2) + '\n');
83
77
  }
84
- function runScaffoldCommands(cwd, commands) {
85
- let skippedCount = 0;
86
- for (const command of commands) {
87
- if (env.ANHEDRAL_SKIP_INSTALL === '1' && command.skippable) {
88
- skippedCount += 1;
89
- continue;
90
- }
91
- if (command.stdinInput == null) {
92
- exec(command.cmd, cwd);
93
- continue;
94
- }
95
- execWithInput(command.cmd, cwd, command.stdinInput);
96
- }
97
- if (skippedCount > 0) {
98
- anhedralPrint.info(`Skipped ${skippedCount} install step${skippedCount === 1 ? '' : 's'} (ANHEDRAL_SKIP_INSTALL=1)`);
99
- }
100
- }
101
78
  function ensureScaffoldRoot(root) {
102
79
  const allowedEntries = new Set(['.git', '.gitignore', '.DS_Store']);
103
80
  const unexpected = readdirSync(root).filter((entry) => !allowedEntries.has(entry));
@@ -110,7 +87,7 @@ function writeRootPackageJson(root, projectName, scripts, workspaces, extraField
110
87
  name: projectName,
111
88
  private: true,
112
89
  version: '0.1.0',
113
- packageManager: PNPM_PACKAGE_MANAGER,
90
+ packageManager: PACKAGE_MANAGER,
114
91
  scripts,
115
92
  ...extraFields,
116
93
  };
@@ -123,26 +100,6 @@ function writePnpmWorkspace(root, packages) {
123
100
  const serializedPackages = packages.map((entry) => ` - '${entry}'`).join('\n');
124
101
  writeFile(path.join(root, 'pnpm-workspace.yaml'), `packages:\n${serializedPackages}\n`);
125
102
  }
126
- function patchPackageJson(root, updates) {
127
- const filePath = path.join(root, 'package.json');
128
- const packageJson = readJsonFile(filePath);
129
- const scripts = (packageJson.scripts ?? {});
130
- if (updates.scripts) {
131
- Object.assign(scripts, updates.scripts);
132
- }
133
- packageJson.scripts = scripts;
134
- if (updates.packageManager) {
135
- packageJson.packageManager = updates.packageManager;
136
- }
137
- writeJsonFile(filePath, packageJson);
138
- }
139
- function replaceInFile(filePath, replacements) {
140
- let contents = readFileSync(filePath, 'utf-8');
141
- for (const [search, replacement] of replacements) {
142
- contents = contents.split(search).join(replacement);
143
- }
144
- writeFile(filePath, contents);
145
- }
146
103
  function writeSkillsGuide(root, commands) {
147
104
  const commandBlock = commands.map((command) => `${command}\n`).join('\n');
148
105
  writeFile(path.join(root, 'install-skills.sh'), `#!/usr/bin/env bash
@@ -162,23 +119,6 @@ function writeRootDocs(root, options, stack) {
162
119
  ? `${options.toolchainChannel} (verified ${stack.outputs.toolchain.verifiedAt})`
163
120
  : `${options.toolchainChannel} (floating latest)`;
164
121
  const generatedPaths = stack.outputs.generated_paths.map((entry) => `- \`${entry}\``).join('\n') || '- `.`';
165
- const frontendLabel = options.frontend === 'next' ? 'Next.js' : 'Expo';
166
- const extensionLine = options.extension ? '- Extension: WXT Chrome extension\n' : '';
167
- const extensionSetup = options.extension
168
- ? '\n5. Build or run the extension separately. It is not deployed by Vercel:\n\n```bash\npnpm dev:extension\npnpm extension:zip\n```\n'
169
- : '';
170
- const frontendDevCommand = options.frontend === 'next' ? 'pnpm dev:web' : 'pnpm dev:mobile';
171
- const frontendEnvCommand = options.frontend === 'next'
172
- ? 'cp apps/web/.env.example apps/web/.env.local'
173
- : '# apps/mobile/.env is generated for local Expo development';
174
- const extensionEnvCommand = options.extension ? '\ncp apps/extension/.env.example apps/extension/.env' : '';
175
- const extensionDevCommand = options.extension ? '\npnpm dev:extension' : '';
176
- const paymentSetup = options.payments === 'stripe'
177
- ? `- Stripe: create a Checkout price, then set \`STRIPE_SECRET_KEY\`, \`STRIPE_WEBHOOK_SECRET\`, and \`STRIPE_PRICE_STARTER\`.`
178
- : `- RevenueCat: create an entitlement named \`pro\`, configure app keys, then set \`RC_SECRET_API_KEY\`, \`RC_WEBHOOK_SECRET\`, and the \`EXPO_PUBLIC_RC_*\` values.`;
179
- const deploySetup = options.frontend === 'next'
180
- ? '- Vercel: deploy this repo from the root. The root `vercel.json` builds `apps/web` and `apps/api`; Fastify is routed under `/backend/*`.'
181
- : '- Vercel: deploy this repo from the root when you want the Fastify API hosted. Expo is built and shipped outside Vercel.';
182
122
  writeFile(path.join(root, 'README.md'), `# ${options.displayName}
183
123
 
184
124
  Generated by anhedral.
@@ -186,15 +126,17 @@ Generated by anhedral.
186
126
  ## Stack
187
127
 
188
128
  - Mode: fullstack
189
- - Frontend: ${frontendLabel}
129
+ - Frontend: Expo + React Native Reusables
190
130
  - Backend: Fastify
191
- ${extensionLine}- Shared packages: \`packages/*\`
131
+ - Extension: WXT Chrome extension
132
+ - Shared packages: \`packages/*\`
192
133
  - Auth: ${options.auth}
193
134
  - Payments: ${options.payments}
194
135
  - Database: ${options.db} + ${options.orm}
195
136
  - Storage: ${options.storage}
196
137
  - API: ${options.api ?? 'framework-native'}
197
138
  - Toolchain: ${toolchainLine}
139
+ - Dependency manifest: recorded in \`stack.json\`
198
140
 
199
141
  ## Generated paths
200
142
 
@@ -205,11 +147,13 @@ ${generatedPaths}
205
147
  \`\`\`bash
206
148
  pnpm install
207
149
  cp .env.example .env
208
- ${frontendEnvCommand}${extensionEnvCommand}
150
+ # apps/frontend/.env is generated for local Expo development
151
+ cp apps/extension/.env.example apps/extension/.env
209
152
  pnpm db:generate
210
153
  pnpm db:migrate
211
154
  pnpm dev:api
212
- ${frontendDevCommand}${extensionDevCommand}
155
+ pnpm dev:frontend
156
+ pnpm dev:extension
213
157
  \`\`\`
214
158
 
215
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.
@@ -217,23 +161,31 @@ For a provider-free smoke test, keep \`ANHEDRAL_DEMO=true\` in \`apps/api/.env\`
217
161
  ## Provider Setup
218
162
 
219
163
  - 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}
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.
222
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\`.
223
- ${deploySetup}
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.
224
169
 
225
170
  ## Setup Notes
226
171
 
227
172
  1. Copy \`.env.example\` into the runtime env files your apps need and fill in provider values.
228
173
  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:
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:
231
177
 
232
178
  \`\`\`bash
233
179
  pnpm db:generate
234
180
  pnpm db:migrate
235
181
  \`\`\`
236
- ${extensionSetup}
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
+ \`\`\`
237
189
  `);
238
190
  }
239
191
  function writeStackFile(root, stack) {
@@ -258,17 +210,8 @@ function writeSharedPackages(root) {
258
210
  'db:generate': 'drizzle-kit generate',
259
211
  'db:migrate': 'tsx --env-file=../../.env src/migrate.ts',
260
212
  },
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
- },
213
+ dependencies: SHARED_DB_DEPENDENCIES.dependencies,
214
+ devDependencies: SHARED_DB_DEPENDENCIES.devDependencies,
272
215
  }, null, 2) + '\n');
273
216
  writeFile(path.join(dbRoot, 'tsconfig.json'), JSON.stringify({
274
217
  compilerOptions: {
@@ -375,8 +318,7 @@ export const users = pgTable('users', {
375
318
  displayName: text('display_name'),
376
319
  lastLoginAt: timestamp('last_login_at'),
377
320
  profileImageUrl: text('profile_image_url'),
378
- stripeCustomerId: text('stripe_customer_id').unique(),
379
- subscriptionTier: text('subscription_tier').notNull().default('starter'),
321
+ subscriptionTier: text('subscription_tier').notNull().default('free'),
380
322
  subscriptionStatus: text('subscription_status').notNull().default('active'),
381
323
  avatarObjectKey: text('avatar_object_key'),
382
324
  avatarMimeType: text('avatar_mime_type'),
@@ -526,17 +468,6 @@ export async function syncUserProfile(input: SyncUserInput) {
526
468
  return findUserByClerkId(input.clerkUserId);
527
469
  }
528
470
 
529
- export async function updateStripeCustomerForUser(clerkUserId: string, stripeCustomerId: string) {
530
- await db
531
- .update(users)
532
- .set({
533
- stripeCustomerId,
534
- subscriptionStatus: 'checkout_started',
535
- updatedAt: new Date(),
536
- })
537
- .where(eq(users.clerkUserId, clerkUserId));
538
- }
539
-
540
471
  export async function updateAvatarForUser(
541
472
  clerkUserId: string,
542
473
  input: { objectKey: string; contentType: string | null },
@@ -600,7 +531,7 @@ console.log('Migrations completed in', Date.now() - start, 'ms');
600
531
  writeFile(path.join(dbRoot, 'migrations/.gitkeep'), '');
601
532
  const simplePackages = [
602
533
  ['types', { exports: { '.': './src/index.ts' } }, `export type ApiEnvelope<T> = { data: T } | { error: string; message: string };
603
- export type ClientPlatform = 'web' | 'mobile' | 'extension';
534
+ export type ClientPlatform = 'frontend' | 'extension';
604
535
  export type SubscriptionEntitlements = {
605
536
  pro: boolean;
606
537
  inTrial: boolean;
@@ -618,7 +549,7 @@ export type AuthMeResponse = {
618
549
  email: string;
619
550
  firstName?: string | null;
620
551
  lastName?: string | null;
621
- displayName?: string | null;
552
+ displayName: string;
622
553
  imageUrl?: string | null;
623
554
  avatarUrl?: string | null;
624
555
  creditsBalance: number;
@@ -627,19 +558,26 @@ export type AuthMeResponse = {
627
558
  };
628
559
  };
629
560
  export type PricingResponse = {
630
- plans: Array<{
631
- id: string;
632
- name: string;
561
+ tiers: Array<{
562
+ tier: 'free' | 'pro';
563
+ displayName: string;
633
564
  description: string;
634
- price: string;
635
- features: 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
+ };
636
575
  }>;
637
576
  };
638
577
  `],
639
578
  ['config', { exports: { '.': './src/index.ts' } }, `export const DEFAULT_API_PATH_PREFIX = '/api';
640
579
  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';
580
+ export const DEFAULT_FRONTEND_URL = 'http://localhost:8081';
643
581
  export const DEFAULT_EXTENSION_URL = 'chrome-extension://';
644
582
 
645
583
  export function joinApiUrl(baseUrl: string, path: string) {
@@ -650,13 +588,21 @@ export function joinApiUrl(baseUrl: string, path: string) {
650
588
  `],
651
589
  ['api-client', {
652
590
  exports: { '.': './src/index.ts' },
653
- dependencies: {
654
- '@anhedral/config': 'workspace:*',
655
- '@anhedral/types': 'workspace:*',
656
- },
591
+ dependencies: API_CLIENT_DEPENDENCIES.dependencies,
657
592
  }, `import { joinApiUrl } from '@anhedral/config';
658
593
  import type { AuthMeResponse, ClientPlatform, PricingResponse, SubscriptionEntitlements } from '@anhedral/types';
659
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
+ }
605
+
660
606
  export type ApiClientOptions = {
661
607
  baseUrl: string;
662
608
  platform: ClientPlatform;
@@ -679,9 +625,16 @@ export class ApiClient {
679
625
  });
680
626
 
681
627
  if (!response.ok) {
682
- throw new Error(\`API request failed: \${response.status}\`);
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
+ );
683
635
  }
684
636
 
637
+ if (response.status === 204) return {} as T;
685
638
  return response.json() as Promise<T>;
686
639
  }
687
640
 
@@ -693,6 +646,10 @@ export class ApiClient {
693
646
  return this.request<PricingResponse>('/subscriptions/pricing');
694
647
  }
695
648
 
649
+ getSubscriptionPricing() {
650
+ return this.getPricing();
651
+ }
652
+
696
653
  getSubscriptionEntitlements(options?: { refresh?: boolean }) {
697
654
  return this.request<SubscriptionEntitlements>(
698
655
  options?.refresh ? '/subscriptions/entitlements/me?refresh=true' : '/subscriptions/entitlements/me',
@@ -705,6 +662,25 @@ export class ApiClient {
705
662
  body: JSON.stringify({ code }),
706
663
  });
707
664
  }
665
+
666
+ signOut() {
667
+ return this.request<{ success: boolean }>('/auth/signout', {
668
+ method: 'POST',
669
+ });
670
+ }
671
+
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
+ }
708
684
  }
709
685
  `],
710
686
  ];
@@ -719,9 +695,7 @@ export class ApiClient {
719
695
  build: 'pnpm typecheck',
720
696
  typecheck: 'tsc --noEmit',
721
697
  },
722
- devDependencies: {
723
- typescript: '^5.9.3',
724
- },
698
+ devDependencies: SHARED_PACKAGE_DEPENDENCIES.devDependencies,
725
699
  ...packageFields,
726
700
  }, null, 2) + '\n');
727
701
  writeFile(path.join(packageRoot, 'tsconfig.json'), JSON.stringify({
@@ -738,54 +712,31 @@ export class ApiClient {
738
712
  writeFile(path.join(packageRoot, 'src/index.ts'), source);
739
713
  }
740
714
  }
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
715
+ function writeRootEnvExample(root) {
716
+ const frontendUrl = 'http://localhost:8081';
717
+ const expoEnv = `EXPO_PUBLIC_API_URL=http://localhost:8787
755
718
  EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_***
756
719
  EXPO_PUBLIC_RC_ENTITLEMENT_ID=pro
757
720
  EXPO_PUBLIC_RC_API_KEY_IOS=
758
721
  EXPO_PUBLIC_RC_API_KEY_ANDROID=
759
722
  EXPO_PUBLIC_RC_WEB_API_KEY=
760
- `
761
- : '';
762
- const extensionEnv = options.extension
763
- ? `VITE_API_URL=http://localhost:8787
723
+ `;
724
+ const extensionEnv = `VITE_API_URL=http://localhost:8787
764
725
  VITE_WEBSITE_URL=${frontendUrl}
765
726
  VITE_CLERK_PUBLISHABLE_KEY=pk_test_***
766
727
  VITE_CRX_PUBLIC_KEY=
767
728
  VITE_RC_BILLING_URL=
768
- `
769
- : '';
770
- const revenueCatEnv = options.payments === 'revenuecat'
771
- ? `# RevenueCat
729
+ `;
730
+ const paymentsEnv = `# RevenueCat
772
731
  RC_SECRET_API_KEY=
773
732
  RC_WEBHOOK_SECRET=
774
733
  RC_ENTITLEMENT_ID=pro
775
734
  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
- : '';
735
+ `;
785
736
  writeFile(path.join(root, '.env.example'), `# Apps
786
737
  FRONTEND_URL=${frontendUrl}
787
738
  ANHEDRAL_DEMO=false
788
- ${nextEnv}${expoEnv}${extensionEnv}
739
+ ${expoEnv}${extensionEnv}
789
740
 
790
741
  # Server
791
742
  PORT=8787
@@ -801,126 +752,123 @@ R2_ACCESS_KEY_ID=
801
752
  R2_SECRET_ACCESS_KEY=
802
753
  R2_BUCKET=
803
754
 
804
- ${revenueCatEnv}${stripeEnv}`);
755
+ ${paymentsEnv}`);
805
756
  }
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');
757
+ function writeRootVercelFiles(root) {
825
758
  writeFile(path.join(root, '.vercelignore'), `apps/extension/.output
826
759
  apps/extension/.wxt
827
760
  apps/extension/dist
828
- apps/mobile/.expo
829
761
  `);
830
762
  }
831
- function patchGeneratedPackageName(root, name) {
832
- const filePath = path.join(root, 'package.json');
833
- const packageJson = readJsonFile(filePath);
834
- packageJson.name = name;
835
- writeJsonFile(filePath, packageJson);
836
- }
837
- function scaffoldNextFrontend(root, options, directoryName) {
838
- const toolchain = resolveToolchain(options.toolchainChannel);
839
- const appsRoot = path.join(root, 'apps');
840
- const appRoot = path.join(appsRoot, directoryName);
841
- anhedralPrint.section('Web (Next.js + shadcn/ui)');
842
- anhedralPrint.step('Scaffolding Next.js app');
843
- writeFile(path.join(appsRoot, '.gitkeep'), '');
844
- runScaffoldCommands(appsRoot, [getDefaultWebInitCommand(toolchain, directoryName)]);
845
- anhedralPrint.done('Next.js app scaffolded');
846
- const packageJsonPath = path.join(appRoot, 'package.json');
847
- const packageJson = readJsonFile(packageJsonPath);
848
- packageJson.dependencies = {
849
- ...(packageJson.dependencies ?? {}),
850
- '@anhedral/db': 'workspace:*',
851
- };
852
- writeJsonFile(packageJsonPath, packageJson);
853
- anhedralPrint.step('Installing web dependencies');
854
- runScaffoldCommands(appRoot, getDefaultWebDependencyCommands());
855
- anhedralPrint.done('Web dependencies installed');
856
- rmSync(path.join(appRoot, '.git'), { recursive: true, force: true });
857
- appendGitignore(appRoot, [
858
- ...SHARED_ENV_GITIGNORE_LINES,
859
- '.next',
860
- 'node_modules',
861
- 'lib/db/migrations',
862
- ...SHARED_TYPESCRIPT_GITIGNORE_LINES,
863
- ]);
864
- patchPackageJson(appRoot, {
865
- packageManager: PNPM_PACKAGE_MANAGER,
866
- scripts: {
867
- typecheck: 'tsc --noEmit',
868
- 'db:generate': 'pnpm --filter @anhedral/db db:generate',
869
- 'db:migrate': 'pnpm --filter @anhedral/db db:migrate',
870
- },
871
- });
872
- patchGeneratedPackageName(appRoot, `${options.projectName}-${directoryName}`);
873
- writeDefaultWebEnvExample(appRoot);
874
- writeDefaultWebFiles(appRoot, options.displayName);
875
- for (const relativePath of [
876
- 'lib/app/dashboard.ts',
877
- 'app/api/account/avatar/route.ts',
878
- 'app/api/stripe/checkout/route.ts',
879
- 'app/api/stripe/portal/route.ts',
880
- ]) {
881
- replaceInFile(path.join(appRoot, relativePath), [
882
- ["@/lib/db/queries", '@anhedral/db/queries'],
883
- ]);
884
- }
885
- rmSync(path.join(appRoot, 'lib/db'), { recursive: true, force: true });
763
+ function writeGeneratedCiWorkflow(root) {
764
+ writeFile(path.join(root, '.github/workflows/ci.yml'), `name: CI
765
+
766
+ on:
767
+ pull_request:
768
+ push:
769
+ branches:
770
+ - main
771
+
772
+ permissions:
773
+ contents: read
774
+
775
+ concurrency:
776
+ group: ci-\${{ github.workflow }}-\${{ github.ref }}
777
+ cancel-in-progress: true
778
+
779
+ jobs:
780
+ verify:
781
+ runs-on: ubuntu-latest
782
+ timeout-minutes: 45
783
+ steps:
784
+ - name: Checkout
785
+ uses: actions/checkout@v4
786
+
787
+ - name: Setup pnpm
788
+ uses: pnpm/action-setup@v4
789
+ with:
790
+ version: 10.15.1
791
+
792
+ - name: Setup Node.js
793
+ uses: actions/setup-node@v4
794
+ with:
795
+ node-version: 20
796
+ cache: pnpm
797
+
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
818
+
819
+ - name: Build workspace
820
+ run: pnpm build
821
+ `);
886
822
  }
887
823
  function writeFullstackRootFiles(root, options) {
888
824
  const appFilters = [
889
- options.frontend === 'next' ? './apps/web' : './apps/mobile',
825
+ './apps/frontend',
890
826
  './apps/api',
891
- ...(options.extension ? ['./apps/extension'] : []),
827
+ './apps/extension',
892
828
  ];
893
- const parallelFilters = appFilters.map((entry) => `--filter ${entry}`).join(' ');
894
- const buildFilters = ['./packages/db', ...appFilters].map((entry) => `--filter ${entry}`).join(' ');
829
+ const parallelFilters = appFilters.map((entry) => `--filter=${entry}`).join(' ');
895
830
  const scripts = {
896
- dev: `pnpm -r --parallel ${parallelFilters} run dev`,
831
+ dev: `turbo dev --parallel ${parallelFilters}`,
897
832
  'dev:api': 'pnpm --filter ./apps/api dev',
898
- build: `pnpm -r ${buildFilters} build`,
899
- typecheck: 'pnpm -r typecheck',
833
+ build: 'turbo build',
834
+ typecheck: 'turbo typecheck',
900
835
  'db:generate': 'pnpm --filter @anhedral/db db:generate',
901
836
  'db:migrate': 'pnpm --filter @anhedral/db db:migrate',
902
837
  };
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/*']);
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,
843
+ });
914
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
+ },
859
+ },
860
+ }, null, 2) + '\n');
915
861
  appendGitignore(root, [
916
862
  'node_modules',
863
+ '.turbo',
917
864
  'apps/*/node_modules',
918
865
  'packages/*/node_modules',
919
866
  ...SHARED_ENV_GITIGNORE_LINES,
920
867
  ...SHARED_TYPESCRIPT_GITIGNORE_LINES,
921
868
  ]);
922
- writeRootEnvExample(root, options);
923
- writeRootVercelJson(root, options);
869
+ writeRootEnvExample(root);
870
+ writeRootVercelFiles(root);
871
+ writeGeneratedCiWorkflow(root);
924
872
  }
925
873
  async function scaffoldFullstack(root, options) {
926
874
  anhedralPrint.section('Workspace root');
@@ -931,36 +879,33 @@ async function scaffoldFullstack(root, options) {
931
879
  anhedralPrint.step('Writing @anhedral/db, types, config, api-client');
932
880
  writeSharedPackages(root);
933
881
  anhedralPrint.done('Shared packages written');
934
- const frontendUrl = options.frontend === 'next' ? 'http://localhost:3000' : 'http://localhost:8081';
882
+ const frontendUrl = 'http://localhost:8081';
935
883
  await scaffoldBackend(root, {
936
884
  projectName: options.projectName,
937
885
  displayName: options.displayName,
938
886
  githubOrg: null,
939
887
  frontendUrl,
888
+ skipInstall: options.skipInstall,
889
+ });
890
+ await scaffoldFrontend(root, {
891
+ projectName: options.projectName,
892
+ displayName: options.displayName,
893
+ githubOrg: null,
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,
940
903
  });
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
904
  return [
961
- options.frontend === 'next' ? 'apps/web' : 'apps/mobile',
905
+ '.github/workflows/ci.yml',
906
+ 'apps/frontend',
962
907
  'apps/api',
963
- ...(options.extension ? ['apps/extension'] : []),
908
+ 'apps/extension',
964
909
  'packages/db',
965
910
  'packages/types',
966
911
  'packages/config',
@@ -978,7 +923,7 @@ export async function scaffoldProject(options) {
978
923
  const generatedPaths = await scaffoldFullstack(root, options);
979
924
  anhedralPrint.section('Project metadata');
980
925
  anhedralPrint.step('Writing skills guide, README, and stack.json');
981
- const skillCommands = getSkillCommands(options);
926
+ const skillCommands = getSkillCommands();
982
927
  writeSkillsGuide(root, skillCommands);
983
928
  const stack = normalizeStack(options, generatedPaths);
984
929
  writeRootDocs(root, options, stack);
@@ -993,18 +938,11 @@ export async function scaffoldProject(options) {
993
938
  anhedralPrint.info('Next commands:');
994
939
  console.log(' pnpm install');
995
940
  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
- }
941
+ console.log(' cp apps/extension/.env.example apps/extension/.env');
1002
942
  console.log(' pnpm db:generate && pnpm db:migrate');
1003
943
  console.log(' pnpm dev:api');
1004
- console.log(options.frontend === 'next' ? ' pnpm dev:web' : ' pnpm dev:mobile');
1005
- if (options.extension) {
1006
- console.log(' pnpm dev:extension');
1007
- }
944
+ console.log(' pnpm dev:frontend');
945
+ console.log(' pnpm dev:extension');
1008
946
  console.log('');
1009
947
  }
1010
948
  finally {