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.
- package/README.md +29 -256
- package/assets/anhedral-cli-init-technical.svg +322 -0
- package/assets/anhedral-cli-init.svg +257 -173
- package/dist/bin.js +28 -162
- package/dist/dependencies.d.ts +7 -56
- package/dist/dependencies.js +96 -305
- package/dist/index.d.ts +2 -7
- package/dist/index.js +1 -4
- package/dist/{templates → platforms}/desktop.d.ts +1 -1
- package/dist/{templates → platforms}/desktop.js +28 -266
- package/dist/{templates → platforms}/extension.d.ts +1 -1
- package/dist/platforms/extension.js +401 -0
- package/dist/{templates → platforms}/mobile.d.ts +1 -1
- package/dist/platforms/mobile.js +232 -0
- package/dist/project.d.ts +4 -0
- package/dist/project.js +1 -0
- package/dist/standard-products.d.ts +2 -0
- package/dist/standard-products.js +6 -0
- package/dist/standard.d.ts +18 -0
- package/dist/standard.js +578 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/docs/application-stack-standard.md +700 -0
- package/package.json +34 -49
- package/CHANGELOG.md +0 -132
- package/CONTRIBUTING.md +0 -73
- package/SECURITY.md +0 -37
- package/SKILL.md +0 -244
- package/assets/images/svg/ably-symbol-color.svg +0 -8
- package/bin/anhedral.js +0 -2
- package/dist/architecture/contributions.d.ts +0 -28
- package/dist/architecture/contributions.js +0 -125
- package/dist/architecture/index.d.ts +0 -12
- package/dist/architecture/index.js +0 -6
- package/dist/architecture/manifest.d.ts +0 -53
- package/dist/architecture/manifest.js +0 -283
- package/dist/architecture/modules.d.ts +0 -37
- package/dist/architecture/modules.js +0 -184
- package/dist/architecture/plan.d.ts +0 -58
- package/dist/architecture/plan.js +0 -145
- package/dist/architecture/templates.d.ts +0 -10
- package/dist/architecture/templates.js +0 -23
- package/dist/cli.d.ts +0 -32
- package/dist/cli.js +0 -235
- package/dist/composers/root.d.ts +0 -6
- package/dist/composers/root.js +0 -35
- package/dist/print.d.ts +0 -8
- package/dist/print.js +0 -48
- package/dist/prompts.d.ts +0 -5
- package/dist/prompts.js +0 -19
- package/dist/scaffold.d.ts +0 -76
- package/dist/scaffold.js +0 -1895
- package/dist/template-source.d.ts +0 -20
- package/dist/template-source.js +0 -180
- package/dist/templates/api.d.ts +0 -2
- package/dist/templates/api.js +0 -3122
- package/dist/templates/assets-private-proxy.d.ts +0 -3
- package/dist/templates/assets-private-proxy.js +0 -277
- package/dist/templates/electron-updater.d.ts +0 -3
- package/dist/templates/electron-updater.js +0 -358
- package/dist/templates/extension.js +0 -732
- package/dist/templates/mobile.js +0 -720
- package/dist/templates/shared.d.ts +0 -2
- package/dist/templates/shared.js +0 -520
- package/dist/templates/web.d.ts +0 -2
- package/dist/templates/web.js +0 -412
- package/dist/toolchain.d.ts +0 -3
- package/dist/toolchain.js +0 -8
- package/dist/ui.d.ts +0 -33
- package/dist/ui.js +0 -199
- package/dist/workspace-config.d.ts +0 -11
- package/dist/workspace-config.js +0 -372
- package/docs/conventions.md +0 -92
- package/docs/master-stack-map.md +0 -199
- package/docs/output-tree-contract.md +0 -482
- package/docs/references/manual-scaffolding.md +0 -403
- package/docs/references/provisioning.md +0 -220
- package/scripts/render-master-stack-map.mjs +0 -510
- package/templates/api-fastify/apps/api/tsconfig.json +0 -20
- package/templates/catalog.json +0 -23
- package/templates/db-drizzle/packages/db/tsconfig.json +0 -14
- package/templates/desktop-electron/apps/desktop/tsconfig.json +0 -37
- package/templates/extension-wxt/apps/extension/tsconfig.json +0 -41
- package/templates/mobile-expo/apps/mobile/expo-env.d.ts +0 -1
- 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
|
-
}
|