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.
@@ -1,37 +1,57 @@
1
+ import { rmSync } from 'node:fs';
1
2
  import path from 'node:path';
2
3
  import { writeFile, exec } from '../util.js';
4
+ import { anhedralPrint } from '../print.js';
5
+ import { EXTENSION_DEPENDENCIES } from '../dependencies.js';
3
6
  import { resolveToolchainChannel, resolveToolchain, toolPackageRef } from '../toolchain.js';
4
- export async function scaffoldExtension(root, { projectName, displayName }) {
5
- const dir = path.join(root, 'extension');
7
+ export async function scaffoldExtension(root, { projectName, displayName, skipInstall }) {
8
+ const appsRoot = path.join(root, 'apps');
9
+ const dir = path.join(appsRoot, 'extension');
6
10
  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
+ anhedralPrint.section('Chrome extension (WXT)');
12
+ anhedralPrint.step('Scaffolding WXT extension');
13
+ writeFile(path.join(appsRoot, '.gitkeep'), '');
14
+ exec(`pnpm dlx ${toolPackageRef('wxt', toolchain.wxt)} init extension -t react --pm pnpm`, appsRoot);
11
15
  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 ─────────────────────────────────────────────
16
+ anhedralPrint.done('WXT extension scaffolded');
17
+ anhedralPrint.step('Installing Clerk + React + Tailwind dependencies');
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');
26
+ cleanWxtStarterFiles(dir);
17
27
  writeWxtConfig(dir, displayName);
18
28
  writeTsConfig(dir);
19
29
  writeEnvExample(dir);
30
+ writeEnvFile(dir);
20
31
  writePostcssConfig(dir);
21
32
  writeTailwindConfig(dir);
22
33
  writeShadcnConfig(dir);
34
+ writeReadme(dir, displayName);
23
35
  writeCnUtil(dir);
24
- // ── 5. Write source files ─────────────────────────────────────────────
36
+ writeButtonComponent(dir);
25
37
  writeAuthContext(dir);
26
38
  writeApiClient(dir);
27
39
  writeBackground(dir);
40
+ writeContentScript(dir);
28
41
  writeSidepanelEntry(dir);
29
42
  writeSidepanelHtml(dir, displayName);
30
43
  writeSidepanelApp(dir);
31
44
  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);
45
+ anhedralPrint.done('Extension source files written');
46
+ }
47
+ function cleanWxtStarterFiles(dir) {
48
+ for (const relativePath of [
49
+ 'entrypoints',
50
+ 'assets/react.svg',
51
+ 'public/wxt.svg',
52
+ ]) {
53
+ rmSync(path.join(dir, relativePath), { recursive: true, force: true });
54
+ }
35
55
  }
36
56
  function writePackageJson(dir, projectName) {
37
57
  writeFile(path.join(dir, 'package.json'), JSON.stringify({
@@ -49,6 +69,8 @@ function writePackageJson(dir, projectName) {
49
69
  'zip:firefox': 'wxt zip -b firefox',
50
70
  typecheck: 'tsc --noEmit',
51
71
  },
72
+ dependencies: EXTENSION_DEPENDENCIES.dependencies,
73
+ devDependencies: EXTENSION_DEPENDENCIES.devDependencies,
52
74
  }, null, 2) + '\n');
53
75
  }
