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/README.md +8 -3
- package/assets/anhedral-cli-init-technical.svg +4 -4
- package/assets/anhedral-cli-init.svg +4 -4
- package/dist/bin.js +27 -8
- package/dist/capabilities.d.ts +54 -0
- package/dist/capabilities.js +90 -0
- package/dist/command.d.ts +5 -0
- package/dist/command.js +16 -0
- package/dist/dependencies.js +5 -14
- package/dist/index.d.ts +3 -0
- package/dist/index.js +2 -0
- package/dist/platforms/desktop.js +24 -19
- package/dist/platforms/extension.js +22 -39
- package/dist/platforms/lint.d.ts +1 -0
- package/dist/platforms/lint.js +5 -0
- package/dist/platforms/mobile.js +21 -15
- package/dist/readiness.d.ts +20 -0
- package/dist/readiness.js +61 -0
- package/dist/render.d.ts +0 -1
- package/dist/render.js +1 -1
- package/dist/standard.js +170 -124
- package/dist/transaction.js +238 -195
- package/dist/util.js +4 -2
- package/dist/version.d.ts +1 -2
- package/dist/version.js +1 -2
- package/docs/application-stack-standard.md +13 -10
- package/package.json +5 -4
- /package/{anhedral.svg → assets/anhedral.svg} +0 -0
- /package/{favicon.ico → assets/favicon.ico} +0 -0
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
|
-
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
const
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
248
|
-
|
|
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
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
-
|
|
277
|
-
|
|
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
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
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', {
|
|
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
|
-
|
|
456
|
-
|
|
457
|
-
|
|
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
|
-
|
|
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 =
|
|
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));
|