anhedral 0.4.1 → 0.6.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 (85) hide show
  1. package/README.md +29 -256
  2. package/assets/anhedral-cli-init-technical.svg +322 -0
  3. package/assets/anhedral-cli-init.svg +257 -173
  4. package/dist/bin.js +28 -162
  5. package/dist/dependencies.d.ts +7 -56
  6. package/dist/dependencies.js +96 -305
  7. package/dist/index.d.ts +2 -7
  8. package/dist/index.js +1 -4
  9. package/dist/{templates → platforms}/desktop.d.ts +1 -1
  10. package/dist/{templates → platforms}/desktop.js +28 -266
  11. package/dist/{templates → platforms}/extension.d.ts +1 -1
  12. package/dist/platforms/extension.js +401 -0
  13. package/dist/{templates → platforms}/mobile.d.ts +1 -1
  14. package/dist/platforms/mobile.js +232 -0
  15. package/dist/project.d.ts +4 -0
  16. package/dist/project.js +1 -0
  17. package/dist/standard-products.d.ts +2 -0
  18. package/dist/standard-products.js +6 -0
  19. package/dist/standard.d.ts +18 -0
  20. package/dist/standard.js +578 -0
  21. package/dist/version.d.ts +1 -1
  22. package/dist/version.js +1 -1
  23. package/docs/application-stack-standard.md +700 -0
  24. package/package.json +34 -49
  25. package/CHANGELOG.md +0 -132
  26. package/CONTRIBUTING.md +0 -73
  27. package/SECURITY.md +0 -37
  28. package/SKILL.md +0 -244
  29. package/assets/images/svg/ably-symbol-color.svg +0 -8
  30. package/bin/anhedral.js +0 -2
  31. package/dist/architecture/contributions.d.ts +0 -28
  32. package/dist/architecture/contributions.js +0 -125
  33. package/dist/architecture/index.d.ts +0 -12
  34. package/dist/architecture/index.js +0 -6
  35. package/dist/architecture/manifest.d.ts +0 -53
  36. package/dist/architecture/manifest.js +0 -283
  37. package/dist/architecture/modules.d.ts +0 -37
  38. package/dist/architecture/modules.js +0 -184
  39. package/dist/architecture/plan.d.ts +0 -58
  40. package/dist/architecture/plan.js +0 -145
  41. package/dist/architecture/templates.d.ts +0 -10
  42. package/dist/architecture/templates.js +0 -23
  43. package/dist/cli.d.ts +0 -32
  44. package/dist/cli.js +0 -235
  45. package/dist/composers/root.d.ts +0 -6
  46. package/dist/composers/root.js +0 -35
  47. package/dist/print.d.ts +0 -8
  48. package/dist/print.js +0 -48
  49. package/dist/prompts.d.ts +0 -5
  50. package/dist/prompts.js +0 -19
  51. package/dist/scaffold.d.ts +0 -76
  52. package/dist/scaffold.js +0 -1895
  53. package/dist/template-source.d.ts +0 -20
  54. package/dist/template-source.js +0 -180
  55. package/dist/templates/api.d.ts +0 -2
  56. package/dist/templates/api.js +0 -3122
  57. package/dist/templates/assets-private-proxy.d.ts +0 -3
  58. package/dist/templates/assets-private-proxy.js +0 -277
  59. package/dist/templates/electron-updater.d.ts +0 -3
  60. package/dist/templates/electron-updater.js +0 -358
  61. package/dist/templates/extension.js +0 -732
  62. package/dist/templates/mobile.js +0 -720
  63. package/dist/templates/shared.d.ts +0 -2
  64. package/dist/templates/shared.js +0 -520
  65. package/dist/templates/web.d.ts +0 -2
  66. package/dist/templates/web.js +0 -412
  67. package/dist/toolchain.d.ts +0 -3
  68. package/dist/toolchain.js +0 -8
  69. package/dist/ui.d.ts +0 -33
  70. package/dist/ui.js +0 -199
  71. package/dist/workspace-config.d.ts +0 -11
  72. package/dist/workspace-config.js +0 -372
  73. package/docs/conventions.md +0 -92
  74. package/docs/master-stack-map.md +0 -199
  75. package/docs/output-tree-contract.md +0 -482
  76. package/docs/references/manual-scaffolding.md +0 -403
  77. package/docs/references/provisioning.md +0 -220
  78. package/scripts/render-master-stack-map.mjs +0 -510
  79. package/templates/api-fastify/apps/api/tsconfig.json +0 -20
  80. package/templates/catalog.json +0 -23
  81. package/templates/db-drizzle/packages/db/tsconfig.json +0 -14
  82. package/templates/desktop-electron/apps/desktop/tsconfig.json +0 -37
  83. package/templates/extension-wxt/apps/extension/tsconfig.json +0 -41
  84. package/templates/mobile-expo/apps/mobile/expo-env.d.ts +0 -1
  85. package/templates/web-next/apps/web/next-env.d.ts +0 -6
