anhedral 0.1.10 → 0.4.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.
Files changed (87) hide show
  1. package/CHANGELOG.md +119 -0
  2. package/CONTRIBUTING.md +73 -0
  3. package/LICENSE +196 -17
  4. package/README.md +202 -145
  5. package/SECURITY.md +37 -0
  6. package/SKILL.md +244 -0
  7. package/bin/anhedral.js +1 -1
  8. package/dist/architecture/contributions.d.ts +28 -0
  9. package/dist/architecture/contributions.js +125 -0
  10. package/dist/architecture/index.d.ts +12 -0
  11. package/dist/architecture/index.js +6 -0
  12. package/dist/architecture/manifest.d.ts +53 -0
  13. package/dist/architecture/manifest.js +283 -0
  14. package/dist/architecture/modules.d.ts +37 -0
  15. package/dist/architecture/modules.js +184 -0
  16. package/dist/architecture/plan.d.ts +58 -0
  17. package/dist/architecture/plan.js +145 -0
  18. package/dist/architecture/templates.d.ts +10 -0
  19. package/dist/architecture/templates.js +23 -0
  20. package/dist/bin.d.ts +2 -0
  21. package/dist/bin.js +179 -0
  22. package/dist/cli.d.ts +24 -2
  23. package/dist/cli.js +189 -21
  24. package/dist/composers/root.d.ts +6 -0
  25. package/dist/composers/root.js +35 -0
  26. package/dist/dependencies.d.ts +31 -9
  27. package/dist/dependencies.js +227 -60
  28. package/dist/index.d.ts +8 -2
  29. package/dist/index.js +5 -31
  30. package/dist/print.js +15 -0
  31. package/dist/prompts.d.ts +5 -0
  32. package/dist/prompts.js +19 -0
  33. package/dist/render.d.ts +11 -0
  34. package/dist/render.js +74 -0
  35. package/dist/scaffold.d.ts +64 -12
  36. package/dist/scaffold.js +1726 -1150
  37. package/dist/template-source.d.ts +20 -0
  38. package/dist/template-source.js +180 -0
  39. package/dist/templates/api.d.ts +2 -0
  40. package/dist/templates/api.js +3122 -0
  41. package/dist/templates/assets-private-proxy.d.ts +3 -0
  42. package/dist/templates/assets-private-proxy.js +277 -0
  43. package/dist/templates/desktop.d.ts +2 -0
  44. package/dist/templates/desktop.js +639 -0
  45. package/dist/templates/electron-updater.d.ts +3 -0
  46. package/dist/templates/electron-updater.js +358 -0
  47. package/dist/templates/extension.d.ts +1 -1
  48. package/dist/templates/extension.js +322 -248
  49. package/dist/templates/mobile.d.ts +2 -0
  50. package/dist/templates/mobile.js +720 -0
  51. package/dist/templates/shared.d.ts +2 -0
  52. package/dist/templates/shared.js +520 -0
  53. package/dist/templates/web.d.ts +2 -0
  54. package/dist/templates/web.js +412 -0
  55. package/dist/toolchain.d.ts +0 -8
  56. package/dist/toolchain.js +0 -21
  57. package/dist/transaction.d.ts +22 -0
  58. package/dist/transaction.js +822 -0
  59. package/dist/ui.d.ts +33 -0
  60. package/dist/ui.js +199 -0
  61. package/dist/util.d.ts +1 -5
  62. package/dist/util.js +70 -159
  63. package/dist/version.d.ts +2 -0
  64. package/dist/version.js +2 -0
  65. package/dist/workspace-config.d.ts +11 -0
  66. package/dist/workspace-config.js +372 -0
  67. package/docs/conventions.md +92 -0
  68. package/docs/master-stack-map.md +192 -0
  69. package/docs/output-tree-contract.md +482 -0
  70. package/docs/references/manual-scaffolding.md +403 -0
  71. package/docs/references/provisioning.md +220 -0
  72. package/package.json +57 -13
  73. package/templates/api-fastify/apps/api/tsconfig.json +20 -0
  74. package/templates/catalog.json +23 -0
  75. package/templates/db-drizzle/packages/db/tsconfig.json +14 -0
  76. package/templates/desktop-electron/apps/desktop/tsconfig.json +37 -0
  77. package/templates/extension-wxt/apps/extension/tsconfig.json +41 -0
  78. package/templates/mobile-expo/apps/mobile/expo-env.d.ts +1 -0
  79. package/templates/web-next/apps/web/next-env.d.ts +6 -0
  80. package/dist/branding.d.ts +0 -5
  81. package/dist/branding.js +0 -17
  82. package/dist/commands.d.ts +0 -7
  83. package/dist/commands.js +0 -24
  84. package/dist/templates/backend.d.ts +0 -2
  85. package/dist/templates/backend.js +0 -2115
  86. package/dist/templates/frontend.d.ts +0 -2
  87. package/dist/templates/frontend.js +0 -1268
@@ -1,49 +1,57 @@
1
1
  import { rmSync } from 'node:fs';
2
2
  import path from 'node:path';
3
- import { writeFile, exec } from '../util.js';
3
+ import { writeFile } from '../util.js';
4
4
  import { anhedralPrint } from '../print.js';
5
5
  import { EXTENSION_DEPENDENCIES } from '../dependencies.js';