54
76
  function writeWxtConfig(dir, displayName) {
@@ -64,11 +86,15 @@ export default defineConfig({
64
86
  description: '${displayName} Chrome Extension',
65
87
  version: '0.1.0',
66
88
  ...(crxPublicKey ? { key: crxPublicKey } : {}),
67
- permissions: ['cookies', 'storage'],
89
+ minimum_chrome_version: '114',
90
+ permissions: ['activeTab', 'cookies', 'storage', 'sidePanel'],
68
91
  host_permissions: [],
69
92
  action: {
70
93
  default_title: 'Open ${displayName}',
71
94
  },
95
+ side_panel: {
96
+ default_path: 'sidepanel.html',
97
+ },
72
98
  };
73
99
  },
74
100
  modules: ['@wxt-dev/module-react'],
@@ -87,11 +113,11 @@ function writeTsConfig(dir) {
87
113
  forceConsistentCasingInFileNames: true,
88
114
  resolveJsonModule: true,
89
115
  isolatedModules: true,
116
+ allowImportingTsExtensions: true,
90
117
  noEmit: true,
91
118
  jsx: 'react-jsx',
92
119
  lib: ['DOM', 'DOM.Iterable', 'ESNext'],
93
120
  types: ['wxt/browser', '@wxt-dev/module-react', 'chrome'],
94
- baseUrl: '.',
95
121
  paths: { '@/*': ['./src/*'] },
96
122
  },
97
123
  include: ['**/*.ts', '**/*.tsx', '.wxt/wxt.d.ts'],
@@ -111,12 +137,29 @@ VITE_WEBSITE_URL=http://localhost:8081
111
137
  # Chrome Extension CRX public key (optional, for stable extension ID)
112
138
  VITE_CRX_PUBLIC_KEY=
113
139
 
140
+ # RevenueCat Web Billing URL (optional, for subscription management)
141
+ VITE_RC_BILLING_URL=
142
+ `);
143
+ }
144
+ function writeEnvFile(dir) {
145
+ writeFile(path.join(dir, '.env'), `# Clerk Authentication
146
+ VITE_CLERK_PUBLISHABLE_KEY=
147
+
148
+ # Backend API URL
149
+ VITE_API_URL=http://localhost:8787
150
+
151
+ # Website URL (for sign-up and subscription links)
152
+ VITE_WEBSITE_URL=http://localhost:8081
153
+
154
+ # Chrome Extension CRX public key (optional, for stable extension ID)
155
+ VITE_CRX_PUBLIC_KEY=
156
+
114
157
  # RevenueCat Web Billing URL (optional, for subscription management)
115
158
  VITE_RC_BILLING_URL=
116
159
  `);
117
160
  }
118
161
  function writePostcssConfig(dir) {
119
- writeFile(path.join(dir, 'postcss.config.js'), `module.exports = {
162
+ writeFile(path.join(dir, 'postcss.config.cjs'), `module.exports = {
120
163
  plugins: {
121
164
  tailwindcss: {},
122
165
  autoprefixer: {},
@@ -156,15 +199,115 @@ export function cn(...inputs: ClassValue[]) {
156
199
  }
157
200
  `);
158
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
+ }
159
237
  function writeTailwindConfig(dir) {
160
- writeFile(path.join(dir, 'tailwind.config.js'), `/** @type {import('tailwindcss').Config} */
238
+ writeFile(path.join(dir, 'tailwind.config.cjs'), `/** @type {import('tailwindcss').Config} */
161
239
  module.exports = {
162
240
  content: ['./src/**/*.{ts,tsx,html}'],
163
- theme: { extend: {} },
241
+ theme: {
242
+ extend: {
243
+ colors: {
244
+ border: 'hsl(var(--border))',
245
+ input: 'hsl(var(--input))',
246
+ ring: 'hsl(var(--ring))',
247
+ background: 'hsl(var(--background))',
248
+ foreground: 'hsl(var(--foreground))',
249
+ primary: {
250
+ DEFAULT: 'hsl(var(--primary))',
251
+ foreground: 'hsl(var(--primary-foreground))',
252
+ },
253
+ secondary: {
254
+ DEFAULT: 'hsl(var(--secondary))',
255
+ foreground: 'hsl(var(--secondary-foreground))',
256
+ },
257
+ destructive: {
258
+ DEFAULT: 'hsl(var(--destructive))',
259
+ foreground: 'hsl(var(--destructive-foreground))',
260
+ },
261
+ muted: {
262
+ DEFAULT: 'hsl(var(--muted))',
263
+ foreground: 'hsl(var(--muted-foreground))',
264
+ },
265
+ accent: {
266
+ DEFAULT: 'hsl(var(--accent))',
267
+ foreground: 'hsl(var(--accent-foreground))',
268
+ },
269
+ popover: {
270
+ DEFAULT: 'hsl(var(--popover))',
271
+ foreground: 'hsl(var(--popover-foreground))',
272
+ },
273
+ card: {
274
+ DEFAULT: 'hsl(var(--card))',
275
+ foreground: 'hsl(var(--card-foreground))',
276
+ },
277
+ },
278
+ borderRadius: {
279
+ lg: 'var(--radius)',
280
+ md: 'calc(var(--radius) - 2px)',
281
+ sm: 'calc(var(--radius) - 4px)',
282
+ },
283
+ },
284
+ },
164
285
  plugins: [],
165
286
  };
166
287
  `);
167
288
  }
289
+ function writeReadme(dir, displayName) {
290
+ writeFile(path.join(dir, 'README.md'), `# ${displayName} Chrome Extension
291
+
292
+ WXT side-panel extension generated by anhedral.
293
+
294
+ ## Development
295
+
296
+ \`\`\`bash
297
+ pnpm dev
298
+ pnpm build
299
+ pnpm zip
300
+ \`\`\`
301
+
302
+ Set \`VITE_CLERK_PUBLISHABLE_KEY\` and \`VITE_API_URL\` in \`.env\` before using auth-backed routes. Set \`VITE_CRX_PUBLIC_KEY\` only when you need a stable Chrome extension ID.
303
+
304
+ ## Chrome
305
+
306
+ Run \`pnpm build\`, then load \`.output/chrome-mv3\` as an unpacked extension from \`chrome://extensions\`.
307
+
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.
309
+ `);
310
+ }
168
311
  function writeAuthContext(dir) {
169
312
  writeFile(path.join(dir, 'src/contexts/auth-context.tsx'), `import * as React from 'react';
170
313
  import { ClerkProvider, useAuth as useClerkAuth, useUser } from '@clerk/chrome-extension';
@@ -306,58 +449,25 @@ export { WEBSITE_URL };
306
449
  `);
307
450
  }
308
451
  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
- };
452
+ writeFile(path.join(dir, 'src/lib/api.ts'), `import { ApiClient } from '@anhedral/api-client';
322
453
 
323
454
  export class APIClient {
324
455
  constructor(private getToken: () => Promise<string | null>) {}
325
456
 
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();
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
+ });
348
463
  }
349
464
 
350
- async getMe() {
351
- return this.request<{
352
- user: { id: string; email: string; subscriptionTier: string; subscriptionStatus: string };
353
- }>('/auth/me');
465
+ getMe() {
466
+ return this.client().getMe();
354
467
  }
355
468
 
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);
469
+ getSubscriptionEntitlements(options?: { refresh?: boolean }) {
470
+ return this.client().getSubscriptionEntitlements(options);
361
471
  }
362
472
  }
363
473
  `);
