anhedral 0.1.0

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.
@@ -0,0 +1,2 @@
1
+ import type { ProjectOptions } from '../scaffold.js';
2
+ export declare function scaffoldExtension(root: string, { projectName, displayName }: ProjectOptions): Promise<void>;
@@ -0,0 +1,457 @@
1
+ import path from 'node:path';
2
+ import { writeFile, exec } from '../util.js';
3
+ import { resolveToolchainChannel, resolveToolchain, toolPackageRef } from '../toolchain.js';
4
+ export async function scaffoldExtension(root, { projectName, displayName }) {
5
+ const dir = path.join(root, 'extension');
6
+ const toolchain = resolveToolchain(resolveToolchainChannel(process.env.ANHEDRAL_TOOLCHAIN));
7
+ // ── 1. Scaffold via WXT CLI ───────────────────────────────────────────
8
+ console.log(' Scaffolding WXT extension...');
9
+ exec(`pnpm dlx ${toolPackageRef('wxt', toolchain.wxt)} init extension -t react --pm pnpm`, root);
10
+ // ── 2. Overwrite package.json with proper scripts ──────────────────────
11
+ writePackageJson(dir, projectName);
12
+ // ── 3. Install dependencies ───────────────────────────────────────────
13
+ console.log(' Installing Clerk + React + UI dependencies...');
14
+ exec('pnpm add @clerk/chrome-extension react react-dom clsx tailwind-merge class-variance-authority lucide-react', dir);
15
+ exec(`pnpm add -D @types/chrome @types/react @types/react-dom @wxt-dev/module-react autoprefixer postcss tailwindcss typescript ${toolPackageRef('wxt', toolchain.wxt)}`, dir);
16
+ // ── 4. Write config files ─────────────────────────────────────────────
17
+ writeWxtConfig(dir, displayName);
18
+ writeTsConfig(dir);
19
+ writeEnvExample(dir);
20
+ writePostcssConfig(dir);
21
+ writeTailwindConfig(dir);
22
+ writeShadcnConfig(dir);
23
+ writeCnUtil(dir);
24
+ // ── 5. Write source files ─────────────────────────────────────────────
25
+ writeAuthContext(dir);
26
+ writeApiClient(dir);
27
+ writeBackground(dir);
28
+ writeSidepanelEntry(dir);
29
+ writeSidepanelHtml(dir, displayName);
30
+ writeSidepanelApp(dir);
31
+ writeStyles(dir);
32
+ // ── 6. Add shadcn button component (sets up CSS variables + component) ─
33
+ console.log(' Adding shadcn button component...');
34
+ exec(`pnpm dlx ${toolPackageRef('shadcn', toolchain.shadcn)} add button`, dir);
35
+ }
36
+ function writePackageJson(dir, projectName) {
37
+ writeFile(path.join(dir, 'package.json'), JSON.stringify({
38
+ name: projectName + '-chrome-ext',
39
+ version: '0.1.0',
40
+ private: true,
41
+ type: 'module',
42
+ scripts: {
43
+ dev: 'wxt',
44
+ 'dev:firefox': 'wxt -b firefox',
45
+ build: 'wxt build',
46
+ 'build:firefox': 'wxt build -b firefox',
47
+ postinstall: 'wxt prepare',
48
+ zip: 'wxt zip',
49
+ 'zip:firefox': 'wxt zip -b firefox',
50
+ typecheck: 'tsc --noEmit',
51
+ },
52
+ }, null, 2) + '\n');
53
+ }
54
+ function writeWxtConfig(dir, displayName) {
55
+ writeFile(path.join(dir, 'wxt.config.ts'), `import { defineConfig } from 'wxt';
56
+
57
+ export default defineConfig({
58
+ srcDir: 'src',
59
+ manifest: () => {
60
+ const crxPublicKey = process.env.VITE_CRX_PUBLIC_KEY || '';
61
+
62
+ return {
63
+ name: '${displayName}',
64
+ description: '${displayName} Chrome Extension',
65
+ version: '0.1.0',
66
+ ...(crxPublicKey ? { key: crxPublicKey } : {}),
67
+ permissions: ['cookies', 'storage'],
68
+ host_permissions: [],
69
+ action: {
70
+ default_title: 'Open ${displayName}',
71
+ },
72
+ };
73
+ },
74
+ modules: ['@wxt-dev/module-react'],
75
+ });
76
+ `);
77
+ }
78
+ function writeTsConfig(dir) {
79
+ writeFile(path.join(dir, 'tsconfig.json'), JSON.stringify({
80
+ compilerOptions: {
81
+ target: 'ESNext',
82
+ module: 'ESNext',
83
+ moduleResolution: 'Bundler',
84
+ strict: true,
85
+ esModuleInterop: true,
86
+ skipLibCheck: true,
87
+ forceConsistentCasingInFileNames: true,
88
+ resolveJsonModule: true,
89
+ isolatedModules: true,
90
+ noEmit: true,
91
+ jsx: 'react-jsx',
92
+ lib: ['DOM', 'DOM.Iterable', 'ESNext'],
93
+ types: ['wxt/browser', '@wxt-dev/module-react', 'chrome'],
94
+ baseUrl: '.',
95
+ paths: { '@/*': ['./src/*'] },
96
+ },
97
+ include: ['**/*.ts', '**/*.tsx', '.wxt/wxt.d.ts'],
98
+ exclude: ['node_modules', '.output', 'dist'],
99
+ }, null, 2) + '\n');
100
+ }
101
+ function writeEnvExample(dir) {
102
+ writeFile(path.join(dir, '.env.example'), `# Clerk Authentication
103
+ VITE_CLERK_PUBLISHABLE_KEY=pk_test_***
104
+
105
+ # Backend API URL
106
+ VITE_API_URL=http://localhost:8787
107
+
108
+ # Website URL (for sign-up and subscription links)
109
+ VITE_WEBSITE_URL=http://localhost:8081
110
+
111
+ # Chrome Extension CRX public key (optional, for stable extension ID)
112
+ VITE_CRX_PUBLIC_KEY=
113
+
114
+ # RevenueCat Web Billing URL (optional, for subscription management)
115
+ VITE_RC_BILLING_URL=
116
+ `);
117
+ }
118
+ function writePostcssConfig(dir) {
119
+ writeFile(path.join(dir, 'postcss.config.js'), `module.exports = {
120
+ plugins: {
121
+ tailwindcss: {},
122
+ autoprefixer: {},
123
+ },
124
+ };
125
+ `);
126
+ }
127
+ function writeShadcnConfig(dir) {
128
+ writeFile(path.join(dir, 'components.json'), JSON.stringify({
129
+ $schema: 'https://ui.shadcn.com/schema.json',
130
+ style: 'new-york',
131
+ rsc: false,
132
+ tsx: true,
133
+ tailwind: {
134
+ config: '',
135
+ css: 'src/styles/main.css',
136
+ baseColor: 'neutral',
137
+ cssVariables: true,
138
+ prefix: '',
139
+ },
140
+ aliases: {
141
+ components: '@/components',
142
+ utils: '@/lib/utils',
143
+ ui: '@/components/ui',
144
+ lib: '@/lib',
145
+ hooks: '@/hooks',
146
+ },
147
+ iconLibrary: 'lucide',
148
+ }, null, 2) + '\n');
149
+ }
150
+ function writeCnUtil(dir) {
151
+ writeFile(path.join(dir, 'src/lib/utils.ts'), `import { clsx, type ClassValue } from 'clsx';
152
+ import { twMerge } from 'tailwind-merge';
153
+
154
+ export function cn(...inputs: ClassValue[]) {
155
+ return twMerge(clsx(inputs));
156
+ }
157
+ `);
158
+ }
159
+ function writeTailwindConfig(dir) {
160
+ writeFile(path.join(dir, 'tailwind.config.js'), `/** @type {import('tailwindcss').Config} */
161
+ module.exports = {
162
+ content: ['./src/**/*.{ts,tsx,html}'],
163
+ theme: { extend: {} },
164
+ plugins: [],
165
+ };
166
+ `);
167
+ }
168
+ function writeAuthContext(dir) {
169
+ writeFile(path.join(dir, 'src/contexts/auth-context.tsx'), `import * as React from 'react';
170
+ import { ClerkProvider, useAuth as useClerkAuth, useUser } from '@clerk/chrome-extension';
171
+ import { APIClient } from '../lib/api';
172
+
173
+ const CLERK_PUBLISHABLE_KEY = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY || '';
174
+ const WEBSITE_URL = import.meta.env.VITE_WEBSITE_URL || 'http://localhost:8081';
175
+
176
+ function getExtensionUrl(path: string) {
177
+ if (typeof chrome !== 'undefined' && chrome.runtime?.getURL) {
178
+ return chrome.runtime.getURL(path);
179
+ }
180
+ return path;
181
+ }
182
+
183
+ const SIDEPANEL_URL = getExtensionUrl('sidepanel.html');
184
+
185
+ type AuthState = {
186
+ isSignedIn: boolean;
187
+ isLoading: boolean;
188
+ userId: string | null;
189
+ subscription: {
190
+ status: 'idle' | 'loading' | 'active' | 'inactive' | 'error';
191
+ canAccess: boolean;
192
+ inTrial?: boolean;
193
+ trialEndsAt?: string;
194
+ expiresAt?: string;
195
+ method?: 'trialing' | 'redeemed' | 'paid' | null;
196
+ managementUrl?: string;
197
+ cancelAtPeriodEnd?: boolean;
198
+ error?: string;
199
+ };
200
+ };
201
+
202
+ type AuthContextValue = AuthState & {
203
+ signOut: () => Promise<void>;
204
+ refreshSubscription: (opts?: { refresh?: boolean }) => Promise<void>;
205
+ };
206
+
207
+ const AuthContext = React.createContext<AuthContextValue | null>(null);
208
+
209
+ function AuthProviderInner({ children }: { children: React.ReactNode }) {
210
+ const { isSignedIn, isLoaded, userId, signOut, getToken } = useClerkAuth();
211
+ const { user } = useUser();
212
+
213
+ const [subscription, setSubscription] = React.useState<AuthState['subscription']>({
214
+ status: 'idle',
215
+ canAccess: false,
216
+ });
217
+
218
+ const apiRef = React.useRef<APIClient | null>(null);
219
+
220
+ React.useEffect(() => {
221
+ if (isSignedIn && getToken) {
222
+ apiRef.current = new APIClient(getToken);
223
+ } else {
224
+ apiRef.current = null;
225
+ }
226
+ }, [isSignedIn, getToken]);
227
+
228
+ const checkSubscription = React.useCallback(
229
+ async (opts?: { refresh?: boolean }) => {
230
+ if (!apiRef.current || !isSignedIn) {
231
+ setSubscription({ status: 'idle', canAccess: false });
232
+ return;
233
+ }
234
+ setSubscription(prev => ({ ...prev, status: 'loading' }));
235
+ try {
236
+ const result = await apiRef.current.getSubscriptionEntitlements(opts);
237
+ const isPro = result.pro;
238
+ const inTrial = result.inTrial;
239
+ setSubscription({
240
+ status: isPro ? 'active' : 'inactive',
241
+ canAccess: isPro,
242
+ inTrial,
243
+ trialEndsAt: result.trialEndsAt,
244
+ expiresAt: result.expiresAt,
245
+ method: result.method,
246
+ managementUrl: result.managementUrl,
247
+ cancelAtPeriodEnd: result.cancelAtPeriodEnd,
248
+ });
249
+ } catch (error) {
250
+ setSubscription({
251
+ status: 'error',
252
+ canAccess: false,
253
+ error: error instanceof Error ? error.message : 'Failed to check subscription',
254
+ });
255
+ }
256
+ },
257
+ [isSignedIn],
258
+ );
259
+
260
+ React.useEffect(() => {
261
+ if (isSignedIn && isLoaded) {
262
+ void checkSubscription({ refresh: true });
263
+ }
264
+ }, [isSignedIn, isLoaded, checkSubscription]);
265
+
266
+ const handleSignOut = React.useCallback(async () => {
267
+ setSubscription({ status: 'idle', canAccess: false });
268
+ await signOut();
269
+ }, [signOut]);
270
+
271
+ const value = React.useMemo<AuthContextValue>(
272
+ () => ({
273
+ isSignedIn: !!isSignedIn,
274
+ isLoading: !isLoaded,
275
+ userId: userId || null,
276
+ subscription,
277
+ signOut: handleSignOut,
278
+ refreshSubscription: (opts?: { refresh?: boolean }) => checkSubscription(opts),
279
+ }),
280
+ [isSignedIn, isLoaded, userId, subscription, handleSignOut, checkSubscription],
281
+ );
282
+
283
+ return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
284
+ }
285
+
286
+ export function AuthProvider({ children }: { children: React.ReactNode }) {
287
+ return (
288
+ <ClerkProvider
289
+ publishableKey={CLERK_PUBLISHABLE_KEY}
290
+ afterSignOutUrl={SIDEPANEL_URL}
291
+ signInFallbackRedirectUrl={SIDEPANEL_URL}
292
+ signUpFallbackRedirectUrl={SIDEPANEL_URL}
293
+ >
294
+ <AuthProviderInner>{children}</AuthProviderInner>
295
+ </ClerkProvider>
296
+ );
297
+ }
298
+
299
+ export function useAuth() {
300
+ const ctx = React.useContext(AuthContext);
301
+ if (!ctx) throw new Error('useAuth must be used within AuthProvider');
302
+ return ctx;
303
+ }
304
+
305
+ export { WEBSITE_URL };
306
+ `);
307
+ }
308
+ function writeApiClient(dir) {
309
+ writeFile(path.join(dir, 'src/lib/api.ts'), `const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8787';
310
+
311
+ export type SubscriptionEntitlements = {
312
+ pro: boolean;
313
+ inTrial: boolean;
314
+ trialEndsAt?: string;
315
+ expiresAt?: string;
316
+ periodStart?: string;
317
+ periodEnd?: string;
318
+ method?: 'trialing' | 'redeemed' | 'paid' | null;
319
+ managementUrl?: string;
320
+ cancelAtPeriodEnd?: boolean;
321
+ };
322
+
323
+ export class APIClient {
324
+ constructor(private getToken: () => Promise<string | null>) {}
325
+
326
+ private async request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
327
+ const token = await this.getToken();
328
+ if (!token) throw new Error('Not authenticated');
329
+
330
+ const headers: Record<string, string> = {
331
+ Authorization: \`Bearer \${token}\`,
332
+ 'Content-Type': 'application/json',
333
+ 'X-Client-Type': 'chrome-extension',
334
+ 'X-Platform': 'chrome',
335
+ ...(options.headers as Record<string, string>),
336
+ };
337
+
338
+ const response = await fetch(\`\${API_BASE_URL}\${endpoint}\`, { ...options, headers });
339
+
340
+ if (!response.ok) {
341
+ let error: { message?: string } = {};
342
+ try { error = await response.json(); } catch {}
343
+ throw new Error(error.message || \`Request failed (\${response.status})\`);
344
+ }
345
+
346
+ if (response.status === 204) return {} as T;
347
+ return response.json();
348
+ }
349
+
350
+ async getMe() {
351
+ return this.request<{
352
+ user: { id: string; email: string; subscriptionTier: string; subscriptionStatus: string };
353
+ }>('/auth/me');
354
+ }
355
+
356
+ async getSubscriptionEntitlements(options?: { refresh?: boolean }): Promise<SubscriptionEntitlements> {
357
+ const path = options?.refresh
358
+ ? '/subscriptions/entitlements/me?refresh=true'
359
+ : '/subscriptions/entitlements/me';
360
+ return this.request<SubscriptionEntitlements>(path);
361
+ }
362
+ }
363
+ `);
364
+ }
365
+ function writeBackground(dir) {
366
+ writeFile(path.join(dir, 'src/entrypoints/background.ts'), `import { createClerkClient } from '@clerk/chrome-extension/background';
367
+
368
+ export default defineBackground(() => {
369
+ // Initialize Clerk in the background script for cookie-based auth
370
+ void createClerkClient({
371
+ publishableKey: import.meta.env.VITE_CLERK_PUBLISHABLE_KEY || '',
372
+ }).catch(() => {});
373
+
374
+ // Open the side panel when the extension icon is clicked
375
+ // The sidePanel permission is auto-added by WXT when the sidepanel entrypoint exists
376
+ chrome.sidePanel
377
+ .setPanelBehavior({ openPanelOnActionClick: true })
378
+ .catch(() => {});
379
+ });
380
+ `);
381
+ }
382
+ function writeSidepanelEntry(dir) {
383
+ writeFile(path.join(dir, 'src/entrypoints/sidepanel/main.tsx'), `import * as React from 'react';
384
+ import { createRoot } from 'react-dom/client';
385
+ import { AuthProvider } from '../../contexts/auth-context';
386
+ import { SidePanelApp } from './app';
387
+ import '../../styles/main.css';
388
+
389
+ const container = document.getElementById('root');
390
+ if (container) {
391
+ const root = createRoot(container);
392
+ root.render(
393
+ <React.StrictMode>
394
+ <AuthProvider>
395
+ <SidePanelApp />
396
+ </AuthProvider>
397
+ </React.StrictMode>
398
+ );
399
+ }
400
+ `);
401
+ }
402
+ function writeSidepanelHtml(dir, displayName) {
403
+ writeFile(path.join(dir, 'src/entrypoints/sidepanel/index.html'), `<!DOCTYPE html>
404
+ <html lang="en">
405
+ <head>
406
+ <meta charset="UTF-8" />
407
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
408
+ <title>${displayName}</title>
409
+ </head>
410
+ <body>
411
+ <div id="root"></div>
412
+ <script type="module" src="./main.tsx"></script>
413
+ </body>
414
+ </html>
415
+ `);
416
+ }
417
+ function writeSidepanelApp(dir) {
418
+ writeFile(path.join(dir, 'src/entrypoints/sidepanel/app.tsx'), `import * as React from 'react';
419
+ import { useAuth } from '../../contexts/auth-context';
420
+ import { SignIn } from '@clerk/chrome-extension';
421
+
422
+ export function SidePanelApp() {
423
+ const { isSignedIn, isLoading, signOut, subscription } = useAuth();
424
+
425
+ if (isLoading) {
426
+ return <div style={{ padding: 24, textAlign: 'center' }}>Loading...</div>;
427
+ }
428
+
429
+ if (!isSignedIn) {
430
+ return (
431
+ <div style={{ padding: 24 }}>
432
+ <SignIn />
433
+ </div>
434
+ );
435
+ }
436
+
437
+ return (
438
+ <div style={{ padding: 24 }}>
439
+ <h2>Welcome!</h2>
440
+ <p>Subscription: {subscription.status}</p>
441
+ <button onClick={signOut}>Sign Out</button>
442
+ </div>
443
+ );
444
+ }
445
+ `);
446
+ }
447
+ function writeStyles(dir) {
448
+ writeFile(path.join(dir, 'src/styles/main.css'), `@tailwind base;
449
+ @tailwind components;
450
+ @tailwind utilities;
451
+
452
+ body {
453
+ margin: 0;
454
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
455
+ }
456
+ `);
457
+ }
@@ -0,0 +1,2 @@
1
+ import type { ProjectOptions } from '../scaffold.js';
2
+ export declare function scaffoldFrontend(root: string, { projectName, displayName }: ProjectOptions): Promise<void>;