anhedral 0.8.5 → 0.9.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 +140 -34
- package/assets/anhedral-cli-init-technical.svg +8 -8
- package/assets/anhedral-cli-init.svg +9 -9
- package/assets/security/LICENSES.txt +63 -0
- package/assets/security/audit-deps.mjs +295 -0
- package/assets/security/braces-regression.mjs +102 -0
- package/assets/security/braces@3.0.3.patch +285 -0
- package/assets/security/decode-uri-component@0.2.2.patch +149 -0
- package/assets/security/decoder-regression.mjs +37 -0
- package/assets/security/forge-regression.mjs +46 -0
- package/assets/security/manifest.json +53 -0
- package/assets/security/node-forge@1.4.0.patch +26 -0
- package/dist/access.d.ts +10 -0
- package/dist/access.js +93 -0
- package/dist/auth-schema-snapshot.d.ts +346 -0
- package/dist/auth-schema-snapshot.js +347 -0
- package/dist/auth-schema.d.ts +1 -0
- package/dist/auth-schema.js +57 -0
- package/dist/bin.js +353 -10
- package/dist/bounded-file.d.ts +2 -0
- package/dist/bounded-file.js +30 -0
- package/dist/capabilities.d.ts +19 -33
- package/dist/capabilities.js +73 -71
- package/dist/cloudflare.d.ts +14 -0
- package/dist/cloudflare.js +66 -0
- package/dist/command.js +12 -3
- package/dist/delivery.d.ts +118 -0
- package/dist/delivery.js +611 -0
- package/dist/dependencies.d.ts +22 -0
- package/dist/dependencies.js +109 -67
- package/dist/dependency-order.d.ts +2 -0
- package/dist/dependency-order.js +24 -0
- package/dist/index.d.ts +18 -1
- package/dist/index.js +12 -1
- package/dist/managed-hosting.d.ts +49 -0
- package/dist/managed-hosting.js +373 -0
- package/dist/management.d.ts +3 -0
- package/dist/management.js +47 -0
- package/dist/platforms/desktop.d.ts +2 -2
- package/dist/platforms/desktop.js +22 -12
- package/dist/platforms/extension.d.ts +2 -2
- package/dist/platforms/extension.js +12 -4
- package/dist/platforms/mobile.d.ts +2 -2
- package/dist/platforms/mobile.js +13 -7
- package/dist/pnpm-policy.d.ts +4 -0
- package/dist/pnpm-policy.js +66 -0
- package/dist/project-state.d.ts +28 -0
- package/dist/project-state.js +226 -0
- package/dist/provider-readiness.d.ts +30 -0
- package/dist/provider-readiness.js +87 -0
- package/dist/provision.d.ts +46 -0
- package/dist/provision.js +197 -0
- package/dist/readiness.d.ts +4 -1
- package/dist/readiness.js +80 -8
- package/dist/registry/add.d.ts +22 -0
- package/dist/registry/add.js +198 -0
- package/dist/registry/bootstrap.d.ts +2 -0
- package/dist/registry/bootstrap.js +14 -0
- package/dist/registry/commands.d.ts +2 -0
- package/dist/registry/commands.js +99 -0
- package/dist/registry/context.d.ts +3 -0
- package/dist/registry/context.js +21 -0
- package/dist/registry/options.d.ts +2 -0
- package/dist/registry/options.js +65 -0
- package/dist/registry/receipt.d.ts +3 -0
- package/dist/registry/receipt.js +73 -0
- package/dist/registry/remote.d.ts +25 -0
- package/dist/registry/remote.js +168 -0
- package/dist/registry/resolve.d.ts +10 -0
- package/dist/registry/resolve.js +98 -0
- package/dist/registry/types.d.ts +64 -0
- package/dist/registry/usage.d.ts +1 -0
- package/dist/registry/usage.js +38 -0
- package/dist/render.js +3 -3
- package/dist/repository.d.ts +24 -0
- package/dist/repository.js +96 -0
- package/dist/scaffold/api.d.ts +2 -0
- package/dist/scaffold/api.js +13 -0
- package/dist/scaffold/auth.d.ts +3 -0
- package/dist/scaffold/auth.js +95 -0
- package/dist/scaffold/bootstrap.d.ts +2 -0
- package/dist/scaffold/bootstrap.js +34 -0
- package/dist/scaffold/compose.d.ts +4 -0
- package/dist/scaffold/compose.js +34 -0
- package/dist/scaffold/database.d.ts +2 -0
- package/dist/scaffold/database.js +43 -0
- package/dist/scaffold/documentation.d.ts +3 -0
- package/dist/scaffold/documentation.js +91 -0
- package/dist/scaffold/environment.d.ts +2 -0
- package/dist/scaffold/environment.js +5 -0
- package/dist/scaffold/files.d.ts +25 -0
- package/dist/scaffold/files.js +108 -0
- package/dist/scaffold/integrations.d.ts +3 -0
- package/dist/scaffold/integrations.js +7 -0
- package/dist/scaffold/services.d.ts +5 -0
- package/dist/scaffold/services.js +84 -0
- package/dist/scaffold/web.d.ts +2 -0
- package/dist/scaffold/web.js +102 -0
- package/dist/scaffold/workspace.d.ts +3 -0
- package/dist/scaffold/workspace.js +150 -0
- package/dist/security-backports.d.ts +3 -0
- package/dist/security-backports.js +26 -0
- package/dist/stack-registry.d.ts +80 -0
- package/dist/stack-registry.js +424 -0
- package/dist/standard.d.ts +6 -17
- package/dist/standard.js +41 -585
- package/dist/transaction.js +46 -26
- package/dist/util.js +12 -2
- package/dist/vercel.d.ts +98 -0
- package/dist/vercel.js +796 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/vinext.d.ts +1 -0
- package/dist/vinext.js +83 -0
- package/dist/workspace-graph.d.ts +7 -0
- package/dist/workspace-graph.js +73 -0
- package/docs/application-stack-standard.md +75 -674
- package/docs/delivery-workflow.md +83 -0
- package/docs/managed-hosting-architecture.md +87 -0
- package/docs/sponsorships.md +25 -0
- package/package.json +23 -28
- package/dist/project.d.ts +0 -4
- package/dist/standard-products.d.ts +0 -2
- package/dist/standard-products.js +0 -6
- /package/assets/{images/svg/logo-white-subtract.svg → anhedral-mark.svg} +0 -0
- /package/dist/{project.js → registry/types.js} +0 -0
package/dist/standard.js
CHANGED
|
@@ -1,571 +1,19 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { configureSecurityBackports } from './security-backports.js';
|
|
2
|
+
import { recordScaffoldFiles } from './registry/receipt.js';
|
|
3
|
+
import { existsSync, lstatSync, mkdirSync, readdirSync, realpathSync, rmdirSync } from 'node:fs';
|
|
2
4
|
import path from 'node:path';
|
|
3
|
-
import { appendGitignore, execFile, writeFile } from './util.js';
|
|
4
|
-
import { packageNameFromText } from './render.js';
|
|
5
5
|
import { spawnSync } from 'node:child_process';
|
|
6
|
-
import {
|
|
6
|
+
import { execFile } from './util.js';
|
|
7
|
+
import { registerVercelApplication } from './vercel.js';
|
|
8
|
+
import { registerApplication } from './delivery.js';
|
|
7
9
|
import { runStagedTransaction } from './transaction.js';
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
export {
|
|
14
|
-
export
|
|
15
|
-
|
|
16
|
-
anhedral new <directory> [products...] [--hosting cloudflare|vercel] [--skip-install] [--no-git] [--dry-run] [--json]
|
|
17
|
-
anhedral init [products...] [--hosting cloudflare|vercel] [--skip-install] [--git] [--dry-run] [--json]
|
|
18
|
-
|
|
19
|
-
Default: Next.js only, from the official shadcn pnpm/Turborepo monorepo.
|
|
20
|
-
Web hosting: Cloudflare Workers + OpenNext. Services are opt-in.
|
|
21
|
-
Products: ${STANDARD_PRODUCTS.join(', ')}
|
|
22
|
-
Examples:
|
|
23
|
-
anhedral new client-app
|
|
24
|
-
anhedral new mobile-app --expo
|
|
25
|
-
anhedral new product --next --expo --hono --neon --clerk --r2
|
|
26
|
-
anhedral new website --next --stripe --resend
|
|
27
|
-
anhedral init --wxt
|
|
28
|
-
anhedral doctor [directory] --json
|
|
29
|
-
|
|
30
|
-
--skip-install skips the final workspace install; the official shadcn bootstrap
|
|
31
|
-
still requires network access and may install its own dependencies.
|
|
32
|
-
--all is intentionally unsupported: select only required capabilities.
|
|
33
|
-
Vercel is an explicit architecture exception requiring project approval.
|
|
34
|
-
`;
|
|
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) {
|
|
49
|
-
const products = new Set();
|
|
50
|
-
let hosting = 'cloudflare';
|
|
51
|
-
let hostingSpecified = false;
|
|
52
|
-
let gitSpecified;
|
|
53
|
-
for (let index = 0; index < remaining.length; index++) {
|
|
54
|
-
const token = remaining[index];
|
|
55
|
-
if (['--skip-install', '--dry-run', '--json', '--verbose'].includes(token))
|
|
56
|
-
continue;
|
|
57
|
-
if (token === '--git' || token === '--no-git') {
|
|
58
|
-
gitSpecified = readGitFlag(token, gitSpecified);
|
|
59
|
-
continue;
|
|
60
|
-
}
|
|
61
|
-
if (token === '--hosting' || token.startsWith('--hosting=')) {
|
|
62
|
-
const value = token === '--hosting' ? remaining[++index] : token.slice('--hosting='.length);
|
|
63
|
-
hosting = readHostingFlag(value, hosting, hostingSpecified);
|
|
64
|
-
hostingSpecified = true;
|
|
65
|
-
continue;
|
|
66
|
-
}
|
|
67
|
-
const value = token.replace(/^--/, '');
|
|
68
|
-
if (!STANDARD_PRODUCTS.includes(value)) {
|
|
69
|
-
throw new Error(`Unknown standard product: ${token}. Select only required products.`);
|
|
70
|
-
}
|
|
71
|
-
products.add(value);
|
|
72
|
-
}
|
|
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);
|
|
83
|
-
return { root, name: packageNameFromText(path.basename(root)), products: [...products].sort(), hosting,
|
|
84
|
-
skipInstall: remaining.includes('--skip-install'), dryRun: remaining.includes('--dry-run'), json: remaining.includes('--json'),
|
|
85
|
-
initializeGit: gitSpecified ?? command === 'new' };
|
|
86
|
-
}
|
|
87
|
-
function readJson(root, file) { return JSON.parse(readFileSync(path.join(root, file), 'utf8')); }
|
|
88
|
-
function json(root, file, value) { writeFile(path.join(root, file), JSON.stringify(value, null, 2) + '\n'); }
|
|
89
|
-
function put(root, file, value) { writeFile(path.join(root, file), value); }
|
|
90
|
-
function patchPackage(root, file, patch) {
|
|
91
|
-
const current = readJson(root, file);
|
|
92
|
-
for (const key of ['scripts', 'dependencies', 'devDependencies']) {
|
|
93
|
-
if (patch[key])
|
|
94
|
-
patch[key] = { ...current[key], ...patch[key] };
|
|
95
|
-
}
|
|
96
|
-
json(root, file, { ...current, ...patch });
|
|
97
|
-
}
|
|
98
|
-
function workerConfig(name) {
|
|
99
|
-
return { $schema: 'node_modules/wrangler/config-schema.json', name, main: 'src/index.ts', compatibility_date: '2026-10-05',
|
|
100
|
-
compatibility_flags: ['nodejs_compat'], observability: { enabled: true } };
|
|
101
|
-
}
|
|
102
|
-
function worker(root, app, config, source, dependencies = {}) {
|
|
103
|
-
json(root, `apps/${app}/package.json`, { name: `@workspace/${app}`, private: true, type: 'module', scripts: {
|
|
104
|
-
dev: 'wrangler dev', build: 'wrangler deploy --dry-run --outdir dist', deploy: 'wrangler deploy',
|
|
105
|
-
typecheck: 'wrangler types && tsc --noEmit', lint: 'eslint . --max-warnings 0',
|
|
106
|
-
}, dependencies, devDependencies: { wrangler: '^4.0.0', typescript: '^5.9.3', '@types/node': '^22.0.0', eslint: '^9.0.0', '@workspace/eslint-config': 'workspace:*' } });
|
|
107
|
-
json(root, `apps/${app}/wrangler.jsonc`, config);
|
|
108
|
-
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'] });
|
|
109
|
-
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');
|
|
110
|
-
put(root, `apps/${app}/src/index.ts`, source);
|
|
111
|
-
}
|
|
112
|
-
function sharedPackage(root, name, source, dependencies) {
|
|
113
|
-
json(root, `packages/${name}/package.json`, { name: `@workspace/${name}`, private: true, type: 'module', exports: { '.': './src/index.ts' },
|
|
114
|
-
scripts: { typecheck: 'tsc --noEmit', lint: 'eslint . --max-warnings 0' }, dependencies,
|
|
115
|
-
devDependencies: { typescript: '^5.9.3', '@types/node': '^22.0.0', eslint: '^9.0.0', '@workspace/eslint-config': 'workspace:*' } });
|
|
116
|
-
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'] });
|
|
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');
|
|
118
|
-
put(root, `packages/${name}/src/index.ts`, source);
|
|
119
|
-
}
|
|
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'));
|
|
135
|
-
}
|
|
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;
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
json(root, manifestPath, manifest);
|
|
152
|
-
}
|
|
153
|
-
function cleanShadcnStarter(root) {
|
|
154
|
-
for (const directory of ['apps/web', 'packages/ui'])
|
|
155
|
-
cleanWorkspaceManifest(root, directory);
|
|
156
|
-
const themePath = 'apps/web/components/theme-provider.tsx';
|
|
157
|
-
if (existsSync(path.join(root, themePath))) {
|
|
158
|
-
const source = readFileSync(path.join(root, themePath), 'utf8');
|
|
159
|
-
// Preserve the upstream keyboard behavior while reducing branching in its guards.
|
|
160
|
-
put(root, themePath, source.replace(/target.tagName === "INPUT" \|\|\s*target.tagName === "TEXTAREA" \|\|\s*target.tagName === "SELECT"/, '["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName)')
|
|
161
|
-
.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 }'));
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
export async function applyStandardOverlays(root, options) {
|
|
165
|
-
const has = (product) => options.products.includes(product);
|
|
166
|
-
// Preserve the upstream workspace and config packages, but remove its unused app.
|
|
167
|
-
if (!has('next'))
|
|
168
|
-
rmSync(path.join(root, 'apps/web'), { recursive: true, force: true });
|
|
169
|
-
if (!has('next'))
|
|
170
|
-
rmSync(path.join(root, 'packages/ui'), { recursive: true, force: true });
|
|
171
|
-
const platformOptions = { projectName: options.name, displayName: options.name };
|
|
172
|
-
if (has('expo'))
|
|
173
|
-
await scaffoldMobile(root, platformOptions);
|
|
174
|
-
if (has('electron'))
|
|
175
|
-
await scaffoldDesktop(root, platformOptions);
|
|
176
|
-
if (has('wxt'))
|
|
177
|
-
await scaffoldExtension(root, platformOptions);
|
|
178
|
-
const bindings = {};
|
|
179
|
-
if (has('neon'))
|
|
180
|
-
bindings.hyperdrive = [{ binding: 'HYPERDRIVE', id: 'REPLACE_WITH_HYPERDRIVE_ID' }];
|
|
181
|
-
if (has('d1'))
|
|
182
|
-
bindings.d1_databases = [{ binding: 'DB', database_name: `${options.name}-db`, database_id: 'REPLACE_WITH_D1_ID', migrations_dir: '../../packages/db/migrations' }];
|
|
183
|
-
if (has('r2'))
|
|
184
|
-
bindings.r2_buckets = [{ binding: 'FILES', bucket_name: `${options.name}-private-files` }];
|
|
185
|
-
if (has('kv'))
|
|
186
|
-
bindings.kv_namespaces = [{ binding: 'CONFIG', id: 'REPLACE_WITH_KV_ID' }];
|
|
187
|
-
if (has('workers-ai'))
|
|
188
|
-
bindings.ai = { binding: 'AI' };
|
|
189
|
-
if (has('queues'))
|
|
190
|
-
bindings.queues = { producers: [{ binding: 'JOBS', queue: `${options.name}-jobs` }] };
|
|
191
|
-
if (has('basin'))
|
|
192
|
-
bindings.pipelines = [{ binding: 'ANALYTICS', stream: 'REPLACE_WITH_BASIN_STREAM_ID' }];
|
|
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);
|
|
208
|
-
if (has('next')) {
|
|
209
|
-
cleanShadcnStarter(root);
|
|
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');
|
|
211
|
-
patchPackage(root, 'apps/web/package.json', { scripts: { typecheck: 'tsc --noEmit', lint: 'eslint . --max-warnings 0' } });
|
|
212
|
-
if (options.hosting === 'cloudflare') {
|
|
213
|
-
patchPackage(root, 'apps/web/package.json', { dependencies: { '@opennextjs/cloudflare': '^1.0.0', next: '^16.3.8' }, devDependencies: { wrangler: '^4.0.0' }, scripts: {
|
|
214
|
-
preview: 'opennextjs-cloudflare build && opennextjs-cloudflare preview', deploy: 'opennextjs-cloudflare build && opennextjs-cloudflare deploy',
|
|
215
|
-
'build:worker': 'opennextjs-cloudflare build', 'cf:typegen': 'wrangler types --env-interface CloudflareEnv cloudflare-env.d.ts',
|
|
216
|
-
} });
|
|
217
|
-
json(root, 'apps/web/wrangler.jsonc', { ...workerConfig(`${options.name}-web`), main: '.open-next/worker.js',
|
|
218
|
-
assets: { directory: '.open-next/assets', binding: 'ASSETS' }, services: [{ binding: 'WORKER_SELF_REFERENCE', service: `${options.name}-web` }],
|
|
219
|
-
...(runtimeApp === 'web' ? bindings : {}) });
|
|
220
|
-
put(root, 'apps/web/open-next.config.ts', 'import { defineCloudflareConfig } from "@opennextjs/cloudflare";\nexport default defineCloudflareConfig();\n');
|
|
221
|
-
put(root, 'apps/web/public/_headers', '/_next/static/*\n Cache-Control: public,max-age=31536000,immutable\n');
|
|
222
|
-
put(root, 'apps/web/.dev.vars.example', 'NEXTJS_ENV=development\n');
|
|
223
|
-
}
|
|
224
|
-
else {
|
|
225
|
-
json(root, 'apps/web/vercel.json', { framework: 'nextjs' });
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
}
|
|
229
|
-
function addApiApplication(root, options, bindings) {
|
|
230
|
-
const has = (product) => options.products.includes(product);
|
|
231
|
-
if (has('hono')) {
|
|
232
|
-
worker(root, 'api', { ...workerConfig(`${options.name}-api`), ...bindings }, `import { OpenAPIHono, createRoute, z } from '@hono/zod-openapi';
|
|
233
|
-
const app = new OpenAPIHono<{ Bindings: Env }>();
|
|
234
|
-
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));
|
|
235
|
-
app.doc('/openapi.json', { openapi: '3.0.0', info: { title: '${options.name}', version: '1.0.0' } });
|
|
236
|
-
export default app;
|
|
237
|
-
`, { hono: '^4.0.0', '@hono/zod-openapi': '^1.0.0' });
|
|
238
|
-
}
|
|
239
|
-
}
|
|
240
|
-
function addDatabase(root, options, runtimeApp) {
|
|
241
|
-
const has = (product) => options.products.includes(product);
|
|
242
|
-
if (has('neon') || has('d1')) {
|
|
243
|
-
sharedPackage(root, 'db', has('neon') ? `import { drizzle } from 'drizzle-orm/postgres-js';
|
|
244
|
-
import postgres from 'postgres';
|
|
245
|
-
import * as schema from './schema.js';
|
|
246
|
-
export function createDatabase(connectionString: string) {
|
|
247
|
-
// Pass env.HYPERDRIVE.connectionString from the Worker. Disable caching for authoritative reads in Hyperdrive configuration.
|
|
248
|
-
const client = postgres(connectionString, { prepare: false, max: 5 });
|
|
249
|
-
return { db: drizzle(client, { schema }), close: () => client.end() };
|
|
250
|
-
}
|
|
251
|
-
` : `import { drizzle } from 'drizzle-orm/d1';
|
|
252
|
-
import * as schema from './schema.js';
|
|
253
|
-
export function createDatabase(binding: Parameters<typeof drizzle>[0]) { return drizzle(binding, { schema }); }
|
|
254
|
-
`, { 'drizzle-orm': '^0.45.3', ...(has('neon') ? { postgres: '^3.4.0' } : {}) });
|
|
255
|
-
put(root, 'packages/db/src/schema.ts', '// Define product tables here; generate and review SQL before migration.\nexport {};\n');
|
|
256
|
-
put(root, 'packages/db/drizzle.config.ts', has('neon') ? `import { defineConfig } from 'drizzle-kit';
|
|
257
|
-
export default defineConfig({ dialect: 'postgresql', schema: './src/schema.ts', out: './migrations', dbCredentials: { url: process.env.DATABASE_URL! } });
|
|
258
|
-
` : `import { defineConfig } from 'drizzle-kit';
|
|
259
|
-
export default defineConfig({ dialect: 'sqlite', schema: './src/schema.ts', out: './migrations' });
|
|
260
|
-
`);
|
|
261
|
-
patchPackage(root, 'packages/db/package.json', { devDependencies: { 'drizzle-kit': '^0.31.0' }, scripts: { 'db:generate': 'drizzle-kit generate' } });
|
|
262
|
-
patchPackage(root, `apps/${runtimeApp}/package.json`, { dependencies: { '@workspace/db': 'workspace:*' } });
|
|
263
|
-
}
|
|
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';
|
|
270
|
-
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
|
|
271
|
-
import type { createDatabase } from '@workspace/db';
|
|
272
|
-
export function createAuth(db: ReturnType<typeof createDatabase>${has('neon') ? "['db']" : ''}, secret: string, baseURL: string) {
|
|
273
|
-
return betterAuth({ database: drizzleAdapter(db, { provider: '${has('neon') ? 'pg' : 'sqlite'}' }), secret, baseURL, emailAndPassword: { enabled: true } });
|
|
274
|
-
}
|
|
275
|
-
`, { 'better-auth': '^1.0.0', '@workspace/db': 'workspace:*' });
|
|
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';
|
|
287
|
-
import { createAuth } from '@workspace/auth';
|
|
288
|
-
import { createDatabase } from '@workspace/db';
|
|
289
|
-
async function handler(request: Request) {
|
|
290
|
-
const { env } = await getCloudflareContext({ async: true });
|
|
291
|
-
const database = ${connection};
|
|
292
|
-
try { return await createAuth(${dbInit}, process.env.BETTER_AUTH_SECRET!, process.env.BETTER_AUTH_URL!).handler(request); }
|
|
293
|
-
finally { ${has('neon') ? 'await database.close();' : '/* D1 bindings need no connection cleanup. */'} }
|
|
294
|
-
}
|
|
295
|
-
export { handler as GET, handler as POST };
|
|
296
|
-
`);
|
|
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' } });
|
|
299
|
-
}
|
|
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);
|
|
318
|
-
}
|
|
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';
|
|
323
|
-
export default clerkMiddleware();
|
|
324
|
-
export const config = { matcher: ['/((?!_next|[^?]*\\\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico)).*)', '/(api|trpc)(.*)'] };
|
|
325
|
-
`);
|
|
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';
|
|
331
|
-
export async function authenticate(request: Request, secretKey: string, authorizedParties: string[]) {
|
|
332
|
-
return createClerkClient({ secretKey }).authenticateRequest(request, { authorizedParties });
|
|
333
|
-
}
|
|
334
|
-
`);
|
|
335
|
-
}
|
|
336
|
-
}
|
|
337
|
-
function addServiceWorkers(root, options) {
|
|
338
|
-
const has = (value) => options.products.includes(value);
|
|
339
|
-
if (has('realtime')) {
|
|
340
|
-
worker(root, 'realtime', { ...workerConfig(`${options.name}-realtime`), durable_objects: { bindings: [{ name: 'ROOMS', class_name: 'Room' }] },
|
|
341
|
-
migrations: [{ tag: 'v1', new_sqlite_classes: ['Room'] }] }, `import { DurableObject } from 'cloudflare:workers';
|
|
342
|
-
export class Room extends DurableObject<Env> {
|
|
343
|
-
async fetch(request: Request): Promise<Response> {
|
|
344
|
-
if (request.headers.get('Upgrade') !== 'websocket') return new Response('WebSocket required', { status: 426 });
|
|
345
|
-
const pair = new WebSocketPair();
|
|
346
|
-
this.ctx.acceptWebSocket(pair[1]);
|
|
347
|
-
return new Response(null, { status: 101, webSocket: pair[0] });
|
|
348
|
-
}
|
|
349
|
-
webSocketMessage(sender: WebSocket, message: string | ArrayBuffer) {
|
|
350
|
-
if ((typeof message === 'string' ? new TextEncoder().encode(message).byteLength : message.byteLength) > 16384) { sender.close(1009, 'Message too large'); return; }
|
|
351
|
-
this.ctx.getWebSockets().filter((socket) => socket !== sender).forEach((socket) => socket.send(message));
|
|
352
|
-
}
|
|
353
|
-
}
|
|
354
|
-
export default {
|
|
355
|
-
async fetch(): Promise<Response> {
|
|
356
|
-
// Authenticate and authorize room membership, then forward to env.ROOMS.getByName(room).fetch(request).
|
|
357
|
-
// Use short-lived single-use tickets for browser WebSockets. Never expose a long-lived service secret.
|
|
358
|
-
return new Response('Configure room authorization before enabling connections', { status: 501 });
|
|
359
|
-
},
|
|
360
|
-
};
|
|
361
|
-
`);
|
|
362
|
-
}
|
|
363
|
-
if (has('queues')) {
|
|
364
|
-
worker(root, 'jobs', { ...workerConfig(`${options.name}-jobs`), queues: {
|
|
365
|
-
producers: [{ binding: 'JOBS', queue: `${options.name}-jobs` }],
|
|
366
|
-
consumers: [{ queue: `${options.name}-jobs`, max_batch_size: 10, max_retries: 3, dead_letter_queue: `${options.name}-jobs-dlq` }],
|
|
367
|
-
} }, `export default {
|
|
368
|
-
async queue(batch: MessageBatch<unknown>): Promise<void> {
|
|
369
|
-
for (const message of batch.messages) {
|
|
370
|
-
// Add transactional idempotency and product processing before acknowledging.
|
|
371
|
-
console.error('Job handler requires product implementation', message.id);
|
|
372
|
-
message.retry({ delaySeconds: 60 });
|
|
373
|
-
}
|
|
374
|
-
},
|
|
375
|
-
};
|
|
376
|
-
`);
|
|
377
|
-
}
|
|
378
|
-
if (has('cron')) {
|
|
379
|
-
worker(root, 'scheduled', { ...workerConfig(`${options.name}-scheduled`), triggers: { crons: ['0 * * * *'] } }, `export default {
|
|
380
|
-
async scheduled(controller: ScheduledController): Promise<void> {
|
|
381
|
-
// Define a product schedule, idempotency key, overlap policy, and failure alerts before release.
|
|
382
|
-
console.log('Scheduled trigger', controller.scheduledTime);
|
|
383
|
-
},
|
|
384
|
-
};
|
|
385
|
-
`);
|
|
386
|
-
}
|
|
387
|
-
if (has('workflows')) {
|
|
388
|
-
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';
|
|
389
|
-
export class ApplicationWorkflow extends WorkflowEntrypoint<Env, { operationId: string }> {
|
|
390
|
-
async run(event: WorkflowEvent<{ operationId: string }>, step: WorkflowStep) {
|
|
391
|
-
return step.do('validate', { retries: { limit: 3, delay: '10 seconds', backoff: 'exponential' } }, async () => {
|
|
392
|
-
if (!event.payload.operationId) throw new Error('operationId required');
|
|
393
|
-
return { operationId: event.payload.operationId };
|
|
394
|
-
});
|
|
395
|
-
}
|
|
396
|
-
}
|
|
397
|
-
export default { async fetch() { return new Response('Use an authorized server binding to create workflows', { status: 403 }); } };
|
|
398
|
-
`);
|
|
399
|
-
}
|
|
400
|
-
}
|
|
401
|
-
function addProviderPackages(root, options) {
|
|
402
|
-
const has = (value) => options.products.includes(value);
|
|
403
|
-
const providers = {
|
|
404
|
-
openai: ['ai', "import OpenAI from 'openai';\nexport function createAI(apiKey: string) { return new OpenAI({ apiKey }); }\n", { openai: '^6.0.0' }],
|
|
405
|
-
'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' }],
|
|
406
|
-
resend: ['email', "import { Resend } from 'resend';\nexport function createEmail(apiKey: string) { return new Resend(apiKey); }\n", { resend: '^6.0.0' }],
|
|
407
|
-
stripe: ['billing', "import Stripe from 'stripe';\nexport function createBilling(apiKey: string) { return new Stripe(apiKey); }\n", { stripe: '^20.0.0' }],
|
|
408
|
-
sentry: ['observability', "// Initialize the platform-specific Sentry SDK in each application's runtime entrypoint.\nexport const redactFields = ['authorization', 'cookie', 'password', 'token'];\n", {}],
|
|
409
|
-
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' }],
|
|
410
|
-
styling: ['styling', "export const styling = { colors: { foreground: '#171717', background: '#ffffff' }, spacing: { small: 4, medium: 8, large: 16 } } as const;\n", {}],
|
|
411
|
-
'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", {}],
|
|
412
|
-
};
|
|
413
|
-
for (const value of options.products) {
|
|
414
|
-
const provider = providers[value];
|
|
415
|
-
if (provider)
|
|
416
|
-
sharedPackage(root, ...provider);
|
|
417
|
-
}
|
|
418
|
-
if (has('basin')) {
|
|
419
|
-
sharedPackage(root, has('posthog') ? 'basin' : 'analytics', `export interface EventStream { send(events: Record<string, unknown>[]): Promise<void> }
|
|
420
|
-
export async function recordEvents(stream: EventStream, events: Record<string, unknown>[]) { await stream.send(events); }
|
|
421
|
-
`, {});
|
|
422
|
-
}
|
|
423
|
-
if (has('revenuecat')) {
|
|
424
|
-
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');
|
|
425
|
-
}
|
|
426
|
-
}
|
|
427
|
-
function writeStandardRoot(root, options) {
|
|
428
|
-
const has = (value) => options.products.includes(value);
|
|
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
|
-
};
|
|
442
|
-
patchPackage(root, 'package.json', { name: options.name, packageManager: PACKAGE_MANAGER, engines: { node: '>=22.13.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 } } });
|
|
448
|
-
put(root, 'scripts/audit.mjs', `import { spawnSync } from 'node:child_process';
|
|
449
|
-
const base = process.env.AUDIT_BASE;
|
|
450
|
-
const hasBase = base || ['origin/main', 'main'].find((ref) => spawnSync('git', ['rev-parse', '--verify', ref], { stdio: 'ignore' }).status === 0);
|
|
451
|
-
const args = hasBase ? ['exec', 'fallow', 'audit', '--base', hasBase] : ['exec', 'fallow', '--fail-on-issues'];
|
|
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 });
|
|
455
|
-
if (result.error) throw result.error;
|
|
456
|
-
process.exitCode = result.status ?? 1;
|
|
457
|
-
`);
|
|
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'] });
|
|
462
|
-
const turbo = readJson(root, 'turbo.json');
|
|
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'];
|
|
464
|
-
turbo.tasks = { ...turbo.tasks, test: { dependsOn: ['^build'], outputs: ['coverage/**'] },
|
|
465
|
-
build: { ...turbo.tasks.build, outputs: ['.next/**', '!.next/cache/**', '.open-next/**', 'dist/**', '.output/**'] } };
|
|
466
|
-
json(root, 'turbo.json', turbo);
|
|
467
|
-
const workspaceFile = path.join(root, 'pnpm-workspace.yaml');
|
|
468
|
-
const workspace = readFileSync(workspaceFile, 'utf8');
|
|
469
|
-
// Keep the upstream package globs and other policies; translate only the build approval section for pnpm 10.
|
|
470
|
-
const policy = 'onlyBuiltDependencies:\n' + ['esbuild', 'sharp', 'unrs-resolver', 'workerd', ...(has('electron') ? ['electron'] : [])].map((name) => ` - ${name}\n`).join('');
|
|
471
|
-
const section = /(?:allowBuilds|onlyBuiltDependencies):\n(?:[ \t]+[^\n]*\n|[ \t]*\n)*/;
|
|
472
|
-
put(root, 'pnpm-workspace.yaml', section.test(workspace) ? workspace.replace(section, policy) : workspace + '\n' + policy);
|
|
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);
|
|
483
|
-
const environment = [
|
|
484
|
-
has('neon') ? 'DATABASE_URL=' : '', has('clerk') ? 'NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=\nCLERK_SECRET_KEY=' : '',
|
|
485
|
-
has('better-auth') ? 'BETTER_AUTH_SECRET=\nBETTER_AUTH_URL=' : '',
|
|
486
|
-
has('openai') || has('ai-sdk') ? 'OPENAI_API_KEY=' : '', has('resend') ? 'RESEND_API_KEY=' : '',
|
|
487
|
-
has('stripe') ? 'STRIPE_SECRET_KEY=\nSTRIPE_WEBHOOK_SECRET=' : '',
|
|
488
|
-
has('posthog') ? 'POSTHOG_API_KEY=\nPOSTHOG_HOST=' : '', has('sentry') ? 'SENTRY_DSN=' : '',
|
|
489
|
-
].filter(Boolean).join('\n');
|
|
490
|
-
put(root, '.env.example', environment + '\n');
|
|
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);
|
|
497
|
-
const agentsFile = path.join(root, 'AGENTS.md');
|
|
498
|
-
const existingAgents = existsSync(agentsFile) && has('next') ? readFileSync(agentsFile, 'utf8') + '\n' : '';
|
|
499
|
-
put(root, 'AGENTS.md', existingAgents + `# Anhedral delivery standard
|
|
500
|
-
|
|
501
|
-
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.
|
|
502
|
-
|
|
503
|
-
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.
|
|
504
|
-
|
|
505
|
-
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.
|
|
506
|
-
|
|
507
|
-
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.
|
|
508
|
-
|
|
509
|
-
Developer tooling includes Cloudflare’s security-audit skill (https://github.com/cloudflare/security-audit-skill) for repository security reviews.
|
|
510
|
-
|
|
511
|
-
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.
|
|
512
|
-
`);
|
|
513
|
-
put(root, 'README.md', `# ${options.name}
|
|
514
|
-
|
|
515
|
-
Initialized from the official shadcn pnpm/Turborepo monorepo, with the Anhedral Application Stack & Delivery Standard.
|
|
516
|
-
|
|
517
|
-
Selected: ${options.products.join(', ')}. ${has('next') ? `Next.js hosting: ${options.hosting === 'cloudflare' ? 'Cloudflare Workers + OpenNext' : 'Vercel (explicit architecture exception)'}.` : ''}
|
|
518
|
-
|
|
519
|
-
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.
|
|
520
|
-
|
|
521
|
-
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.
|
|
522
|
-
|
|
523
|
-
${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' : ''}
|
|
524
|
-
${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' : ''}
|
|
525
|
-
${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' : ''}
|
|
526
|
-
${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' : ''}
|
|
527
|
-
${has('r2') ? 'FILES is a private R2 binding. Implement authentication and file-level authorization before exposing file routes. Clients never receive R2 credentials.\n' : ''}
|
|
528
|
-
${has('realtime') ? 'Durable Object rooms support hibernating WebSockets; the public entrypoint remains disabled until room authorization and short-lived tickets are implemented.\n' : ''}
|
|
529
|
-
${has('queues') ? 'Queue processing retries unimplemented jobs and uses a dead-letter queue. Add product processing and transactional idempotency before acknowledging messages.\n' : ''}
|
|
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' : ''}
|
|
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.
|
|
532
|
-
|
|
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.
|
|
534
|
-
`);
|
|
535
|
-
put(root, '.github/workflows/check.yml', `name: Check
|
|
536
|
-
on:
|
|
537
|
-
pull_request:
|
|
538
|
-
push:
|
|
539
|
-
branches: [main]
|
|
540
|
-
permissions:
|
|
541
|
-
contents: read
|
|
542
|
-
jobs:
|
|
543
|
-
check:
|
|
544
|
-
runs-on: ubuntu-latest
|
|
545
|
-
timeout-minutes: 20
|
|
546
|
-
steps:
|
|
547
|
-
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10
|
|
548
|
-
with:
|
|
549
|
-
fetch-depth: 0
|
|
550
|
-
persist-credentials: false
|
|
551
|
-
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271
|
|
552
|
-
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38
|
|
553
|
-
with:
|
|
554
|
-
node-version: '22'
|
|
555
|
-
cache: pnpm
|
|
556
|
-
- run: pnpm install --frozen-lockfile
|
|
557
|
-
- run: pnpm lint && pnpm typecheck && pnpm audit:deps
|
|
558
|
-
- name: Audit pull request
|
|
559
|
-
if: github.event_name == 'pull_request'
|
|
560
|
-
env:
|
|
561
|
-
AUDIT_BASE: \${{ github.event.pull_request.base.sha }}
|
|
562
|
-
run: pnpm exec fallow audit --base "$AUDIT_BASE"
|
|
563
|
-
- name: Audit branch
|
|
564
|
-
if: github.event_name != 'pull_request'
|
|
565
|
-
run: pnpm audit:full
|
|
566
|
-
- run: pnpm test && pnpm build${workerBuildSuffix(options)}
|
|
567
|
-
`);
|
|
568
|
-
}
|
|
10
|
+
import { createSetupPlan } from './capabilities.js';
|
|
11
|
+
import { bootstrapWorkspace } from './scaffold/bootstrap.js';
|
|
12
|
+
import { composeStack } from './scaffold/compose.js';
|
|
13
|
+
import { parseStandardOptions } from './registry/options.js';
|
|
14
|
+
import { STANDARD_USAGE } from './registry/usage.js';
|
|
15
|
+
export { parseStandardOptions, STANDARD_USAGE };
|
|
16
|
+
export { STANDARD_PRODUCTS } from './stack-registry.js';
|
|
569
17
|
const defaultRunner = (command, args, cwd) => execFile(command, args, cwd);
|
|
570
18
|
function assertBootstrapPlatform(run) {
|
|
571
19
|
if (process.platform === 'win32' && run === defaultRunner) {
|
|
@@ -595,40 +43,48 @@ export async function scaffoldStandardProject(options, run = defaultRunner) {
|
|
|
595
43
|
if (unexpected.length)
|
|
596
44
|
throw new Error(`Destination must be empty (apart from .git/.gitignore): ${unexpected[0]}`);
|
|
597
45
|
const commitPaths = [];
|
|
598
|
-
await runStagedTransaction(root, {
|
|
46
|
+
await runStagedTransaction(root, {
|
|
47
|
+
commitPaths,
|
|
48
|
+
seedPaths: existsSync(path.join(root, '.gitignore')) ? ['.gitignore'] : [],
|
|
599
49
|
prepare: () => {
|
|
600
50
|
const entries = readdirSync(root).filter((name) => !['.git', '.gitignore', '.anhedral.lock', '.anhedral-txn'].includes(name));
|
|
601
51
|
if (entries.length)
|
|
602
52
|
throw new Error('Destination changed before generation.');
|
|
603
53
|
},
|
|
604
54
|
build: async (stageRoot) => {
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
if (
|
|
610
|
-
|
|
611
|
-
const seededIgnore = existsSync(path.join(stageRoot, '.gitignore')) ? readFileSync(path.join(stageRoot, '.gitignore'), 'utf8') : '';
|
|
612
|
-
for (const entry of readdirSync(generated)) {
|
|
613
|
-
if (['.git', 'node_modules', '.next', '.turbo'].includes(entry))
|
|
614
|
-
continue;
|
|
615
|
-
cpSync(path.join(generated, entry), path.join(stageRoot, entry), { recursive: true, filter: (source) => !['node_modules', '.git', '.next', '.turbo'].includes(path.basename(source)) });
|
|
616
|
-
}
|
|
617
|
-
rmSync(bootstrap, { recursive: true, force: true });
|
|
618
|
-
if (seededIgnore)
|
|
619
|
-
appendGitignore(stageRoot, seededIgnore.split(/\r?\n/));
|
|
620
|
-
await applyStandardOverlays(stageRoot, options);
|
|
55
|
+
bootstrapWorkspace(stageRoot, options.name, run);
|
|
56
|
+
await composeStack(stageRoot, options, plan.registry);
|
|
57
|
+
if (options.products.includes('next') && options.hosting === 'vercel')
|
|
58
|
+
registerVercelApplication(stageRoot);
|
|
59
|
+
if (readdirSync(path.join(stageRoot, 'apps')).some((app) => existsSync(path.join(stageRoot, 'apps', app, 'wrangler.jsonc'))))
|
|
60
|
+
registerApplication(stageRoot);
|
|
621
61
|
run('pnpm', ['install', '--lockfile-only', '--no-frozen-lockfile'], stageRoot);
|
|
62
|
+
if (configureSecurityBackports(stageRoot))
|
|
63
|
+
run('pnpm', ['install', '--lockfile-only', '--no-frozen-lockfile'], stageRoot);
|
|
64
|
+
recordScaffoldFiles(stageRoot);
|
|
622
65
|
commitPaths.push(...readdirSync(stageRoot).filter((name) => name !== 'node_modules'));
|
|
623
66
|
},
|
|
624
67
|
afterCommit: () => {
|
|
625
|
-
if (options.initializeGit
|
|
626
|
-
|
|
68
|
+
if (options.initializeGit) {
|
|
69
|
+
const checkout = spawnSync('git', ['rev-parse', '--show-toplevel'], {
|
|
70
|
+
cwd: root,
|
|
71
|
+
encoding: 'utf8',
|
|
72
|
+
});
|
|
73
|
+
if (checkout.status !== 0 || realpathSync(checkout.stdout.trim()) !== realpathSync(root))
|
|
74
|
+
run('git', ['init'], root);
|
|
75
|
+
}
|
|
627
76
|
if (!options.skipInstall)
|
|
628
77
|
run('pnpm', ['install', '--frozen-lockfile'], root);
|
|
629
78
|
},
|
|
630
79
|
});
|
|
631
|
-
console.log(options.json
|
|
80
|
+
console.log(options.json
|
|
81
|
+
? JSON.stringify({ ...plan, generated: true })
|
|
82
|
+
: `Created ${root}. Next: cd ${JSON.stringify(root)}, then pnpm dev. Implement the product flows, configure selected resources using README.md and anhedral.setup.json, and run pnpm check. Record stack decisions in docs/architecture.md and exercised acceptance criteria in docs/verification.md.`);
|
|
83
|
+
if (!options.json) {
|
|
84
|
+
console.log('\nHelp keep Anhedral Init free and open source.');
|
|
85
|
+
console.log('Sponsor ongoing development, maintenance, and documentation.');
|
|
86
|
+
console.log('Become a sponsor: https://github.com/anhedral/anhedral-init/blob/main/docs/sponsorships.md');
|
|
87
|
+
}
|
|
632
88
|
}
|
|
633
89
|
finally {
|
|
634
90
|
if (created && existsSync(root) && readdirSync(root).length === 0)
|