anhedral 0.4.2 → 0.6.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 +29 -269
- package/assets/anhedral-cli-init-technical.svg +322 -0
- package/assets/anhedral-cli-init.svg +257 -173
- package/dist/bin.js +28 -191
- package/dist/dependencies.d.ts +7 -57
- package/dist/dependencies.js +96 -308
- package/dist/index.d.ts +2 -7
- package/dist/index.js +1 -4
- package/dist/platforms/desktop.js +401 -0
- package/dist/platforms/extension.js +401 -0
- package/dist/platforms/mobile.js +232 -0
- package/dist/project.d.ts +0 -26
- package/dist/standard-products.d.ts +2 -0
- package/dist/standard-products.js +6 -0
- package/dist/standard.d.ts +18 -0
- package/dist/standard.js +578 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/docs/application-stack-standard.md +700 -0
- package/package.json +34 -51
- package/CHANGELOG.md +0 -178
- package/CONTRIBUTING.md +0 -77
- package/SECURITY.md +0 -37
- package/SKILL.md +0 -339
- package/assets/images/svg/ably-symbol-color.svg +0 -8
- package/bin/anhedral.js +0 -2
- package/dist/architecture/contributions.d.ts +0 -28
- package/dist/architecture/contributions.js +0 -125
- package/dist/architecture/index.d.ts +0 -12
- package/dist/architecture/index.js +0 -6
- package/dist/architecture/manifest.d.ts +0 -53
- package/dist/architecture/manifest.js +0 -283
- package/dist/architecture/modules.d.ts +0 -41
- package/dist/architecture/modules.js +0 -187
- package/dist/architecture/plan.d.ts +0 -58
- package/dist/architecture/plan.js +0 -145
- package/dist/architecture/templates.d.ts +0 -10
- package/dist/architecture/templates.js +0 -23
- package/dist/cli.d.ts +0 -32
- package/dist/cli.js +0 -281
- package/dist/composers/root.d.ts +0 -6
- package/dist/composers/root.js +0 -35
- package/dist/print.d.ts +0 -8
- package/dist/print.js +0 -48
- package/dist/prompts.d.ts +0 -6
- package/dist/prompts.js +0 -41
- package/dist/scaffold.d.ts +0 -64
- package/dist/scaffold.js +0 -2102
- package/dist/template-source.d.ts +0 -20
- package/dist/template-source.js +0 -180
- package/dist/templates/api.d.ts +0 -2
- package/dist/templates/api.js +0 -3187
- package/dist/templates/assets-private-proxy.d.ts +0 -3
- package/dist/templates/assets-private-proxy.js +0 -277
- package/dist/templates/desktop.js +0 -792
- package/dist/templates/electron-updater.d.ts +0 -3
- package/dist/templates/electron-updater.js +0 -358
- package/dist/templates/extension.js +0 -856
- package/dist/templates/first-run.d.ts +0 -2
- package/dist/templates/first-run.js +0 -167
- package/dist/templates/mobile.js +0 -861
- package/dist/templates/shared.d.ts +0 -2
- package/dist/templates/shared.js +0 -577
- package/dist/templates/web.d.ts +0 -2
- package/dist/templates/web.js +0 -569
- package/dist/toolchain.d.ts +0 -3
- package/dist/toolchain.js +0 -8
- package/dist/ui.d.ts +0 -33
- package/dist/ui.js +0 -199
- package/dist/workspace-config.d.ts +0 -11
- package/dist/workspace-config.js +0 -372
- package/docs/NORTHSTAR.md +0 -114
- package/docs/conventions.md +0 -94
- package/docs/engineering-principles.md +0 -88
- package/docs/master-stack-map.md +0 -199
- package/docs/output-tree-contract.md +0 -493
- package/docs/references/manual-scaffolding.md +0 -421
- package/docs/references/provisioning.md +0 -577
- package/scripts/render-master-stack-map.mjs +0 -510
- package/templates/api-fastify/apps/api/tsconfig.json +0 -20
- package/templates/catalog.json +0 -23
- package/templates/db-drizzle/packages/db/tsconfig.json +0 -14
- package/templates/desktop-electron/apps/desktop/tsconfig.json +0 -37
- package/templates/extension-wxt/apps/extension/tsconfig.json +0 -41
- package/templates/mobile-expo/apps/mobile/expo-env.d.ts +0 -1
- package/templates/web-next/apps/web/next-env.d.ts +0 -6
- /package/dist/{templates → platforms}/desktop.d.ts +0 -0
- /package/dist/{templates → platforms}/extension.d.ts +0 -0
- /package/dist/{templates → platforms}/mobile.d.ts +0 -0
package/dist/standard.js
ADDED
|
@@ -0,0 +1,578 @@
|
|
|
1
|
+
import { cpSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, rmSync, rmdirSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { appendGitignore, execFile, writeFile } from './util.js';
|
|
4
|
+
import { packageNameFromText } from './render.js';
|
|
5
|
+
import { spawnSync } from 'node:child_process';
|
|
6
|
+
import { PACKAGE_MANAGER } from './dependencies.js';
|
|
7
|
+
import { runStagedTransaction } from './transaction.js';
|
|
8
|
+
import { scaffoldDesktop } from './platforms/desktop.js';
|
|
9
|
+
import { scaffoldMobile } from './platforms/mobile.js';
|
|
10
|
+
import { scaffoldExtension } from './platforms/extension.js';
|
|
11
|
+
import { STANDARD_PRODUCTS } from './standard-products.js';
|
|
12
|
+
export { STANDARD_PRODUCTS } from './standard-products.js';
|
|
13
|
+
export const STANDARD_USAGE = `Anhedral Application Stack & Delivery Standard
|
|
14
|
+
|
|
15
|
+
anhedral new <directory> [products...] [--hosting cloudflare|vercel] [--skip-install] [--no-git] [--dry-run] [--json]
|
|
16
|
+
anhedral init [products...] [--hosting cloudflare|vercel] [--skip-install] [--git] [--dry-run] [--json]
|
|
17
|
+
|
|
18
|
+
Default: Next.js only, from the official shadcn pnpm/Turborepo monorepo.
|
|
19
|
+
Web hosting: Cloudflare Workers + OpenNext. Services are opt-in.
|
|
20
|
+
Products: ${STANDARD_PRODUCTS.join(', ')}
|
|
21
|
+
Examples:
|
|
22
|
+
anhedral new client-app
|
|
23
|
+
anhedral new mobile-app --expo
|
|
24
|
+
anhedral new product --next --expo --hono --neon --clerk --r2
|
|
25
|
+
anhedral new website --next --stripe --resend
|
|
26
|
+
anhedral init --wxt
|
|
27
|
+
|
|
28
|
+
--skip-install skips the final workspace install; the official shadcn bootstrap
|
|
29
|
+
still requires network access and may install its own dependencies.
|
|
30
|
+
--all is intentionally unsupported: select only required capabilities.
|
|
31
|
+
Vercel is an explicit architecture exception requiring project approval.
|
|
32
|
+
`;
|
|
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);
|
|
39
|
+
const products = new Set();
|
|
40
|
+
let hosting = 'cloudflare';
|
|
41
|
+
let hostingSpecified = false;
|
|
42
|
+
let gitSpecified;
|
|
43
|
+
for (let index = 0; index < remaining.length; index++) {
|
|
44
|
+
const token = remaining[index];
|
|
45
|
+
if (['--skip-install', '--dry-run', '--json', '--verbose'].includes(token))
|
|
46
|
+
continue;
|
|
47
|
+
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;
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (token === '--hosting' || token.startsWith('--hosting=')) {
|
|
55
|
+
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;
|
|
61
|
+
hostingSpecified = true;
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
const value = token.replace(/^--/, '');
|
|
65
|
+
if (!STANDARD_PRODUCTS.includes(value)) {
|
|
66
|
+
throw new Error(`Unknown standard product: ${token}. Select only required products.`);
|
|
67
|
+
}
|
|
68
|
+
products.add(value);
|
|
69
|
+
}
|
|
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.');
|
|
88
|
+
return { root, name: packageNameFromText(path.basename(root)), products: [...products].sort(), hosting,
|
|
89
|
+
skipInstall: remaining.includes('--skip-install'), dryRun: remaining.includes('--dry-run'), json: remaining.includes('--json'),
|
|
90
|
+
initializeGit: gitSpecified ?? command === 'new' };
|
|
91
|
+
}
|
|
92
|
+
function readJson(root, file) { return JSON.parse(readFileSync(path.join(root, file), 'utf8')); }
|
|
93
|
+
function json(root, file, value) { writeFile(path.join(root, file), JSON.stringify(value, null, 2) + '\n'); }
|
|
94
|
+
function put(root, file, value) { writeFile(path.join(root, file), value); }
|
|
95
|
+
function patchPackage(root, file, patch) {
|
|
96
|
+
const current = readJson(root, file);
|
|
97
|
+
for (const key of ['scripts', 'dependencies', 'devDependencies']) {
|
|
98
|
+
if (patch[key])
|
|
99
|
+
patch[key] = { ...current[key], ...patch[key] };
|
|
100
|
+
}
|
|
101
|
+
json(root, file, { ...current, ...patch });
|
|
102
|
+
}
|
|
103
|
+
function workerConfig(name) {
|
|
104
|
+
return { $schema: 'node_modules/wrangler/config-schema.json', name, main: 'src/index.ts', compatibility_date: '2026-10-05',
|
|
105
|
+
compatibility_flags: ['nodejs_compat'], observability: { enabled: true } };
|
|
106
|
+
}
|
|
107
|
+
function worker(root, app, config, source, dependencies = {}) {
|
|
108
|
+
json(root, `apps/${app}/package.json`, { name: `@workspace/${app}`, private: true, type: 'module', scripts: {
|
|
109
|
+
dev: 'wrangler dev', build: 'wrangler deploy --dry-run --outdir dist', deploy: 'wrangler deploy',
|
|
110
|
+
typecheck: 'wrangler types && tsc --noEmit', lint: 'eslint . --max-warnings 0',
|
|
111
|
+
}, dependencies, devDependencies: { wrangler: '^4.0.0', typescript: '^5.9.3', '@types/node': '^22.0.0', eslint: '^9.0.0', '@workspace/eslint-config': 'workspace:*' } });
|
|
112
|
+
json(root, `apps/${app}/wrangler.jsonc`, config);
|
|
113
|
+
json(root, `apps/${app}/tsconfig.json`, { compilerOptions: { target: 'ES2022', lib: ['ES2022'], module: 'ESNext', moduleResolution: 'Bundler', strict: true, noUnusedLocals: true, noUnusedParameters: true, skipLibCheck: true, noEmit: true, types: ['node'] }, include: ['src/**/*.ts', 'worker-configuration.d.ts'] });
|
|
114
|
+
put(root, `apps/${app}/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');
|
|
115
|
+
put(root, `apps/${app}/src/index.ts`, source);
|
|
116
|
+
}
|
|
117
|
+
function sharedPackage(root, name, source, dependencies) {
|
|
118
|
+
json(root, `packages/${name}/package.json`, { name: `@workspace/${name}`, private: true, type: 'module', exports: { '.': './src/index.ts' },
|
|
119
|
+
scripts: { typecheck: 'tsc --noEmit', lint: 'eslint . --max-warnings 0' }, dependencies,
|
|
120
|
+
devDependencies: { typescript: '^5.9.3', '@types/node': '^22.0.0', eslint: '^9.0.0', '@workspace/eslint-config': 'workspace:*' } });
|
|
121
|
+
json(root, `packages/${name}/tsconfig.json`, { compilerOptions: { target: 'ES2022', lib: ['ES2022', 'DOM'], module: 'ESNext', moduleResolution: 'Bundler', strict: true, noUnusedLocals: true, noUnusedParameters: true, skipLibCheck: true, noEmit: true, types: ['node'] }, include: ['src/**/*.ts'] });
|
|
122
|
+
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
|
+
put(root, `packages/${name}/src/index.ts`, source);
|
|
124
|
+
}
|
|
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];
|
|
147
|
+
}
|
|
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
|
+
}
|
|
153
|
+
json(root, manifestPath, manifest);
|
|
154
|
+
}
|
|
155
|
+
const themePath = 'apps/web/components/theme-provider.tsx';
|
|
156
|
+
if (existsSync(path.join(root, themePath))) {
|
|
157
|
+
const source = readFileSync(path.join(root, themePath), 'utf8');
|
|
158
|
+
// Preserve the upstream keyboard behavior while reducing branching in its guards.
|
|
159
|
+
put(root, themePath, source.replace(/target.tagName === "INPUT" \|\|\s*target.tagName === "TEXTAREA" \|\|\s*target.tagName === "SELECT"/, '["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName)')
|
|
160
|
+
.replace(/if \(event.defaultPrevented \|\| event.repeat\) \{\s*return\s*\}\s*if \(event.metaKey \|\| event.ctrlKey \|\| event.altKey\) \{\s*return\s*\}\s*if \(event.key.toLowerCase\(\) !== "d"\) \{\s*return\s*\}\s*if \(isTypingTarget\(event.target\)\) \{\s*return\s*\}/, 'if ([event.defaultPrevented, event.repeat, event.metaKey, event.ctrlKey, event.altKey, event.key.toLowerCase() !== "d", isTypingTarget(event.target)].some(Boolean)) { return }'));
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
export async function applyStandardOverlays(root, options) {
|
|
164
|
+
const has = (product) => options.products.includes(product);
|
|
165
|
+
// Preserve the upstream workspace and config packages, but remove its unused app.
|
|
166
|
+
if (!has('next'))
|
|
167
|
+
rmSync(path.join(root, 'apps/web'), { recursive: true, force: true });
|
|
168
|
+
if (!has('next'))
|
|
169
|
+
rmSync(path.join(root, 'packages/ui'), { recursive: true, force: true });
|
|
170
|
+
const platformOptions = { projectName: options.name, displayName: options.name };
|
|
171
|
+
if (has('expo'))
|
|
172
|
+
await scaffoldMobile(root, platformOptions);
|
|
173
|
+
if (has('electron'))
|
|
174
|
+
await scaffoldDesktop(root, platformOptions);
|
|
175
|
+
if (has('wxt'))
|
|
176
|
+
await scaffoldExtension(root, platformOptions);
|
|
177
|
+
const bindings = {};
|
|
178
|
+
if (has('neon'))
|
|
179
|
+
bindings.hyperdrive = [{ binding: 'HYPERDRIVE', id: 'REPLACE_WITH_HYPERDRIVE_ID' }];
|
|
180
|
+
if (has('d1'))
|
|
181
|
+
bindings.d1_databases = [{ binding: 'DB', database_name: `${options.name}-db`, database_id: 'REPLACE_WITH_D1_ID', migrations_dir: '../../packages/db/migrations' }];
|
|
182
|
+
if (has('r2'))
|
|
183
|
+
bindings.r2_buckets = [{ binding: 'FILES', bucket_name: `${options.name}-private-files` }];
|
|
184
|
+
if (has('kv'))
|
|
185
|
+
bindings.kv_namespaces = [{ binding: 'CONFIG', id: 'REPLACE_WITH_KV_ID' }];
|
|
186
|
+
if (has('workers-ai'))
|
|
187
|
+
bindings.ai = { binding: 'AI' };
|
|
188
|
+
if (has('queues'))
|
|
189
|
+
bindings.queues = { producers: [{ binding: 'JOBS', queue: `${options.name}-jobs` }] };
|
|
190
|
+
if (has('basin'))
|
|
191
|
+
bindings.pipelines = [{ binding: 'ANALYTICS', stream: 'REPLACE_WITH_BASIN_STREAM_ID' }];
|
|
192
|
+
const runtimeApp = has('hono') ? 'api' : 'web';
|
|
193
|
+
if (has('next')) {
|
|
194
|
+
cleanShadcnStarter(root);
|
|
195
|
+
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');
|
|
196
|
+
patchPackage(root, 'apps/web/package.json', { scripts: { typecheck: 'tsc --noEmit', lint: 'eslint . --max-warnings 0' } });
|
|
197
|
+
if (options.hosting === 'cloudflare') {
|
|
198
|
+
patchPackage(root, 'apps/web/package.json', { dependencies: { '@opennextjs/cloudflare': '^1.0.0', next: '^16.3.8' }, devDependencies: { wrangler: '^4.0.0' }, scripts: {
|
|
199
|
+
preview: 'opennextjs-cloudflare build && opennextjs-cloudflare preview', deploy: 'opennextjs-cloudflare build && opennextjs-cloudflare deploy',
|
|
200
|
+
'build:worker': 'opennextjs-cloudflare build', 'cf:typegen': 'wrangler types --env-interface CloudflareEnv cloudflare-env.d.ts',
|
|
201
|
+
} });
|
|
202
|
+
json(root, 'apps/web/wrangler.jsonc', { ...workerConfig(`${options.name}-web`), main: '.open-next/worker.js',
|
|
203
|
+
assets: { directory: '.open-next/assets', binding: 'ASSETS' }, services: [{ binding: 'WORKER_SELF_REFERENCE', service: `${options.name}-web` }],
|
|
204
|
+
...(runtimeApp === 'web' ? bindings : {}) });
|
|
205
|
+
put(root, 'apps/web/open-next.config.ts', 'import { defineCloudflareConfig } from "@opennextjs/cloudflare";\nexport default defineCloudflareConfig();\n');
|
|
206
|
+
put(root, 'apps/web/public/_headers', '/_next/static/*\n Cache-Control: public,max-age=31536000,immutable\n');
|
|
207
|
+
put(root, 'apps/web/.dev.vars.example', 'NEXTJS_ENV=development\n');
|
|
208
|
+
}
|
|
209
|
+
else {
|
|
210
|
+
json(root, 'apps/web/vercel.json', { framework: 'nextjs' });
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
if (has('hono')) {
|
|
214
|
+
worker(root, 'api', { ...workerConfig(`${options.name}-api`), ...bindings }, `import { OpenAPIHono, createRoute, z } from '@hono/zod-openapi';
|
|
215
|
+
const app = new OpenAPIHono<{ Bindings: Env }>();
|
|
216
|
+
app.openapi(createRoute({ method: 'get', path: '/health', responses: { 200: { description: 'Healthy', content: { 'application/json': { schema: z.object({ ok: z.literal(true) }) } } } } }), (c) => c.json({ ok: true as const }, 200));
|
|
217
|
+
app.doc('/openapi.json', { openapi: '3.0.0', info: { title: '${options.name}', version: '1.0.0' } });
|
|
218
|
+
export default app;
|
|
219
|
+
`, { hono: '^4.0.0', '@hono/zod-openapi': '^1.0.0' });
|
|
220
|
+
}
|
|
221
|
+
if (has('neon') || has('d1')) {
|
|
222
|
+
sharedPackage(root, 'db', has('neon') ? `import { drizzle } from 'drizzle-orm/postgres-js';
|
|
223
|
+
import postgres from 'postgres';
|
|
224
|
+
import * as schema from './schema.js';
|
|
225
|
+
export function createDatabase(connectionString: string) {
|
|
226
|
+
// Pass env.HYPERDRIVE.connectionString from the Worker. Disable caching for authoritative reads in Hyperdrive configuration.
|
|
227
|
+
const client = postgres(connectionString, { prepare: false, max: 5 });
|
|
228
|
+
return { db: drizzle(client, { schema }), close: () => client.end() };
|
|
229
|
+
}
|
|
230
|
+
` : `import { drizzle } from 'drizzle-orm/d1';
|
|
231
|
+
import * as schema from './schema.js';
|
|
232
|
+
export function createDatabase(binding: Parameters<typeof drizzle>[0]) { return drizzle(binding, { schema }); }
|
|
233
|
+
`, { 'drizzle-orm': '^0.44.0', ...(has('neon') ? { postgres: '^3.4.0' } : {}) });
|
|
234
|
+
put(root, 'packages/db/src/schema.ts', '// Define product tables here; generate and review SQL before migration.\nexport {};\n');
|
|
235
|
+
put(root, 'packages/db/drizzle.config.ts', has('neon') ? `import { defineConfig } from 'drizzle-kit';
|
|
236
|
+
export default defineConfig({ dialect: 'postgresql', schema: './src/schema.ts', out: './migrations', dbCredentials: { url: process.env.DATABASE_URL! } });
|
|
237
|
+
` : `import { defineConfig } from 'drizzle-kit';
|
|
238
|
+
export default defineConfig({ dialect: 'sqlite', schema: './src/schema.ts', out: './migrations' });
|
|
239
|
+
`);
|
|
240
|
+
patchPackage(root, 'packages/db/package.json', { devDependencies: { 'drizzle-kit': '^0.31.0' }, scripts: { 'db:generate': 'drizzle-kit generate' } });
|
|
241
|
+
patchPackage(root, `apps/${runtimeApp}/package.json`, { dependencies: { '@workspace/db': 'workspace:*' } });
|
|
242
|
+
}
|
|
243
|
+
if (has('better-auth')) {
|
|
244
|
+
sharedPackage(root, 'auth', `import { betterAuth } from 'better-auth';
|
|
245
|
+
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
|
|
246
|
+
import type { createDatabase } from '@workspace/db';
|
|
247
|
+
export function createAuth(db: ReturnType<typeof createDatabase>${has('neon') ? "['db']" : ''}, secret: string, baseURL: string) {
|
|
248
|
+
return betterAuth({ database: drizzleAdapter(db, { provider: '${has('neon') ? 'pg' : 'sqlite'}' }), secret, baseURL, emailAndPassword: { enabled: true } });
|
|
249
|
+
}
|
|
250
|
+
`, { 'better-auth': '^1.0.0', '@workspace/db': 'workspace:*' });
|
|
251
|
+
patchPackage(root, `apps/${runtimeApp}/package.json`, { dependencies: { '@workspace/auth': 'workspace:*' } });
|
|
252
|
+
const dbInit = has('neon') ? 'database.db' : 'database';
|
|
253
|
+
const connection = has('neon') ? 'createDatabase(env.HYPERDRIVE.connectionString)' : 'createDatabase(env.DB)';
|
|
254
|
+
if (has('hono')) {
|
|
255
|
+
const source = readFileSync(path.join(root, 'apps/api/src/index.ts'), 'utf8');
|
|
256
|
+
put(root, 'apps/api/src/index.ts', `import { createAuth } from '@workspace/auth';\nimport { createDatabase } from '@workspace/db';\n` + source.replace('export default app;', `app.on(['GET', 'POST'], '/api/auth/*', async (c) => {\n const env = c.env;\n const database = ${connection};\n try { return await createAuth(${dbInit}, env.BETTER_AUTH_SECRET, env.BETTER_AUTH_URL).handler(c.req.raw); } finally { ${has('neon') ? 'await database.close();' : '/* D1 bindings need no connection cleanup. */'} }\n});\nexport default app;`));
|
|
257
|
+
const config = readJson(root, 'apps/api/wrangler.jsonc');
|
|
258
|
+
json(root, 'apps/api/wrangler.jsonc', { ...config, vars: { BETTER_AUTH_URL: 'http://localhost:8787' } });
|
|
259
|
+
}
|
|
260
|
+
else {
|
|
261
|
+
put(root, 'apps/web/app/api/auth/[...all]/route.ts', `import { getCloudflareContext } from '@opennextjs/cloudflare';
|
|
262
|
+
import { createAuth } from '@workspace/auth';
|
|
263
|
+
import { createDatabase } from '@workspace/db';
|
|
264
|
+
async function handler(request: Request) {
|
|
265
|
+
const { env } = await getCloudflareContext({ async: true });
|
|
266
|
+
const database = ${connection};
|
|
267
|
+
try { return await createAuth(${dbInit}, process.env.BETTER_AUTH_SECRET!, process.env.BETTER_AUTH_URL!).handler(request); }
|
|
268
|
+
finally { ${has('neon') ? 'await database.close();' : '/* D1 bindings need no connection cleanup. */'} }
|
|
269
|
+
}
|
|
270
|
+
export { handler as GET, handler as POST };
|
|
271
|
+
`);
|
|
272
|
+
put(root, 'apps/web/cloudflare-env.d.ts', `interface CloudflareEnv { ${has('neon') ? 'HYPERDRIVE: { connectionString: string };' : "DB: Parameters<typeof import('drizzle-orm/d1').drizzle>[0];"} }\n`);
|
|
273
|
+
patchPackage(root, 'apps/web/package.json', { dependencies: { 'drizzle-orm': '^0.44.0' } });
|
|
274
|
+
}
|
|
275
|
+
put(root, `apps/${runtimeApp}/.dev.vars.example`, 'BETTER_AUTH_SECRET=\nBETTER_AUTH_URL=http://localhost:8787\n');
|
|
276
|
+
if (has('hono'))
|
|
277
|
+
put(root, 'apps/api/src/secrets.d.ts', 'interface Env { BETTER_AUTH_SECRET: string }\n');
|
|
278
|
+
}
|
|
279
|
+
if (has('clerk')) {
|
|
280
|
+
if (has('next')) {
|
|
281
|
+
patchPackage(root, 'apps/web/package.json', { dependencies: { '@clerk/nextjs': '^7.0.0' } });
|
|
282
|
+
const layoutPath = path.join(root, 'apps/web/app/layout.tsx');
|
|
283
|
+
if (existsSync(layoutPath)) {
|
|
284
|
+
const layout = readFileSync(layoutPath, 'utf8');
|
|
285
|
+
if (!layout.includes('ClerkProvider'))
|
|
286
|
+
put(root, 'apps/web/app/layout.tsx', 'import { ClerkProvider } from "@clerk/nextjs";\n' + layout.replace('<body>', '<body><ClerkProvider>').replace('</body>', '</ClerkProvider></body>'));
|
|
287
|
+
}
|
|
288
|
+
put(root, 'apps/web/app/sign-in/[[...sign-in]]/page.tsx', 'import { SignIn } from "@clerk/nextjs";\nexport default function SignInPage() { return <SignIn />; }\n');
|
|
289
|
+
put(root, 'apps/web/app/sign-up/[[...sign-up]]/page.tsx', 'import { SignUp } from "@clerk/nextjs";\nexport default function SignUpPage() { return <SignUp />; }\n');
|
|
290
|
+
put(root, 'apps/web/proxy.ts', `import { clerkMiddleware } from '@clerk/nextjs/server';
|
|
291
|
+
export default clerkMiddleware();
|
|
292
|
+
export const config = { matcher: ['/((?!_next|[^?]*\\\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico)).*)', '/(api|trpc)(.*)'] };
|
|
293
|
+
`);
|
|
294
|
+
// Middleware makes auth available; product routes must explicitly require identity and authorization.
|
|
295
|
+
}
|
|
296
|
+
if (has('hono')) {
|
|
297
|
+
patchPackage(root, 'apps/api/package.json', { dependencies: { '@clerk/backend': '^3.0.0' } });
|
|
298
|
+
put(root, 'apps/api/src/identity.ts', `import { createClerkClient } from '@clerk/backend';
|
|
299
|
+
export async function authenticate(request: Request, secretKey: string, authorizedParties: string[]) {
|
|
300
|
+
return createClerkClient({ secretKey }).authenticateRequest(request, { authorizedParties });
|
|
301
|
+
}
|
|
302
|
+
`);
|
|
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
|
+
}
|
|
308
|
+
await addServiceWorkers(root, options);
|
|
309
|
+
addProviderPackages(root, options);
|
|
310
|
+
writeStandardRoot(root, options);
|
|
311
|
+
}
|
|
312
|
+
async function addServiceWorkers(root, options) {
|
|
313
|
+
const has = (value) => options.products.includes(value);
|
|
314
|
+
if (has('realtime')) {
|
|
315
|
+
worker(root, 'realtime', { ...workerConfig(`${options.name}-realtime`), durable_objects: { bindings: [{ name: 'ROOMS', class_name: 'Room' }] },
|
|
316
|
+
migrations: [{ tag: 'v1', new_sqlite_classes: ['Room'] }] }, `import { DurableObject } from 'cloudflare:workers';
|
|
317
|
+
export class Room extends DurableObject<Env> {
|
|
318
|
+
async fetch(request: Request): Promise<Response> {
|
|
319
|
+
if (request.headers.get('Upgrade') !== 'websocket') return new Response('WebSocket required', { status: 426 });
|
|
320
|
+
const pair = new WebSocketPair();
|
|
321
|
+
this.ctx.acceptWebSocket(pair[1]);
|
|
322
|
+
return new Response(null, { status: 101, webSocket: pair[0] });
|
|
323
|
+
}
|
|
324
|
+
webSocketMessage(sender: WebSocket, message: string | ArrayBuffer) {
|
|
325
|
+
if ((typeof message === 'string' ? new TextEncoder().encode(message).byteLength : message.byteLength) > 16384) { sender.close(1009, 'Message too large'); return; }
|
|
326
|
+
this.ctx.getWebSockets().filter((socket) => socket !== sender).forEach((socket) => socket.send(message));
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
export default {
|
|
330
|
+
async fetch(): Promise<Response> {
|
|
331
|
+
// Authenticate and authorize room membership, then forward to env.ROOMS.getByName(room).fetch(request).
|
|
332
|
+
// Use short-lived single-use tickets for browser WebSockets. Never expose a long-lived service secret.
|
|
333
|
+
return new Response('Configure room authorization before enabling connections', { status: 501 });
|
|
334
|
+
},
|
|
335
|
+
};
|
|
336
|
+
`);
|
|
337
|
+
}
|
|
338
|
+
if (has('queues')) {
|
|
339
|
+
worker(root, 'jobs', { ...workerConfig(`${options.name}-jobs`), queues: {
|
|
340
|
+
producers: [{ binding: 'JOBS', queue: `${options.name}-jobs` }],
|
|
341
|
+
consumers: [{ queue: `${options.name}-jobs`, max_batch_size: 10, max_retries: 3, dead_letter_queue: `${options.name}-jobs-dlq` }],
|
|
342
|
+
} }, `export default {
|
|
343
|
+
async queue(batch: MessageBatch<unknown>): Promise<void> {
|
|
344
|
+
for (const message of batch.messages) {
|
|
345
|
+
// Add transactional idempotency and product processing before acknowledging.
|
|
346
|
+
console.error('Job handler requires product implementation', message.id);
|
|
347
|
+
message.retry({ delaySeconds: 60 });
|
|
348
|
+
}
|
|
349
|
+
},
|
|
350
|
+
};
|
|
351
|
+
`);
|
|
352
|
+
}
|
|
353
|
+
if (has('cron')) {
|
|
354
|
+
worker(root, 'scheduled', { ...workerConfig(`${options.name}-scheduled`), triggers: { crons: ['0 * * * *'] } }, `export default {
|
|
355
|
+
async scheduled(controller: ScheduledController): Promise<void> {
|
|
356
|
+
// Define a product schedule, idempotency key, overlap policy, and failure alerts before release.
|
|
357
|
+
console.log('Scheduled trigger', controller.scheduledTime);
|
|
358
|
+
},
|
|
359
|
+
};
|
|
360
|
+
`);
|
|
361
|
+
}
|
|
362
|
+
if (has('workflows')) {
|
|
363
|
+
worker(root, 'workflows', { ...workerConfig(`${options.name}-workflows`), workflows: [{ name: `${options.name}-workflow`, binding: 'WORKFLOW', class_name: 'ApplicationWorkflow' }] }, `import { WorkflowEntrypoint, type WorkflowEvent, type WorkflowStep } from 'cloudflare:workers';
|
|
364
|
+
export class ApplicationWorkflow extends WorkflowEntrypoint<Env, { operationId: string }> {
|
|
365
|
+
async run(event: WorkflowEvent<{ operationId: string }>, step: WorkflowStep) {
|
|
366
|
+
return step.do('validate', { retries: { limit: 3, delay: '10 seconds', backoff: 'exponential' } }, async () => {
|
|
367
|
+
if (!event.payload.operationId) throw new Error('operationId required');
|
|
368
|
+
return { operationId: event.payload.operationId };
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
export default { async fetch() { return new Response('Use an authorized server binding to create workflows', { status: 403 }); } };
|
|
373
|
+
`);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
function addProviderPackages(root, options) {
|
|
377
|
+
const has = (value) => options.products.includes(value);
|
|
378
|
+
const providers = {
|
|
379
|
+
openai: ['ai', "import OpenAI from 'openai';\nexport function createAI(apiKey: string) { return new OpenAI({ apiKey }); }\n", { openai: '^6.0.0' }],
|
|
380
|
+
'ai-sdk': ['ai', "export { generateText, streamText } from 'ai';\nexport { createOpenAI } from '@ai-sdk/openai';\n", { ai: '^6.0.0', '@ai-sdk/openai': '^3.0.0' }],
|
|
381
|
+
resend: ['email', "import { Resend } from 'resend';\nexport function createEmail(apiKey: string) { return new Resend(apiKey); }\n", { resend: '^6.0.0' }],
|
|
382
|
+
stripe: ['billing', "import Stripe from 'stripe';\nexport function createBilling(apiKey: string) { return new Stripe(apiKey); }\n", { stripe: '^20.0.0' }],
|
|
383
|
+
sentry: ['observability', "// Initialize the platform-specific Sentry SDK in each application's runtime entrypoint.\nexport const redactFields = ['authorization', 'cookie', 'password', 'token'];\n", {}],
|
|
384
|
+
posthog: ['analytics', "import { PostHog } from 'posthog-node';\nexport function createAnalytics(apiKey: string, host: string) { return new PostHog(apiKey, { host }); }\n", { 'posthog-node': '^5.0.0' }],
|
|
385
|
+
styling: ['styling', "export const styling = { colors: { foreground: '#171717', background: '#ffffff' }, spacing: { small: 4, medium: 8, large: 16 } } as const;\n", {}],
|
|
386
|
+
'local-data': ['local-data', "// Device-local SQLite and filesystem need platform-specific adapters.\nexport interface LocalDataStore { read(key: string): Promise<string | null>; write(key: string, value: string): Promise<void>; }\n", {}],
|
|
387
|
+
};
|
|
388
|
+
for (const value of options.products) {
|
|
389
|
+
const provider = providers[value];
|
|
390
|
+
if (provider)
|
|
391
|
+
sharedPackage(root, ...provider);
|
|
392
|
+
}
|
|
393
|
+
if (has('basin')) {
|
|
394
|
+
sharedPackage(root, has('posthog') ? 'basin' : 'analytics', `export interface EventStream { send(events: Record<string, unknown>[]): Promise<void> }
|
|
395
|
+
export async function recordEvents(stream: EventStream, events: Record<string, unknown>[]) { await stream.send(events); }
|
|
396
|
+
`, {});
|
|
397
|
+
}
|
|
398
|
+
if (has('revenuecat')) {
|
|
399
|
+
put(root, 'packages/billing/revenuecat.md', '# RevenueCat + Stripe\n\nConfigure web/App Store/Play Store products and offerings in client-owned accounts. Add the platform SDK to each selected app. Verify signed provider webhooks, reconcile entitlement authority, and keep transactional idempotent credits/quotas separate from subscription status. No payment activation occurs during init.\n');
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
function writeStandardRoot(root, options) {
|
|
403
|
+
const has = (value) => options.products.includes(value);
|
|
404
|
+
const existing = readJson(root, 'package.json');
|
|
405
|
+
patchPackage(root, 'package.json', { name: options.name, packageManager: PACKAGE_MANAGER, engines: { node: '>=22.13.0' },
|
|
406
|
+
scripts: { dev: 'turbo dev', build: 'turbo build', lint: 'turbo lint', typecheck: 'turbo typecheck',
|
|
407
|
+
test: 'turbo test', audit: 'node scripts/audit.mjs', 'audit:full': 'fallow --fail-on-issues', check: 'pnpm lint && pnpm typecheck && pnpm run audit && pnpm test && pnpm build' },
|
|
408
|
+
devDependencies: { fallow: '^3.31.0' } });
|
|
409
|
+
put(root, 'scripts/audit.mjs', `import { spawnSync } from 'node:child_process';
|
|
410
|
+
const base = process.env.AUDIT_BASE;
|
|
411
|
+
const hasBase = base || ['origin/main', 'main'].find((ref) => spawnSync('git', ['rev-parse', '--verify', ref], { stdio: 'ignore' }).status === 0);
|
|
412
|
+
const args = hasBase ? ['exec', 'fallow', 'audit', '--base', hasBase] : ['exec', 'fallow', '--fail-on-issues'];
|
|
413
|
+
const result = spawnSync('pnpm', args, { stdio: 'inherit', shell: false });
|
|
414
|
+
if (result.error) throw result.error;
|
|
415
|
+
process.exitCode = result.status ?? 1;
|
|
416
|
+
`);
|
|
417
|
+
json(root, '.fallowrc.json', { ignorePatterns: ['**/.next/**', '**/.open-next/**', '**/.wrangler/**', '**/dist/**', '**/worker-configuration.d.ts', '**/cloudflare-env.d.ts'] });
|
|
418
|
+
const turbo = readJson(root, 'turbo.json');
|
|
419
|
+
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
|
+
turbo.tasks = { ...turbo.tasks, test: { dependsOn: ['^build'], outputs: ['coverage/**'] },
|
|
421
|
+
build: { ...turbo.tasks.build, outputs: ['.next/**', '!.next/cache/**', '.open-next/**', 'dist/**', '.output/**'] } };
|
|
422
|
+
json(root, 'turbo.json', turbo);
|
|
423
|
+
const workspaceFile = path.join(root, 'pnpm-workspace.yaml');
|
|
424
|
+
const workspace = readFileSync(workspaceFile, 'utf8');
|
|
425
|
+
// Keep the upstream package globs and other policies; translate only the build approval section for pnpm 10.
|
|
426
|
+
const policy = 'onlyBuiltDependencies:\n' + ['esbuild', 'sharp', 'unrs-resolver', 'workerd', ...(has('electron') ? ['electron'] : [])].map((name) => ` - ${name}\n`).join('');
|
|
427
|
+
const section = /(?:allowBuilds|onlyBuiltDependencies):\n(?:[ \t]+[^\n]*\n|[ \t]*\n)*/;
|
|
428
|
+
put(root, 'pnpm-workspace.yaml', section.test(workspace) ? workspace.replace(section, policy) : workspace + '\n' + policy);
|
|
429
|
+
appendGitignore(root, ['node_modules/', '.open-next/', '.wrangler/', '.dev.vars', '.dev.vars.*', '!.dev.vars.example', '.env', '.env.*', '!.env.example', '.fallow/']);
|
|
430
|
+
const environment = [
|
|
431
|
+
has('neon') ? 'DATABASE_URL=' : '', has('clerk') ? 'NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=\nCLERK_SECRET_KEY=' : '',
|
|
432
|
+
has('better-auth') ? 'BETTER_AUTH_SECRET=\nBETTER_AUTH_URL=' : '',
|
|
433
|
+
has('openai') || has('ai-sdk') ? 'OPENAI_API_KEY=' : '', has('resend') ? 'RESEND_API_KEY=' : '',
|
|
434
|
+
has('stripe') ? 'STRIPE_SECRET_KEY=\nSTRIPE_WEBHOOK_SECRET=' : '',
|
|
435
|
+
has('posthog') ? 'POSTHOG_API_KEY=\nPOSTHOG_HOST=' : '', has('sentry') ? 'SENTRY_DSN=' : '',
|
|
436
|
+
].filter(Boolean).join('\n');
|
|
437
|
+
put(root, '.env.example', environment + '\n');
|
|
438
|
+
json(root, 'anhedral.standard.json', { schemaVersion: 1, standard: 'anhedral-application-stack', products: options.products,
|
|
439
|
+
hosting: has('next') ? options.hosting : null, bootstrap: { command: 'pnpm dlx shadcn@latest init --monorepo --template next', upstreamPackageManager: existing.packageManager },
|
|
440
|
+
status: 'starter', resourcesProvisioned: false });
|
|
441
|
+
const agentsFile = path.join(root, 'AGENTS.md');
|
|
442
|
+
const existingAgents = existsSync(agentsFile) && has('next') ? readFileSync(agentsFile, 'utf8') + '\n' : '';
|
|
443
|
+
put(root, 'AGENTS.md', existingAgents + `# Anhedral delivery standard
|
|
444
|
+
|
|
445
|
+
Keep accounts, source, infrastructure, billing, and recovery client-owned. Use macOS, OpenAI/Codex, Computer Use and Control Chrome during implementation and verification. Use provider plugins, APIs/MCP and CLIs for operations; Cloudflare plugin + cf where available, Wrangler for project commands.
|
|
446
|
+
|
|
447
|
+
Cloudflare-first, free-first where suitable. Human approval is required for paid activation, material spending, architecture exceptions, and production releases. Do not deploy or activate providers as part of initialization.
|
|
448
|
+
|
|
449
|
+
Add apps and shared packages only when needed. All services belong in apps/*, reusable code in packages/*. Shared styling is named styling. Keep secrets and privileged integrations out of client bundles. Use Clerk, Better Auth, or no auth according to project needs. Protect product routes explicitly and implement resource authorization.
|
|
450
|
+
|
|
451
|
+
Use private R2 Worker bindings. KV is eventually consistent configuration/cache, never transactional authority. Neon uses Drizzle + Hyperdrive; disable Hyperdrive query caching for auth, permissions, entitlements, balances, quotas, financial records and paid-unit consumption. Define job retries, concurrency, idempotency and failure handling. Review SQL migrations before applying them.
|
|
452
|
+
|
|
453
|
+
Developer tooling includes Cloudflare’s security-audit skill (https://github.com/cloudflare/security-audit-skill) for repository security reviews.
|
|
454
|
+
|
|
455
|
+
Run pnpm check. Use pnpm exec fallow during development and pnpm exec fallow audit for changes. Fix legitimate findings; use narrow documented exceptions. Verify real UI flows with Computer Use and Control Chrome, plus relevant integrations, native apps/simulators, and deployed previews. Keep production release approval separate from deterministic checks.
|
|
456
|
+
`);
|
|
457
|
+
put(root, 'README.md', `# ${options.name}
|
|
458
|
+
|
|
459
|
+
Initialized from the official shadcn pnpm/Turborepo monorepo, with the Anhedral Application Stack & Delivery Standard.
|
|
460
|
+
|
|
461
|
+
Selected: ${options.products.join(', ')}. ${has('next') ? `Next.js hosting: ${options.hosting === 'cloudflare' ? 'Cloudflare Workers + OpenNext' : 'Vercel (explicit architecture exception)'}.` : ''}
|
|
462
|
+
|
|
463
|
+
Run pnpm install, commit pnpm-lock.yaml, then pnpm dev. Run pnpm check for lint, types, Fallow audit, tests and build. Without a Git comparison base, audit runs the full Fallow gate. Add meaningful product tests and integration checks as behavior is implemented. The tests task runs only workspaces that define tests; init does not claim product verification.
|
|
464
|
+
|
|
465
|
+
Cloudflare bindings contain REPLACE_WITH_* IDs. Create separate preview/production resources in client-owned accounts, replace IDs, and generate Worker types before development. Workers expose local dev and explicit deploy scripts. Next.js uses pnpm --filter web preview to exercise the Workers runtime (use the actual package name from apps/web/package.json). No infrastructure or production deployment is performed by init.
|
|
466
|
+
|
|
467
|
+
${has('next') && options.hosting === 'cloudflare' ? 'OpenNext is configured without a provisioned incremental-cache bucket. Choose and configure a cache adapter before using ISR or cache features that require shared persistence.\n' : ''}
|
|
468
|
+
${has('neon') ? 'Create Hyperdrive against Neon; disable query caching for correctness-sensitive reads. DATABASE_URL is for reviewed migration tooling, while Workers use the HYPERDRIVE binding. Close direct PostgreSQL clients after use.\n' : ''}
|
|
469
|
+
${has('better-auth') ? 'Generate Better Auth tables into the Drizzle schema, review and apply migrations before using /api/auth. Configure BETTER_AUTH_SECRET, BETTER_AUTH_URL and trusted origins for the correct environment.\n' : ''}
|
|
470
|
+
${has('clerk') ? 'Clerk middleware exposes server identity; protect selected routes explicitly. The web starter includes ClerkProvider and sign-in/sign-up pages. Configure the selected native SDKs when implementing account flows.\n' : ''}
|
|
471
|
+
${has('r2') ? 'FILES is a private R2 binding. Implement authentication and file-level authorization before exposing file routes. Clients never receive R2 credentials.\n' : ''}
|
|
472
|
+
${has('realtime') ? 'Durable Object rooms support hibernating WebSockets; the public entrypoint remains disabled until room authorization and short-lived tickets are implemented.\n' : ''}
|
|
473
|
+
${has('queues') ? 'Queue processing retries unimplemented jobs and uses a dead-letter queue. Add product processing and transactional idempotency before acknowledging messages.\n' : ''}
|
|
474
|
+
${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
|
+
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
|
+
|
|
477
|
+
Outbound application mail uses Resend. Inbound forwarding uses Cloudflare Email Routing. Keep the existing business mailbox provider. GitHub Actions runs PR checks and never deploys automatically. Human approval is required for paid activation, material spending, architecture exceptions and production releases.
|
|
478
|
+
`);
|
|
479
|
+
put(root, '.github/workflows/check.yml', `name: Check
|
|
480
|
+
on:
|
|
481
|
+
pull_request:
|
|
482
|
+
push:
|
|
483
|
+
branches: [main]
|
|
484
|
+
permissions:
|
|
485
|
+
contents: read
|
|
486
|
+
jobs:
|
|
487
|
+
check:
|
|
488
|
+
runs-on: ubuntu-latest
|
|
489
|
+
steps:
|
|
490
|
+
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10
|
|
491
|
+
with:
|
|
492
|
+
fetch-depth: 0
|
|
493
|
+
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271
|
|
494
|
+
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38
|
|
495
|
+
with:
|
|
496
|
+
node-version: '22'
|
|
497
|
+
cache: pnpm
|
|
498
|
+
- run: pnpm install --frozen-lockfile
|
|
499
|
+
- run: pnpm lint && pnpm typecheck
|
|
500
|
+
- name: Audit pull request
|
|
501
|
+
if: github.event_name == 'pull_request'
|
|
502
|
+
env:
|
|
503
|
+
AUDIT_BASE: \${{ github.event.pull_request.base.sha }}
|
|
504
|
+
run: pnpm exec fallow audit --base "$AUDIT_BASE"
|
|
505
|
+
- name: Audit branch
|
|
506
|
+
if: github.event_name != 'pull_request'
|
|
507
|
+
run: pnpm audit:full
|
|
508
|
+
- run: pnpm test && pnpm build
|
|
509
|
+
`);
|
|
510
|
+
}
|
|
511
|
+
const defaultRunner = (command, args, cwd) => execFile(command, args, cwd);
|
|
512
|
+
export async function scaffoldStandardProject(options, run = defaultRunner) {
|
|
513
|
+
const plan = { standard: 'anhedral-application-stack', root: options.root, products: options.products, hosting: options.hosting,
|
|
514
|
+
bootstrap: ['pnpm', 'dlx', 'shadcn@latest', 'init', '--monorepo', '--template', 'next'], dryRun: options.dryRun };
|
|
515
|
+
if (options.dryRun) {
|
|
516
|
+
console.log(options.json ? JSON.stringify(plan, null, 2) : JSON.stringify(plan));
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
const root = options.root;
|
|
520
|
+
const previousQuiet = process.env.ANHEDRAL_QUIET;
|
|
521
|
+
if (options.json)
|
|
522
|
+
process.env.ANHEDRAL_QUIET = '1';
|
|
523
|
+
let created = false;
|
|
524
|
+
if (!existsSync(root)) {
|
|
525
|
+
mkdirSync(root, { recursive: true });
|
|
526
|
+
created = true;
|
|
527
|
+
}
|
|
528
|
+
try {
|
|
529
|
+
if (lstatSync(root).isSymbolicLink() || !lstatSync(root).isDirectory())
|
|
530
|
+
throw new Error('Destination must be a real directory.');
|
|
531
|
+
const unexpected = readdirSync(root).filter((name) => !['.git', '.gitignore'].includes(name));
|
|
532
|
+
if (unexpected.length)
|
|
533
|
+
throw new Error(`Destination must be empty (apart from .git/.gitignore): ${unexpected[0]}`);
|
|
534
|
+
const commitPaths = [];
|
|
535
|
+
await runStagedTransaction(root, { commitPaths, seedPaths: existsSync(path.join(root, '.gitignore')) ? ['.gitignore'] : [],
|
|
536
|
+
prepare: () => {
|
|
537
|
+
const entries = readdirSync(root).filter((name) => !['.git', '.gitignore', '.anhedral.lock', '.anhedral-txn'].includes(name));
|
|
538
|
+
if (entries.length)
|
|
539
|
+
throw new Error('Destination changed before generation.');
|
|
540
|
+
},
|
|
541
|
+
build: async (stageRoot) => {
|
|
542
|
+
const bootstrap = path.join(stageRoot, '.bootstrap');
|
|
543
|
+
mkdirSync(bootstrap);
|
|
544
|
+
run('pnpm', ['dlx', 'shadcn@latest', 'init', '--monorepo', '--template', 'next', '--defaults', '--name', options.name, '--cwd', bootstrap], stageRoot);
|
|
545
|
+
const generated = path.join(bootstrap, options.name);
|
|
546
|
+
if (!existsSync(path.join(generated, 'pnpm-workspace.yaml')) || !existsSync(path.join(generated, 'apps/web/package.json')))
|
|
547
|
+
throw new Error('Official shadcn bootstrap did not produce the expected monorepo.');
|
|
548
|
+
const seededIgnore = existsSync(path.join(stageRoot, '.gitignore')) ? readFileSync(path.join(stageRoot, '.gitignore'), 'utf8') : '';
|
|
549
|
+
for (const entry of readdirSync(generated)) {
|
|
550
|
+
if (['.git', 'node_modules', '.next', '.turbo'].includes(entry))
|
|
551
|
+
continue;
|
|
552
|
+
cpSync(path.join(generated, entry), path.join(stageRoot, entry), { recursive: true, filter: (source) => !['node_modules', '.git', '.next', '.turbo'].includes(path.basename(source)) });
|
|
553
|
+
}
|
|
554
|
+
rmSync(bootstrap, { recursive: true, force: true });
|
|
555
|
+
if (seededIgnore)
|
|
556
|
+
appendGitignore(stageRoot, seededIgnore.split(/\r?\n/));
|
|
557
|
+
await applyStandardOverlays(stageRoot, options);
|
|
558
|
+
run('pnpm', ['install', '--lockfile-only', '--no-frozen-lockfile'], stageRoot);
|
|
559
|
+
commitPaths.push(...readdirSync(stageRoot).filter((name) => name !== 'node_modules'));
|
|
560
|
+
},
|
|
561
|
+
afterCommit: () => {
|
|
562
|
+
if (options.initializeGit && spawnSync('git', ['rev-parse', '--is-inside-work-tree'], { cwd: root, stdio: 'ignore' }).status !== 0)
|
|
563
|
+
run('git', ['init'], root);
|
|
564
|
+
if (!options.skipInstall)
|
|
565
|
+
run('pnpm', ['install', '--frozen-lockfile'], root);
|
|
566
|
+
},
|
|
567
|
+
});
|
|
568
|
+
console.log(options.json ? JSON.stringify({ ...plan, generated: true }) : `Created ${root}. Follow README.md for resource setup, verification and delivery.`);
|
|
569
|
+
}
|
|
570
|
+
finally {
|
|
571
|
+
if (created && existsSync(root) && readdirSync(root).length === 0)
|
|
572
|
+
rmdirSync(root);
|
|
573
|
+
if (previousQuiet === undefined)
|
|
574
|
+
delete process.env.ANHEDRAL_QUIET;
|
|
575
|
+
else
|
|
576
|
+
process.env.ANHEDRAL_QUIET = previousQuiet;
|
|
577
|
+
}
|
|
578
|
+
}
|
package/dist/version.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** Kept in sync with package.json by tests/release checks. */
|
|
2
|
-
export declare const GENERATOR_VERSION = "0.
|
|
2
|
+
export declare const GENERATOR_VERSION = "0.6.0";
|
package/dist/version.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** Kept in sync with package.json by tests/release checks. */
|
|
2
|
-
export const GENERATOR_VERSION = '0.
|
|
2
|
+
export const GENERATOR_VERSION = '0.6.0';
|