@@ -1,732 +0,0 @@
1
- import { rmSync } from 'node:fs';
2
- import path from 'node:path';
3
- import { writeFile } from '../util.js';
4
- import { anhedralPrint } from '../print.js';
5
- import { EXTENSION_DEPENDENCIES } from '../dependencies.js';
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');
20
- anhedralPrint.section('Chrome extension (WXT)');
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`);
25
- if (skipInstall) {
26
- anhedralPrint.info('Skipping extension dependency install (--skip-install)');
27
- anhedralPrint.info('Run after init: pnpm install');
28
- }
29
- anhedralPrint.done('Extension dependency manifests written');
30
- cleanWxtStarterFiles(dir);
31
- writeWxtConfig(dir, displayName, options);
32
- writeEnvExample(dir, options);
33
- writePostcssConfig(dir);
34
- writeTailwindConfig(dir);
35
- writeShadcnConfig(dir);
36
- writeReadme(dir, displayName, options);
37
- writeCnUtil(dir);
38
- writeButtonComponent(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);
47
- writeSidepanelHtml(dir, displayName);
48
- writeSidepanelApp(dir, options.features.auth, options.features.billing);
49
- writeStyles(dir);
50
- anhedralPrint.done('Extension source files written');
51
- }
52
- function cleanWxtStarterFiles(dir) {
53
- for (const relativePath of [
54
- '.git',
55
- 'entrypoints',
56
- 'assets/react.svg',
57
- 'public/wxt.svg',
58
- ]) {
59
- rmSync(path.join(dir, relativePath), { recursive: true, force: true });
60
- }
61
- }
62
- function writePackageJson(dir, projectName, options) {
63
- writeFile(path.join(dir, 'package.json'), JSON.stringify({
64
- name: childPackageName(projectName, 'chrome-ext'),
65
- version: '0.1.0',
66
- private: true,
67
- type: 'module',
68
- scripts: {
69
- dev: 'wxt',
70
- build: 'wxt build',
71
- postinstall: 'wxt prepare',
72
- zip: 'wxt zip',
73
- typecheck: 'tsc --noEmit',
74
- },
75
- dependencies: selectedDependencies(options),
76
- devDependencies: EXTENSION_DEPENDENCIES.devDependencies,
77
- }, null, 2) + '\n');
78
- }
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' : '[]';
129
- writeFile(path.join(dir, 'wxt.config.ts'), `import { defineConfig } from 'wxt';
130
- ${permissionHelper}
131
-
132
- export default defineConfig({
133
- srcDir: 'src',
134
- manifest: () => {
135
- const crxPublicKey = import.meta.env.VITE_CRX_PUBLIC_KEY || '';
136
- ${hostPermissionsSetup}
137
-
138
- return {
139
- name: ${nameLiteral},
140
- description: ${descriptionLiteral},
141
- version: '0.1.0',
142
- ...(crxPublicKey ? { key: crxPublicKey } : {}),
143
- minimum_chrome_version: '114',
144
- permissions: ${permissionsLiteral},
145
- host_permissions: ${hostPermissionsLiteral},
146
- action: {
147
- default_title: ${actionTitleLiteral},
148
- },
149
- side_panel: {
150
- default_path: 'sidepanel.html',
151
- },
152
- };
153
- },
154
- modules: ['@wxt-dev/module-react'],
155
- vite: () => ({
156
- build: {
157
- chunkSizeWarningLimit: 3000,
158
- },
159
- }),
160
- });
161
- `);
162
- }
163
- function extensionEnvContents(options) {
164
- const sections = [];
165
- if (options.features.auth) {
166
- sections.push(`# Clerk Authentication
167
- VITE_CLERK_PUBLISHABLE_KEY=pk_test_***
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`;
178
- }
179
- function writeEnvExample(dir, options) {
180
- writeFile(path.join(dir, '.env.example'), extensionEnvContents(options));
181
- }
182
- function writePostcssConfig(dir) {
183
- writeFile(path.join(dir, 'postcss.config.cjs'), `module.exports = {
184
- plugins: {
185
- tailwindcss: {},
186
- autoprefixer: {},
187
- },
188
- };
189
- `);
190
- }
191
- function writeShadcnConfig(dir) {
192
- writeFile(path.join(dir, 'components.json'), JSON.stringify({
193
- $schema: 'https://ui.shadcn.com/schema.json',
194
- style: 'new-york',
195
- rsc: false,
196
- tsx: true,
197
- tailwind: {
198
- config: '',
199
- css: 'src/styles/main.css',
200
- baseColor: 'neutral',
201
- cssVariables: true,
202
- prefix: '',
203
- },
204
- aliases: {
205
- components: '@/components',
206
- utils: '@/lib/utils',
207
- ui: '@/components/ui',
208
- lib: '@/lib',
209
- hooks: '@/hooks',
210
- },
211
- iconLibrary: 'lucide',
212
- }, null, 2) + '\n');
213
- }
214
- function writeCnUtil(dir) {
215
- writeFile(path.join(dir, 'src/lib/utils.ts'), `import { clsx, type ClassValue } from 'clsx';
216
- import { twMerge } from 'tailwind-merge';
217
-
218
- export function cn(...inputs: ClassValue[]) {
219
- return twMerge(clsx(inputs));
220
- }
221
- `);
222
- }
223
- function writeButtonComponent(dir) {
224
- writeFile(path.join(dir, 'src/components/ui/button.tsx'), `import * as React from 'react';
225
- import { cn } from '@/lib/utils';
226
-
227
- type ButtonVariant = 'default' | 'outline';
228
-
229
- export type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
230
- variant?: ButtonVariant;
231
- };
232
-
233
- const variantClasses: Record<ButtonVariant, string> = {
234
- default: 'bg-primary text-primary-foreground hover:bg-primary/90',
235
- outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
236
- };
237
-
238
- export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
239
- ({ className, variant = 'default', type = 'button', ...props }, ref) => {
240
- return (
241
- <button
242
- ref={ref}
243
- type={type}
244
- className={cn(
245
- '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',
246
- variantClasses[variant],
247
- className,
248
- )}
249
- {...props}
250
- />
251
- );
252
- },
253
- );
254
-
255
- Button.displayName = 'Button';
256
- `);
257
- }
258
- function writeTailwindConfig(dir) {
259
- writeFile(path.join(dir, 'tailwind.config.cjs'), `/** @type {import('tailwindcss').Config} */
260
- module.exports = {
261
- content: ['./src/**/*.{ts,tsx,html}'],
262
- theme: {
263
- extend: {
264
- colors: {
265
- border: 'hsl(var(--border))',
266
- input: 'hsl(var(--input))',
267
- ring: 'hsl(var(--ring))',
268
- background: 'hsl(var(--background))',
269
- foreground: 'hsl(var(--foreground))',
270
- primary: {
271
- DEFAULT: 'hsl(var(--primary))',
272
- foreground: 'hsl(var(--primary-foreground))',
273
- },
274
- secondary: {
275
- DEFAULT: 'hsl(var(--secondary))',
276
- foreground: 'hsl(var(--secondary-foreground))',
277
- },
278
- destructive: {
279
- DEFAULT: 'hsl(var(--destructive))',
280
- foreground: 'hsl(var(--destructive-foreground))',
281
- },
282
- muted: {
283
- DEFAULT: 'hsl(var(--muted))',
284
- foreground: 'hsl(var(--muted-foreground))',
285
- },
286
- accent: {
287
- DEFAULT: 'hsl(var(--accent))',
288
- foreground: 'hsl(var(--accent-foreground))',
289
- },
290
- popover: {
291
- DEFAULT: 'hsl(var(--popover))',
292
- foreground: 'hsl(var(--popover-foreground))',
293
- },
294
- card: {
295
- DEFAULT: 'hsl(var(--card))',
296
- foreground: 'hsl(var(--card-foreground))',
297
- },
298
- },
299
- borderRadius: {
300
- lg: 'var(--radius)',
301
- md: 'calc(var(--radius) - 2px)',
302
- sm: 'calc(var(--radius) - 4px)',
303
- },
304
- },
305
- },
306
- plugins: [],
307
- };
308
- `);
309
- }
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
327
-
328
- WXT side-panel extension generated by anhedral.
329
-
330
- ## Development
331
-
332
- \`\`\`bash
333
- cp .env.example .env
334
- pnpm dev
335
- pnpm build
336
- pnpm zip
337
- \`\`\`
338
-
339
- Run these commands from \`apps/extension\`. Anhedral generates only \`.env.example\`; keep the local \`.env\` file uncommitted. ${configuration}
340
-
341
- ## Chrome
342
-
343
- Run \`pnpm build\`, then load \`.output/chrome-mv3\` as an unpacked extension from \`chrome://extensions\`.
344
-
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}
346
- `);
347
- }
348
- function writeAuthContext(dir) {
349
- writeFile(path.join(dir, 'src/contexts/auth-context.tsx'), `import * as React from 'react';
350
- import { ClerkProvider, useAuth as useClerkAuth } from '@clerk/chrome-extension';
351
- import { apiConfigurationError, createApiClient } from '../lib/api';
352
-
353
- const CLERK_PUBLISHABLE_KEY = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY || '';
354
- const CLERK_SYNC_HOST = import.meta.env.VITE_CLERK_SYNC_HOST || undefined;
355
- const BACKGROUND_AUTH_ERROR_KEY = 'anhedralClerkBackgroundError';
356
-
357
- function getExtensionUrl(path: string) {
358
- if (typeof chrome !== 'undefined' && chrome.runtime?.getURL) {
359
- return chrome.runtime.getURL(path);
360
- }
361
- return path;
362
- }
363
-
364
- const SIDEPANEL_URL = getExtensionUrl('sidepanel.html');
365
-
366
- type AuthState = {
367
- isSignedIn: boolean;
368
- isLoading: boolean;
369
- userId: string | null;
370
- };
371
-
372
- type AuthContextValue = AuthState & {
373
- api: ReturnType<typeof createApiClient>;
374
- signOut: () => Promise<void>;
375
- };
376
-
377
- const AuthContext = React.createContext<AuthContextValue | null>(null);
378
-
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>
385
- );
386
- }
387
-
388
- function useBackgroundAuthError(): string | null {
389
- const [error, setError] = React.useState<string | null>(null);
390
- React.useEffect(() => {
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
- }
409
-
410
- function AuthProviderInner({ children }: { children: React.ReactNode }) {
411
- const { isSignedIn, isLoaded, userId, signOut, getToken } = useClerkAuth();
412
- const api = React.useMemo(() => createApiClient(() => getToken()), [getToken]);
413
-
414
- const value = React.useMemo<AuthContextValue>(
415
- () => ({
416
- api,
417
- isSignedIn: !!isSignedIn,
418
- isLoading: !isLoaded,
419
- userId: userId || null,
420
- signOut,
421
- }),
422
- [api, isSignedIn, isLoaded, userId, signOut],
423
- );
424
-
425
- return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
426
- }
427
-
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} />;
436
- return (
437
- <ClerkProvider
438
- publishableKey={CLERK_PUBLISHABLE_KEY}
439
- syncHost={CLERK_SYNC_HOST}
440
- afterSignOutUrl={SIDEPANEL_URL}
441
- signInFallbackRedirectUrl={SIDEPANEL_URL}
442
- signUpFallbackRedirectUrl={SIDEPANEL_URL}
443
- >
444
- <AuthProviderInner>{children}</AuthProviderInner>
445
- </ClerkProvider>
446
- );
447
- }
448
-
449
- export function useAuth() {
450
- const ctx = React.useContext(AuthContext);
451
- if (!ctx) throw new Error('useAuth must be used within AuthProvider');
452
- return ctx;
453
- }
454
- `);
455
- }
456
- function writeApiClient(dir) {
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');
467
- }
468
- return normalized;
469
- }
470
-
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';
477
- }
478
- }
479
-
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 };
525
- }
526
- `);
527
- }
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
- }
551
-
552
- `
553
- : '';
554
- writeFile(path.join(dir, 'src/entrypoints/background.ts'), `${clerkImport}type ChromeWithSidePanel = typeof chrome & {
555
- sidePanel: {
556
- setPanelBehavior: (behavior: { openPanelOnActionClick: boolean }) => Promise<void>;
557
- };
558
- };
559
-
560
- export default defineBackground(() => {
561
- ${clerkInitialization}
562
- // Open the side panel when the extension icon is clicked.
563
- (chrome as ChromeWithSidePanel).sidePanel
564
- .setPanelBehavior({ openPanelOnActionClick: true })
565
- .catch(() => {});
566
- });
567
- `);
568
- }
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 />';
576
- writeFile(path.join(dir, 'src/entrypoints/sidepanel/main.tsx'), `import * as React from 'react';
577
- import { createRoot } from 'react-dom/client';
578
- ${authImport}import { SidePanelApp } from './app';
579
- import '../../styles/main.css';
580
-
581
- const container = document.getElementById('root');
582
- if (container) {
583
- const root = createRoot(container);
584
- root.render(
585
- <React.StrictMode>
586
- ${app}
587
- </React.StrictMode>
588
- );
589
- }
590
- `);
591
- }
592
- function writeSidepanelHtml(dir, displayName) {
593
- writeFile(path.join(dir, 'src/entrypoints/sidepanel/index.html'), `<!DOCTYPE html>
594
- <html lang="en">
595
- <head>
596
- <meta charset="UTF-8" />
597
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
598
- <title>${htmlText(displayName)}</title>
599
- </head>
600
- <body>
601
- <div id="root"></div>
602
- <script type="module" src="./main.tsx"></script>
603
- </body>
604
- </html>
605
- `);
606
- }
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
- : '';
639
- writeFile(path.join(dir, 'src/entrypoints/sidepanel/app.tsx'), `import * as React from 'react';
640
- ${authImports}${entitlementImport}import { Button } from '../../components/ui/button';
641
-
642
- type PageSnapshot = {
643
- title: string;
644
- location: string;
645
- };
646
-
647
- export function SidePanelApp() {
648
- ${authState}${entitlementState} const [page, setPage] = React.useState<PageSnapshot | null>(null);
649
- const [pageError, setPageError] = React.useState<string | null>(null);
650
-
651
- const readActivePage = React.useCallback(async () => {
652
- setPageError(null);
653
- const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
654
- if (!tab.id) {
655
- setPageError('No active tab is available.');
656
- return;
657
- }
658
-
659
- try {
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);
666
- } catch {
667
- setPageError('Chrome does not allow this page to be inspected. Open a normal website and try again.');
668
- }
669
- }, []);
670
-
671
- ${authGuards}
672
- return (
673
- <div style={{ padding: 24 }}>
674
- <h2>Welcome!</h2>
675
- <Button type="button" onClick={() => void readActivePage()}>Read active page</Button>
676
- {page ? (
677
- <div role="status" aria-live="polite" style={{ marginTop: 16 }}>
678
- <strong>{page.title || 'Untitled page'}</strong>
679
- <p style={{ overflowWrap: 'anywhere' }}>{page.location}</p>
680
- </div>
681
- ) : null}
682
- {pageError ? <p role="alert" aria-live="assertive" style={{ color: 'hsl(var(--destructive))' }}>{pageError}</p> : null}
683
- ${entitlementStatus}${signOutButton} </div>
684
- );
685
- }
686
- `);
687
- }
688
- function writeStyles(dir) {
689
- writeFile(path.join(dir, 'src/styles/main.css'), `@tailwind base;
690
- @tailwind components;
691
- @tailwind utilities;
692
-
693
- @layer base {
694
- :root {
695
- --background: 0 0% 100%;
696
- --foreground: 0 0% 9%;
697
- --card: 0 0% 100%;
698
- --card-foreground: 0 0% 9%;
699
- --popover: 0 0% 100%;
700
- --popover-foreground: 0 0% 9%;
701
- --primary: 0 0% 9%;
702
- --primary-foreground: 0 0% 98%;
703
- --secondary: 0 0% 96.1%;
704
- --secondary-foreground: 0 0% 9%;
705
- --muted: 0 0% 96.1%;
706
- --muted-foreground: 0 0% 45.1%;
707
- --accent: 0 0% 96.1%;
708
- --accent-foreground: 0 0% 9%;
709
- --destructive: 0 84.2% 60.2%;
710
- --destructive-foreground: 0 0% 98%;
711
- --border: 0 0% 89.8%;
712
- --input: 0 0% 89.8%;
713
- --ring: 0 0% 63.9%;
714
- --radius: 0.5rem;
715
- }
716
-
717
- * {
718
- border-color: hsl(var(--border));
719
- }
720
-
721
- body {
722
- background: hsl(var(--background));
723
- color: hsl(var(--foreground));
724
- }
725
- }
726
-
727
- body {
728
- margin: 0;
729
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
730
- }
731
- `);
732
- }