anhedral 0.4.2 → 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 (89) hide show
  1. package/README.md +29 -269
  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 -191
  5. package/dist/dependencies.d.ts +7 -57
  6. package/dist/dependencies.js +96 -308
  7. package/dist/index.d.ts +2 -7
  8. package/dist/index.js +1 -4
  9. package/dist/platforms/desktop.js +401 -0
  10. package/dist/platforms/extension.js +401 -0
  11. package/dist/platforms/mobile.js +232 -0
  12. package/dist/project.d.ts +0 -26
  13. package/dist/standard-products.d.ts +2 -0
  14. package/dist/standard-products.js +6 -0
  15. package/dist/standard.d.ts +18 -0
  16. package/dist/standard.js +578 -0
  17. package/dist/version.d.ts +1 -1
  18. package/dist/version.js +1 -1
  19. package/docs/application-stack-standard.md +700 -0
  20. package/package.json +34 -51
  21. package/CHANGELOG.md +0 -178
  22. package/CONTRIBUTING.md +0 -77
  23. package/SECURITY.md +0 -37
  24. package/SKILL.md +0 -339
  25. package/assets/images/svg/ably-symbol-color.svg +0 -8
  26. package/bin/anhedral.js +0 -2
  27. package/dist/architecture/contributions.d.ts +0 -28
  28. package/dist/architecture/contributions.js +0 -125
  29. package/dist/architecture/index.d.ts +0 -12
  30. package/dist/architecture/index.js +0 -6
  31. package/dist/architecture/manifest.d.ts +0 -53
  32. package/dist/architecture/manifest.js +0 -283
  33. package/dist/architecture/modules.d.ts +0 -41
  34. package/dist/architecture/modules.js +0 -187
  35. package/dist/architecture/plan.d.ts +0 -58
  36. package/dist/architecture/plan.js +0 -145
  37. package/dist/architecture/templates.d.ts +0 -10
  38. package/dist/architecture/templates.js +0 -23
  39. package/dist/cli.d.ts +0 -32
  40. package/dist/cli.js +0 -281
  41. package/dist/composers/root.d.ts +0 -6
  42. package/dist/composers/root.js +0 -35
  43. package/dist/print.d.ts +0 -8
  44. package/dist/print.js +0 -48
  45. package/dist/prompts.d.ts +0 -6
  46. package/dist/prompts.js +0 -41
  47. package/dist/scaffold.d.ts +0 -64
  48. package/dist/scaffold.js +0 -2102
  49. package/dist/template-source.d.ts +0 -20
  50. package/dist/template-source.js +0 -180
  51. package/dist/templates/api.d.ts +0 -2
  52. package/dist/templates/api.js +0 -3187
  53. package/dist/templates/assets-private-proxy.d.ts +0 -3
  54. package/dist/templates/assets-private-proxy.js +0 -277
  55. package/dist/templates/desktop.js +0 -792
  56. package/dist/templates/electron-updater.d.ts +0 -3
  57. package/dist/templates/electron-updater.js +0 -358
  58. package/dist/templates/extension.js +0 -856
  59. package/dist/templates/first-run.d.ts +0 -2
  60. package/dist/templates/first-run.js +0 -167
  61. package/dist/templates/mobile.js +0 -861
  62. package/dist/templates/shared.d.ts +0 -2
  63. package/dist/templates/shared.js +0 -577
  64. package/dist/templates/web.d.ts +0 -2
  65. package/dist/templates/web.js +0 -569
  66. package/dist/toolchain.d.ts +0 -3
  67. package/dist/toolchain.js +0 -8
  68. package/dist/ui.d.ts +0 -33
  69. package/dist/ui.js +0 -199
  70. package/dist/workspace-config.d.ts +0 -11
  71. package/dist/workspace-config.js +0 -372
  72. package/docs/NORTHSTAR.md +0 -114
  73. package/docs/conventions.md +0 -94
  74. package/docs/engineering-principles.md +0 -88
  75. package/docs/master-stack-map.md +0 -199
  76. package/docs/output-tree-contract.md +0 -493
  77. package/docs/references/manual-scaffolding.md +0 -421
  78. package/docs/references/provisioning.md +0 -577
  79. package/scripts/render-master-stack-map.mjs +0 -510
  80. package/templates/api-fastify/apps/api/tsconfig.json +0 -20
  81. package/templates/catalog.json +0 -23
  82. package/templates/db-drizzle/packages/db/tsconfig.json +0 -14
  83. package/templates/desktop-electron/apps/desktop/tsconfig.json +0 -37
  84. package/templates/extension-wxt/apps/extension/tsconfig.json +0 -41
  85. package/templates/mobile-expo/apps/mobile/expo-env.d.ts +0 -1
  86. package/templates/web-next/apps/web/next-env.d.ts +0 -6
  87. /package/dist/{templates → platforms}/desktop.d.ts +0 -0
  88. /package/dist/{templates → platforms}/extension.d.ts +0 -0
  89. /package/dist/{templates → platforms}/mobile.d.ts +0 -0
