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.
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,40 +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' };
152
- if (manifest.dependencies?.shadcn) {
153
- manifest.devDependencies.shadcn = manifest.dependencies.shadcn;
154
- delete manifest.dependencies.shadcn;
155
- }
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;
156
149
  }
157
- json(root, manifestPath, manifest);
158
150
  }
151
+ json(root, manifestPath, manifest);
152
+ }
153
+ function cleanShadcnStarter(root) {
154
+ for (const directory of ['apps/web', 'packages/ui'])
155
+ cleanWorkspaceManifest(root, directory);
159
156
  const themePath = 'apps/web/components/theme-provider.tsx';
160
157
  if (existsSync(path.join(root, themePath))) {
161
158
  const source = readFileSync(path.join(root, themePath), 'utf8');
@@ -194,6 +191,20 @@ export async function applyStandardOverlays(root, options) {
194
191
  if (has('basin'))
195
192
  bindings.pipelines = [{ binding: 'ANALYTICS', stream: 'REPLACE_WITH_BASIN_STREAM_ID' }];
196
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);
197
208
  if (has('next')) {
198
209
  cleanShadcnStarter(root);
199
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');
@@ -214,6 +225,9 @@ export async function applyStandardOverlays(root, options) {
214
225
  json(root, 'apps/web/vercel.json', { framework: 'nextjs' });
215
226
  }
216
227
  }
228
+ }
229
+ function addApiApplication(root, options, bindings) {
230
+ const has = (product) => options.products.includes(product);
217
231
  if (has('hono')) {
218
232
  worker(root, 'api', { ...workerConfig(`${options.name}-api`), ...bindings }, `import { OpenAPIHono, createRoute, z } from '@hono/zod-openapi';
219
233
  const app = new OpenAPIHono<{ Bindings: Env }>();
@@ -222,6 +236,9 @@ app.doc('/openapi.json', { openapi: '3.0.0', info: { title: '${options.name}', v
222
236
  export default app;
223
237
  `, { hono: '^4.0.0', '@hono/zod-openapi': '^1.0.0' });
224
238
  }
239
+ }
240
+ function addDatabase(root, options, runtimeApp) {
241
+ const has = (product) => options.products.includes(product);
225
242
  if (has('neon') || has('d1')) {
226
243
  sharedPackage(root, 'db', has('neon') ? `import { drizzle } from 'drizzle-orm/postgres-js';
227
244
  import postgres from 'postgres';
@@ -244,25 +261,29 @@ export default defineConfig({ dialect: 'sqlite', schema: './src/schema.ts', out:
244
261
  patchPackage(root, 'packages/db/package.json', { devDependencies: { 'drizzle-kit': '^0.31.0' }, scripts: { 'db:generate': 'drizzle-kit generate' } });
245
262
  patchPackage(root, `apps/${runtimeApp}/package.json`, { dependencies: { '@workspace/db': 'workspace:*' } });
246
263
  }
247
- if (has('better-auth')) {
248
- 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';
249
270
  import { drizzleAdapter } from 'better-auth/adapters/drizzle';
250
271
  import type { createDatabase } from '@workspace/db';
251
272
  export function createAuth(db: ReturnType<typeof createDatabase>${has('neon') ? "['db']" : ''}, secret: string, baseURL: string) {
252
273
  return betterAuth({ database: drizzleAdapter(db, { provider: '${has('neon') ? 'pg' : 'sqlite'}' }), secret, baseURL, emailAndPassword: { enabled: true } });
253
274
  }
254
275
  `, { 'better-auth': '^1.0.0', '@workspace/db': 'workspace:*' });
255
- patchPackage(root, `apps/${runtimeApp}/package.json`, { dependencies: { '@workspace/auth': 'workspace:*' } });
256
- const dbInit = has('neon') ? 'database.db' : 'database';
257
- const connection = has('neon') ? 'createDatabase(env.HYPERDRIVE.connectionString)' : 'createDatabase(env.DB)';
258
- if (has('hono')) {
259
- const source = readFileSync(path.join(root, 'apps/api/src/index.ts'), 'utf8');
260
- 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;`));
261
- const config = readJson(root, 'apps/api/wrangler.jsonc');
262
- json(root, 'apps/api/wrangler.jsonc', { ...config, vars: { BETTER_AUTH_URL: 'http://localhost:8787' } });
263
- }
264
- else {
265
- 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';
266
287
  import { createAuth } from '@workspace/auth';
267
288
  import { createDatabase } from '@workspace/db';
268
289
  async function handler(request: Request) {
@@ -273,47 +294,47 @@ async function handler(request: Request) {
273
294
  }
274
295
  export { handler as GET, handler as POST };
275
296
  `);
276
- 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`);
277
- patchPackage(root, 'apps/web/package.json', { dependencies: { 'drizzle-orm': '^0.45.3' } });
278
- }
279
- put(root, `apps/${runtimeApp}/.dev.vars.example`, 'BETTER_AUTH_SECRET=\nBETTER_AUTH_URL=http://localhost:8787\n');
280
- if (has('hono'))
281
- 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' } });
282
299
  }
283
- if (has('clerk')) {
284
- if (has('next')) {
285
- patchPackage(root, 'apps/web/package.json', { dependencies: { '@clerk/nextjs': '^7.0.0' } });
286
- const layoutPath = path.join(root, 'apps/web/app/layout.tsx');
287
- if (existsSync(layoutPath)) {
288
- const layout = readFileSync(layoutPath, 'utf8');
289
- if (!layout.includes('ClerkProvider'))
290
- 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);
291
318
  }
292
- put(root, 'apps/web/app/sign-in/[[...sign-in]]/page.tsx', 'import { SignIn } from "@clerk/nextjs";\nexport default function SignInPage() { return <SignIn />; }\n');
293
- put(root, 'apps/web/app/sign-up/[[...sign-up]]/page.tsx', 'import { SignUp } from "@clerk/nextjs";\nexport default function SignUpPage() { return <SignUp />; }\n');
294
- 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';
295
323
  export default clerkMiddleware();
296
324
  export const config = { matcher: ['/((?!_next|[^?]*\\\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico)).*)', '/(api|trpc)(.*)'] };
297
325
  `);
298
- // Middleware makes auth available; product routes must explicitly require identity and authorization.
299
- }
300
- if (has('hono')) {
301
- patchPackage(root, 'apps/api/package.json', { dependencies: { '@clerk/backend': '^3.0.0' } });
302
- 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';
303
331
  export async function authenticate(request: Request, secretKey: string, authorizedParties: string[]) {
304
332
  return createClerkClient({ secretKey }).authenticateRequest(request, { authorizedParties });
305
333
  }
306
334
  `);
307
- }
308
335
  }
309
- if (has('r2')) {
310
- 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');
311
- }
312
- await addServiceWorkers(root, options);
313
- addProviderPackages(root, options);
314
- writeStandardRoot(root, options);
315
336
  }
316
- async function addServiceWorkers(root, options) {
337
+ function addServiceWorkers(root, options) {
317
338
  const has = (value) => options.products.includes(value);
318
339
  if (has('realtime')) {
319
340
  worker(root, 'realtime', { ...workerConfig(`${options.name}-realtime`), durable_objects: { bindings: [{ name: 'ROOMS', class_name: 'Room' }] },
@@ -407,19 +428,20 @@ function writeStandardRoot(root, options) {
407
428
  const has = (value) => options.products.includes(value);
408
429
  const existing = readJson(root, 'package.json');
409
430
  const securityOverrides = {
410
- "sharp@<0.35.5": "0.35.5",
431
+ ...(has('wxt') ? { '@wxt-dev/module-react>@vitejs/plugin-react': '5.2.0' } : {}),
432
+ ...(has('next') ? { "sharp@<0.35.5": "0.35.5" } : {}),
411
433
  "source-map-js@<1.2.2": "1.2.2",
412
434
  "baseline-browser-mapping@<2.11.0": "2.11.27",
413
435
  "browserslist@<4.28.7": "4.29.3",
414
- "esbuild@<0.25.0": "0.25.12",
436
+ ...(has('next') || has('hono') || has('electron') || has('wxt') ? { "esbuild@<0.25.0": "0.25.12" } : {}),
415
437
  "brace-expansion@<2": "1.1.21",
416
438
  "brace-expansion@>=2 <3": "2.1.7",
417
439
  "brace-expansion@>=3 <4": "3.0.9",
418
440
  "brace-expansion@>=4 <5.0.12": "5.0.12"
419
441
  };
420
442
  patchPackage(root, 'package.json', { name: options.name, packageManager: PACKAGE_MANAGER, engines: { node: '>=22.13.0' },
421
- scripts: { dev: 'turbo dev', build: 'turbo build', lint: 'turbo lint', typecheck: 'turbo typecheck',
422
- 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' },
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)}` },
423
445
  devDependencies: { fallow: '^3.31.0' },
424
446
  // Upstream starter lockfiles can retain vulnerable transitive versions. Keep fixes within compatible majors.
425
447
  pnpm: { ...existing.pnpm, overrides: { ...existing.pnpm?.overrides, ...securityOverrides } } });
