anhedral 0.6.0 → 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.
package/dist/standard.js CHANGED
@@ -8,6 +8,7 @@ import { runStagedTransaction } from './transaction.js';
8
8
  import { scaffoldDesktop } from './platforms/desktop.js';
9
9
  import { scaffoldMobile } from './platforms/mobile.js';
10
10
  import { scaffoldExtension } from './platforms/extension.js';
11
+ import { createSetupPlan, resolveStandardProducts } from './capabilities.js';
11
12
  import { STANDARD_PRODUCTS } from './standard-products.js';
12
13
  export { STANDARD_PRODUCTS } from './standard-products.js';
13
14
  export const STANDARD_USAGE = `Anhedral Application Stack & Delivery Standard
@@ -24,18 +25,27 @@ Examples:
24
25
  anhedral new product --next --expo --hono --neon --clerk --r2
25
26
  anhedral new website --next --stripe --resend
26
27
  anhedral init --wxt
28
+ anhedral doctor [directory] --json
27
29
 
28
30
  --skip-install skips the final workspace install; the official shadcn bootstrap
29
31
  still requires network access and may install its own dependencies.
30
32
  --all is intentionally unsupported: select only required capabilities.
31
33
  Vercel is an explicit architecture exception requiring project approval.
32
34
  `;
