anhedral 0.1.3 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +126 -20
- package/dist/cli.d.ts +3 -5
- package/dist/cli.js +8 -27
- package/dist/commands.d.ts +3 -7
- package/dist/commands.js +12 -38
- package/dist/dependencies.d.ts +35 -0
- package/dist/dependencies.js +175 -0
- package/dist/scaffold.d.ts +6 -7
- package/dist/scaffold.js +223 -285
- package/dist/templates/backend.d.ts +1 -1
- package/dist/templates/backend.js +46 -10
- package/dist/templates/extension.d.ts +1 -1
- package/dist/templates/extension.js +74 -58
- package/dist/templates/frontend.d.ts +1 -1
- package/dist/templates/frontend.js +83 -171
- package/dist/toolchain.js +5 -7
- package/dist/util.d.ts +1 -0
- package/dist/util.js +25 -1
- package/package.json +5 -4
- package/dist/templates/default-web.d.ts +0 -2
- package/dist/templates/default-web.js +0 -1950
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
import type { ProjectOptions } from '../scaffold.js';
|
|
2
|
-
export declare function scaffoldBackend(root: string, { projectName, displayName, frontendUrl }: ProjectOptions): Promise<void>;
|
|
2
|
+
export declare function scaffoldBackend(root: string, { projectName, displayName, frontendUrl, skipInstall }: ProjectOptions): Promise<void>;
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
|
-
import { writeFile,
|
|
2
|
+
import { writeFile, execCommand } from '../util.js';
|
|
3
3
|
import { anhedralPrint } from '../print.js';
|
|
4
4
|
import { getBackendInstallCommands } from '../commands.js';
|
|
5
|
-
|
|
5
|
+
import { BACKEND_DEPENDENCIES } from '../dependencies.js';
|
|
6
|
+
export async function scaffoldBackend(root, { projectName, displayName, frontendUrl, skipInstall }) {
|
|
6
7
|
const dir = path.join(root, 'apps/api');
|
|
7
8
|
anhedralPrint.section('Backend (Fastify)');
|
|
8
9
|
anhedralPrint.step('Writing backend source files');
|
|
@@ -24,15 +25,17 @@ export async function scaffoldBackend(root, { projectName, displayName, frontend
|
|
|
24
25
|
writeRoutes(dir, displayName);
|
|
25
26
|
writeAppAndIndex(dir, displayName);
|
|
26
27
|
writeVercelEntry(dir);
|
|
28
|
+
writeVercelConfig(dir);
|
|
27
29
|
writeTestFiles(dir);
|
|
28
30
|
anhedralPrint.done('Backend source files written');
|
|
29
|
-
if (
|
|
30
|
-
anhedralPrint.info('Skipping backend dependency install (
|
|
31
|
+
if (skipInstall) {
|
|
32
|
+
anhedralPrint.info('Skipping backend dependency install (--skip-install)');
|
|
33
|
+
anhedralPrint.info('Run after init: pnpm install');
|
|
31
34
|
return;
|
|
32
35
|
}
|
|
33
36
|
anhedralPrint.step('Installing backend dependencies');
|
|
34
37
|
for (const command of getBackendInstallCommands()) {
|
|
35
|
-
|
|
38
|
+
execCommand(command.command, command.args, dir);
|
|
36
39
|
}
|
|
37
40
|
anhedralPrint.done('Backend dependencies installed');
|
|
38
41
|
}
|
|
@@ -59,9 +62,8 @@ function writePackageJson(dir, projectName) {
|
|
|
59
62
|
},
|
|
60
63
|
keywords: [],
|
|
61
64
|
license: 'MIT',
|
|
62
|
-
dependencies:
|
|
63
|
-
|
|
64
|
-
},
|
|
65
|
+
dependencies: BACKEND_DEPENDENCIES.dependencies,
|
|
66
|
+
devDependencies: BACKEND_DEPENDENCIES.devDependencies,
|
|
65
67
|
}, null, 2) + '\n');
|
|
66
68
|
}
|
|
67
69
|
function writeTsConfig(dir) {
|
|
@@ -1143,8 +1145,8 @@ const configPlugin: FastifyPluginAsync = async (fastify: FastifyInstance) => {
|
|
|
1143
1145
|
R2_ACCESS_KEY_ID: { type: 'string', nullable: true },
|
|
1144
1146
|
R2_SECRET_ACCESS_KEY: { type: 'string', nullable: true },
|
|
1145
1147
|
R2_BUCKET: { type: 'string', nullable: true },
|
|
1146
|
-
RC_SECRET_API_KEY: { type: 'string', default: '' },
|
|
1147
|
-
RC_WEBHOOK_SECRET: { type: 'string', default: '' },
|
|
1148
|
+
RC_SECRET_API_KEY: { type: 'string', nullable: true, default: '' },
|
|
1149
|
+
RC_WEBHOOK_SECRET: { type: 'string', nullable: true, default: '' },
|
|
1148
1150
|
RC_ENTITLEMENT_ID: { type: 'string', default: 'pro' },
|
|
1149
1151
|
RC_OFFERING_ID: { type: 'string', default: 'default' },
|
|
1150
1152
|
},
|
|
@@ -1155,6 +1157,23 @@ const configPlugin: FastifyPluginAsync = async (fastify: FastifyInstance) => {
|
|
|
1155
1157
|
dotenv: !process.env.VERCEL,
|
|
1156
1158
|
confKey: 'env',
|
|
1157
1159
|
} as unknown as Record<string, unknown>);
|
|
1160
|
+
|
|
1161
|
+
if (fastify.env.NODE_ENV === 'production') {
|
|
1162
|
+
if (fastify.env.ANHEDRAL_DEMO === 'true') {
|
|
1163
|
+
throw new Error('ANHEDRAL_DEMO must be false in production');
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
const required = [
|
|
1167
|
+
'CLERK_PUBLISHABLE_KEY',
|
|
1168
|
+
'CLERK_SECRET_KEY',
|
|
1169
|
+
'RC_SECRET_API_KEY',
|
|
1170
|
+
'RC_WEBHOOK_SECRET',
|
|
1171
|
+
] as const;
|
|
1172
|
+
const missing = required.filter((key) => !fastify.env[key]);
|
|
1173
|
+
if (missing.length > 0) {
|
|
1174
|
+
throw new Error(\`Missing production environment variables: \${missing.join(', ')}\`);
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1158
1177
|
};
|
|
1159
1178
|
|
|
1160
1179
|
export default fp(configPlugin, { name: 'env-config', fastify: '5.x' });
|
|
@@ -2189,6 +2208,23 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
|
|
|
2189
2208
|
}
|
|
2190
2209
|
`);
|
|
2191
2210
|
}
|
|
2211
|
+
function writeVercelConfig(dir) {
|
|
2212
|
+
writeFile(path.join(dir, 'vercel.json'), JSON.stringify({
|
|
2213
|
+
version: 2,
|
|
2214
|
+
builds: [
|
|
2215
|
+
{
|
|
2216
|
+
src: 'api/index.ts',
|
|
2217
|
+
use: '@vercel/node',
|
|
2218
|
+
},
|
|
2219
|
+
],
|
|
2220
|
+
routes: [
|
|
2221
|
+
{
|
|
2222
|
+
src: '/(.*)',
|
|
2223
|
+
dest: 'api/index.ts',
|
|
2224
|
+
},
|
|
2225
|
+
],
|
|
2226
|
+
}, null, 2) + '\n');
|
|
2227
|
+
}
|
|
2192
2228
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
2193
2229
|
// Tests
|
|
2194
2230
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
import type { ProjectOptions } from '../scaffold.js';
|
|
2
|
-
export declare function scaffoldExtension(root: string, { projectName, displayName }: ProjectOptions): Promise<void>;
|
|
2
|
+
export declare function scaffoldExtension(root: string, { projectName, displayName, skipInstall }: ProjectOptions): Promise<void>;
|
|
@@ -2,8 +2,9 @@ import { rmSync } from 'node:fs';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { writeFile, exec } from '../util.js';
|
|
4
4
|
import { anhedralPrint } from '../print.js';
|
|
5
|
+
import { EXTENSION_DEPENDENCIES } from '../dependencies.js';
|
|
5
6
|
import { resolveToolchainChannel, resolveToolchain, toolPackageRef } from '../toolchain.js';
|
|
6
|
-
export async function scaffoldExtension(root, { projectName, displayName }) {
|
|
7
|
+
export async function scaffoldExtension(root, { projectName, displayName, skipInstall }) {
|
|
7
8
|
const appsRoot = path.join(root, 'apps');
|
|
8
9
|
const dir = path.join(appsRoot, 'extension');
|
|
9
10
|
const toolchain = resolveToolchain(resolveToolchainChannel(process.env.ANHEDRAL_TOOLCHAIN));
|
|
@@ -14,9 +15,14 @@ export async function scaffoldExtension(root, { projectName, displayName }) {
|
|
|
14
15
|
writePackageJson(dir, projectName);
|
|
15
16
|
anhedralPrint.done('WXT extension scaffolded');
|
|
16
17
|
anhedralPrint.step('Installing Clerk + React + Tailwind dependencies');
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
if (skipInstall) {
|
|
19
|
+
anhedralPrint.info('Skipping extension dependency install (--skip-install)');
|
|
20
|
+
anhedralPrint.info('Run after init: pnpm install');
|
|
21
|
+
}
|
|
22
|
+
else {
|
|
23
|
+
exec('pnpm install --no-frozen-lockfile', dir);
|
|
24
|
+
}
|
|
25
|
+
anhedralPrint.done(skipInstall ? 'Extension dependency manifests written' : 'Extension dependencies installed');
|
|
20
26
|
cleanWxtStarterFiles(dir);
|
|
21
27
|
writeWxtConfig(dir, displayName);
|
|
22
28
|
writeTsConfig(dir);
|
|
@@ -27,6 +33,7 @@ export async function scaffoldExtension(root, { projectName, displayName }) {
|
|
|
27
33
|
writeShadcnConfig(dir);
|
|
28
34
|
writeReadme(dir, displayName);
|
|
29
35
|
writeCnUtil(dir);
|
|
36
|
+
writeButtonComponent(dir);
|
|
30
37
|
writeAuthContext(dir);
|
|
31
38
|
writeApiClient(dir);
|
|
32
39
|
writeBackground(dir);
|
|
@@ -35,10 +42,7 @@ export async function scaffoldExtension(root, { projectName, displayName }) {
|
|
|
35
42
|
writeSidepanelHtml(dir, displayName);
|
|
36
43
|
writeSidepanelApp(dir);
|
|
37
44
|
writeStyles(dir);
|
|
38
|
-
anhedralPrint.
|
|
39
|
-
exec(`pnpm dlx ${toolPackageRef('shadcn', toolchain.shadcn)} add button`, dir);
|
|
40
|
-
writeStyles(dir);
|
|
41
|
-
anhedralPrint.done('shadcn button installed');
|
|
45
|
+
anhedralPrint.done('Extension source files written');
|
|
42
46
|
}
|
|
43
47
|
function cleanWxtStarterFiles(dir) {
|
|
44
48
|
for (const relativePath of [
|
|
@@ -65,6 +69,8 @@ function writePackageJson(dir, projectName) {
|
|
|
65
69
|
'zip:firefox': 'wxt zip -b firefox',
|
|
66
70
|
typecheck: 'tsc --noEmit',
|
|
67
71
|
},
|
|
72
|
+
dependencies: EXTENSION_DEPENDENCIES.dependencies,
|
|
73
|
+
devDependencies: EXTENSION_DEPENDENCIES.devDependencies,
|
|
68
74
|
}, null, 2) + '\n');
|
|
69
75
|
}
|
|
70
76
|
function writeWxtConfig(dir, displayName) {
|
|
@@ -80,11 +86,15 @@ export default defineConfig({
|
|
|
80
86
|
description: '${displayName} Chrome Extension',
|
|
81
87
|
version: '0.1.0',
|
|
82
88
|
...(crxPublicKey ? { key: crxPublicKey } : {}),
|
|
83
|
-
|
|
89
|
+
minimum_chrome_version: '114',
|
|
90
|
+
permissions: ['activeTab', 'cookies', 'storage', 'sidePanel'],
|
|
84
91
|
host_permissions: [],
|
|
85
92
|
action: {
|
|
86
93
|
default_title: 'Open ${displayName}',
|
|
87
94
|
},
|
|
95
|
+
side_panel: {
|
|
96
|
+
default_path: 'sidepanel.html',
|
|
97
|
+
},
|
|
88
98
|
};
|
|
89
99
|
},
|
|
90
100
|
modules: ['@wxt-dev/module-react'],
|
|
@@ -108,7 +118,6 @@ function writeTsConfig(dir) {
|
|
|
108
118
|
jsx: 'react-jsx',
|
|
109
119
|
lib: ['DOM', 'DOM.Iterable', 'ESNext'],
|
|
110
120
|
types: ['wxt/browser', '@wxt-dev/module-react', 'chrome'],
|
|
111
|
-
baseUrl: '.',
|
|
112
121
|
paths: { '@/*': ['./src/*'] },
|
|
113
122
|
},
|
|
114
123
|
include: ['**/*.ts', '**/*.tsx', '.wxt/wxt.d.ts'],
|
|
@@ -190,6 +199,41 @@ export function cn(...inputs: ClassValue[]) {
|
|
|
190
199
|
}
|
|
191
200
|
`);
|
|
192
201
|
}
|
|
202
|
+
function writeButtonComponent(dir) {
|
|
203
|
+
writeFile(path.join(dir, 'src/components/ui/button.tsx'), `import * as React from 'react';
|
|
204
|
+
import { cn } from '@/lib/utils';
|
|
205
|
+
|
|
206
|
+
type ButtonVariant = 'default' | 'outline';
|
|
207
|
+
|
|
208
|
+
export type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
|
|
209
|
+
variant?: ButtonVariant;
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
const variantClasses: Record<ButtonVariant, string> = {
|
|
213
|
+
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
|
214
|
+
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
|
218
|
+
({ className, variant = 'default', type = 'button', ...props }, ref) => {
|
|
219
|
+
return (
|
|
220
|
+
<button
|
|
221
|
+
ref={ref}
|
|
222
|
+
type={type}
|
|
223
|
+
className={cn(
|
|
224
|
+
'inline-flex h-9 items-center justify-center whitespace-nowrap rounded-md px-4 py-2 text-sm font-medium transition-colors disabled:pointer-events-none disabled:opacity-50',
|
|
225
|
+
variantClasses[variant],
|
|
226
|
+
className,
|
|
227
|
+
)}
|
|
228
|
+
{...props}
|
|
229
|
+
/>
|
|
230
|
+
);
|
|
231
|
+
},
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
Button.displayName = 'Button';
|
|
235
|
+
`);
|
|
236
|
+
}
|
|
193
237
|
function writeTailwindConfig(dir) {
|
|
194
238
|
writeFile(path.join(dir, 'tailwind.config.cjs'), `/** @type {import('tailwindcss').Config} */
|
|
195
239
|
module.exports = {
|
|
@@ -261,7 +305,7 @@ Set \`VITE_CLERK_PUBLISHABLE_KEY\` and \`VITE_API_URL\` in \`.env\` before using
|
|
|
261
305
|
|
|
262
306
|
Run \`pnpm build\`, then load \`.output/chrome-mv3\` as an unpacked extension from \`chrome://extensions\`.
|
|
263
307
|
|
|
264
|
-
The browser action opens the
|
|
308
|
+
The extension uses Chrome's Side Panel API. The browser action opens \`sidepanel.html\`, \`wxt.config.ts\` declares the \`sidePanel\` permission and Chrome 114+ minimum version, the background script initializes Clerk, and the content script is ready for page-to-extension messages.
|
|
265
309
|
`);
|
|
266
310
|
}
|
|
267
311
|
function writeAuthContext(dir) {
|
|
@@ -405,58 +449,25 @@ export { WEBSITE_URL };
|
|
|
405
449
|
`);
|
|
406
450
|
}
|
|
407
451
|
function writeApiClient(dir) {
|
|
408
|
-
writeFile(path.join(dir, 'src/lib/api.ts'), `
|
|
409
|
-
|
|
410
|
-
export type SubscriptionEntitlements = {
|
|
411
|
-
pro: boolean;
|
|
412
|
-
inTrial: boolean;
|
|
413
|
-
trialEndsAt?: string;
|
|
414
|
-
expiresAt?: string;
|
|
415
|
-
periodStart?: string;
|
|
416
|
-
periodEnd?: string;
|
|
417
|
-
method?: 'trialing' | 'redeemed' | 'paid' | null;
|
|
418
|
-
managementUrl?: string;
|
|
419
|
-
cancelAtPeriodEnd?: boolean;
|
|
420
|
-
};
|
|
452
|
+
writeFile(path.join(dir, 'src/lib/api.ts'), `import { ApiClient } from '@anhedral/api-client';
|
|
421
453
|
|
|
422
454
|
export class APIClient {
|
|
423
455
|
constructor(private getToken: () => Promise<string | null>) {}
|
|
424
456
|
|
|
425
|
-
private
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
'Content-Type': 'application/json',
|
|
432
|
-
'X-Client-Type': 'chrome-extension',
|
|
433
|
-
'X-Platform': 'chrome',
|
|
434
|
-
...(options.headers as Record<string, string>),
|
|
435
|
-
};
|
|
436
|
-
|
|
437
|
-
const response = await fetch(\`\${API_BASE_URL}\${endpoint}\`, { ...options, headers });
|
|
438
|
-
|
|
439
|
-
if (!response.ok) {
|
|
440
|
-
let error: { message?: string } = {};
|
|
441
|
-
try { error = await response.json(); } catch {}
|
|
442
|
-
throw new Error(error.message || \`Request failed (\${response.status})\`);
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
if (response.status === 204) return {} as T;
|
|
446
|
-
return response.json();
|
|
457
|
+
private client() {
|
|
458
|
+
return new ApiClient({
|
|
459
|
+
baseUrl: import.meta.env.VITE_API_URL || 'http://localhost:8787',
|
|
460
|
+
getToken: this.getToken,
|
|
461
|
+
platform: 'extension',
|
|
462
|
+
});
|
|
447
463
|
}
|
|
448
464
|
|
|
449
|
-
|
|
450
|
-
return this.
|
|
451
|
-
user: { id: string; email: string; subscriptionTier: string; subscriptionStatus: string };
|
|
452
|
-
}>('/auth/me');
|
|
465
|
+
getMe() {
|
|
466
|
+
return this.client().getMe();
|
|
453
467
|
}
|
|
454
468
|
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
? '/subscriptions/entitlements/me?refresh=true'
|
|
458
|
-
: '/subscriptions/entitlements/me';
|
|
459
|
-
return this.request<SubscriptionEntitlements>(path);
|
|
469
|
+
getSubscriptionEntitlements(options?: { refresh?: boolean }) {
|
|
470
|
+
return this.client().getSubscriptionEntitlements(options);
|
|
460
471
|
}
|
|
461
472
|
}
|
|
462
473
|
`);
|
|
@@ -464,15 +475,20 @@ export class APIClient {
|
|
|
464
475
|
function writeBackground(dir) {
|
|
465
476
|
writeFile(path.join(dir, 'src/entrypoints/background.ts'), `import { createClerkClient } from '@clerk/chrome-extension/background';
|
|
466
477
|
|
|
478
|
+
type ChromeWithSidePanel = typeof chrome & {
|
|
479
|
+
sidePanel: {
|
|
480
|
+
setPanelBehavior: (behavior: { openPanelOnActionClick: boolean }) => Promise<void>;
|
|
481
|
+
};
|
|
482
|
+
};
|
|
483
|
+
|
|
467
484
|
export default defineBackground(() => {
|
|
468
485
|
// Initialize Clerk in the background script for cookie-based auth
|
|
469
486
|
void createClerkClient({
|
|
470
487
|
publishableKey: import.meta.env.VITE_CLERK_PUBLISHABLE_KEY || '',
|
|
471
488
|
}).catch(() => {});
|
|
472
489
|
|
|
473
|
-
// Open the side panel when the extension icon is clicked
|
|
474
|
-
|
|
475
|
-
chrome.sidePanel
|
|
490
|
+
// Open the side panel when the extension icon is clicked.
|
|
491
|
+
(chrome as ChromeWithSidePanel).sidePanel
|
|
476
492
|
.setPanelBehavior({ openPanelOnActionClick: true })
|
|
477
493
|
.catch(() => {});
|
|
478
494
|
});
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
import type { ProjectOptions } from '../scaffold.js';
|
|
2
|
-
export declare function scaffoldFrontend(root: string, { projectName, displayName }: ProjectOptions): Promise<void>;
|
|
2
|
+
export declare function scaffoldFrontend(root: string, { projectName, displayName, skipInstall }: ProjectOptions): Promise<void>;
|
|
@@ -3,11 +3,12 @@ import path from 'node:path';
|
|
|
3
3
|
import { writeAnhedralLogo } from '../branding.js';
|
|
4
4
|
import { anhedralPrint } from '../print.js';
|
|
5
5
|
import { appendGitignore, writeFile, exec, execExpect, liftNestedProject } from '../util.js';
|
|
6
|
+
import { FRONTEND_ADDON_DEPENDENCIES, withVersions } from '../dependencies.js';
|
|
6
7
|
import { resolveToolchainChannel, resolveToolchain, toolPackageRef } from '../toolchain.js';
|
|
7
|
-
export async function scaffoldFrontend(root, { projectName, displayName }) {
|
|
8
|
-
const dir = path.join(root, 'apps/
|
|
8
|
+
export async function scaffoldFrontend(root, { projectName, displayName, skipInstall }) {
|
|
9
|
+
const dir = path.join(root, 'apps/frontend');
|
|
9
10
|
const toolchain = resolveToolchain(resolveToolchainChannel(process.env.ANHEDRAL_TOOLCHAIN));
|
|
10
|
-
anhedralPrint.section('
|
|
11
|
+
anhedralPrint.section('Frontend (Expo)');
|
|
11
12
|
anhedralPrint.step('Scaffolding Expo app with react-native-reusables');
|
|
12
13
|
fs.mkdirSync(dir, { recursive: true });
|
|
13
14
|
await execExpect(`pnpm dlx ${toolPackageRef('@react-native-reusables/cli', toolchain.reactNativeReusables)} init -t clerk-auth`, dir, [
|
|
@@ -17,11 +18,15 @@ export async function scaffoldFrontend(root, { projectName, displayName }) {
|
|
|
17
18
|
]);
|
|
18
19
|
liftNestedProject(dir, projectName);
|
|
19
20
|
anhedralPrint.done('Expo app scaffolded');
|
|
20
|
-
anhedralPrint.step('Installing
|
|
21
|
-
exec('pnpm install --no-frozen-lockfile', dir);
|
|
21
|
+
anhedralPrint.step('Installing frontend dependencies');
|
|
22
22
|
patchFrontendPackageJson(dir);
|
|
23
|
+
patchExpoAppConfig(dir);
|
|
24
|
+
if (!skipInstall) {
|
|
25
|
+
exec('pnpm install --no-frozen-lockfile', dir);
|
|
26
|
+
exec('pnpm exec expo install --fix --pnpm', dir);
|
|
27
|
+
}
|
|
23
28
|
appendGitignore(dir, ['.env', '.env.*', '!.env.example']);
|
|
24
|
-
anhedralPrint.done('
|
|
29
|
+
anhedralPrint.done(skipInstall ? 'Frontend dependency manifests written' : 'Frontend dependencies installed');
|
|
25
30
|
writeApiClient(dir);
|
|
26
31
|
writeConfigFile(dir);
|
|
27
32
|
writeApiHook(dir);
|
|
@@ -30,190 +35,81 @@ export async function scaffoldFrontend(root, { projectName, displayName }) {
|
|
|
30
35
|
writeAppShellFiles(dir, displayName);
|
|
31
36
|
writeEnvExample(dir);
|
|
32
37
|
writeEnvFile(dir);
|
|
38
|
+
writeVercelConfig(dir);
|
|
33
39
|
writeAnhedralLogo(dir);
|
|
34
40
|
writeSubscriptionProvider(dir);
|
|
35
41
|
writeUseSubscription(dir);
|
|
36
|
-
anhedralPrint.step('Installing
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
42
|
+
anhedralPrint.step('Installing Expo native packages');
|
|
43
|
+
if (skipInstall) {
|
|
44
|
+
anhedralPrint.info('Skipping Expo native package install (--skip-install)');
|
|
45
|
+
anhedralPrint.info('Run after init: pnpm --filter ./apps/frontend exec expo install --pnpm expo-image-picker');
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
exec('pnpm exec expo install --pnpm expo-image-picker', dir);
|
|
49
|
+
exec('pnpm exec expo install --fix --pnpm', dir);
|
|
50
|
+
}
|
|
51
|
+
anhedralPrint.done(skipInstall ? 'Expo native package install skipped' : 'Expo native packages installed');
|
|
52
|
+
anhedralPrint.step('Installing RevenueCat SDKs');
|
|
53
|
+
const revenueCatDependencies = withVersions({
|
|
54
|
+
'react-native-purchases': FRONTEND_ADDON_DEPENDENCIES['react-native-purchases'],
|
|
55
|
+
'react-native-purchases-ui': FRONTEND_ADDON_DEPENDENCIES['react-native-purchases-ui'],
|
|
56
|
+
'@revenuecat/purchases-js': FRONTEND_ADDON_DEPENDENCIES['@revenuecat/purchases-js'],
|
|
57
|
+
});
|
|
58
|
+
if (skipInstall) {
|
|
59
|
+
anhedralPrint.info(`Skipping RevenueCat SDK install (--skip-install). Run after init: pnpm --filter ./apps/frontend add ${revenueCatDependencies.join(' ')}`);
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
exec(`pnpm add ${revenueCatDependencies.join(' ')}`, dir);
|
|
63
|
+
}
|
|
64
|
+
anhedralPrint.done(skipInstall ? 'RevenueCat SDK install skipped' : 'Additional Expo SDKs installed');
|
|
40
65
|
}
|
|
41
66
|
function patchFrontendPackageJson(dir) {
|
|
42
67
|
const filePath = path.join(dir, 'package.json');
|
|
43
68
|
const packageJson = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
69
|
+
packageJson.dependencies = {
|
|
70
|
+
...(packageJson.dependencies ?? {}),
|
|
71
|
+
'@anhedral/api-client': FRONTEND_ADDON_DEPENDENCIES['@anhedral/api-client'],
|
|
72
|
+
'@clerk/clerk-expo': FRONTEND_ADDON_DEPENDENCIES['@clerk/clerk-expo'],
|
|
73
|
+
'react-native-purchases': FRONTEND_ADDON_DEPENDENCIES['react-native-purchases'],
|
|
74
|
+
'react-native-purchases-ui': FRONTEND_ADDON_DEPENDENCIES['react-native-purchases-ui'],
|
|
75
|
+
'@revenuecat/purchases-js': FRONTEND_ADDON_DEPENDENCIES['@revenuecat/purchases-js'],
|
|
76
|
+
};
|
|
44
77
|
packageJson.scripts = {
|
|
45
78
|
...(packageJson.scripts ?? {}),
|
|
46
|
-
build: 'pnpm typecheck',
|
|
79
|
+
build: 'pnpm typecheck && pnpm build:web',
|
|
80
|
+
'build:web': 'expo export --platform web',
|
|
47
81
|
typecheck: 'tsc --noEmit',
|
|
48
82
|
};
|
|
49
83
|
writeFile(filePath, JSON.stringify(packageJson, null, 2) + '\n');
|
|
50
84
|
}
|
|
51
|
-
function
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
const SWR_MAX_SIZE = 200;
|
|
57
|
-
const SWR_DEFAULT_STALE_MS = 15_000;
|
|
58
|
-
const SWR_DEFAULT_MAX_AGE_MS = 300_000;
|
|
59
|
-
|
|
60
|
-
type SWREntry<T> = { data: T; fetchedAt: number; staleMs: number; maxAgeMs: number };
|
|
61
|
-
const SWR_CACHE = new Map<string, SWREntry<unknown>>();
|
|
62
|
-
const SWR_INFLIGHT = new Map<string, Promise<unknown>>();
|
|
63
|
-
|
|
64
|
-
function swrEvict() {
|
|
65
|
-
if (SWR_CACHE.size <= SWR_MAX_SIZE) return;
|
|
66
|
-
const entries = [...SWR_CACHE.entries()].sort((a, b) => a[1].fetchedAt - b[1].fetchedAt);
|
|
67
|
-
const toRemove = entries.slice(0, entries.length - SWR_MAX_SIZE);
|
|
68
|
-
for (const [key] of toRemove) SWR_CACHE.delete(key);
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
export class APIRequestError extends Error {
|
|
72
|
-
constructor(
|
|
73
|
-
public statusCode: number,
|
|
74
|
-
message: string,
|
|
75
|
-
public errorCode?: string,
|
|
76
|
-
) {
|
|
77
|
-
super(message);
|
|
78
|
-
this.name = 'APIRequestError';
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
export class APIClient {
|
|
83
|
-
constructor(
|
|
84
|
-
private baseUrl: string,
|
|
85
|
-
private getToken: () => Promise<string | null>,
|
|
86
|
-
) {}
|
|
87
|
-
|
|
88
|
-
private async request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
|
89
|
-
const token = await this.getToken();
|
|
90
|
-
const headers: Record<string, string> = {
|
|
91
|
-
'Content-Type': 'application/json',
|
|
92
|
-
'X-Platform': 'web',
|
|
93
|
-
};
|
|
94
|
-
if (token) headers['Authorization'] = \`Bearer \${token}\`;
|
|
95
|
-
|
|
96
|
-
const res = await fetch(\`\${this.baseUrl}\${path}\`, {
|
|
97
|
-
method,
|
|
98
|
-
headers,
|
|
99
|
-
body: body ? JSON.stringify(body) : undefined,
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
if (!res.ok) {
|
|
103
|
-
let err: { error?: string; message?: string } = {};
|
|
104
|
-
try { err = await res.json(); } catch {}
|
|
105
|
-
throw new APIRequestError(res.status, err.message || \`Request failed (\${res.status})\`, err.error);
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
if (res.status === 204) return {} as T;
|
|
109
|
-
return res.json();
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
async get<T>(path: string): Promise<T> { return this.request<T>('GET', path); }
|
|
113
|
-
async post<T>(path: string, body?: unknown): Promise<T> { return this.request<T>('POST', path, body); }
|
|
114
|
-
async delete<T>(path: string): Promise<T> { return this.request<T>('DELETE', path); }
|
|
115
|
-
|
|
116
|
-
async getCached<T>(
|
|
117
|
-
path: string,
|
|
118
|
-
opts?: { staleMs?: number; maxAgeMs?: number },
|
|
119
|
-
): Promise<T> {
|
|
120
|
-
const staleMs = opts?.staleMs ?? SWR_DEFAULT_STALE_MS;
|
|
121
|
-
const maxAgeMs = opts?.maxAgeMs ?? SWR_DEFAULT_MAX_AGE_MS;
|
|
122
|
-
const now = Date.now();
|
|
123
|
-
const cached = SWR_CACHE.get(path) as SWREntry<T> | undefined;
|
|
124
|
-
|
|
125
|
-
if (cached) {
|
|
126
|
-
const age = now - cached.fetchedAt;
|
|
127
|
-
if (age < staleMs) return cached.data;
|
|
128
|
-
if (age < maxAgeMs) {
|
|
129
|
-
if (!SWR_INFLIGHT.has(path)) {
|
|
130
|
-
const p = this.get<T>(path).then((data) => {
|
|
131
|
-
SWR_CACHE.set(path, { data, fetchedAt: Date.now(), staleMs, maxAgeMs });
|
|
132
|
-
return data;
|
|
133
|
-
}).finally(() => SWR_INFLIGHT.delete(path));
|
|
134
|
-
SWR_INFLIGHT.set(path, p);
|
|
135
|
-
}
|
|
136
|
-
return cached.data;
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
const inflight = SWR_INFLIGHT.get(path) as Promise<T> | undefined;
|
|
141
|
-
if (inflight) return inflight;
|
|
142
|
-
|
|
143
|
-
const p = this.get<T>(path).then((data) => {
|
|
144
|
-
SWR_CACHE.set(path, { data, fetchedAt: Date.now(), staleMs, maxAgeMs });
|
|
145
|
-
swrEvict();
|
|
146
|
-
return data;
|
|
147
|
-
}).finally(() => SWR_INFLIGHT.delete(path));
|
|
148
|
-
SWR_INFLIGHT.set(path, p);
|
|
149
|
-
return p;
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
invalidateCache(path: string): void { SWR_CACHE.delete(path); }
|
|
153
|
-
invalidateCachePrefix(prefix: string): void {
|
|
154
|
-
for (const key of SWR_CACHE.keys()) {
|
|
155
|
-
if (key.startsWith(prefix)) SWR_CACHE.delete(key);
|
|
85
|
+
function patchExpoAppConfig(dir) {
|
|
86
|
+
const filePath = path.join(dir, 'app.json');
|
|
87
|
+
if (!fs.existsSync(filePath)) {
|
|
88
|
+
return;
|
|
156
89
|
}
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
id: string;
|
|
169
|
-
email: string;
|
|
170
|
-
firstName?: string | null;
|
|
171
|
-
lastName?: string | null;
|
|
172
|
-
displayName: string;
|
|
173
|
-
imageUrl?: string | null;
|
|
174
|
-
avatarUrl?: string | null;
|
|
175
|
-
creditsBalance: number;
|
|
176
|
-
subscriptionTier: string;
|
|
177
|
-
subscriptionStatus: string;
|
|
178
|
-
};
|
|
179
|
-
}>('/auth/me');
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
async signOut() {
|
|
183
|
-
return this.post<{ success: boolean }>('/auth/signout');
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
async uploadAvatar(input: { base64: string; mimeType: string; fileName?: string }) {
|
|
187
|
-
return this.post<{ ok: boolean; avatarUrl: string }>('/auth/avatar', input);
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
async deleteAccount() {
|
|
191
|
-
return this.delete<void>('/auth/account');
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
// ── Subscription endpoints ───────────────────────────────────────────
|
|
195
|
-
async getSubscriptionPricing() {
|
|
196
|
-
return this.getCached<{
|
|
197
|
-
tiers: { tier: string; displayName: string; description: string; priceMonthly: number | null; priceYearly: number | null; currency: string; limits: { dailyLimit: number | null }; paymentInfo?: { revenueCatEntitlementId: string; revenueCatOfferingId: string } }[];
|
|
198
|
-
}>('/subscriptions/pricing', { staleMs: 300_000, maxAgeMs: 3_600_000 });
|
|
199
|
-
}
|
|
90
|
+
const appJson = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
91
|
+
appJson.expo ??= {};
|
|
92
|
+
appJson.expo.web = {
|
|
93
|
+
...(appJson.expo.web ?? {}),
|
|
94
|
+
bundler: 'metro',
|
|
95
|
+
output: 'static',
|
|
96
|
+
};
|
|
97
|
+
writeFile(filePath, JSON.stringify(appJson, null, 2) + '\n');
|
|
98
|
+
}
|
|
99
|
+
function writeApiClient(dir) {
|
|
100
|
+
writeFile(path.join(dir, 'api/client.ts'), `import { ApiClient, APIRequestError } from '@anhedral/api-client';
|
|
200
101
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
pro: boolean; inTrial: boolean; trialEndsAt?: string; expiresAt?: string;
|
|
205
|
-
periodStart?: string; periodEnd?: string;
|
|
206
|
-
method?: 'trialing' | 'redeemed' | 'paid' | null;
|
|
207
|
-
managementUrl?: string; cancelAtPeriodEnd?: boolean;
|
|
208
|
-
}>(path);
|
|
102
|
+
export class APIClient extends ApiClient {
|
|
103
|
+
constructor(baseUrl: string, getToken: () => Promise<string | null>) {
|
|
104
|
+
super({ baseUrl, getToken, platform: 'frontend' });
|
|
209
105
|
}
|
|
210
106
|
|
|
211
|
-
|
|
212
|
-
return this.
|
|
107
|
+
redeemPromoCode(code: string) {
|
|
108
|
+
return this.redeemCode(code);
|
|
213
109
|
}
|
|
214
110
|
}
|
|
215
111
|
|
|
216
|
-
export {
|
|
112
|
+
export { APIRequestError };
|
|
217
113
|
`);
|
|
218
114
|
writeFile(path.join(dir, 'api/index.ts'), `export { APIClient, APIRequestError } from './client';
|
|
219
115
|
`);
|
|
@@ -284,6 +180,22 @@ export function useAPI() {
|
|
|
284
180
|
}
|
|
285
181
|
`);
|
|
286
182
|
}
|
|
183
|
+
function writeVercelConfig(dir) {
|
|
184
|
+
writeFile(path.join(dir, 'vercel.json'), JSON.stringify({
|
|
185
|
+
$schema: 'https://openapi.vercel.sh/vercel.json',
|
|
186
|
+
buildCommand: 'pnpm build:web',
|
|
187
|
+
outputDirectory: 'dist',
|
|
188
|
+
devCommand: 'pnpm dev',
|
|
189
|
+
cleanUrls: true,
|
|
190
|
+
framework: null,
|
|
191
|
+
rewrites: [
|
|
192
|
+
{
|
|
193
|
+
source: '/:path*',
|
|
194
|
+
destination: '/index.html',
|
|
195
|
+
},
|
|
196
|
+
],
|
|
197
|
+
}, null, 2) + '\n');
|
|
198
|
+
}
|
|
287
199
|
function writeAccountHook(dir) {
|
|
288
200
|
writeFile(path.join(dir, 'hooks/useAccount.ts'), `import { useAuth } from '@clerk/clerk-expo';
|
|
289
201
|
import * as React from 'react';
|
|
@@ -859,7 +771,7 @@ import { ScrollView, View } from 'react-native';
|
|
|
859
771
|
const FEATURES = [
|
|
860
772
|
{
|
|
861
773
|
title: 'Landing page here',
|
|
862
|
-
description: 'Replace this placeholder copy with the actual acquisition story for your
|
|
774
|
+
description: 'Replace this placeholder copy with the actual acquisition story for your Expo app.',
|
|
863
775
|
icon: CloudIcon,
|
|
864
776
|
},
|
|
865
777
|
{
|