@@ -427,11 +449,16 @@ function writeStandardRoot(root, options) {
427
449
  const base = process.env.AUDIT_BASE;
428
450
  const hasBase = base || ['origin/main', 'main'].find((ref) => spawnSync('git', ['rev-parse', '--verify', ref], { stdio: 'ignore' }).status === 0);
429
451
  const args = hasBase ? ['exec', 'fallow', 'audit', '--base', hasBase] : ['exec', 'fallow', '--fail-on-issues'];
430
- 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 });
431
455
  if (result.error) throw result.error;
432
456
  process.exitCode = result.status ?? 1;
433
457
  `);
434
- json(root, '.fallowrc.json', { ignorePatterns: ['**/next-env.d.ts', '**/.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'] });
435
462
  const turbo = readJson(root, 'turbo.json');
436
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'];
437
464
  turbo.tasks = { ...turbo.tasks, test: { dependsOn: ['^build'], outputs: ['coverage/**'] },
@@ -444,6 +471,15 @@ process.exitCode = result.status ?? 1;
444
471
  const section = /(?:allowBuilds|onlyBuiltDependencies):\n(?:[ \t]+[^\n]*\n|[ \t]*\n)*/;
445
472
  put(root, 'pnpm-workspace.yaml', section.test(workspace) ? workspace.replace(section, policy) : workspace + '\n' + policy);
446
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);
447
483
  const environment = [
448
484
  has('neon') ? 'DATABASE_URL=' : '', has('clerk') ? 'NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=\nCLERK_SECRET_KEY=' : '',
449
485
  has('better-auth') ? 'BETTER_AUTH_SECRET=\nBETTER_AUTH_URL=' : '',
@@ -452,9 +488,12 @@ process.exitCode = result.status ?? 1;
452
488
  has('posthog') ? 'POSTHOG_API_KEY=\nPOSTHOG_HOST=' : '', has('sentry') ? 'SENTRY_DSN=' : '',
453
489
  ].filter(Boolean).join('\n');
454
490
  put(root, '.env.example', environment + '\n');
455
- json(root, 'anhedral.standard.json', { schemaVersion: 1, standard: 'anhedral-application-stack', products: options.products,
456
- hosting: has('next') ? options.hosting : null, bootstrap: { command: 'pnpm dlx shadcn@latest init --monorepo --template next', upstreamPackageManager: existing.packageManager },
457
- 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);
458
497
  const agentsFile = path.join(root, 'AGENTS.md');
459
498
  const existingAgents = existsSync(agentsFile) && has('next') ? readFileSync(agentsFile, 'utf8') + '\n' : '';
460
499
  put(root, 'AGENTS.md', existingAgents + `# Anhedral delivery standard
