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/README.md +82 -20
- package/dist/branding.d.ts +2 -0
- package/dist/branding.js +9 -1
- package/dist/cli.d.ts +12 -0
- package/dist/cli.js +86 -0
- package/dist/commands.d.ts +2 -3
- package/dist/commands.js +9 -33
- package/dist/index.js +8 -134
- package/dist/print.d.ts +8 -0
- package/dist/print.js +33 -0
- package/dist/scaffold.d.ts +3 -3
- package/dist/scaffold.js +770 -381
- package/dist/templates/backend.js +76 -190
- package/dist/templates/default-web.js +819 -237
- package/dist/templates/extension.js +200 -18
- package/dist/templates/frontend.js +12 -11
- package/dist/toolchain.d.ts +0 -3
- package/dist/toolchain.js +3 -6
- package/dist/util.d.ts +1 -1
- package/dist/util.js +100 -43
- package/favicon.ico +0 -0
- package/package.json +16 -10
package/dist/scaffold.js
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { readFileSync, readdirSync, rmSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { env } from 'node:process';
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
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.
|
|
53
|
+
const frontend = options.frontend === 'next'
|
|
45
54
|
? 'nextjs_shadcn'
|
|
46
|
-
:
|
|
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:
|
|
58
|
+
mode: 'fullstack',
|
|
55
59
|
project_name: options.projectName,
|
|
56
60
|
display_name: options.displayName,
|
|
57
61
|
frontend,
|
|
58
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
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
|
-
|
|
167
|
-
R2_ACCOUNT_ID=
|
|
168
|
-
R2_ACCESS_KEY_ID=
|
|
169
|
-
R2_SECRET_ACCESS_KEY=
|
|
170
|
-
R2_BUCKET=
|
|
186
|
+
## Stack
|
|
171
187
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
204
|
-
R2_ACCOUNT_ID=
|
|
205
|
-
R2_ACCESS_KEY_ID=
|
|
206
|
-
R2_SECRET_ACCESS_KEY=
|
|
207
|
-
R2_BUCKET=
|
|
203
|
+
## First Run
|
|
208
204
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
|
216
|
-
|
|
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
|
|
227
|
-
|
|
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: '
|
|
231
|
-
moduleResolution: '
|
|
276
|
+
module: 'ESNext',
|
|
277
|
+
moduleResolution: 'Bundler',
|
|
232
278
|
strict: true,
|
|
233
|
-
esModuleInterop: true,
|
|
234
279
|
skipLibCheck: true,
|
|
235
|
-
|
|
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
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
|
298
|
+
import * as schema from './schema';
|
|
264
299
|
|
|
265
|
-
|
|
266
|
-
|
|
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
|
|
270
|
-
|
|
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(
|
|
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
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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
|
-
|
|
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
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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
|
-
|
|
310
|
-
|
|
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
|
|
314
|
-
|
|
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
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
})
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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(
|
|
352
|
-
import
|
|
353
|
-
import {
|
|
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
|
-
|
|
357
|
-
|
|
496
|
+
type SyncUserInput = {
|
|
497
|
+
clerkUserId: string;
|
|
498
|
+
email: string;
|
|
499
|
+
displayName: string | null;
|
|
500
|
+
};
|
|
358
501
|
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
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
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
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
|
-
|
|
374
|
-
|
|
375
|
-
for (const entry of value) headers.append(key, entry);
|
|
376
|
-
return;
|
|
377
|
-
}
|
|
526
|
+
return findUserByClerkId(input.clerkUserId);
|
|
527
|
+
}
|
|
378
528
|
|
|
379
|
-
|
|
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
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
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
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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
|
-
|
|
396
|
-
|
|
397
|
-
|
|
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(
|
|
411
|
-
|
|
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
|
-
|
|
592
|
+
const client = neon(process.env.DATABASE_URL);
|
|
593
|
+
const database = drizzle(client);
|
|
451
594
|
|
|
452
|
-
|
|
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
|
-
|
|
455
|
-
|
|
456
|
-
|
|
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
|
-
|
|
685
|
+
return response.json() as Promise<T>;
|
|
686
|
+
}
|
|
463
687
|
|
|
464
|
-
|
|
688
|
+
getMe() {
|
|
689
|
+
return this.request<AuthMeResponse>('/auth/me');
|
|
690
|
+
}
|
|
465
691
|
|
|
466
|
-
|
|
692
|
+
getPricing() {
|
|
693
|
+
return this.request<PricingResponse>('/subscriptions/pricing');
|
|
694
|
+
}
|
|
467
695
|
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
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
|
-
|
|
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
|
-
|
|
477
|
-
|
|
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
|
|
480
|
-
const
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
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
|
-
|
|
513
|
-
|
|
514
|
-
|
|
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(
|
|
864
|
+
patchPackageJson(appRoot, {
|
|
522
865
|
packageManager: PNPM_PACKAGE_MANAGER,
|
|
523
866
|
scripts: {
|
|
524
867
|
typecheck: 'tsc --noEmit',
|
|
525
|
-
'db:generate': '
|
|
526
|
-
'db:migrate': 'pnpm
|
|
868
|
+
'db:generate': 'pnpm --filter @anhedral/db db:generate',
|
|
869
|
+
'db:migrate': 'pnpm --filter @anhedral/db db:migrate',
|
|
527
870
|
},
|
|
528
871
|
});
|
|
529
|
-
patchGeneratedPackageName(
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
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
|
-
'
|
|
543
|
-
'
|
|
917
|
+
'apps/*/node_modules',
|
|
918
|
+
'packages/*/node_modules',
|
|
544
919
|
...SHARED_ENV_GITIGNORE_LINES,
|
|
920
|
+
...SHARED_TYPESCRIPT_GITIGNORE_LINES,
|
|
545
921
|
]);
|
|
546
|
-
|
|
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
|
|
939
|
+
frontendUrl,
|
|
552
940
|
});
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
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
|
-
|
|
601
|
-
const generatedPaths =
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
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
|
-
|
|
614
|
-
console.log(
|
|
615
|
-
|
|
986
|
+
anhedralPrint.done('Project metadata written');
|
|
987
|
+
console.log('');
|
|
988
|
+
anhedralPrint.banner(`${options.displayName} is ready`);
|
|
616
989
|
for (const generatedPath of generatedPaths) {
|
|
617
|
-
|
|
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
|
}
|