33
- export function parseStandardOptions(command, args) {
34
- const remaining = [...args];
35
- const directory = command === 'new' ? remaining.shift() : process.cwd();
36
- if (!directory || directory.startsWith('-'))
37
- throw new Error('new requires a destination directory.');
38
- const root = path.resolve(directory);
35
+ function readGitFlag(token, previous) {
36
+ const value = token === '--git';
37
+ if (previous !== undefined && value !== previous)
38
+ throw new Error('Conflicting Git options.');
39
+ return value;
40
+ }
41
+ function readHostingFlag(value, previous, specified) {
42
+ if (value !== 'cloudflare' && value !== 'vercel')
43
+ throw new Error('hosting must be cloudflare or vercel.');
44
+ if (specified && previous !== value)
45
+ throw new Error('Conflicting hosting options.');
46
+ return value;
47
+ }
48
+ function parseStandardFlags(remaining) {
39
49
  const products = new Set();
40
50
  let hosting = 'cloudflare';
41
51
  let hostingSpecified = false;
@@ -45,19 +55,12 @@ export function parseStandardOptions(command, args) {
45
55
  if (['--skip-install', '--dry-run', '--json', '--verbose'].includes(token))
46
56
  continue;
47
57
  if (token === '--git' || token === '--no-git') {
48
- const value = token === '--git';
49
- if (gitSpecified !== undefined && value !== gitSpecified)
50
- throw new Error('Conflicting Git options.');
51
- gitSpecified = value;
58
+ gitSpecified = readGitFlag(token, gitSpecified);
52
59
  continue;
53
60
  }
54
61
  if (token === '--hosting' || token.startsWith('--hosting=')) {
55
62
  const value = token === '--hosting' ? remaining[++index] : token.slice('--hosting='.length);
56
- if (value !== 'cloudflare' && value !== 'vercel')
57
- throw new Error('hosting must be cloudflare or vercel.');
58
- if (hostingSpecified && hosting !== value)
59
- throw new Error('Conflicting hosting options.');
60
- hosting = value;
63
+ hosting = readHostingFlag(value, hosting, hostingSpecified);
61
64
  hostingSpecified = true;
62
65
  continue;
63
66
  }
@@ -67,24 +70,16 @@ export function parseStandardOptions(command, args) {
67
70
  }
68
71
  products.add(value);
69
72
  }
70
- if (![...products].some((value) => ['next', 'expo', 'electron', 'wxt', 'hono'].includes(value)))
71
- products.add('next');
72
- for (const pair of [['neon', 'd1'], ['clerk', 'better-auth'], ['openai', 'ai-sdk']]) {
73
- if (pair.every((value) => products.has(value)))
74
- throw new Error(`Choose one of ${pair.join(' / ')}.`);
75
- }
76
- if (products.has('better-auth') && !products.has('neon') && !products.has('d1'))
77
- throw new Error('Better Auth requires an explicitly selected neon or d1 database.');
78
- if (products.has('revenuecat') && !products.has('stripe'))
79
- throw new Error('RevenueCat is an addition to Stripe; select stripe explicitly.');
80
- const needsRuntime = ['neon', 'd1', 'better-auth', 'r2', 'kv', 'workers-ai', 'basin'].some((value) => products.has(value));
81
- if (needsRuntime && !products.has('hono') && (!products.has('next') || hosting !== 'cloudflare')) {
82
- throw new Error('Selected server bindings need a Cloudflare Next.js app or an explicitly selected Hono Worker.');
83
- }
84
- if (products.has('clerk') && !products.has('next') && !products.has('hono'))
85
- throw new Error('Select next or hono for the Clerk server integration. Native client sign-in must be configured for the project.');
86
- if (hostingSpecified && !products.has('next'))
87
- throw new Error('--hosting applies to the Next.js app.');
73
+ return { products, hosting, hostingSpecified, gitSpecified };
74
+ }
75
+ export function parseStandardOptions(command, args) {
76
+ const remaining = [...args];
77
+ const directory = command === 'new' ? remaining.shift() : process.cwd();
78
+ if (!directory || directory.startsWith('-'))
79
+ throw new Error('new requires a destination directory.');
80
+ const root = path.resolve(directory);
81
+ const { products, hosting, hostingSpecified, gitSpecified } = parseStandardFlags(remaining);
82
+ resolveStandardProducts(products, hosting, hostingSpecified);
88
83
  return { root, name: packageNameFromText(path.basename(root)), products: [...products].sort(), hosting,
89
84
  skipInstall: remaining.includes('--skip-install'), dryRun: remaining.includes('--dry-run'), json: remaining.includes('--json'),
90
85
  initializeGit: gitSpecified ?? command === 'new' };
@@ -122,36 +117,42 @@ function sharedPackage(root, name, source, dependencies) {
122
117
  put(root, `packages/${name}/eslint.config.mjs`, 'import { config } from "@workspace/eslint-config/base";\nexport default [...config, { ignores: ["dist/**", ".wrangler/**", "worker-configuration.d.ts"] }, { files: ["**/*.ts"], rules: { "no-undef": "off", "no-unused-vars": "off" } }];\n');
123
118
  put(root, `packages/${name}/src/index.ts`, source);
124
119
  }
125
- function cleanShadcnStarter(root) {
126
- for (const directory of ['apps/web', 'packages/ui']) {
127
- const manifestPath = `${directory}/package.json`;
128
- if (!existsSync(path.join(root, manifestPath)))
129
- continue;
130
- const manifest = readJson(root, manifestPath);
131
- const sources = [];
132
- const collect = (folder) => {
133
- for (const entry of readdirSync(folder, { withFileTypes: true })) {
134
- if (['node_modules', '.next', '.open-next', '.git'].includes(entry.name))
135
- continue;
136
- const file = path.join(folder, entry.name);
137
- if (entry.isDirectory())
138
- collect(file);
139
- else if (/\.(tsx?|m?js|css)$/.test(entry.name))
140
- sources.push(readFileSync(file, 'utf8'));
141
- }
142
- };
143
- collect(path.join(root, directory));
144
- for (const name of ['lucide-react', 'zod', 'next-themes']) {
145
- if (!sources.some((source) => source.includes(`"${name}`) || source.includes(`'${name}`)))
146
- delete manifest.dependencies?.[name];
120
+ function cleanWorkspaceManifest(root, directory) {
121
+ const manifestPath = `${directory}/package.json`;
122
+ if (!existsSync(path.join(root, manifestPath)))
123
+ return;
124
+ const manifest = readJson(root, manifestPath);
125
+ const sources = [];
126
+ const collect = (folder) => {
127
+ for (const entry of readdirSync(folder, { withFileTypes: true })) {
128
+ if (['node_modules', '.next', '.open-next', '.git'].includes(entry.name))
129
+ continue;
130
+ const file = path.join(folder, entry.name);
131
+ if (entry.isDirectory())
132
+ collect(file);
133
+ else if (/\.(tsx?|m?js|css)$/.test(entry.name))
134
+ sources.push(readFileSync(file, 'utf8'));
147
135
  }
148
- if (directory === 'packages/ui') {
149
- if (!sources.some((source) => source.includes('@turbo/gen')) && !Object.values(manifest.scripts ?? {}).some((script) => String(script).includes('turbo gen')))
150
- delete manifest.devDependencies?.['@turbo/gen'];
151
- manifest.devDependencies = { ...manifest.devDependencies, 'postcss-load-config': '^6.0.1' };
136
+ };
137
+ collect(path.join(root, directory));
138
+ for (const name of ['lucide-react', 'zod', 'next-themes']) {
139
+ if (!sources.some((source) => source.includes(`"${name}`) || source.includes(`'${name}`)))
140
+ delete manifest.dependencies?.[name];
141
+ }
142
+ if (directory === 'packages/ui') {
143
+ if (!sources.some((source) => source.includes('@turbo/gen')) && !Object.values(manifest.scripts ?? {}).some((script) => String(script).includes('turbo gen')))
144
+ delete manifest.devDependencies?.['@turbo/gen'];
145
+ manifest.devDependencies = { ...manifest.devDependencies, 'postcss-load-config': '^6.0.1' };
146
+ if (manifest.dependencies?.shadcn) {
147
+ manifest.devDependencies.shadcn = manifest.dependencies.shadcn;
148
+ delete manifest.dependencies.shadcn;
152
149
  }
153
- json(root, manifestPath, manifest);
154
150
  }
151
+ json(root, manifestPath, manifest);
152
+ }
153
+ function cleanShadcnStarter(root) {
154
+ for (const directory of ['apps/web', 'packages/ui'])
155
+ cleanWorkspaceManifest(root, directory);
155
156
  const themePath = 'apps/web/components/theme-provider.tsx';
156
157
  if (existsSync(path.join(root, themePath))) {
157
158
  const source = readFileSync(path.join(root, themePath), 'utf8');
@@ -190,6 +191,20 @@ export async function applyStandardOverlays(root, options) {
190
191
  if (has('basin'))
191
192
  bindings.pipelines = [{ binding: 'ANALYTICS', stream: 'REPLACE_WITH_BASIN_STREAM_ID' }];
192
193
  const runtimeApp = has('hono') ? 'api' : 'web';
194
+ addWebApplication(root, options, bindings, runtimeApp);
195
+ addApiApplication(root, options, bindings);
196
+ addDatabase(root, options, runtimeApp);
197
+ addBetterAuth(root, options, runtimeApp);
198
+ addClerk(root, options);
199
+ if (has('r2')) {
200
+ put(root, `apps/${runtimeApp}/private-files.md`, '# Private files\n\nFILES is a private R2 Worker binding. Authenticate the request and enforce file-level product authorization before calling get/put/delete. No public buckets, R2 keys, generic download endpoints, or client credentials are generated. Use the binding directly in Hono or getCloudflareContext in Next.js.\n');
201
+ }
202
+ addServiceWorkers(root, options);
203
+ addProviderPackages(root, options);
204
+ writeStandardRoot(root, options);
205
+ }
206
+ function addWebApplication(root, options, bindings, runtimeApp) {
207
+ const has = (product) => options.products.includes(product);
193
208
  if (has('next')) {
194
209
  cleanShadcnStarter(root);
195
210
  put(root, 'apps/web/eslint.config.js', 'import { nextJsConfig } from "@workspace/eslint-config/next-js";\nexport default [...nextJsConfig, { ignores: [".open-next/**", ".wrangler/**", "cloudflare-env.d.ts"] }];\n');
@@ -210,6 +225,9 @@ export async function applyStandardOverlays(root, options) {
210
225
  json(root, 'apps/web/vercel.json', { framework: 'nextjs' });
211
226
  }
212
227
  }
228
+ }
229
+ function addApiApplication(root, options, bindings) {
230
+ const has = (product) => options.products.includes(product);
213
231
  if (has('hono')) {
214
232
  worker(root, 'api', { ...workerConfig(`${options.name}-api`), ...bindings }, `import { OpenAPIHono, createRoute, z } from '@hono/zod-openapi';
215
233
  const app = new OpenAPIHono<{ Bindings: Env }>();
@@ -218,6 +236,9 @@ app.doc('/openapi.json', { openapi: '3.0.0', info: { title: '${options.name}', v
218
236
  export default app;
219
237
  `, { hono: '^4.0.0', '@hono/zod-openapi': '^1.0.0' });
220
238
  }
239
+ }
240
+ function addDatabase(root, options, runtimeApp) {
241
+ const has = (product) => options.products.includes(product);
221
242
  if (has('neon') || has('d1')) {
222
243
  sharedPackage(root, 'db', has('neon') ? `import { drizzle } from 'drizzle-orm/postgres-js';
223
244
  import postgres from 'postgres';
@@ -230,7 +251,7 @@ export function createDatabase(connectionString: string) {
230
251
  ` : `import { drizzle } from 'drizzle-orm/d1';
231
252
  import * as schema from './schema.js';
232
253
  export function createDatabase(binding: Parameters<typeof drizzle>[0]) { return drizzle(binding, { schema }); }
233
- `, { 'drizzle-orm': '^0.44.0', ...(has('neon') ? { postgres: '^3.4.0' } : {}) });
254
+ `, { 'drizzle-orm': '^0.45.3', ...(has('neon') ? { postgres: '^3.4.0' } : {}) });
234
255
  put(root, 'packages/db/src/schema.ts', '// Define product tables here; generate and review SQL before migration.\nexport {};\n');
235
256
  put(root, 'packages/db/drizzle.config.ts', has('neon') ? `import { defineConfig } from 'drizzle-kit';
236
257
  export default defineConfig({ dialect: 'postgresql', schema: './src/schema.ts', out: './migrations', dbCredentials: { url: process.env.DATABASE_URL! } });
@@ -240,25 +261,29 @@ export default defineConfig({ dialect: 'sqlite', schema: './src/schema.ts', out:
240
261
  patchPackage(root, 'packages/db/package.json', { devDependencies: { 'drizzle-kit': '^0.31.0' }, scripts: { 'db:generate': 'drizzle-kit generate' } });
241
262
  patchPackage(root, `apps/${runtimeApp}/package.json`, { dependencies: { '@workspace/db': 'workspace:*' } });
242
263
  }
243
- if (has('better-auth')) {
244
- sharedPackage(root, 'auth', `import { betterAuth } from 'better-auth';
264
+ }
265
+ function addBetterAuth(root, options, runtimeApp) {
266
+ const has = (product) => options.products.includes(product);
267
+ if (!has('better-auth'))
268
+ return;
269
+ sharedPackage(root, 'auth', `import { betterAuth } from 'better-auth';
245
270
  import { drizzleAdapter } from 'better-auth/adapters/drizzle';
246
271
  import type { createDatabase } from '@workspace/db';
247
272
  export function createAuth(db: ReturnType<typeof createDatabase>${has('neon') ? "['db']" : ''}, secret: string, baseURL: string) {
248
273
  return betterAuth({ database: drizzleAdapter(db, { provider: '${has('neon') ? 'pg' : 'sqlite'}' }), secret, baseURL, emailAndPassword: { enabled: true } });
249
274
  }
250
275
  `, { 'better-auth': '^1.0.0', '@workspace/db': 'workspace:*' });
251
- patchPackage(root, `apps/${runtimeApp}/package.json`, { dependencies: { '@workspace/auth': 'workspace:*' } });
252
- const dbInit = has('neon') ? 'database.db' : 'database';
253
- const connection = has('neon') ? 'createDatabase(env.HYPERDRIVE.connectionString)' : 'createDatabase(env.DB)';
254
- if (has('hono')) {
255
- const source = readFileSync(path.join(root, 'apps/api/src/index.ts'), 'utf8');
256
- put(root, 'apps/api/src/index.ts', `import { createAuth } from '@workspace/auth';\nimport { createDatabase } from '@workspace/db';\n` + source.replace('export default app;', `app.on(['GET', 'POST'], '/api/auth/*', async (c) => {\n const env = c.env;\n const database = ${connection};\n try { return await createAuth(${dbInit}, env.BETTER_AUTH_SECRET, env.BETTER_AUTH_URL).handler(c.req.raw); } finally { ${has('neon') ? 'await database.close();' : '/* D1 bindings need no connection cleanup. */'} }\n});\nexport default app;`));
257
- const config = readJson(root, 'apps/api/wrangler.jsonc');
258
- json(root, 'apps/api/wrangler.jsonc', { ...config, vars: { BETTER_AUTH_URL: 'http://localhost:8787' } });
259
- }
260
- else {
261
- put(root, 'apps/web/app/api/auth/[...all]/route.ts', `import { getCloudflareContext } from '@opennextjs/cloudflare';
276
+ patchPackage(root, `apps/${runtimeApp}/package.json`, { dependencies: { '@workspace/auth': 'workspace:*' } });
277
+ const dbInit = has('neon') ? 'database.db' : 'database';
278
+ const connection = has('neon') ? 'createDatabase(env.HYPERDRIVE.connectionString)' : 'createDatabase(env.DB)';
279
+ if (has('hono')) {
280
+ const source = readFileSync(path.join(root, 'apps/api/src/index.ts'), 'utf8');
281
+ put(root, 'apps/api/src/index.ts', `import { createAuth } from '@workspace/auth';\nimport { createDatabase } from '@workspace/db';\n` + source.replace('export default app;', `app.on(['GET', 'POST'], '/api/auth/*', async (c) => {\n const env = c.env;\n const database = ${connection};\n try { return await createAuth(${dbInit}, env.BETTER_AUTH_SECRET, env.BETTER_AUTH_URL).handler(c.req.raw); } finally { ${has('neon') ? 'await database.close();' : '/* D1 bindings need no connection cleanup. */'} }\n});\nexport default app;`));
282
+ const config = readJson(root, 'apps/api/wrangler.jsonc');
283
+ json(root, 'apps/api/wrangler.jsonc', { ...config, vars: { BETTER_AUTH_URL: 'http://localhost:8787' } });
284
+ }
285
+ else {
286
+ put(root, 'apps/web/app/api/auth/[...all]/route.ts', `import { getCloudflareContext } from '@opennextjs/cloudflare';
262
287
  import { createAuth } from '@workspace/auth';
263
288
  import { createDatabase } from '@workspace/db';
264
289
  async function handler(request: Request) {
@@ -269,47 +294,47 @@ async function handler(request: Request) {
269
294
  }
270
295
  export { handler as GET, handler as POST };
271
296
  `);
272
- put(root, 'apps/web/cloudflare-env.d.ts', `interface CloudflareEnv { ${has('neon') ? 'HYPERDRIVE: { connectionString: string };' : "DB: Parameters<typeof import('drizzle-orm/d1').drizzle>[0];"} }\n`);
273
- patchPackage(root, 'apps/web/package.json', { dependencies: { 'drizzle-orm': '^0.44.0' } });
274
- }
275
- put(root, `apps/${runtimeApp}/.dev.vars.example`, 'BETTER_AUTH_SECRET=\nBETTER_AUTH_URL=http://localhost:8787\n');
276
- if (has('hono'))
277
- put(root, 'apps/api/src/secrets.d.ts', 'interface Env { BETTER_AUTH_SECRET: string }\n');
297
+ put(root, 'apps/web/cloudflare-env.d.ts', `interface CloudflareEnv { ${has('neon') ? 'HYPERDRIVE: { connectionString: string };' : "DB: Parameters<typeof import('drizzle-orm/d1').drizzle>[0];"} }\n`);
298
+ patchPackage(root, 'apps/web/package.json', { dependencies: { 'drizzle-orm': '^0.45.3' } });
278
299
  }
279
- if (has('clerk')) {
280
- if (has('next')) {
281
- patchPackage(root, 'apps/web/package.json', { dependencies: { '@clerk/nextjs': '^7.0.0' } });
282
- const layoutPath = path.join(root, 'apps/web/app/layout.tsx');
283
- if (existsSync(layoutPath)) {
284
- const layout = readFileSync(layoutPath, 'utf8');
285
- if (!layout.includes('ClerkProvider'))
286
- put(root, 'apps/web/app/layout.tsx', 'import { ClerkProvider } from "@clerk/nextjs";\n' + layout.replace('<body>', '<body><ClerkProvider>').replace('</body>', '</ClerkProvider></body>'));
300
+ put(root, `apps/${runtimeApp}/.dev.vars.example`, 'BETTER_AUTH_SECRET=\nBETTER_AUTH_URL=http://localhost:8787\n');
301
+ if (has('hono'))
302
+ put(root, 'apps/api/src/secrets.d.ts', 'interface Env { BETTER_AUTH_SECRET: string }\n');
303
+ }
304
+ function addClerk(root, options) {
305
+ const has = (product) => options.products.includes(product);
306
+ if (!has('clerk'))
307
+ return;
308
+ if (has('next')) {
309
+ patchPackage(root, 'apps/web/package.json', { dependencies: { '@clerk/nextjs': '^7.0.0' } });
310
+ const layoutPath = path.join(root, 'apps/web/app/layout.tsx');
311
+ if (existsSync(layoutPath)) {
312
+ const layout = readFileSync(layoutPath, 'utf8');
313
+ if (!layout.includes('ClerkProvider')) {
314
+ const wrapped = layout.replace(/(<body\b[^>]*>)([\s\S]*?)(<\/body>)/, '$1<ClerkProvider>$2</ClerkProvider>$3');
315
+ if (wrapped === layout)
316
+ throw new Error('Cannot configure ClerkProvider: the web layout must contain a body element.');
317
+ put(root, 'apps/web/app/layout.tsx', 'import { ClerkProvider } from "@clerk/nextjs";\n' + wrapped);
287
318
  }
288
- put(root, 'apps/web/app/sign-in/[[...sign-in]]/page.tsx', 'import { SignIn } from "@clerk/nextjs";\nexport default function SignInPage() { return <SignIn />; }\n');
289
- put(root, 'apps/web/app/sign-up/[[...sign-up]]/page.tsx', 'import { SignUp } from "@clerk/nextjs";\nexport default function SignUpPage() { return <SignUp />; }\n');
290
- put(root, 'apps/web/proxy.ts', `import { clerkMiddleware } from '@clerk/nextjs/server';
319
+ }
320
+ put(root, 'apps/web/app/sign-in/[[...sign-in]]/page.tsx', 'import { SignIn } from "@clerk/nextjs";\nexport default function SignInPage() { return <SignIn />; }\n');
321
+ put(root, 'apps/web/app/sign-up/[[...sign-up]]/page.tsx', 'import { SignUp } from "@clerk/nextjs";\nexport default function SignUpPage() { return <SignUp />; }\n');
322
+ put(root, 'apps/web/proxy.ts', `import { clerkMiddleware } from '@clerk/nextjs/server';
291
323
  export default clerkMiddleware();
292
324
  export const config = { matcher: ['/((?!_next|[^?]*\\\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico)).*)', '/(api|trpc)(.*)'] };
293
325
  `);
294
- // Middleware makes auth available; product routes must explicitly require identity and authorization.
295
- }
296
- if (has('hono')) {
297
- patchPackage(root, 'apps/api/package.json', { dependencies: { '@clerk/backend': '^3.0.0' } });
298
- put(root, 'apps/api/src/identity.ts', `import { createClerkClient } from '@clerk/backend';
326
+ // Middleware makes auth available; product routes must explicitly require identity and authorization.
327
+ }
328
+ if (has('hono')) {
329
+ patchPackage(root, 'apps/api/package.json', { dependencies: { '@clerk/backend': '^3.0.0' } });
330
+ put(root, 'apps/api/src/identity.ts', `import { createClerkClient } from '@clerk/backend';
299
331
  export async function authenticate(request: Request, secretKey: string, authorizedParties: string[]) {
300
332
  return createClerkClient({ secretKey }).authenticateRequest(request, { authorizedParties });
301
333
  }
302
334
  `);
303
- }
304
- }
305
- if (has('r2')) {
306
- put(root, `apps/${runtimeApp}/private-files.md`, '# Private files\n\nFILES is a private R2 Worker binding. Authenticate the request and enforce file-level product authorization before calling get/put/delete. No public buckets, R2 keys, generic download endpoints, or client credentials are generated. Use the binding directly in Hono or getCloudflareContext in Next.js.\n');
307
335
  }
308
- await addServiceWorkers(root, options);
309
- addProviderPackages(root, options);
310
- writeStandardRoot(root, options);
311
336
  }
312
- async function addServiceWorkers(root, options) {
337
+ function addServiceWorkers(root, options) {
313
338
  const has = (value) => options.products.includes(value);
314
339
  if (has('realtime')) {
315
340
  worker(root, 'realtime', { ...workerConfig(`${options.name}-realtime`), durable_objects: { bindings: [{ name: 'ROOMS', class_name: 'Room' }] },
@@ -402,19 +427,38 @@ export async function recordEvents(stream: EventStream, events: Record<string, u
402
427
  function writeStandardRoot(root, options) {
403
428
  const has = (value) => options.products.includes(value);
404
429
  const existing = readJson(root, 'package.json');
430
+ const securityOverrides = {
431
+ ...(has('wxt') ? { '@wxt-dev/module-react>@vitejs/plugin-react': '5.2.0' } : {}),
432
+ ...(has('next') ? { "sharp@<0.35.5": "0.35.5" } : {}),
433
+ "source-map-js@<1.2.2": "1.2.2",
434
+ "baseline-browser-mapping@<2.11.0": "2.11.27",
435
+ "browserslist@<4.28.7": "4.29.3",
436
+ ...(has('next') || has('hono') || has('electron') || has('wxt') ? { "esbuild@<0.25.0": "0.25.12" } : {}),
437
+ "brace-expansion@<2": "1.1.21",
438
+ "brace-expansion@>=2 <3": "2.1.7",
439
+ "brace-expansion@>=3 <4": "3.0.9",
440
+ "brace-expansion@>=4 <5.0.12": "5.0.12"
441
+ };
405
442
  patchPackage(root, 'package.json', { name: options.name, packageManager: PACKAGE_MANAGER, engines: { node: '>=22.13.0' },
406
- scripts: { dev: 'turbo dev', build: 'turbo build', lint: 'turbo lint', typecheck: 'turbo typecheck',
407
- test: 'turbo test', audit: 'node scripts/audit.mjs', 'audit:full': 'fallow --fail-on-issues', check: 'pnpm lint && pnpm typecheck && pnpm run audit && pnpm test && pnpm build' },
408
- devDependencies: { fallow: '^3.31.0' } });
443
+ scripts: { dev: 'turbo dev', build: 'turbo build', ...(has('next') && options.hosting === 'cloudflare' ? { 'build:worker': 'pnpm --dir apps/web run build:worker' } : {}), lint: 'turbo lint', typecheck: 'turbo typecheck',
444
+ test: 'turbo test', audit: 'node scripts/audit.mjs', 'audit:full': 'fallow --fail-on-issues', 'audit:deps': 'pnpm audit --prod --audit-level high', check: `pnpm lint && pnpm typecheck && pnpm run audit && pnpm audit:deps && pnpm test && pnpm build${workerBuildSuffix(options)}` },
445
+ devDependencies: { fallow: '^3.31.0' },
446
+ // Upstream starter lockfiles can retain vulnerable transitive versions. Keep fixes within compatible majors.
447
+ pnpm: { ...existing.pnpm, overrides: { ...existing.pnpm?.overrides, ...securityOverrides } } });
409
448
  put(root, 'scripts/audit.mjs', `import { spawnSync } from 'node:child_process';
410
449
  const base = process.env.AUDIT_BASE;
411
450
  const hasBase = base || ['origin/main', 'main'].find((ref) => spawnSync('git', ['rev-parse', '--verify', ref], { stdio: 'ignore' }).status === 0);
412
451
  const args = hasBase ? ['exec', 'fallow', 'audit', '--base', hasBase] : ['exec', 'fallow', '--fail-on-issues'];
413
- const result = spawnSync('pnpm', args, { stdio: 'inherit', shell: false });
452
+ const entry = process.env.npm_execpath;
453
+ if (!entry) throw new Error('Run this check through pnpm run audit.');
454
+ const result = spawnSync(process.execPath, [entry, ...args], { stdio: 'inherit', shell: false });
414
455
  if (result.error) throw result.error;
415
456
  process.exitCode = result.status ?? 1;
416
457
  `);
417
- json(root, '.fallowrc.json', { ignorePatterns: ['**/.next/**', '**/.open-next/**', '**/.wrangler/**', '**/dist/**', '**/worker-configuration.d.ts', '**/cloudflare-env.d.ts'] });
458
+ json(root, '.fallowrc.json', { ...(has('electron') ? { entry: ['apps/desktop/src/main/preload.cts'] } : {}),
459
+ // Expo web export and NativeWind load these runtime dependencies dynamically.
460
+ ...(has('expo') ? { ignoreDependencies: ['react-native-web', 'react-native-css-interop', 'react-native-reanimated', 'react-native-worklets'] } : {}),
461
+ ignorePatterns: ['**/next-env.d.ts', '**/.next/**', '**/.open-next/**', '**/.wrangler/**', '**/dist/**', '**/worker-configuration.d.ts', '**/cloudflare-env.d.ts'] });
418
462
  const turbo = readJson(root, 'turbo.json');
419
463
  turbo.globalEnv = ['DATABASE_URL', 'NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY', 'CLERK_SECRET_KEY', 'BETTER_AUTH_SECRET', 'BETTER_AUTH_URL', 'OPENAI_API_KEY', 'RESEND_API_KEY', 'STRIPE_SECRET_KEY', 'STRIPE_WEBHOOK_SECRET', 'POSTHOG_API_KEY', 'POSTHOG_HOST', 'SENTRY_DSN'];
420
464
  turbo.tasks = { ...turbo.tasks, test: { dependsOn: ['^build'], outputs: ['coverage/**'] },
@@ -427,6 +471,15 @@ process.exitCode = result.status ?? 1;
427
471
  const section = /(?:allowBuilds|onlyBuiltDependencies):\n(?:[ \t]+[^\n]*\n|[ \t]*\n)*/;
428
472
  put(root, 'pnpm-workspace.yaml', section.test(workspace) ? workspace.replace(section, policy) : workspace + '\n' + policy);
429
473
  appendGitignore(root, ['node_modules/', '.open-next/', '.wrangler/', '.dev.vars', '.dev.vars.*', '!.dev.vars.example', '.env', '.env.*', '!.env.example', '.fallow/']);
474
+ writeEnvironmentExample(root, options);
475
+ json(root, 'anhedral.standard.json', { schemaVersion: 1, standard: 'anhedral-application-stack', products: options.products,
476
+ hosting: has('next') ? options.hosting : null, bootstrap: { command: 'pnpm dlx shadcn@latest init --monorepo --template next', upstreamPackageManager: existing.packageManager },
477
+ status: 'starter', resourcesProvisioned: false });
478
+ json(root, 'anhedral.setup.json', createSetupPlan({ ...options, dryRun: false }));
479
+ writeSetupDocumentation(root, options);
480
+ }
481
+ function writeEnvironmentExample(root, options) {
482
+ const has = (product) => options.products.includes(product);
430
483
  const environment = [
431
484
  has('neon') ? 'DATABASE_URL=' : '', has('clerk') ? 'NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=\nCLERK_SECRET_KEY=' : '',
432
485
  has('better-auth') ? 'BETTER_AUTH_SECRET=\nBETTER_AUTH_URL=' : '',
@@ -435,9 +488,12 @@ process.exitCode = result.status ?? 1;
435
488
  has('posthog') ? 'POSTHOG_API_KEY=\nPOSTHOG_HOST=' : '', has('sentry') ? 'SENTRY_DSN=' : '',
436
489
  ].filter(Boolean).join('\n');
437
490
  put(root, '.env.example', environment + '\n');
438
- json(root, 'anhedral.standard.json', { schemaVersion: 1, standard: 'anhedral-application-stack', products: options.products,
439
- hosting: has('next') ? options.hosting : null, bootstrap: { command: 'pnpm dlx shadcn@latest init --monorepo --template next', upstreamPackageManager: existing.packageManager },
440
- status: 'starter', resourcesProvisioned: false });
491
+ }
492
+ function workerBuildSuffix(options) {
493
+ return options.products.includes('next') && options.hosting === 'cloudflare' ? ' && pnpm build:worker' : '';
494
+ }
495
+ function writeSetupDocumentation(root, options) {
496
+ const has = (product) => options.products.includes(product);
441
497
  const agentsFile = path.join(root, 'AGENTS.md');
442
498
  const existingAgents = existsSync(agentsFile) && has('next') ? readFileSync(agentsFile, 'utf8') + '\n' : '';
443
499
  put(root, 'AGENTS.md', existingAgents + `# Anhedral delivery standard
@@ -474,7 +530,7 @@ ${has('queues') ? 'Queue processing retries unimplemented jobs and uses a dead-l
474
530
  ${has('basin') ? 'Basin uses a Pipelines stream binding. Configure its stream, sink, R2/catalog and SQL in client-owned infrastructure; it is distinct from operational logs.\n' : ''}
475
531
  Provider packages are SDK factories or integration boundaries, not finished business features. Connect only needed packages to server entrypoints. Billing needs verified webhooks and idempotent consumption; RevenueCat adds cross-store entitlement configuration. Local data needs a platform-specific SQLite/filesystem adapter. Sentry needs a platform SDK and runtime initialization. Native auth and subscriptions need platform-specific setup.
476
532
 
477
- Outbound application mail uses Resend. Inbound forwarding uses Cloudflare Email Routing. Keep the existing business mailbox provider. GitHub Actions runs PR checks and never deploys automatically. Human approval is required for paid activation, material spending, architecture exceptions and production releases.
533
+ Purchase the domain in the client's GoDaddy account, then add its zone to the client's Cloudflare account and configure Cloudflare nameservers. The client retains registration, renewal, billing and recovery ownership. Verify DNS records and zone activation before connecting application domains and mail. Inbound mail uses Cloudflare Email Routing. Outbound business and application mail defaults to Cloudflare Email Sending; Resend is an alternative when selected. Configure the selected mail provider before use; init does not provision domains or mail services. GitHub Actions runs PR checks and never deploys automatically. Human approval is required for paid activation, material spending, architecture exceptions and production releases.
478
534
  `);
479
535
  put(root, '.github/workflows/check.yml', `name: Check
480
536
  on:
@@ -486,17 +542,19 @@ permissions:
486
542
  jobs:
487
543
  check:
488
544
  runs-on: ubuntu-latest
545
+ timeout-minutes: 20
489
546
  steps:
490
547
  - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10
491
548
  with:
492
549
  fetch-depth: 0
550
+ persist-credentials: false
493
551
  - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271
494
552
  - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38
495
553
  with:
496
554
  node-version: '22'
497
555
  cache: pnpm
498
556
  - run: pnpm install --frozen-lockfile
499
- - run: pnpm lint && pnpm typecheck
557
+ - run: pnpm lint && pnpm typecheck && pnpm audit:deps
500
558
  - name: Audit pull request
501
559
  if: github.event_name == 'pull_request'
502
560
  env:
@@ -505,27 +563,32 @@ jobs:
505
563
  - name: Audit branch
506
564
  if: github.event_name != 'pull_request'
507
565
  run: pnpm audit:full
508
- - run: pnpm test && pnpm build
566
+ - run: pnpm test && pnpm build${workerBuildSuffix(options)}
509
567
  `);
510
568
  }
511
569
  const defaultRunner = (command, args, cwd) => execFile(command, args, cwd);
570
+ function assertBootstrapPlatform(run) {
571
+ if (process.platform === 'win32' && run === defaultRunner) {
572
+ throw new Error('Native Windows initialization is blocked because the upstream shadcn monorepo bootstrap writes component files outside the project. Run Anhedral inside WSL. Planning and doctor remain available on Windows.');
573
+ }
574
+ }
512
575
  export async function scaffoldStandardProject(options, run = defaultRunner) {
513
- const plan = { standard: 'anhedral-application-stack', root: options.root, products: options.products, hosting: options.hosting,
514
- bootstrap: ['pnpm', 'dlx', 'shadcn@latest', 'init', '--monorepo', '--template', 'next'], dryRun: options.dryRun };
576
+ const plan = createSetupPlan(options);
515
577
  if (options.dryRun) {
516
578
  console.log(options.json ? JSON.stringify(plan, null, 2) : JSON.stringify(plan));
517
579
  return;
518
580
  }
581
+ assertBootstrapPlatform(run);
519
582
  const root = options.root;
520
583
  const previousQuiet = process.env.ANHEDRAL_QUIET;
521
584
  if (options.json)
522
585
  process.env.ANHEDRAL_QUIET = '1';
523
586
  let created = false;
524
- if (!existsSync(root)) {
525
- mkdirSync(root, { recursive: true });
526
- created = true;
527
- }
528
587
  try {
588
+ if (!existsSync(root)) {
589
+ mkdirSync(root, { recursive: true });
590
+ created = true;
591
+ }
529
592
  if (lstatSync(root).isSymbolicLink() || !lstatSync(root).isDirectory())
530
593
  throw new Error('Destination must be a real directory.');
531
594
  const unexpected = readdirSync(root).filter((name) => !['.git', '.gitignore'].includes(name));