@@ -365,20 +475,42 @@ export class APIClient {
365
475
  function writeBackground(dir) {
366
476
  writeFile(path.join(dir, 'src/entrypoints/background.ts'), `import { createClerkClient } from '@clerk/chrome-extension/background';
367
477
 
478
+ type ChromeWithSidePanel = typeof chrome & {
479
+ sidePanel: {
480
+ setPanelBehavior: (behavior: { openPanelOnActionClick: boolean }) => Promise<void>;
481
+ };
482
+ };
483
+
368
484
  export default defineBackground(() => {
369
485
  // Initialize Clerk in the background script for cookie-based auth
370
486
  void createClerkClient({
371
487
  publishableKey: import.meta.env.VITE_CLERK_PUBLISHABLE_KEY || '',
372
488
  }).catch(() => {});
373
489
 
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
490
+ // Open the side panel when the extension icon is clicked.
491
+ (chrome as ChromeWithSidePanel).sidePanel
377
492
  .setPanelBehavior({ openPanelOnActionClick: true })
378
493
  .catch(() => {});
379
494
  });
380
495
  `);
381
496
  }
497
+ function writeContentScript(dir) {
498
+ writeFile(path.join(dir, 'src/entrypoints/content.ts'), `export default defineContentScript({
499
+ matches: ['<all_urls>'],
500
+ main() {
501
+ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
502
+ if (message?.type !== 'ANHEDRAL_PAGE_SNAPSHOT') return false;
503
+
504
+ sendResponse({
505
+ title: document.title,
506
+ location: window.location.href,
507
+ });
508
+ return true;
509
+ });
510
+ },
511
+ });
512
+ `);
513
+ }
382
514
  function writeSidepanelEntry(dir) {
383
515
  writeFile(path.join(dir, 'src/entrypoints/sidepanel/main.tsx'), `import * as React from 'react';
384
516
  import { createRoot } from 'react-dom/client';
@@ -418,9 +550,33 @@ function writeSidepanelApp(dir) {
418
550
  writeFile(path.join(dir, 'src/entrypoints/sidepanel/app.tsx'), `import * as React from 'react';
419
551
  import { useAuth } from '../../contexts/auth-context';
420
552
  import { SignIn } from '@clerk/chrome-extension';
553
+ import { Button } from '../../components/ui/button';
554
+
555
+ type PageSnapshot = {
556
+ title: string;
557
+ location: string;
558
+ };
421
559
 
422
560
  export function SidePanelApp() {
423
561
  const { isSignedIn, isLoading, signOut, subscription } = useAuth();
562
+ const [page, setPage] = React.useState<PageSnapshot | null>(null);
563
+ const [pageError, setPageError] = React.useState<string | null>(null);
564
+
565
+ const readActivePage = React.useCallback(async () => {
566
+ setPageError(null);
567
+ const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
568
+ if (!tab.id) {
569
+ setPageError('No active tab is available.');
570
+ return;
571
+ }
572
+
573
+ try {
574
+ const response = await chrome.tabs.sendMessage(tab.id, { type: 'ANHEDRAL_PAGE_SNAPSHOT' });
575
+ setPage(response as PageSnapshot);
576
+ } catch {
577
+ setPageError('Refresh the active page, then try again.');
578
+ }
579
+ }, []);
424
580
 
425
581
  if (isLoading) {
426
582
  return <div style={{ padding: 24, textAlign: 'center' }}>Loading...</div>;
@@ -438,7 +594,15 @@ export function SidePanelApp() {
438
594
  <div style={{ padding: 24 }}>
439
595
  <h2>Welcome!</h2>
440
596
  <p>Subscription: {subscription.status}</p>
441
- <button onClick={signOut}>Sign Out</button>
597
+ <Button type="button" onClick={() => void readActivePage()}>Read active page</Button>
598
+ {page ? (
599
+ <div style={{ marginTop: 16 }}>
600
+ <strong>{page.title || 'Untitled page'}</strong>
601
+ <p style={{ overflowWrap: 'anywhere' }}>{page.location}</p>
602
+ </div>
603
+ ) : null}
604
+ {pageError ? <p style={{ color: 'hsl(var(--destructive))' }}>{pageError}</p> : null}
605
+ <Button type="button" variant="outline" onClick={signOut}>Sign Out</Button>
442
606
  </div>
443
607
  );
444
608
  }
@@ -449,6 +613,40 @@ function writeStyles(dir) {
449
613
  @tailwind components;
450
614
  @tailwind utilities;
451
615
 
616
+ @layer base {
617
+ :root {
618
+ --background: 0 0% 100%;
619
+ --foreground: 0 0% 9%;
620
+ --card: 0 0% 100%;
621
+ --card-foreground: 0 0% 9%;
622
+ --popover: 0 0% 100%;
623
+ --popover-foreground: 0 0% 9%;
624
+ --primary: 0 0% 9%;
625
+ --primary-foreground: 0 0% 98%;
626
+ --secondary: 0 0% 96.1%;
627
+ --secondary-foreground: 0 0% 9%;
628
+ --muted: 0 0% 96.1%;
629
+ --muted-foreground: 0 0% 45.1%;
630
+ --accent: 0 0% 96.1%;
631
+ --accent-foreground: 0 0% 9%;
632
+ --destructive: 0 84.2% 60.2%;
633
+ --destructive-foreground: 0 0% 98%;
634
+ --border: 0 0% 89.8%;
635
+ --input: 0 0% 89.8%;
636
+ --ring: 0 0% 63.9%;
637
+ --radius: 0.5rem;
638
+ }
639
+
640
+ * {
641
+ border-color: hsl(var(--border));
642
+ }
643
+
644
+ body {
645
+ background: hsl(var(--background));
646
+ color: hsl(var(--foreground));
647
+ }
648
+ }
649
+
452
650
  body {
453
651
  margin: 0;
454
652
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
@@ -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>;