@@ -1,856 +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.apps.api && options.features.database)
44
- writeItemList(dir, options.features.auth);
45
- if (options.features.billing)
46
- writeEntitlementHook(dir);
47
- writeBackground(dir, options.features.auth);
48
- writeSidepanelEntry(dir, options.features.auth);
49
- writeSidepanelHtml(dir, displayName);
50
- writeSidepanelApp(dir, options.features.auth, options.features.billing, options.apps.api && options.features.database);
51
- writeStyles(dir);
52
- anhedralPrint.done('Extension source files written');
53
- }
54
- function cleanWxtStarterFiles(dir) {
55
- for (const relativePath of [
56
- '.git',
57
- 'entrypoints',
58
- 'assets/react.svg',
59
- 'public/wxt.svg',
60
- ]) {
61
- rmSync(path.join(dir, relativePath), { recursive: true, force: true });
62
- }
63
- }
64
- function writePackageJson(dir, projectName, options) {
65
- writeFile(path.join(dir, 'package.json'), JSON.stringify({
66
- name: childPackageName(projectName, 'chrome-ext'),
67
- version: '0.1.0',
68
- private: true,
69
- type: 'module',
70
- scripts: {
71
- dev: 'wxt',
72
- build: 'wxt build',
73
- postinstall: 'wxt prepare',
74
- zip: 'wxt zip',
75
- typecheck: 'tsc --noEmit',
76
- },
77
- dependencies: selectedDependencies(options),
78
- devDependencies: EXTENSION_DEPENDENCIES.devDependencies,
79
- }, null, 2) + '\n');
80
- }
81
- function writeWxtConfig(dir, displayName, options) {
82
- const nameLiteral = jsString(displayName);
83
- const descriptionLiteral = jsString(`${displayName} Chrome Extension`);
84
- const actionTitleLiteral = jsString(`Open ${displayName}`);
85
- const permissionsLiteral = options.features.auth
86
- ? "['activeTab', 'cookies', 'scripting', 'storage', 'sidePanel']"
87
- : "['activeTab', 'scripting', 'sidePanel']";
88
- const permissionSources = [
89
- options.apps.api
90
- ? " permissionForUrl('VITE_API_URL', import.meta.env.VITE_API_URL || (import.meta.env.DEV ? 'http://localhost:8787/api' : undefined)),"
91
- : null,
92
- options.features.auth
93
- ? " permissionForUrl('VITE_CLERK_FRONTEND_API_URL', import.meta.env.VITE_CLERK_FRONTEND_API_URL),"
94
- : null,
95
- options.features.auth
96
- ? " permissionForUrl('VITE_CLERK_SYNC_HOST', import.meta.env.VITE_CLERK_SYNC_HOST),"
97
- : null,
98
- ].filter((value) => value !== null);
99
- const permissionHelper = permissionSources.length > 0
100
- ? `
101
- function permissionForUrl(name: string, value: string | undefined): string | null {
102
- const candidate = value?.trim();
103
- if (!candidate) return null;
104
-
105
- let url: URL;
106
- try {
107
- url = new URL(candidate);
108
- } catch {
109
- throw new Error(\`\${name} must be a valid absolute URL\`);
110
- }
111
- if (url.protocol !== 'http:' && url.protocol !== 'https:') {
112
- throw new Error(\`\${name} must use http: or https:\`);
113
- }
114
- if (url.username || url.password) throw new Error(\`\${name} must not include URL credentials\`);
115
- if (url.search || candidate.includes('?')) throw new Error(\`\${name} must not include a query string\`);
116
- if (url.hash || candidate.includes('#')) throw new Error(\`\${name} must not include a URL fragment\`);
117
- if (import.meta.env.PROD && url.protocol !== 'https:') {
118
- throw new Error(\`\${name} must use https: in production\`);
119
- }
120
- return \`\${url.protocol}//\${url.hostname}/*\`;
121
- }
122
- `
123
- : '';
124
- const hostPermissionsSetup = permissionSources.length > 0
125
- ? ` const hostPermissions = [...new Set([
126
- ${permissionSources.join('\n')}
127
- ].filter((value): value is string => value !== null))];
128
- `
129
- : '';
130
- const hostPermissionsLiteral = permissionSources.length > 0 ? 'hostPermissions' : '[]';
131
- writeFile(path.join(dir, 'wxt.config.ts'), `import { defineConfig } from 'wxt';
132
- ${permissionHelper}
133
-
134
- export default defineConfig({
135
- srcDir: 'src',
136
- manifest: () => {
137
- const crxPublicKey = import.meta.env.VITE_CRX_PUBLIC_KEY || '';
138
- ${hostPermissionsSetup}
139
-
140
- return {
141
- name: ${nameLiteral},
142
- description: ${descriptionLiteral},
143
- version: '0.1.0',
144
- ...(crxPublicKey ? { key: crxPublicKey } : {}),
145
- minimum_chrome_version: '114',
146
- permissions: ${permissionsLiteral},
147
- host_permissions: ${hostPermissionsLiteral},
148
- action: {
149
- default_title: ${actionTitleLiteral},
150
- },
151
- side_panel: {
152
- default_path: 'sidepanel.html',
153
- },
154
- };
155
- },
156
- modules: ['@wxt-dev/module-react'],
157
- vite: () => ({
158
- build: {
159
- chunkSizeWarningLimit: 3000,
160
- },
161
- }),
162
- });
163
- `);
164
- }
165
- function extensionEnvContents(options) {
166
- const sections = [];
167
- if (options.features.auth) {
168
- sections.push(`# Clerk Authentication
169
- VITE_CLERK_PUBLISHABLE_KEY=pk_test_***
170
- VITE_CLERK_FRONTEND_API_URL=
171
- VITE_CLERK_SYNC_HOST=`);
172
- }
173
- if (options.apps.api) {
174
- sections.push(`# API URL
175
- VITE_API_URL=http://localhost:8787/api`);
176
- }
177
- sections.push(`# Chrome Extension CRX public key (optional, for stable extension ID)
178
- VITE_CRX_PUBLIC_KEY=`);
179
- return `${sections.join('\n\n')}\n`;
180
- }
181
- function writeEnvExample(dir, options) {
182
- writeFile(path.join(dir, '.env.example'), extensionEnvContents(options));
183
- }
184
- function writePostcssConfig(dir) {
185
- writeFile(path.join(dir, 'postcss.config.cjs'), `module.exports = {
186
- plugins: {
187
- tailwindcss: {},
188
- autoprefixer: {},
189
- },
190
- };
191
- `);
192
- }
193
- function writeShadcnConfig(dir) {
194
- writeFile(path.join(dir, 'components.json'), JSON.stringify({
195
- $schema: 'https://ui.shadcn.com/schema.json',
196
- style: 'new-york',
197
- rsc: false,
198
- tsx: true,
199
- tailwind: {
200
- config: '',
201
- css: 'src/styles/main.css',
202
- baseColor: 'neutral',
203
- cssVariables: true,
204
- prefix: '',
205
- },
206
- aliases: {
207
- components: '@/components',
208
- utils: '@/lib/utils',
209
- ui: '@/components/ui',
210
- lib: '@/lib',
211
- hooks: '@/hooks',
212
- },
213
- iconLibrary: 'lucide',
214
- }, null, 2) + '\n');
215
- }
216
- function writeCnUtil(dir) {
217
- writeFile(path.join(dir, 'src/lib/utils.ts'), `import { clsx, type ClassValue } from 'clsx';
218
- import { twMerge } from 'tailwind-merge';
219
-
220
- export function cn(...inputs: ClassValue[]) {
221
- return twMerge(clsx(inputs));
222
- }
223
- `);
224
- }
225
- function writeButtonComponent(dir) {
226
- writeFile(path.join(dir, 'src/components/ui/button.tsx'), `import * as React from 'react';
227
- import { cn } from '@/lib/utils';
228
-
229
- type ButtonVariant = 'default' | 'outline';
230
-
231
- export type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
232
- variant?: ButtonVariant;
233
- };
234
-
235
- const variantClasses: Record<ButtonVariant, string> = {
236
- default: 'bg-primary text-primary-foreground hover:bg-primary/90',
237
- outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
238
- };
239
-
240
- export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
241
- ({ className, variant = 'default', type = 'button', ...props }, ref) => {
242
- return (
243
- <button
244
- ref={ref}
245
- type={type}
246
- className={cn(
247
- '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',
248
- variantClasses[variant],
249
- className,
250
- )}
251
- {...props}
252
- />
253
- );
254
- },
255
- );
256
-
257
- Button.displayName = 'Button';
258
- `);
259
- }
260
- function writeTailwindConfig(dir) {
261
- writeFile(path.join(dir, 'tailwind.config.cjs'), `/** @type {import('tailwindcss').Config} */
262
- module.exports = {
263
- content: ['./src/**/*.{ts,tsx,html}'],
264
- theme: {
265
- extend: {
266
- colors: {
267
- border: 'hsl(var(--border))',
268
- input: 'hsl(var(--input))',
269
- ring: 'hsl(var(--ring))',
270
- background: 'hsl(var(--background))',
271
- foreground: 'hsl(var(--foreground))',
272
- primary: {
273
- DEFAULT: 'hsl(var(--primary))',
274
- foreground: 'hsl(var(--primary-foreground))',
275
- },
276
- secondary: {
277
- DEFAULT: 'hsl(var(--secondary))',
278
- foreground: 'hsl(var(--secondary-foreground))',
279
- },
280
- destructive: {
281
- DEFAULT: 'hsl(var(--destructive))',
282
- foreground: 'hsl(var(--destructive-foreground))',
283
- },
284
- muted: {
285
- DEFAULT: 'hsl(var(--muted))',
286
- foreground: 'hsl(var(--muted-foreground))',
287
- },
288
- accent: {
289
- DEFAULT: 'hsl(var(--accent))',
290
- foreground: 'hsl(var(--accent-foreground))',
291
- },
292
- popover: {
293
- DEFAULT: 'hsl(var(--popover))',
294
- foreground: 'hsl(var(--popover-foreground))',
295
- },
296
- card: {
297
- DEFAULT: 'hsl(var(--card))',
298
- foreground: 'hsl(var(--card-foreground))',
299
- },
300
- },
301
- borderRadius: {
302
- lg: 'var(--radius)',
303
- md: 'calc(var(--radius) - 2px)',
304
- sm: 'calc(var(--radius) - 4px)',
305
- },
306
- },
307
- },
308
- plugins: [],
309
- };
310
- `);
311
- }
312
- function writeReadme(dir, displayName, options) {
313
- const configuration = [
314
- options.features.auth
315
- ? '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.'
316
- : null,
317
- options.apps.api ? 'Set `VITE_API_URL` before using the shared API client.' : null,
318
- 'Set `VITE_CRX_PUBLIC_KEY` only when you need a stable Chrome extension ID.',
319
- ].filter((value) => value !== null).join(' ');
320
- const authBehavior = options.features.auth ? ', the background script initializes Clerk' : '';
321
- const networkIntegrations = [
322
- options.apps.api ? '`VITE_API_URL`' : null,
323
- options.features.auth ? '`VITE_CLERK_FRONTEND_API_URL` and optional `VITE_CLERK_SYNC_HOST`' : null,
324
- ].filter((value) => value !== null).join(' and ');
325
- const hostPermissionsGuidance = networkIntegrations
326
- ? `\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.`
327
- : '';
328
- writeFile(path.join(dir, 'README.md'), `# ${markdownHeading(displayName)} Chrome Extension
329
-
330
- WXT side-panel extension generated by anhedral.
331
-
332
- ## Development
333
-
334
- \`\`\`bash
335
- cp .env.example .env
336
- pnpm dev
337
- pnpm build
338
- pnpm zip
339
- \`\`\`
340
-
341
- Run these commands from \`apps/extension\`. Anhedral generates only \`.env.example\`; keep the local \`.env\` file uncommitted. ${configuration}
342
-
343
- ## Chrome
344
-
345
- Run \`pnpm build\`, then load \`.output/chrome-mv3\` as an unpacked extension from \`chrome://extensions\`.
346
-
347
- 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}
348
- `);
349
- }
350
- function writeAuthContext(dir) {
351
- writeFile(path.join(dir, 'src/contexts/auth-context.tsx'), `import * as React from 'react';
352
- import { ClerkProvider, useAuth as useClerkAuth } from '@clerk/chrome-extension';
353
- import { apiConfigurationError, createApiClient } from '../lib/api';
354
-
355
- const CLERK_PUBLISHABLE_KEY = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY || '';
356
- const CLERK_SYNC_HOST = import.meta.env.VITE_CLERK_SYNC_HOST || undefined;
357
- const BACKGROUND_AUTH_ERROR_KEY = 'anhedralClerkBackgroundError';
358
-
359
- function getExtensionUrl(path: string) {
360
- if (typeof chrome !== 'undefined' && chrome.runtime?.getURL) {
361
- return chrome.runtime.getURL(path);
362
- }
363
- return path;
364
- }
365
-
366
- const SIDEPANEL_URL = getExtensionUrl('sidepanel.html');
367
-
368
- type AuthState = {
369
- isSignedIn: boolean;
370
- isLoading: boolean;
371
- userId: string | null;
372
- };
373
-
374
- type AuthContextValue = AuthState & {
375
- api: ReturnType<typeof createApiClient>;
376
- signOut: () => Promise<void>;
377
- };
378
-
379
- const AuthContext = React.createContext<AuthContextValue | null>(null);
380
-
381
- function ConfigurationError({ message }: { message: string }) {
382
- return (
383
- <div role="alert" aria-live="assertive" style={{ padding: 24, color: 'hsl(var(--destructive))' }}>
384
- <h2>Extension configuration required</h2>
385
- <p>{message}</p>
386
- </div>
387
- );
388
- }
389
-
390
- function useBackgroundAuthError(): string | null {
391
- const [error, setError] = React.useState<string | null>(null);
392
- React.useEffect(() => {
393
- let active = true;
394
- void chrome.storage.local.get(BACKGROUND_AUTH_ERROR_KEY).then((values) => {
395
- const message = values[BACKGROUND_AUTH_ERROR_KEY];
396
- if (active) setError(typeof message === 'string' ? message : null);
397
- });
398
- const listener = (changes: Record<string, chrome.storage.StorageChange>, areaName: string) => {
399
- if (areaName !== 'local') return;
400
- const message = changes[BACKGROUND_AUTH_ERROR_KEY]?.newValue;
401
- setError(typeof message === 'string' ? message : null);
402
- };
403
- chrome.storage.onChanged.addListener(listener);
404
- return () => {
405
- active = false;
406
- chrome.storage.onChanged.removeListener(listener);
407
- };
408
- }, []);
409
- return error;
410
- }
411
-
412
- function AuthProviderInner({ children }: { children: React.ReactNode }) {
413
- const { isSignedIn, isLoaded, userId, signOut, getToken } = useClerkAuth();
414
- const api = React.useMemo(() => createApiClient(() => getToken()), [getToken]);
415
-
416
- const value = React.useMemo<AuthContextValue>(
417
- () => ({
418
- api,
419
- isSignedIn: !!isSignedIn,
420
- isLoading: !isLoaded,
421
- userId: userId || null,
422
- signOut,
423
- }),
424
- [api, isSignedIn, isLoaded, userId, signOut],
425
- );
426
-
427
- return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
428
- }
429
-
430
- export function AuthProvider({ children }: { children: React.ReactNode }) {
431
- const backgroundError = useBackgroundAuthError();
432
- if (!CLERK_PUBLISHABLE_KEY || CLERK_PUBLISHABLE_KEY.includes('***')) {
433
- return <ConfigurationError message="Set VITE_CLERK_PUBLISHABLE_KEY before building the extension." />;
434
- }
435
- const apiError = apiConfigurationError();
436
- if (apiError) return <ConfigurationError message={apiError} />;
437
- if (backgroundError) return <ConfigurationError message={backgroundError} />;
438
- return (
439
- <ClerkProvider
440
- publishableKey={CLERK_PUBLISHABLE_KEY}
441
- syncHost={CLERK_SYNC_HOST}
442
- afterSignOutUrl={SIDEPANEL_URL}
443
- signInFallbackRedirectUrl={SIDEPANEL_URL}
444
- signUpFallbackRedirectUrl={SIDEPANEL_URL}
445
- >
446
- <AuthProviderInner>{children}</AuthProviderInner>
447
- </ClerkProvider>
448
- );
449
- }
450
-
451
- export function useAuth() {
452
- const ctx = React.useContext(AuthContext);
453
- if (!ctx) throw new Error('useAuth must be used within AuthProvider');
454
- return ctx;
455
- }
456
- `);
457
- }
458
- function writeApiClient(dir) {
459
- writeFile(path.join(dir, 'src/lib/api.ts'), `import { ApiClient, normalizeApiBaseUrl } from '@shared/api-client';
460
-
461
- function apiBaseUrl(): string {
462
- const configured = import.meta.env.VITE_API_URL?.trim();
463
- const candidate = configured || (import.meta.env.DEV ? 'http://localhost:8787/api' : '');
464
- if (!candidate) throw new Error('VITE_API_URL is required in production builds');
465
- const normalized = normalizeApiBaseUrl(candidate, 'VITE_API_URL');
466
- const url = new URL(normalized);
467
- if (import.meta.env.PROD && url.protocol !== 'https:') {
468
- throw new Error('VITE_API_URL must use https: in production');
469
- }
470
- return normalized;
471
- }
472
-
473
- export function apiConfigurationError(): string | null {
474
- try {
475
- apiBaseUrl();
476
- return null;
477
- } catch (error) {
478
- return error instanceof Error ? error.message : 'The API URL is invalid';
479
- }
480
- }
481
-
482
- export function createApiClient(getToken?: () => Promise<string | null>) {
483
- return new ApiClient({
484
- baseUrl: apiBaseUrl(),
485
- getToken,
486
- });
487
- }
488
- `);
489
- }
490
- function writeItemList(dir, hasAuth) {
491
- const apiImport = hasAuth
492
- ? "import { useAuth } from '../contexts/auth-context';\n"
493
- : "import { createApiClient } from '../lib/api';\n";
494
- const apiState = hasAuth
495
- ? ' const { api, userId } = useAuth();\n const identity = userId;\n'
496
- : " const api = React.useMemo(() => createApiClient(), []);\n const identity = 'public';\n";
497
- writeFile(path.join(dir, 'src/components/item-list.tsx'), `import { createItem, listItems, type Item } from '@shared/api-client';
498
- ${apiImport}import * as React from 'react';
499
- import { Button } from './ui/button';
500
-
501
- export function ItemList() {
502
- ${apiState} const [items, setItems] = React.useState<Item[]>([]);
503
- const [loadedIdentity, setLoadedIdentity] = React.useState<string | null>(null);
504
- const [name, setName] = React.useState('');
505
- const [status, setStatus] = React.useState<'loading' | 'ready' | 'saving'>('loading');
506
- const [error, setError] = React.useState<string | null>(null);
507
- const identityRef = React.useRef(identity);
508
- const visibleItems = loadedIdentity === identity ? items : [];
509
- const visibleError = loadedIdentity === identity ? error : null;
510
- const isLoading = status === 'loading' || loadedIdentity !== identity;
511
-
512
- React.useEffect(() => {
513
- identityRef.current = identity;
514
- if (!identity) return;
515
- let active = true;
516
- setLoadedIdentity(identity);
517
- setItems([]);
518
- setName('');
519
- setError(null);
520
- setStatus('loading');
521
- void listItems(api).then((nextItems) => {
522
- if (active) setItems(nextItems);
523
- }).catch((cause: unknown) => {
524
- if (active) setError(cause instanceof Error ? cause.message : 'Unable to load items');
525
- }).finally(() => {
526
- if (active) setStatus('ready');
527
- });
528
- return () => { active = false; };
529
- }, [api, identity]);
530
-
531
- async function submit(event: React.FormEvent<HTMLFormElement>) {
532
- event.preventDefault();
533
- const nextName = name.trim();
534
- if (!identity || !nextName || status !== 'ready' || loadedIdentity !== identity) return;
535
- const submittedIdentity = identity;
536
- setStatus('saving');
537
- try {
538
- const created = await createItem(api, { name: nextName });
539
- if (identityRef.current !== submittedIdentity) return;
540
- setItems((current) => [created, ...current]);
541
- setName('');
542
- setError(null);
543
- } catch (cause) {
544
- if (identityRef.current === submittedIdentity) {
545
- setError(cause instanceof Error ? cause.message : 'Unable to create item');
546
- }
547
- } finally {
548
- if (identityRef.current === submittedIdentity) setStatus('ready');
549
- }
550
- }
551
-
552
- return (
553
- <section aria-labelledby="starter-feature-title" style={{ display: 'grid', gap: 10, marginTop: 20 }}>
554
- <h3 id="starter-feature-title" style={{ margin: 0 }}>Working starter feature</h3>
555
- <form onSubmit={(event) => void submit(event)} style={{ display: 'grid', gap: 8 }}>
556
- <label htmlFor="item-name">Item name</label>
557
- <input
558
- id="item-name"
559
- maxLength={120}
560
- onChange={(event) => setName(event.target.value)}
561
- placeholder="Your first item"
562
- style={{ minHeight: 36, border: '1px solid hsl(var(--input))', borderRadius: 6, padding: '0 10px' }}
563
- value={name}
564
- />
565
- <Button disabled={status !== 'ready' || loadedIdentity !== identity || !name.trim()} type="submit">
566
- {status === 'saving' ? 'Adding…' : 'Add item'}
567
- </Button>
568
- </form>
569
- {visibleError ? (
570
- <p aria-live="assertive" role="alert" style={{ color: 'hsl(var(--destructive))' }}>
571
- {visibleError}. Check DATABASE_URL, run pnpm db:migrate, and make sure the API is running.
572
- </p>
573
- ) : null}
574
- {isLoading ? <p role="status">Loading items…</p> : null}
575
- {!isLoading && visibleItems.length === 0 && !visibleError
576
- ? <p>Your database is connected. Add the first item.</p>
577
- : null}
578
- {visibleItems.length > 0 ? (
579
- <ul style={{ margin: 0, paddingLeft: 20 }}>
580
- {visibleItems.map((item) => <li key={item.id}>{item.name}</li>)}
581
- </ul>
582
- ) : null}
583
- </section>
584
- );
585
- }
586
- `);
587
- }
588
- function writeEntitlementHook(dir) {
589
- writeFile(path.join(dir, 'src/hooks/use-entitlement.ts'), `import { subscribeToSubscriptionChanges } from '@shared/realtime';
590
- import * as React from 'react';
591
- import { useAuth } from '../contexts/auth-context';
592
-
593
- export function useEntitlement() {
594
- const { api, isSignedIn, userId } = useAuth();
595
- const identity = isSignedIn ? userId : null;
596
- const [entitlement, setEntitlement] = React.useState<Awaited<ReturnType<typeof api.getEntitlement>> | null>(null);
597
- const [loadedIdentity, setLoadedIdentity] = React.useState<string | null>(null);
598
- const [error, setError] = React.useState<string | null>(null);
599
- const revision = React.useRef(0);
600
- const identityRef = React.useRef(identity);
601
- const refresh = React.useCallback(async () => {
602
- if (!identity) return;
603
- try {
604
- const next = await api.getEntitlement();
605
- if (identityRef.current !== identity) return;
606
- revision.current = Math.max(revision.current, next.revision);
607
- setEntitlement(next);
608
- setLoadedIdentity(identity);
609
- setError(null);
610
- } catch (cause) {
611
- if (identityRef.current !== identity) return;
612
- setLoadedIdentity(identity);
613
- setError(cause instanceof Error ? cause.message : 'Unable to load subscription');
614
- }
615
- }, [api, identity]);
616
- React.useEffect(() => {
617
- identityRef.current = identity;
618
- revision.current = 0;
619
- setEntitlement(null);
620
- setLoadedIdentity(null);
621
- setError(null);
622
- void refresh();
623
- }, [identity, refresh]);
624
- React.useEffect(() => {
625
- const onVisibility = () => { if (document.visibilityState === 'visible') void refresh(); };
626
- document.addEventListener('visibilitychange', onVisibility);
627
- return () => document.removeEventListener('visibilitychange', onVisibility);
628
- }, [refresh]);
629
- React.useEffect(() => {
630
- if (!identity) return;
631
- return subscribeToSubscriptionChanges({
632
- userId: identity,
633
- getTokenRequest: () => api.getRealtimeToken(),
634
- onChange: (nextRevision) => { if (nextRevision > revision.current) void refresh(); },
635
- onError: (cause) => {
636
- if (identityRef.current === identity) {
637
- setLoadedIdentity(identity);
638
- setError(cause.message);
639
- }
640
- },
641
- });
642
- }, [api, identity, refresh]);
643
- return {
644
- entitlement: loadedIdentity === identity ? entitlement : null,
645
- error: loadedIdentity === identity ? error : null,
646
- };
647
- }
648
- `);
649
- }
650
- function writeBackground(dir, hasAuth) {
651
- const clerkImport = hasAuth
652
- ? "import { createClerkClient } from '@clerk/chrome-extension/background';\n\n"
653
- : '';
654
- const clerkInitialization = hasAuth
655
- ? ` const backgroundAuthErrorKey = 'anhedralClerkBackgroundError';
656
- const publishableKey = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY?.trim();
657
- if (!publishableKey || publishableKey.includes('***')) {
658
- void chrome.storage.local.set({
659
- [backgroundAuthErrorKey]: 'Set VITE_CLERK_PUBLISHABLE_KEY before building the extension.',
660
- });
661
- } else {
662
- // Initialize Clerk in the background script for cookie-based auth and expose failures to the panel.
663
- void createClerkClient({
664
- publishableKey,
665
- syncHost: import.meta.env.VITE_CLERK_SYNC_HOST || undefined,
666
- }).then(
667
- () => chrome.storage.local.remove(backgroundAuthErrorKey),
668
- () => chrome.storage.local.set({
669
- [backgroundAuthErrorKey]: 'Clerk background initialization failed. Check the key and allowed origins.',
670
- }),
671
- );
672
- }
673
-
674
- `
675
- : '';
676
- writeFile(path.join(dir, 'src/entrypoints/background.ts'), `${clerkImport}type ChromeWithSidePanel = typeof chrome & {
677
- sidePanel: {
678
- setPanelBehavior: (behavior: { openPanelOnActionClick: boolean }) => Promise<void>;
679
- };
680
- };
681
-
682
- export default defineBackground(() => {
683
- ${clerkInitialization}
684
- // Open the side panel when the extension icon is clicked.
685
- (chrome as ChromeWithSidePanel).sidePanel
686
- .setPanelBehavior({ openPanelOnActionClick: true })
687
- .catch(() => {});
688
- });
689
- `);
690
- }
691
- function writeSidepanelEntry(dir, hasAuth) {
692
- const authImport = hasAuth ? "import { AuthProvider } from '../../contexts/auth-context';\n" : '';
693
- const app = hasAuth
694
- ? ` <AuthProvider>
695
- <SidePanelApp />
696
- </AuthProvider>`
697
- : ' <SidePanelApp />';
698
- writeFile(path.join(dir, 'src/entrypoints/sidepanel/main.tsx'), `import * as React from 'react';
699
- import { createRoot } from 'react-dom/client';
700
- ${authImport}import { SidePanelApp } from './app';
701
- import '../../styles/main.css';
702
-
703
- const container = document.getElementById('root');
704
- if (container) {
705
- const root = createRoot(container);
706
- root.render(
707
- <React.StrictMode>
708
- ${app}
709
- </React.StrictMode>
710
- );
711
- }
712
- `);
713
- }
714
- function writeSidepanelHtml(dir, displayName) {
715
- writeFile(path.join(dir, 'src/entrypoints/sidepanel/index.html'), `<!DOCTYPE html>
716
- <html lang="en">
717
- <head>
718
- <meta charset="UTF-8" />
719
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
720
- <title>${htmlText(displayName)}</title>
721
- </head>
722
- <body>
723
- <div id="root"></div>
724
- <script type="module" src="./main.tsx"></script>
725
- </body>
726
- </html>
727
- `);
728
- }
729
- function writeSidepanelApp(dir, hasAuth, hasBilling, hasItems) {
730
- const authImports = hasAuth
731
- ? "import { useAuth } from '../../contexts/auth-context';\nimport { SignIn } from '@clerk/chrome-extension';\n"
732
- : '';
733
- const authState = hasAuth
734
- ? ' const { isSignedIn, isLoading, signOut } = useAuth();\n'
735
- : '';
736
- const authGuards = hasAuth
737
- ? `
738
- if (isLoading) {
739
- return <div role="status" aria-live="polite" style={{ padding: 24, textAlign: 'center' }}>Loading account…</div>;
740
- }
741
-
742
- if (!isSignedIn) {
743
- return (
744
- <div aria-label="Sign in" style={{ padding: 24 }}>
745
- <SignIn />
746
- </div>
747
- );
748
- }
749
- `
750
- : '';
751
- const signOutButton = hasAuth
752
- ? ' <Button type="button" variant="outline" onClick={() => void signOut()}>Sign Out</Button>\n'
753
- : '';
754
- const entitlementImport = hasBilling ? "import { useEntitlement } from '../../hooks/use-entitlement';\n" : '';
755
- const entitlementState = hasBilling ? ' const { entitlement, error: entitlementError } = useEntitlement();\n' : '';
756
- const entitlementStatus = hasBilling
757
- ? ` {entitlement ? <p role="status">Plan: {entitlement.entitlement} ({entitlement.status})</p> : null}
758
- {entitlementError ? <p role="alert" style={{ color: 'hsl(var(--destructive))' }}>{entitlementError}</p> : null}
759
- `
760
- : '';
761
- const itemListImport = hasItems ? "import { ItemList } from '../../components/item-list';\n" : '';
762
- const itemList = hasItems ? ' <ItemList />\n' : '';
763
- writeFile(path.join(dir, 'src/entrypoints/sidepanel/app.tsx'), `import * as React from 'react';
764
- ${authImports}${entitlementImport}${itemListImport}import { Button } from '../../components/ui/button';
765
-
766
- type PageSnapshot = {
767
- title: string;
768
- location: string;
769
- };
770
-
771
- export function SidePanelApp() {
772
- ${authState}${entitlementState} const [page, setPage] = React.useState<PageSnapshot | null>(null);
773
- const [pageError, setPageError] = React.useState<string | null>(null);
774
-
775
- const readActivePage = React.useCallback(async () => {
776
- setPageError(null);
777
- const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
778
- if (!tab.id) {
779
- setPageError('No active tab is available.');
780
- return;
781
- }
782
-
783
- try {
784
- const [injection] = await chrome.scripting.executeScript({
785
- target: { tabId: tab.id },
786
- func: () => ({ title: document.title, location: window.location.href }),
787
- });
788
- if (!injection?.result) throw new Error('The active page did not return a snapshot.');
789
- setPage(injection.result as PageSnapshot);
790
- } catch {
791
- setPageError('Chrome does not allow this page to be inspected. Open a normal website and try again.');
792
- }
793
- }, []);
794
-
795
- ${authGuards}
796
- return (
797
- <div style={{ padding: 24 }}>
798
- <h2>Welcome!</h2>
799
- <Button type="button" onClick={() => void readActivePage()}>Read active page</Button>
800
- {page ? (
801
- <div role="status" aria-live="polite" style={{ marginTop: 16 }}>
802
- <strong>{page.title || 'Untitled page'}</strong>
803
- <p style={{ overflowWrap: 'anywhere' }}>{page.location}</p>
804
- </div>
805
- ) : null}
806
- {pageError ? <p role="alert" aria-live="assertive" style={{ color: 'hsl(var(--destructive))' }}>{pageError}</p> : null}
807
- ${itemList}${entitlementStatus}${signOutButton} </div>
808
- );
809
- }
810
- `);
811
- }
812
- function writeStyles(dir) {
813
- writeFile(path.join(dir, 'src/styles/main.css'), `@tailwind base;
814
- @tailwind components;
815
- @tailwind utilities;
816
-
817
- @layer base {
818
- :root {
819
- --background: 0 0% 100%;
820
- --foreground: 0 0% 9%;
821
- --card: 0 0% 100%;
822
- --card-foreground: 0 0% 9%;
823
- --popover: 0 0% 100%;
824
- --popover-foreground: 0 0% 9%;
825
- --primary: 0 0% 9%;
826
- --primary-foreground: 0 0% 98%;
827
- --secondary: 0 0% 96.1%;
828
- --secondary-foreground: 0 0% 9%;
829
- --muted: 0 0% 96.1%;
830
- --muted-foreground: 0 0% 45.1%;
831
- --accent: 0 0% 96.1%;
832
- --accent-foreground: 0 0% 9%;
833
- --destructive: 0 84.2% 60.2%;
834
- --destructive-foreground: 0 0% 98%;
835
- --border: 0 0% 89.8%;
836
- --input: 0 0% 89.8%;
837
- --ring: 0 0% 63.9%;
838
- --radius: 0.5rem;
839
- }
840
-
841
- * {
842
- border-color: hsl(var(--border));
843
- }
844
-
845
- body {
846
- background: hsl(var(--background));
847
- color: hsl(var(--foreground));
848
- }
849
- }
850
-
851
- body {
852
- margin: 0;
853
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
854
- }
855
- `);
856
- }