6
- import { resolveToolchainChannel, resolveToolchain, toolPackageRef } from '../toolchain.js';
7
- export async function scaffoldExtension(root, { projectName, displayName, skipInstall }) {
8
- const dir = path.join(root, 'Extension');
9
- const toolchain = resolveToolchain(resolveToolchainChannel(process.env.ANHEDRAL_TOOLCHAIN));
6
+ import { childPackageName, htmlText, jsString, markdownHeading } from '../render.js';
7
+ function selectedDependencies(options) {
8
+ const dependencies = { ...(EXTENSION_DEPENDENCIES.dependencies ?? {}) };
9
+ if (!options.apps.api)
10
+ delete dependencies['@shared/api-client'];
11
+ if (!options.features.billing)
12
+ delete dependencies['@shared/realtime'];
13
+ if (!options.features.auth)
14
+ delete dependencies['@clerk/chrome-extension'];
15
+ return dependencies;
16
+ }
17
+ export async function scaffoldExtension(root, options) {
18
+ const { projectName, displayName, skipInstall } = options;
19
+ const dir = path.join(root, 'apps/extension');
10
20
  anhedralPrint.section('Chrome extension (WXT)');
11
- anhedralPrint.step('Scaffolding WXT extension');
12
- exec(`pnpm dlx --allow-build=esbuild --allow-build=spawn-sync ${toolPackageRef('wxt', toolchain.wxt)} init Extension -t react --pm pnpm`, root);
13
- writePackageJson(dir, projectName);
14
- anhedralPrint.done('WXT extension scaffolded');
15
- anhedralPrint.step('Installing Clerk + React + Tailwind dependencies');
21
+ anhedralPrint.step('Materializing bundled WXT substrate');
22
+ writePackageJson(dir, projectName, options);
23
+ anhedralPrint.done('WXT extension substrate materialized');
24
+ anhedralPrint.step(`Recording ${options.features.auth ? 'Clerk + ' : ''}React + Tailwind dependencies`);
16
25
  if (skipInstall) {
17
26
  anhedralPrint.info('Skipping extension dependency install (--skip-install)');
18
27
  anhedralPrint.info('Run after init: pnpm install');
19
28
  }
20
- else {
21
- exec('pnpm install --no-frozen-lockfile', dir);
22
- }
23
- anhedralPrint.done(skipInstall ? 'Extension dependency manifests written' : 'Extension dependencies installed');
29
+ anhedralPrint.done('Extension dependency manifests written');
24
30
  cleanWxtStarterFiles(dir);
25
- writeWxtConfig(dir, displayName);
26
- writeTsConfig(dir);
27
- writeEnvExample(dir);
28
- writeEnvFile(dir);
31
+ writeWxtConfig(dir, displayName, options);
32
+ writeEnvExample(dir, options);
29
33
  writePostcssConfig(dir);
30
34
  writeTailwindConfig(dir);
31
35
  writeShadcnConfig(dir);
32
- writeReadme(dir, displayName);
36
+ writeReadme(dir, displayName, options);
33
37
  writeCnUtil(dir);
34
38
  writeButtonComponent(dir);
35
- writeAuthContext(dir);
36
- writeApiClient(dir);
37
- writeBackground(dir);
38
- writeContentScript(dir);
39
- writeSidepanelEntry(dir);
39
+ if (options.features.auth)
40
+ writeAuthContext(dir);
41
+ if (options.apps.api)
42
+ writeApiClient(dir);
43
+ if (options.features.billing)
44
+ writeEntitlementHook(dir);
45
+ writeBackground(dir, options.features.auth);
46
+ writeSidepanelEntry(dir, options.features.auth);
40
47
  writeSidepanelHtml(dir, displayName);
41
- writeSidepanelApp(dir);
48
+ writeSidepanelApp(dir, options.features.auth, options.features.billing);
42
49
  writeStyles(dir);
43
50
  anhedralPrint.done('Extension source files written');
44
51
  }
45
52
  function cleanWxtStarterFiles(dir) {
46
53
  for (const relativePath of [
54
+ '.git',
47
55
  'entrypoints',
48
56
  'assets/react.svg',
49
57
  'public/wxt.svg',
@@ -51,44 +59,92 @@ function cleanWxtStarterFiles(dir) {
51
59
  rmSync(path.join(dir, relativePath), { recursive: true, force: true });
52
60
  }
53
61
  }
54
- function writePackageJson(dir, projectName) {
62
+ function writePackageJson(dir, projectName, options) {
55
63
  writeFile(path.join(dir, 'package.json'), JSON.stringify({
56
- name: projectName + '-chrome-ext',
64
+ name: childPackageName(projectName, 'chrome-ext'),
57
65
  version: '0.1.0',
58
66
  private: true,
59
67
  type: 'module',
60
68
  scripts: {
61
69
  dev: 'wxt',
62
- 'dev:firefox': 'wxt -b firefox',
63
70
  build: 'wxt build',
64
- 'build:firefox': 'wxt build -b firefox',
65
71
  postinstall: 'wxt prepare',
66
72
  zip: 'wxt zip',
67
- 'zip:firefox': 'wxt zip -b firefox',
68
73
  typecheck: 'tsc --noEmit',
69
74
  },
70
- dependencies: EXTENSION_DEPENDENCIES.dependencies,
75
+ dependencies: selectedDependencies(options),
71
76
  devDependencies: EXTENSION_DEPENDENCIES.devDependencies,
72
77
  }, null, 2) + '\n');
73
78
  }
74
- function writeWxtConfig(dir, displayName) {
79
+ function writeWxtConfig(dir, displayName, options) {
80
+ const nameLiteral = jsString(displayName);
81
+ const descriptionLiteral = jsString(`${displayName} Chrome Extension`);
82
+ const actionTitleLiteral = jsString(`Open ${displayName}`);
83
+ const permissionsLiteral = options.features.auth
84
+ ? "['activeTab', 'cookies', 'scripting', 'storage', 'sidePanel']"
85
+ : "['activeTab', 'scripting', 'sidePanel']";
86
+ const permissionSources = [
87
+ options.apps.api
88
+ ? " permissionForUrl('VITE_API_URL', import.meta.env.VITE_API_URL || (import.meta.env.DEV ? 'http://localhost:8787/api' : undefined)),"
89
+ : null,
90
+ options.features.auth
91
+ ? " permissionForUrl('VITE_CLERK_FRONTEND_API_URL', import.meta.env.VITE_CLERK_FRONTEND_API_URL),"
92
+ : null,
93
+ options.features.auth
94
+ ? " permissionForUrl('VITE_CLERK_SYNC_HOST', import.meta.env.VITE_CLERK_SYNC_HOST),"
95
+ : null,
96
+ ].filter((value) => value !== null);
97
+ const permissionHelper = permissionSources.length > 0
98
+ ? `
99
+ function permissionForUrl(name: string, value: string | undefined): string | null {
100
+ const candidate = value?.trim();
101
+ if (!candidate) return null;
102
+
103
+ let url: URL;
104
+ try {
105
+ url = new URL(candidate);
106
+ } catch {
107
+ throw new Error(\`\${name} must be a valid absolute URL\`);
108
+ }
109
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
110
+ throw new Error(\`\${name} must use http: or https:\`);
111
+ }
112
+ if (url.username || url.password) throw new Error(\`\${name} must not include URL credentials\`);
113
+ if (url.search || candidate.includes('?')) throw new Error(\`\${name} must not include a query string\`);
114
+ if (url.hash || candidate.includes('#')) throw new Error(\`\${name} must not include a URL fragment\`);
115
+ if (import.meta.env.PROD && url.protocol !== 'https:') {
116
+ throw new Error(\`\${name} must use https: in production\`);
117
+ }
118
+ return \`\${url.protocol}//\${url.hostname}/*\`;
119
+ }
120
+ `
121
+ : '';
122
+ const hostPermissionsSetup = permissionSources.length > 0
123
+ ? ` const hostPermissions = [...new Set([
124
+ ${permissionSources.join('\n')}
125
+ ].filter((value): value is string => value !== null))];
126
+ `
127
+ : '';
128
+ const hostPermissionsLiteral = permissionSources.length > 0 ? 'hostPermissions' : '[]';
75
129
  writeFile(path.join(dir, 'wxt.config.ts'), `import { defineConfig } from 'wxt';
130
+ ${permissionHelper}
76
131
 
77
132
  export default defineConfig({
78
133
  srcDir: 'src',
79
134
  manifest: () => {
80
- const crxPublicKey = process.env.VITE_CRX_PUBLIC_KEY || '';
135
+ const crxPublicKey = import.meta.env.VITE_CRX_PUBLIC_KEY || '';
136
+ ${hostPermissionsSetup}
81
137
 
82
138
  return {
83
- name: '${displayName}',
84
- description: '${displayName} Chrome Extension',
139
+ name: ${nameLiteral},
140
+ description: ${descriptionLiteral},
85
141
  version: '0.1.0',
86
142
  ...(crxPublicKey ? { key: crxPublicKey } : {}),
87
143
  minimum_chrome_version: '114',
88
- permissions: ['activeTab', 'cookies', 'storage', 'sidePanel'],
89
- host_permissions: [],
144
+ permissions: ${permissionsLiteral},
145
+ host_permissions: ${hostPermissionsLiteral},
90
146
  action: {
91
- default_title: 'Open ${displayName}',
147
+ default_title: ${actionTitleLiteral},
92
148
  },
93
149
  side_panel: {
94
150
  default_path: 'sidepanel.html',
@@ -104,62 +160,24 @@ export default defineConfig({
104
160
  });
105
161
  `);
106
162
  }
107
- function writeTsConfig(dir) {
108
- writeFile(path.join(dir, 'tsconfig.json'), JSON.stringify({
109
- compilerOptions: {
110
- target: 'ESNext',
111
- module: 'ESNext',
112
- moduleResolution: 'Bundler',
113
- strict: true,
114
- esModuleInterop: true,
115
- skipLibCheck: true,
116
- forceConsistentCasingInFileNames: true,
117
- resolveJsonModule: true,
118
- isolatedModules: true,
119
- allowImportingTsExtensions: true,
120
- noEmit: true,
121
- jsx: 'react-jsx',
122
- lib: ['DOM', 'DOM.Iterable', 'ESNext'],
123
- types: ['wxt/browser', '@wxt-dev/module-react', 'chrome'],
124
- paths: { '@/*': ['./src/*'] },
125
- },
126
- include: ['**/*.ts', '**/*.tsx', '.wxt/wxt.d.ts'],
127
- exclude: ['node_modules', '.output', 'dist'],
128
- }, null, 2) + '\n');
129
- }
130
- function writeEnvExample(dir) {
131
- writeFile(path.join(dir, '.env.example'), `# Clerk Authentication
163
+ function extensionEnvContents(options) {
164
+ const sections = [];
165
+ if (options.features.auth) {
166
+ sections.push(`# Clerk Authentication
132
167
  VITE_CLERK_PUBLISHABLE_KEY=pk_test_***
133
-
134
- # Backend API URL
135
- VITE_API_URL=http://localhost:8787
136
-
137
- # Website URL (for sign-up and subscription links)
138
- VITE_WEBSITE_URL=http://localhost:8081
139
-
140
- # Chrome Extension CRX public key (optional, for stable extension ID)
141
- VITE_CRX_PUBLIC_KEY=
142
-
143
- # RevenueCat Web Billing URL (optional, for subscription management)
144
- VITE_RC_BILLING_URL=
145
- `);
168
+ VITE_CLERK_FRONTEND_API_URL=
169
+ VITE_CLERK_SYNC_HOST=`);
170
+ }
171
+ if (options.apps.api) {
172
+ sections.push(`# API URL
173
+ VITE_API_URL=http://localhost:8787/api`);
174
+ }
175
+ sections.push(`# Chrome Extension CRX public key (optional, for stable extension ID)
176
+ VITE_CRX_PUBLIC_KEY=`);
177
+ return `${sections.join('\n\n')}\n`;
146
178
  }
147
- function writeEnvFile(dir) {
148
- writeFile(path.join(dir, '.env'), `# Clerk Authentication
149
- VITE_CLERK_PUBLISHABLE_KEY=
150
-
151
- # Backend API URL
152
- VITE_API_URL=http://localhost:8787
153
-
154
- # Website URL (for sign-up and subscription links)
155
- VITE_WEBSITE_URL=http://localhost:8081
156
-
157
- # Chrome Extension CRX public key (optional, for stable extension ID)
158
- VITE_CRX_PUBLIC_KEY=
159
-
160
- # RevenueCat Web Billing URL (optional, for subscription management)
161
- VITE_RC_BILLING_URL=
162
- `);
179
+ function writeEnvExample(dir, options) {
180
+ writeFile(path.join(dir, '.env.example'), extensionEnvContents(options));
163
181
  }
164
182
  function writePostcssConfig(dir) {
165
183
  writeFile(path.join(dir, 'postcss.config.cjs'), `module.exports = {
@@ -289,35 +307,52 @@ module.exports = {
289
307
  };
290
308
  `);
291
309
  }
292
- function writeReadme(dir, displayName) {
293
- writeFile(path.join(dir, 'README.md'), `# ${displayName} Chrome Extension
310
+ function writeReadme(dir, displayName, options) {
311
+ const configuration = [
312
+ options.features.auth
313
+ ? 'Set `VITE_CLERK_PUBLISHABLE_KEY` and `VITE_CLERK_FRONTEND_API_URL` before using authenticated routes. Set `VITE_CLERK_SYNC_HOST` only when syncing auth with another Clerk application.'
314
+ : null,
315
+ options.apps.api ? 'Set `VITE_API_URL` before using the shared API client.' : null,
316
+ 'Set `VITE_CRX_PUBLIC_KEY` only when you need a stable Chrome extension ID.',
317
+ ].filter((value) => value !== null).join(' ');
318
+ const authBehavior = options.features.auth ? ', the background script initializes Clerk' : '';
319
+ const networkIntegrations = [
320
+ options.apps.api ? '`VITE_API_URL`' : null,
321
+ options.features.auth ? '`VITE_CLERK_FRONTEND_API_URL` and optional `VITE_CLERK_SYNC_HOST`' : null,
322
+ ].filter((value) => value !== null).join(' and ');
323
+ const hostPermissionsGuidance = networkIntegrations
324
+ ? `\n\nThe manifest derives deduplicated Chrome host permissions from ${networkIntegrations}. Only absolute HTTP(S) URLs are accepted; malformed configured URLs fail the build. Set production URLs in the extension environment before publishing—no source edit is required.`
325
+ : '';
326
+ writeFile(path.join(dir, 'README.md'), `# ${markdownHeading(displayName)} Chrome Extension
294
327
 
295
328
  WXT side-panel extension generated by anhedral.
296
329
 
297
330
  ## Development
298
331
 
299
332
  \`\`\`bash
333
+ cp .env.example .env
300
334
  pnpm dev
301
335
  pnpm build
302
336
  pnpm zip
303
337
  \`\`\`
304
338
 
305
- 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.
339
+ Run these commands from \`apps/extension\`. Anhedral generates only \`.env.example\`; keep the local \`.env\` file uncommitted. ${configuration}
306
340
 
307
341
  ## Chrome
308
342
 
309
343
  Run \`pnpm build\`, then load \`.output/chrome-mv3\` as an unpacked extension from \`chrome://extensions\`.
310
344
 
311
- 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.
345
+ 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${authBehavior}, and active-page reads use a user-triggered \`activeTab\` + \`scripting\` grant instead of persistent site access.${hostPermissionsGuidance}
312
346
  `);
313
347
  }
314
348
  function writeAuthContext(dir) {
315
349
  writeFile(path.join(dir, 'src/contexts/auth-context.tsx'), `import * as React from 'react';
316
- import { ClerkProvider, useAuth as useClerkAuth, useUser } from '@clerk/chrome-extension';
317
- import { APIClient } from '../lib/api';
350
+ import { ClerkProvider, useAuth as useClerkAuth } from '@clerk/chrome-extension';
351
+ import { apiConfigurationError, createApiClient } from '../lib/api';
318
352
 
319
353
  const CLERK_PUBLISHABLE_KEY = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY || '';
320
- const WEBSITE_URL = import.meta.env.VITE_WEBSITE_URL || 'http://localhost:8081';
354
+ const CLERK_SYNC_HOST = import.meta.env.VITE_CLERK_SYNC_HOST || undefined;
355
+ const BACKGROUND_AUTH_ERROR_KEY = 'anhedralClerkBackgroundError';
321
356
 
322
357
  function getExtensionUrl(path: string) {
323
358
  if (typeof chrome !== 'undefined' && chrome.runtime?.getURL) {
@@ -332,107 +367,76 @@ type AuthState = {
332
367
  isSignedIn: boolean;
333
368
  isLoading: boolean;
334
369
  userId: string | null;
335
- subscription: {
336
- status: 'idle' | 'loading' | 'active' | 'inactive' | 'error';
337
- canAccess: boolean;
338
- inTrial?: boolean;
339
- trialEndsAt?: string;
340
- expiresAt?: string;
341
- method?: 'trialing' | 'redeemed' | 'paid' | null;
342
- managementUrl?: string;
343
- cancelAtPeriodEnd?: boolean;
344
- error?: string;
345
- };
346
370
  };
347
371
 
348
372
  type AuthContextValue = AuthState & {
373
+ api: ReturnType<typeof createApiClient>;
349
374
  signOut: () => Promise<void>;
350
- refreshSubscription: (opts?: { refresh?: boolean }) => Promise<void>;
351
375
  };
352
376
 
353
377
  const AuthContext = React.createContext<AuthContextValue | null>(null);
354
378
 
355
- function AuthProviderInner({ children }: { children: React.ReactNode }) {
356
- const { isSignedIn, isLoaded, userId, signOut, getToken } = useClerkAuth();
357
- const { user } = useUser();
358
-
359
- const [subscription, setSubscription] = React.useState<AuthState['subscription']>({
360
- status: 'idle',
361
- canAccess: false,
362
- });
363
-
364
- const apiRef = React.useRef<APIClient | null>(null);
365
-
366
- React.useEffect(() => {
367
- if (isSignedIn && getToken) {
368
- apiRef.current = new APIClient(getToken);
369
- } else {
370
- apiRef.current = null;
371
- }
372
- }, [isSignedIn, getToken]);
373
-
374
- const checkSubscription = React.useCallback(
375
- async (opts?: { refresh?: boolean }) => {
376
- if (!apiRef.current || !isSignedIn) {
377
- setSubscription({ status: 'idle', canAccess: false });
378
- return;
379
- }
380
- setSubscription(prev => ({ ...prev, status: 'loading' }));
381
- try {
382
- const result = await apiRef.current.getSubscriptionEntitlements(opts);
383
- const isPro = result.pro;
384
- const inTrial = result.inTrial;
385
- setSubscription({
386
- status: isPro ? 'active' : 'inactive',
387
- canAccess: isPro,
388
- inTrial,
389
- trialEndsAt: result.trialEndsAt,
390
- expiresAt: result.expiresAt,
391
- method: result.method,
392
- managementUrl: result.managementUrl,
393
- cancelAtPeriodEnd: result.cancelAtPeriodEnd,
394
- });
395
- } catch (error) {
396
- setSubscription({
397
- status: 'error',
398
- canAccess: false,
399
- error: error instanceof Error ? error.message : 'Failed to check subscription',
400
- });
401
- }
402
- },
403
- [isSignedIn],
379
+ function ConfigurationError({ message }: { message: string }) {
380
+ return (
381
+ <div role="alert" aria-live="assertive" style={{ padding: 24, color: 'hsl(var(--destructive))' }}>
382
+ <h2>Extension configuration required</h2>
383
+ <p>{message}</p>
384
+ </div>
404
385
  );
386
+ }
405
387
 
388
+ function useBackgroundAuthError(): string | null {
389
+ const [error, setError] = React.useState<string | null>(null);
406
390
  React.useEffect(() => {
407
- if (isSignedIn && isLoaded) {
408
- void checkSubscription({ refresh: true });
409
- }
410
- }, [isSignedIn, isLoaded, checkSubscription]);
391
+ let active = true;
392
+ void chrome.storage.local.get(BACKGROUND_AUTH_ERROR_KEY).then((values) => {
393
+ const message = values[BACKGROUND_AUTH_ERROR_KEY];
394
+ if (active) setError(typeof message === 'string' ? message : null);
395
+ });
396
+ const listener = (changes: Record<string, chrome.storage.StorageChange>, areaName: string) => {
397
+ if (areaName !== 'local') return;
398
+ const message = changes[BACKGROUND_AUTH_ERROR_KEY]?.newValue;
399
+ setError(typeof message === 'string' ? message : null);
400
+ };
401
+ chrome.storage.onChanged.addListener(listener);
402
+ return () => {
403
+ active = false;
404
+ chrome.storage.onChanged.removeListener(listener);
405
+ };
406
+ }, []);
407
+ return error;
408
+ }
411
409
 
412
- const handleSignOut = React.useCallback(async () => {
413
- setSubscription({ status: 'idle', canAccess: false });
414
- await signOut();
415
- }, [signOut]);
410
+ function AuthProviderInner({ children }: { children: React.ReactNode }) {
411
+ const { isSignedIn, isLoaded, userId, signOut, getToken } = useClerkAuth();
412
+ const api = React.useMemo(() => createApiClient(() => getToken()), [getToken]);
416
413
 
417
414
  const value = React.useMemo<AuthContextValue>(
418
415
  () => ({
416
+ api,
419
417
  isSignedIn: !!isSignedIn,
420
418
  isLoading: !isLoaded,
421
419
  userId: userId || null,
422
- subscription,
423
- signOut: handleSignOut,
424
- refreshSubscription: (opts?: { refresh?: boolean }) => checkSubscription(opts),
420
+ signOut,
425
421
  }),
426
- [isSignedIn, isLoaded, userId, subscription, handleSignOut, checkSubscription],
422
+ [api, isSignedIn, isLoaded, userId, signOut],
427
423
  );
428
424
 
429
425
  return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
430
426
  }
431
427
 
432
428
  export function AuthProvider({ children }: { children: React.ReactNode }) {
429
+ const backgroundError = useBackgroundAuthError();
430
+ if (!CLERK_PUBLISHABLE_KEY) {
431
+ return <ConfigurationError message="Set VITE_CLERK_PUBLISHABLE_KEY before building the extension." />;
432
+ }
433
+ const apiError = apiConfigurationError();
434
+ if (apiError) return <ConfigurationError message={apiError} />;
435
+ if (backgroundError) return <ConfigurationError message={backgroundError} />;
433
436
  return (
434
437
  <ClerkProvider
435
438
  publishableKey={CLERK_PUBLISHABLE_KEY}
439
+ syncHost={CLERK_SYNC_HOST}
436
440
  afterSignOutUrl={SIDEPANEL_URL}
437
441
  signInFallbackRedirectUrl={SIDEPANEL_URL}
438
442
  signUpFallbackRedirectUrl={SIDEPANEL_URL}
@@ -447,49 +451,114 @@ export function useAuth() {
447
451
  if (!ctx) throw new Error('useAuth must be used within AuthProvider');
448
452
  return ctx;
449
453
  }
450
-
451
- export { WEBSITE_URL };
452
454
  `);
453
455
  }
454
456
  function writeApiClient(dir) {
455
- writeFile(path.join(dir, 'src/lib/api.ts'), `import { ApiClient } from '@shared/api-client';
456
-
457
- export class APIClient {
458
- constructor(private getToken: () => Promise<string | null>) {}
459
-
460
- private client() {
461
- return new ApiClient({
462
- baseUrl: import.meta.env.VITE_API_URL || 'http://localhost:8787',
463
- getToken: this.getToken,
464
- platform: 'extension',
465
- });
457
+ writeFile(path.join(dir, 'src/lib/api.ts'), `import { ApiClient, normalizeApiBaseUrl } from '@shared/api-client';
458
+
459
+ function apiBaseUrl(): string {
460
+ const configured = import.meta.env.VITE_API_URL?.trim();
461
+ const candidate = configured || (import.meta.env.DEV ? 'http://localhost:8787/api' : '');
462
+ if (!candidate) throw new Error('VITE_API_URL is required in production builds');
463
+ const normalized = normalizeApiBaseUrl(candidate, 'VITE_API_URL');
464
+ const url = new URL(normalized);
465
+ if (import.meta.env.PROD && url.protocol !== 'https:') {
466
+ throw new Error('VITE_API_URL must use https: in production');
466
467
  }
468
+ return normalized;
469
+ }
467
470
 
468
- getMe() {
469
- return this.client().getMe();
471
+ export function apiConfigurationError(): string | null {
472
+ try {
473
+ apiBaseUrl();
474
+ return null;
475
+ } catch (error) {
476
+ return error instanceof Error ? error.message : 'The API URL is invalid';
470
477
  }
478
+ }
471
479
 
472
- getSubscriptionEntitlements(options?: { refresh?: boolean }) {
473
- return this.client().getSubscriptionEntitlements(options);
474
- }
480
+ export function createApiClient(getToken?: () => Promise<string | null>) {
481
+ return new ApiClient({
482
+ baseUrl: apiBaseUrl(),
483
+ getToken,
484
+ });
485
+ }
486
+ `);
487
+ }
488
+ function writeEntitlementHook(dir) {
489
+ writeFile(path.join(dir, 'src/hooks/use-entitlement.ts'), `import { subscribeToSubscriptionChanges } from '@shared/realtime';
490
+ import * as React from 'react';
491
+ import { useAuth } from '../contexts/auth-context';
492
+
493
+ export function useEntitlement() {
494
+ const { api, isSignedIn, userId } = useAuth();
495
+ const [entitlement, setEntitlement] = React.useState<Awaited<ReturnType<typeof api.getEntitlement>> | null>(null);
496
+ const [error, setError] = React.useState<string | null>(null);
497
+ const revision = React.useRef(0);
498
+ const refresh = React.useCallback(async () => {
499
+ if (!isSignedIn) return;
500
+ try {
501
+ const next = await api.getEntitlement();
502
+ revision.current = Math.max(revision.current, next.revision);
503
+ setEntitlement(next);
504
+ setError(null);
505
+ } catch (cause) {
506
+ setError(cause instanceof Error ? cause.message : 'Unable to load subscription');
507
+ }
508
+ }, [api, isSignedIn]);
509
+ React.useEffect(() => { void refresh(); }, [refresh]);
510
+ React.useEffect(() => {
511
+ const onVisibility = () => { if (document.visibilityState === 'visible') void refresh(); };
512
+ document.addEventListener('visibilitychange', onVisibility);
513
+ return () => document.removeEventListener('visibilitychange', onVisibility);
514
+ }, [refresh]);
515
+ React.useEffect(() => {
516
+ if (!userId || !isSignedIn) return;
517
+ return subscribeToSubscriptionChanges({
518
+ userId,
519
+ getTokenRequest: () => api.getRealtimeToken(),
520
+ onChange: (nextRevision) => { if (nextRevision > revision.current) void refresh(); },
521
+ onError: (cause) => setError(cause.message),
522
+ });
523
+ }, [api, isSignedIn, refresh, userId]);
524
+ return { entitlement, error };
475
525
  }
476
526
  `);
477
527
  }
478
- function writeBackground(dir) {
479
- writeFile(path.join(dir, 'src/entrypoints/background.ts'), `import { createClerkClient } from '@clerk/chrome-extension/background';
528
+ function writeBackground(dir, hasAuth) {
529
+ const clerkImport = hasAuth
530
+ ? "import { createClerkClient } from '@clerk/chrome-extension/background';\n\n"
531
+ : '';
532
+ const clerkInitialization = hasAuth
533
+ ? ` const backgroundAuthErrorKey = 'anhedralClerkBackgroundError';
534
+ const publishableKey = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY?.trim();
535
+ if (!publishableKey) {
536
+ void chrome.storage.local.set({
537
+ [backgroundAuthErrorKey]: 'Set VITE_CLERK_PUBLISHABLE_KEY before building the extension.',
538
+ });
539
+ } else {
540
+ // Initialize Clerk in the background script for cookie-based auth and expose failures to the panel.
541
+ void createClerkClient({
542
+ publishableKey,
543
+ syncHost: import.meta.env.VITE_CLERK_SYNC_HOST || undefined,
544
+ }).then(
545
+ () => chrome.storage.local.remove(backgroundAuthErrorKey),
546
+ () => chrome.storage.local.set({
547
+ [backgroundAuthErrorKey]: 'Clerk background initialization failed. Check the key and allowed origins.',
548
+ }),
549
+ );
550
+ }
480
551
 
481
- type ChromeWithSidePanel = typeof chrome & {
552
+ `
553
+ : '';
554
+ writeFile(path.join(dir, 'src/entrypoints/background.ts'), `${clerkImport}type ChromeWithSidePanel = typeof chrome & {
482
555
  sidePanel: {
483
556
  setPanelBehavior: (behavior: { openPanelOnActionClick: boolean }) => Promise<void>;
484
557
  };
485
558
  };
486
559
 
487
560
  export default defineBackground(() => {
488
- // Initialize Clerk in the background script for cookie-based auth
489
- void createClerkClient({
490
- publishableKey: import.meta.env.VITE_CLERK_PUBLISHABLE_KEY || '',
491
- }).catch(() => {});
492
-
561
+ ${clerkInitialization}
493
562
  // Open the side panel when the extension icon is clicked.
494
563
  (chrome as ChromeWithSidePanel).sidePanel
495
564
  .setPanelBehavior({ openPanelOnActionClick: true })
@@ -497,28 +566,16 @@ export default defineBackground(() => {
497
566
  });
498
567
  `);
499
568
  }
500
- function writeContentScript(dir) {
501
- writeFile(path.join(dir, 'src/entrypoints/content.ts'), `export default defineContentScript({
502
- matches: ['<all_urls>'],
503
- main() {
504
- chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
505
- if (message?.type !== 'ANHEDRAL_PAGE_SNAPSHOT') return false;
506
-
507
- sendResponse({
508
- title: document.title,
509
- location: window.location.href,
510
- });
511
- return true;
512
- });
513
- },
514
- });
515
- `);
516
- }
517
- function writeSidepanelEntry(dir) {
569
+ function writeSidepanelEntry(dir, hasAuth) {
570
+ const authImport = hasAuth ? "import { AuthProvider } from '../../contexts/auth-context';\n" : '';
571
+ const app = hasAuth
572
+ ? ` <AuthProvider>
573
+ <SidePanelApp />
574
+ </AuthProvider>`
575
+ : ' <SidePanelApp />';
518
576
  writeFile(path.join(dir, 'src/entrypoints/sidepanel/main.tsx'), `import * as React from 'react';
519
577
  import { createRoot } from 'react-dom/client';
520
- import { AuthProvider } from '../../contexts/auth-context';
521
- import { SidePanelApp } from './app';
578
+ ${authImport}import { SidePanelApp } from './app';
522
579
  import '../../styles/main.css';
523
580
 
524
581
  const container = document.getElementById('root');
@@ -526,9 +583,7 @@ if (container) {
526
583
  const root = createRoot(container);
527
584
  root.render(
528
585
  <React.StrictMode>
529
- <AuthProvider>
530
- <SidePanelApp />
531
- </AuthProvider>
586
+ ${app}
532
587
  </React.StrictMode>
533
588
  );
534
589
  }
@@ -540,7 +595,7 @@ function writeSidepanelHtml(dir, displayName) {
540
595
  <head>
541
596
  <meta charset="UTF-8" />
542
597
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
543
- <title>${displayName}</title>
598
+ <title>${htmlText(displayName)}</title>
544
599
  </head>
545
600
  <body>
546
601
  <div id="root"></div>
@@ -549,11 +604,40 @@ function writeSidepanelHtml(dir, displayName) {
549
604
  </html>
550
605
  `);
551
606
  }
552
- function writeSidepanelApp(dir) {
607
+ function writeSidepanelApp(dir, hasAuth, hasBilling) {
608
+ const authImports = hasAuth
609
+ ? "import { useAuth } from '../../contexts/auth-context';\nimport { SignIn } from '@clerk/chrome-extension';\n"
610
+ : '';
611
+ const authState = hasAuth
612
+ ? ' const { isSignedIn, isLoading, signOut } = useAuth();\n'
613
+ : '';
614
+ const authGuards = hasAuth
615
+ ? `
616
+ if (isLoading) {
617
+ return <div role="status" aria-live="polite" style={{ padding: 24, textAlign: 'center' }}>Loading account…</div>;
618
+ }
619
+
620
+ if (!isSignedIn) {
621
+ return (
622
+ <div aria-label="Sign in" style={{ padding: 24 }}>
623
+ <SignIn />
624
+ </div>
625
+ );
626
+ }
627
+ `
628
+ : '';
629
+ const signOutButton = hasAuth
630
+ ? ' <Button type="button" variant="outline" onClick={() => void signOut()}>Sign Out</Button>\n'
631
+ : '';
632
+ const entitlementImport = hasBilling ? "import { useEntitlement } from '../../hooks/use-entitlement';\n" : '';
633
+ const entitlementState = hasBilling ? ' const { entitlement, error: entitlementError } = useEntitlement();\n' : '';
634
+ const entitlementStatus = hasBilling
635
+ ? ` {entitlement ? <p role="status">Plan: {entitlement.entitlement} ({entitlement.status})</p> : null}
636
+ {entitlementError ? <p role="alert" style={{ color: 'hsl(var(--destructive))' }}>{entitlementError}</p> : null}
637
+ `
638
+ : '';
553
639
  writeFile(path.join(dir, 'src/entrypoints/sidepanel/app.tsx'), `import * as React from 'react';
554
- import { useAuth } from '../../contexts/auth-context';
555
- import { SignIn } from '@clerk/chrome-extension';
556
- import { Button } from '../../components/ui/button';
640
+ ${authImports}${entitlementImport}import { Button } from '../../components/ui/button';
557
641
 
558
642
  type PageSnapshot = {
559
643
  title: string;
@@ -561,8 +645,7 @@ type PageSnapshot = {
561
645
  };
562
646
 
563
647
  export function SidePanelApp() {
564
- const { isSignedIn, isLoading, signOut, subscription } = useAuth();
565
- const [page, setPage] = React.useState<PageSnapshot | null>(null);
648
+ ${authState}${entitlementState} const [page, setPage] = React.useState<PageSnapshot | null>(null);
566
649
  const [pageError, setPageError] = React.useState<string | null>(null);
567
650
 
568
651
  const readActivePage = React.useCallback(async () => {
@@ -574,39 +657,30 @@ export function SidePanelApp() {
574
657
  }
575
658
 
576
659
  try {
577
- const response = await chrome.tabs.sendMessage(tab.id, { type: 'ANHEDRAL_PAGE_SNAPSHOT' });
578
- setPage(response as PageSnapshot);
660
+ const [injection] = await chrome.scripting.executeScript({
661
+ target: { tabId: tab.id },
662
+ func: () => ({ title: document.title, location: window.location.href }),
663
+ });
664
+ if (!injection?.result) throw new Error('The active page did not return a snapshot.');
665
+ setPage(injection.result as PageSnapshot);
579
666
  } catch {
580
- setPageError('Refresh the active page, then try again.');
667
+ setPageError('Chrome does not allow this page to be inspected. Open a normal website and try again.');
581
668
  }
582
669
  }, []);
583
670
 
584
- if (isLoading) {
585
- return <div style={{ padding: 24, textAlign: 'center' }}>Loading...</div>;
586
- }
587
-
588
- if (!isSignedIn) {
589
- return (
590
- <div style={{ padding: 24 }}>
591
- <SignIn />
592
- </div>
593
- );
594
- }
595
-
671
+ ${authGuards}
596
672
  return (
597
673
  <div style={{ padding: 24 }}>
598
674
  <h2>Welcome!</h2>
599
- <p>Subscription: {subscription.status}</p>
600
675
  <Button type="button" onClick={() => void readActivePage()}>Read active page</Button>
601
676
  {page ? (
602
- <div style={{ marginTop: 16 }}>
677
+ <div role="status" aria-live="polite" style={{ marginTop: 16 }}>
603
678
  <strong>{page.title || 'Untitled page'}</strong>
604
679
  <p style={{ overflowWrap: 'anywhere' }}>{page.location}</p>
605
680
  </div>
606
681
  ) : null}
607
- {pageError ? <p style={{ color: 'hsl(var(--destructive))' }}>{pageError}</p> : null}
608
- <Button type="button" variant="outline" onClick={signOut}>Sign Out</Button>
609
- </div>
682
+ {pageError ? <p role="alert" aria-live="assertive" style={{ color: 'hsl(var(--destructive))' }}>{pageError}</p> : null}
683
+ ${entitlementStatus}${signOutButton} </div>
610
684
  );
611
685
  }
612
686
  `);