@@ -491,7 +530,7 @@ ${has('queues') ? 'Queue processing retries unimplemented jobs and uses a dead-l
491
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' : ''}
492
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.
493
532
 
494
- 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.
495
534
  `);
496
535
  put(root, '.github/workflows/check.yml', `name: Check
497
536
  on:
@@ -503,10 +542,12 @@ permissions:
503
542
  jobs:
504
543
  check:
505
544
  runs-on: ubuntu-latest
545
+ timeout-minutes: 20
506
546
  steps:
507
547
  - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10
508
548
  with:
509
549
  fetch-depth: 0
550
+ persist-credentials: false
510
551
  - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271
511
552
  - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38
512
553
  with:
@@ -522,27 +563,32 @@ jobs:
522
563
  - name: Audit branch
523
564
  if: github.event_name != 'pull_request'
524
565
  run: pnpm audit:full
525
- - run: pnpm test && pnpm build
566
+ - run: pnpm test && pnpm build${workerBuildSuffix(options)}
526
567
  `);
527
568
  }
528
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
+ }
529
575
  export async function scaffoldStandardProject(options, run = defaultRunner) {
530
- const plan = { standard: 'anhedral-application-stack', root: options.root, products: options.products, hosting: options.hosting,
531
- bootstrap: ['pnpm', 'dlx', 'shadcn@latest', 'init', '--monorepo', '--template', 'next'], dryRun: options.dryRun };
576
+ const plan = createSetupPlan(options);
532
577
  if (options.dryRun) {
533
578
  console.log(options.json ? JSON.stringify(plan, null, 2) : JSON.stringify(plan));
534
579
  return;
535
580
  }
581
+ assertBootstrapPlatform(run);
536
582
  const root = options.root;
537
583
  const previousQuiet = process.env.ANHEDRAL_QUIET;
538
584
  if (options.json)
539
585
  process.env.ANHEDRAL_QUIET = '1';
540
586
  let created = false;
541
- if (!existsSync(root)) {
542
- mkdirSync(root, { recursive: true });
543
- created = true;
544
- }
545
587
  try {
588
+ if (!existsSync(root)) {
589
+ mkdirSync(root, { recursive: true });
590
+ created = true;
591
+ }
546
592
  if (lstatSync(root).isSymbolicLink() || !lstatSync(root).isDirectory())
547
593
  throw new Error('Destination must be a real directory.');
548
594
  const unexpected = readdirSync(root).filter((name) => !['.git', '.gitignore'].includes(name));