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/README.md +145 -35
- package/dist/cli.d.ts +10 -0
- package/dist/cli.js +67 -0
- package/dist/commands.d.ts +3 -8
- package/dist/commands.js +14 -64
- package/dist/dependencies.d.ts +35 -0
- package/dist/dependencies.js +175 -0
- package/dist/index.js +8 -134
- package/dist/print.d.ts +8 -0
- package/dist/print.js +33 -0
- package/dist/scaffold.d.ts +6 -7
- package/dist/scaffold.js +746 -419
- package/dist/templates/backend.d.ts +1 -1
- package/dist/templates/backend.js +117 -195
- package/dist/templates/extension.d.ts +1 -1
- package/dist/templates/extension.js +266 -68
- package/dist/templates/frontend.d.ts +1 -1
- package/dist/templates/frontend.js +86 -173
- package/dist/toolchain.d.ts +0 -3
- package/dist/toolchain.js +5 -16
- package/dist/util.d.ts +1 -0
- package/dist/util.js +65 -8
- package/package.json +12 -13
- package/dist/templates/default-web.d.ts +0 -2
- package/dist/templates/default-web.js +0 -1936
package/dist/scaffold.js
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { readdirSync } 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, 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 {
|
|
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:
|
|
54
|
+
mode: 'fullstack',
|
|
55
55
|
project_name: options.projectName,
|
|
56
56
|
display_name: options.displayName,
|
|
57
|
-
frontend,
|
|
58
|
-
|
|
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(
|
|
64
|
+
skills: getSkillCommands(),
|
|
64
65
|
outputs: {
|
|
65
|
-
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:
|
|
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
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
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
|
-
|
|
167
|
-
R2_ACCOUNT_ID=
|
|
168
|
-
R2_ACCESS_KEY_ID=
|
|
169
|
-
R2_SECRET_ACCESS_KEY=
|
|
170
|
-
R2_BUCKET=
|
|
126
|
+
## Stack
|
|
171
127
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
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
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
R2_SECRET_ACCESS_KEY=
|
|
207
|
-
R2_BUCKET=
|
|
143
|
+
${generatedPaths}
|
|
144
|
+
|
|
145
|
+
## First Run
|
|
208
146
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
|
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
|
-
});
|
|
191
|
+
function writeStackFile(root, stack) {
|
|
192
|
+
writeFile(path.join(root, 'stack.json'), JSON.stringify(stack, null, 2) + '\n');
|
|
225
193
|
}
|
|
226
|
-
function
|
|
227
|
-
|
|
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: '
|
|
231
|
-
moduleResolution: '
|
|
219
|
+
module: 'ESNext',
|
|
220
|
+
moduleResolution: 'Bundler',
|
|
232
221
|
strict: true,
|
|
233
|
-
esModuleInterop: true,
|
|
234
222
|
skipLibCheck: true,
|
|
235
|
-
|
|
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
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
|
241
|
+
import * as schema from './schema';
|
|
264
242
|
|
|
265
|
-
|
|
266
|
-
|
|
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
|
|
270
|
-
|
|
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(
|
|
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
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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
|
-
|
|
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(
|
|
293
|
-
import { db } from '../
|
|
294
|
-
import { users } from '../schema
|
|
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
|
-
|
|
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.
|
|
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(
|
|
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(
|
|
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
|
|
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
|
|
314
|
-
const
|
|
523
|
+
const client = neon(process.env.DATABASE_URL);
|
|
524
|
+
const database = drizzle(client);
|
|
315
525
|
|
|
316
|
-
|
|
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(
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
export
|
|
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
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
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
|
|
357
|
-
|
|
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
|
-
|
|
360
|
-
|
|
361
|
-
|
|
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
|
-
|
|
637
|
+
if (response.status === 204) return {} as T;
|
|
638
|
+
return response.json() as Promise<T>;
|
|
639
|
+
}
|
|
365
640
|
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
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
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
return;
|
|
377
|
-
}
|
|
645
|
+
getPricing() {
|
|
646
|
+
return this.request<PricingResponse>('/subscriptions/pricing');
|
|
647
|
+
}
|
|
378
648
|
|
|
379
|
-
|
|
380
|
-
|
|
649
|
+
getSubscriptionPricing() {
|
|
650
|
+
return this.getPricing();
|
|
651
|
+
}
|
|
381
652
|
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
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
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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
|
-
|
|
396
|
-
|
|
397
|
-
|
|
666
|
+
signOut() {
|
|
667
|
+
return this.request<{ success: boolean }>('/auth/signout', {
|
|
668
|
+
method: 'POST',
|
|
398
669
|
});
|
|
670
|
+
}
|
|
399
671
|
|
|
400
|
-
|
|
401
|
-
|
|
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
|
-
|
|
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
|
-
|
|
755
|
+
${paymentsEnv}`);
|
|
408
756
|
}
|
|
409
|
-
|
|
410
|
-
writeFile(path.join(root, '
|
|
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
|
-
});
|
|
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
|
|
427
|
-
|
|
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
|
-
|
|
766
|
+
on:
|
|
767
|
+
pull_request:
|
|
768
|
+
push:
|
|
769
|
+
branches:
|
|
770
|
+
- main
|
|
451
771
|
|
|
452
|
-
|
|
772
|
+
permissions:
|
|
773
|
+
contents: read
|
|
453
774
|
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
-
|
|
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
|
-
|
|
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
|
-
|
|
787
|
+
- name: Setup pnpm
|
|
788
|
+
uses: pnpm/action-setup@v4
|
|
789
|
+
with:
|
|
790
|
+
version: 10.15.1
|
|
465
791
|
|
|
466
|
-
|
|
792
|
+
- name: Setup Node.js
|
|
793
|
+
uses: actions/setup-node@v4
|
|
794
|
+
with:
|
|
795
|
+
node-version: 20
|
|
796
|
+
cache: pnpm
|
|
467
797
|
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
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
|
-
|
|
819
|
+
- name: Build workspace
|
|
820
|
+
run: pnpm build
|
|
474
821
|
`);
|
|
475
822
|
}
|
|
476
|
-
function
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
'
|
|
487
|
-
'
|
|
488
|
-
'
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
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
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
}
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
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
|
-
'
|
|
543
|
-
'
|
|
863
|
+
'.turbo',
|
|
864
|
+
'apps/*/node_modules',
|
|
865
|
+
'packages/*/node_modules',
|
|
544
866
|
...SHARED_ENV_GITIGNORE_LINES,
|
|
867
|
+
...SHARED_TYPESCRIPT_GITIGNORE_LINES,
|
|
545
868
|
]);
|
|
546
|
-
|
|
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
|
|
887
|
+
frontendUrl,
|
|
888
|
+
skipInstall: options.skipInstall,
|
|
552
889
|
});
|
|
553
|
-
|
|
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
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
await
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
'
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
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
|
-
|
|
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);
|
|
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
|
-
|
|
614
|
-
console.log(
|
|
615
|
-
|
|
931
|
+
anhedralPrint.done('Project metadata written');
|
|
932
|
+
console.log('');
|
|
933
|
+
anhedralPrint.banner(`${options.displayName} is ready`);
|
|
616
934
|
for (const generatedPath of generatedPaths) {
|
|
617
|
-
|
|
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) {
|