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