anhedral 0.6.1 → 0.7.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.
@@ -1,5 +1,5 @@
1
- import { rmSync } from 'node:fs';
2
1
  import path from 'node:path';
2
+ import { writeReactLintConfig } from './lint.js';
3
3
  import { writeFile } from '../util.js';
4
4
  import { EXTENSION_DEPENDENCIES } from '../dependencies.js';
5
5
  import { childPackageName, htmlText, jsString, markdownHeading } from '../render.js';
@@ -7,7 +7,8 @@ export async function scaffoldExtension(root, options) {
7
7
  const { projectName, displayName } = options;
8
8
  const dir = path.join(root, 'apps/extension');
9
9
  writePackageJson(dir, projectName);
10
- cleanWxtStarterFiles(dir);
10
+ writeFile(path.join(dir, 'tsconfig.json'), JSON.stringify({ extends: './.wxt/tsconfig.json', compilerOptions: { strict: true, jsx: 'react-jsx', paths: { '@/*': ['./src/*'] } } }, null, 2) + '\n');
11
+ writeReactLintConfig(dir);
11
12
  writeWxtConfig(dir, displayName);
12
13
  writeEnvExample(dir);
13
14
  writePostcssConfig(dir);
@@ -22,16 +23,6 @@ export async function scaffoldExtension(root, options) {
22
23
  writeSidepanelApp(dir);
23
24
  writeStyles(dir);
24
25
  }
25
- function cleanWxtStarterFiles(dir) {
26
- for (const relativePath of [
27
- '.git',
28
- 'entrypoints',
29
- 'assets/react.svg',
30
- 'public/wxt.svg',
31
- ]) {
32
- rmSync(path.join(dir, relativePath), { recursive: true, force: true });
33
- }
34
- }
35
26
  function writePackageJson(dir, projectName) {
36
27
  writeFile(path.join(dir, 'package.json'), JSON.stringify({
37
28
  name: childPackageName(projectName, 'chrome-ext'),
@@ -44,9 +35,10 @@ function writePackageJson(dir, projectName) {
44
35
  postinstall: 'wxt prepare',
45
36
  zip: 'wxt zip',
46
37
  typecheck: 'tsc --noEmit',
38
+ lint: 'eslint src --max-warnings 0',
47
39
  },
48
40
  dependencies: EXTENSION_DEPENDENCIES.dependencies,
49
- devDependencies: EXTENSION_DEPENDENCIES.devDependencies,
41
+ devDependencies: { ...EXTENSION_DEPENDENCIES.devDependencies, '@workspace/eslint-config': 'workspace:*', eslint: '10.0.3' },
50
42
  }, null, 2) + '\n');
51
43
  }
52
44
  function writeWxtConfig(dir, displayName) {
@@ -68,7 +60,7 @@ export default defineConfig({
68
60
  version: '0.1.0',
69
61
  ...(crxPublicKey ? { key: crxPublicKey } : {}),
70
62
  minimum_chrome_version: '114',
71
- permissions: ${"['activeTab', 'scripting', 'sidePanel']"},
63
+ permissions: ['activeTab', 'scripting', 'sidePanel'],
72
64
  host_permissions: [],
73
65
  action: {
74
66
  default_title: ${actionTitleLiteral},
@@ -143,7 +135,7 @@ import { cn } from '@/lib/utils';
143
135
 
144
136
  type ButtonVariant = 'default' | 'outline';
145
137
 
146
- export type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
138
+ type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
147
139
  variant?: ButtonVariant;
148
140
  };
149
141
 
@@ -225,15 +217,6 @@ module.exports = {
225
217
  `);
226
218
  }
227
219
  function writeReadme(dir, displayName) {
228
- const configuration = [
229
- null,
230
- null,
231
- 'Set `VITE_CRX_PUBLIC_KEY` only when you need a stable Chrome extension ID.',
232
- ].filter((value) => value !== null).join(' ');
233
- const networkIntegrations = [].join(' and ');
234
- const hostPermissionsGuidance = networkIntegrations
235
- ? `\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.`
236
- : '';
237
220
  writeFile(path.join(dir, 'README.md'), `# ${markdownHeading(displayName)} Chrome Extension
238
221
 
239
222
  WXT side-panel extension generated by anhedral.
@@ -247,13 +230,13 @@ pnpm build
247
230
  pnpm zip
248
231
  \`\`\`
249
232
 
250
- Run these commands from \`apps/extension\`. Anhedral generates only \`.env.example\`; keep the local \`.env\` file uncommitted. ${configuration}
233
+ Run these commands from \`apps/extension\`. Anhedral generates only \`.env.example\`; keep the local \`.env\` file uncommitted. Set \`VITE_CRX_PUBLIC_KEY\` only when you need a stable Chrome extension ID.
251
234
 
252
235
  ## Chrome
253
236
 
254
237
  Run \`pnpm build\`, then load \`.output/chrome-mv3\` as an unpacked extension from \`chrome://extensions\`.
255
238
 
256
- 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, and active-page reads use a user-triggered \`activeTab\` + \`scripting\` grant instead of persistent site access.${hostPermissionsGuidance}
239
+ 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, and active-page reads use a user-triggered \`activeTab\` + \`scripting\` grant instead of persistent site access.
257
240
  `);
258
241
  }
259
242
  function writeBackground(dir) {
@@ -283,7 +266,7 @@ if (container) {
283
266
  const root = createRoot(container);
284
267
  root.render(
285
268
  <React.StrictMode>
286
- ${" <SidePanelApp />"}
269
+ <SidePanelApp />
287
270
  </React.StrictMode>
288
271
  );
289
272
  }
@@ -313,25 +296,25 @@ type PageSnapshot = {
313
296
  location: string;
314
297
  };
315
298
 
299
+ async function readPageSnapshot(): Promise<PageSnapshot> {
300
+ const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
301
+ if (!tab?.id) throw new Error('No active tab is available.');
302
+ const [{ result }] = await chrome.scripting.executeScript({
303
+ target: { tabId: tab.id },
304
+ func: () => ({ title: document.title, location: window.location.href }),
305
+ });
306
+ if (!result) throw new Error('The active page did not return a snapshot.');
307
+ return result as PageSnapshot;
308
+ }
309
+
316
310
  export function SidePanelApp() {
317
311
  const [page, setPage] = React.useState<PageSnapshot | null>(null);
318
312
  const [pageError, setPageError] = React.useState<string | null>(null);
319
313
 
320
314
  const readActivePage = React.useCallback(async () => {
321
315
  setPageError(null);
322
- const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
323
- if (!tab.id) {
324
- setPageError('No active tab is available.');
325
- return;
326
- }
327
-
328
316
  try {
329
- const [injection] = await chrome.scripting.executeScript({
330
- target: { tabId: tab.id },
331
- func: () => ({ title: document.title, location: window.location.href }),
332
- });
333
- if (!injection?.result) throw new Error('The active page did not return a snapshot.');
334
- setPage(injection.result as PageSnapshot);
317
+ setPage(await readPageSnapshot());
335
318
  } catch {
336
319
  setPageError('Chrome does not allow this page to be inspected. Open a normal website and try again.');
337
320
  }
@@ -0,0 +1 @@
1
+ export declare function writeReactLintConfig(directory: string): void;
@@ -0,0 +1,5 @@
1
+ import path from 'node:path';
2
+ import { writeFile } from '../util.js';
3
+ export function writeReactLintConfig(directory) {
4
+ writeFile(path.join(directory, 'eslint.config.mjs'), 'import { config } from "@workspace/eslint-config/react-internal";\nexport default [...config, { files: ["**/*.{ts,tsx,cts}"], languageOptions: { parserOptions: { babelOptions: { babelrc: false, configFile: false, parserOpts: { plugins: ["jsx", "typescript"] } } } }, rules: { "no-undef": "off", "no-unused-vars": "off" } }];\n');
5
+ }
@@ -1,4 +1,5 @@
1
1
  import path from 'node:path';
2
+ import { writeReactLintConfig } from './lint.js';
2
3
  import { appendGitignore, writeFile } from '../util.js';
3
4
  import { childPackageName, jsString } from '../render.js';
4
5
  import { MOBILE_APP_DEPENDENCIES, MOBILE_NATIVEWIND_DEPENDENCIES, EAS_CLI_VERSION, } from '../dependencies.js';
@@ -29,6 +30,17 @@ import { twMerge } from 'tailwind-merge';
29
30
  export function cn(...inputs: ClassValue[]) {
30
31
  return twMerge(clsx(inputs));
31
32
  }
33
+ `);
34
+ writeFile(path.join(dir, 'components/ui/button.tsx'), `import { Pressable, Text, type PressableProps } from 'react-native';
35
+ import { cn } from '@/lib/utils';
36
+
37
+ type ButtonProps = Omit<PressableProps, 'children'> & { children: string; className?: string };
38
+
39
+ export function Button({ children, className, ...props }: ButtonProps) {
40
+ return <Pressable accessibilityRole="button" className={cn('h-10 items-center justify-center rounded-md bg-primary px-4', className)} {...props}>
41
+ <Text className="font-medium text-primary-foreground">{children}</Text>
42
+ </Pressable>;
43
+ }
32
44
  `);
33
45
  writeFile(path.join(dir, 'babel.config.js'), `module.exports = function (api) {
34
46
  api.cache(true);
@@ -45,6 +57,7 @@ const config = getDefaultConfig(__dirname);
45
57
  module.exports = withNativeWind(config, { input: './global.css', inlineRem: 16 });
46
58
  `);
47
59
  writeFile(path.join(dir, 'nativewind-env.d.ts'), '/// <reference types="nativewind/types" />\n');
60
+ writeFile(path.join(dir, 'styles.d.ts'), "declare module '*.css';\n");
48
61
  writeFile(path.join(dir, 'tailwind.config.js'), `const { hairlineWidth } = require('nativewind/theme');
49
62
 
50
63
  /** @type {import('tailwindcss').Config} */
@@ -122,6 +135,7 @@ export async function scaffoldMobile(root, options) {
122
135
  const dir = path.join(root, 'apps/mobile');
123
136
  const { projectName, displayName } = options;
124
137
  const nameLiteral = jsString(displayName);
138
+ writeReactLintConfig(dir);
125
139
  writeFile(path.join(dir, 'package.json'), JSON.stringify({
126
140
  name: childPackageName(projectName, 'mobile'),
127
141
  version: '0.1.0',
@@ -142,9 +156,10 @@ export async function scaffoldMobile(root, options) {
142
156
  'submit:ios': `pnpm dlx eas-cli@${EAS_CLI_VERSION} submit --platform ios --profile production --latest`,
143
157
  'submit:android': `pnpm dlx eas-cli@${EAS_CLI_VERSION} submit --platform android --profile production --latest`,
144
158
  typecheck: 'tsc --noEmit',
159
+ lint: 'eslint app components lib --max-warnings 0',
145
160
  },
146
161
  dependencies: { ...MOBILE_APP_DEPENDENCIES.dependencies, ...MOBILE_NATIVEWIND_DEPENDENCIES.dependencies },
147
- devDependencies: MOBILE_APP_DEPENDENCIES.devDependencies,
162
+ devDependencies: { ...MOBILE_APP_DEPENDENCIES.devDependencies, '@workspace/eslint-config': 'workspace:*', eslint: '10.0.3' },
148
163
  }, null, 2) + '\n');
149
164
  writeFile(path.join(dir, 'app.json'), JSON.stringify({
150
165
  expo: {
@@ -153,9 +168,8 @@ export async function scaffoldMobile(root, options) {
153
168
  version: '1.0.0',
154
169
  orientation: 'portrait',
155
170
  scheme: expoScheme(projectName),
156
- userInterfaceStyle: 'automatic',
157
171
  web: { bundler: 'metro', output: 'static' },
158
- plugins: ['expo-router', ...([]), 'expo-status-bar'],
172
+ plugins: ['expo-router', 'expo-status-bar'],
159
173
  experiments: { reactCompiler: true, typedRoutes: true },
160
174
  },
161
175
  }, null, 2) + '\n');
@@ -175,7 +189,6 @@ export async function scaffoldMobile(root, options) {
175
189
  ],
176
190
  }, null, 2) + '\n');
177
191
  writeReactNativeReusablesConfig(dir);
178
- const layoutBody = ` return <>${"<Stack />"}<PortalHost /></>;`;
179
192
  writeFile(path.join(dir, 'app/_layout.tsx'), `import '@/global.css';
180
193
 
181
194
  import { PortalHost } from '@rn-primitives/portal';
@@ -183,15 +196,12 @@ import { Stack } from 'expo-router';
183
196
 
184
197
 
185
198
  export default function RootLayout() {
186
- ${layoutBody}
199
+ return <><Stack /><PortalHost /></>;
187
200
  }
188
201
  `);
189
- const homeImports = [].join('\n');
190
202
  writeFile(path.join(dir, 'app/index.tsx'), `import { StatusBar } from 'expo-status-bar';
191
- import { ${"ScrollView, StyleSheet, Text, View"} } from 'react-native';
192
- ${homeImports}
193
-
194
- const enabledModules = [];
203
+ import { Linking, ScrollView, StyleSheet, Text, View } from 'react-native';
204
+ import { Button } from '@/components/ui/button';
195
205
 
196
206
  export default function HomeScreen() {
197
207
  return (
@@ -200,7 +210,7 @@ export default function HomeScreen() {
200
210
  <View style={styles.content}>
201
211
  <Text accessibilityRole="header" style={styles.title}>{${nameLiteral}}</Text>
202
212
  <Text selectable style={styles.subtitle}>Deterministic Expo application generated by Anhedral.</Text>
203
- {enabledModules.map((moduleName) => <Text selectable key={moduleName} style={styles.module}>• {moduleName}</Text>)}
213
+ <Button onPress={() => void Linking.openURL('https://docs.expo.dev')}>Learn more</Button>
204
214
  </View>
205
215
  </ScrollView>
206
216
  );
@@ -210,12 +220,8 @@ const styles = StyleSheet.create({
210
220
  screen: { flex: 1, backgroundColor: '#f8fafc' },
211
221
  container: { flexGrow: 1, justifyContent: 'center', padding: 32 },
212
222
  content: { gap: 12 },
213
- actions: { gap: 8, paddingTop: 8 },
214
223
  title: { color: '#0f172a', fontSize: 34, fontWeight: '700' },
215
224
  subtitle: { color: '#475569', fontSize: 17, paddingBottom: 12 },
216
- module: { color: '#1e293b', fontSize: 15 },
217
- status: { color: '#475569', fontSize: 14 },
218
- error: { color: '#b91c1c', fontSize: 14 },
219
225
  });
220
226
  `);
221
227
  writeFile(path.join(dir, '.env.example'), '');
@@ -0,0 +1,20 @@
1
+ import { type StandardProduct } from './standard-products.js';
2
+ export declare function inspectProject(root: string): {
3
+ schemaVersion: number;
4
+ root: string;
5
+ checks: {
6
+ id: string;
7
+ ok: boolean;
8
+ detail: string;
9
+ }[];
10
+ localReady: boolean;
11
+ providerVerification: string;
12
+ productionReady: boolean;
13
+ };
14
+ export declare function validateSetupPlan(plan: unknown): asserts plan is {
15
+ schemaVersion: 1;
16
+ cliVersion: string;
17
+ registryVersion: 1;
18
+ products: StandardProduct[];
19
+ hosting: 'cloudflare' | 'vercel';
20
+ };
@@ -0,0 +1,61 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { CAPABILITY_REGISTRY, resolveStandardProducts } from './capabilities.js';
4
+ import { STANDARD_PRODUCTS } from './standard-products.js';
5
+ export function inspectProject(root) {
6
+ const checks = [];
7
+ const record = (id, ok, detail) => checks.push({ id, ok, detail });
8
+ const read = (file) => JSON.parse(readFileSync(path.join(root, file), 'utf8'));
9
+ record('workspace', existsSync(path.join(root, 'pnpm-workspace.yaml')), 'pnpm workspace manifest');
10
+ record('lockfile', existsSync(path.join(root, 'pnpm-lock.yaml')), 'Committed dependency resolution required');
11
+ try {
12
+ const plan = read('anhedral.setup.json');
13
+ validateSetupPlan(plan);
14
+ const standard = read('anhedral.standard.json');
15
+ record('setup-plan', true, `Requirements recorded by CLI ${plan.cliVersion}`);
16
+ record('cli-compatibility', plan.cliVersion === CAPABILITY_REGISTRY.cliVersion, 'Use the project CLI version or review upgrade differences');
17
+ record('selection', standard.schemaVersion === 1 && JSON.stringify(standard.products) === JSON.stringify(plan.products) && standard.hosting === (plan.products.includes('next') ? plan.hosting : null), 'Setup plan matches project selection');
18
+ }
19
+ catch {
20
+ record('setup-plan', false, 'Missing, invalid or unsupported setup plan; inspect the project before changing it');
21
+ }
22
+ return { schemaVersion: 1, root, checks, localReady: checks.every((check) => check.ok), providerVerification: 'unverified', productionReady: false };
23
+ }
24
+ export function validateSetupPlan(plan) {
25
+ if (!plan || typeof plan !== 'object')
26
+ throw new Error('Invalid setup plan.');
27
+ const value = plan;
28
+ if (value.schemaVersion !== 1 || value.registryVersion !== 1 || typeof value.cliVersion !== 'string')
29
+ throw new Error('Unsupported setup plan version.');
30
+ if (!Array.isArray(value.products) || !value.products.length || !value.products.every((id) => STANDARD_PRODUCTS.includes(id)))
31
+ throw new Error('Unknown setup capabilities.');
32
+ if (value.hosting !== 'cloudflare' && value.hosting !== 'vercel')
33
+ throw new Error('Invalid hosting.');
34
+ validateRequirements(value.setup, value.products);
35
+ const selected = new Set(value.products);
36
+ resolveStandardProducts(selected, value.hosting, false);
37
+ if (selected.size !== value.products.length)
38
+ throw new Error('Invalid or incomplete setup selection.');
39
+ }
40
+ function validateRequirements(setup, products) {
41
+ if (!setup || typeof setup !== 'object')
42
+ throw new Error('Missing setup requirements.');
43
+ const value = setup;
44
+ if (value.resourcesProvisioned !== false || !Array.isArray(value.capabilities) || value.capabilities.length !== products.length)
45
+ throw new Error('Invalid capability requirements.');
46
+ const ids = value.capabilities.map((item) => validateCapability(item));
47
+ if (new Set(ids).size !== products.length || !products.every((id) => ids.includes(id)))
48
+ throw new Error('Capability selection mismatch.');
49
+ }
50
+ function validateCapability(item) {
51
+ if (!item || typeof item !== 'object')
52
+ throw new Error('Invalid capability.');
53
+ const value = item;
54
+ if (typeof value.id !== 'string' || typeof value.purpose !== 'string' || !['planned', 'starter'].includes(String(value.status)))
55
+ throw new Error('Invalid capability metadata.');
56
+ for (const key of ['tools', 'access', 'credentials', 'resources', 'verify']) {
57
+ if (!Array.isArray(value[key]) || !value[key].every((entry) => typeof entry === 'string'))
58
+ throw new Error('Invalid requirement fields.');
59
+ }
60
+ return value.id;
61
+ }
package/dist/render.d.ts CHANGED
@@ -4,7 +4,6 @@ export declare function jsString(value: string): string;
4
4
  export declare function htmlText(value: string): string;
5
5
  /** Keep generated Markdown headings on one line and prevent heading/code injection. */
6
6
  export declare function markdownHeading(value: string): string;
7
- export declare function assertPackageName(value: string): string;
8
7
  /** Derive a valid, deterministic unscoped npm package name from arbitrary text. */
9
8
  export declare function packageNameFromText(value: string): string;
10
9
  export declare function childPackageName(projectName: string, suffix: string): string;
package/dist/render.js CHANGED
@@ -20,7 +20,7 @@ export function markdownHeading(value) {
20
20
  const normalized = value.replace(/[\r\n]+/g, ' ').trim() || 'Anhedral App';
21
21
  return normalized.replace(/([\\`*_{}\[\]()<>#+.!|~-])/g, '\\$1');
22
22
  }
23
- export function assertPackageName(value) {
23
+ function assertPackageName(value) {
24
24
  const leafName = value.slice(value.lastIndexOf('/') + 1);
25
25
  if (value.length > MAX_PACKAGE_NAME_LENGTH
26
26
  || !PACKAGE_NAME_PATTERN.test(value)