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/README.md +19 -4
- 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 +8 -17
- 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 +184 -121
- 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 -9
- /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,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
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
244
|
-
|
|
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
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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
|
-
|
|
273
|
-
|
|
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
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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
|
-
|
|
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',
|
|
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
|
|
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', {
|
|
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
|
-
|
|
439
|
-
|
|
440
|
-
|
|
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
|
-
|
|
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 =
|
|
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));
|