anhedral 0.1.10 → 0.4.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/CHANGELOG.md +119 -0
- package/CONTRIBUTING.md +73 -0
- package/LICENSE +196 -17
- package/README.md +202 -145
- package/SECURITY.md +37 -0
- package/SKILL.md +244 -0
- package/bin/anhedral.js +1 -1
- package/dist/architecture/contributions.d.ts +28 -0
- package/dist/architecture/contributions.js +125 -0
- package/dist/architecture/index.d.ts +12 -0
- package/dist/architecture/index.js +6 -0
- package/dist/architecture/manifest.d.ts +53 -0
- package/dist/architecture/manifest.js +283 -0
- package/dist/architecture/modules.d.ts +37 -0
- package/dist/architecture/modules.js +184 -0
- package/dist/architecture/plan.d.ts +58 -0
- package/dist/architecture/plan.js +145 -0
- package/dist/architecture/templates.d.ts +10 -0
- package/dist/architecture/templates.js +23 -0
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +179 -0
- package/dist/cli.d.ts +24 -2
- package/dist/cli.js +189 -21
- package/dist/composers/root.d.ts +6 -0
- package/dist/composers/root.js +35 -0
- package/dist/dependencies.d.ts +31 -9
- package/dist/dependencies.js +227 -60
- package/dist/index.d.ts +8 -2
- package/dist/index.js +5 -31
- package/dist/print.js +15 -0
- package/dist/prompts.d.ts +5 -0
- package/dist/prompts.js +19 -0
- package/dist/render.d.ts +11 -0
- package/dist/render.js +74 -0
- package/dist/scaffold.d.ts +64 -12
- package/dist/scaffold.js +1726 -1150
- package/dist/template-source.d.ts +20 -0
- package/dist/template-source.js +180 -0
- package/dist/templates/api.d.ts +2 -0
- package/dist/templates/api.js +3122 -0
- package/dist/templates/assets-private-proxy.d.ts +3 -0
- package/dist/templates/assets-private-proxy.js +277 -0
- package/dist/templates/desktop.d.ts +2 -0
- package/dist/templates/desktop.js +639 -0
- package/dist/templates/electron-updater.d.ts +3 -0
- package/dist/templates/electron-updater.js +358 -0
- package/dist/templates/extension.d.ts +1 -1
- package/dist/templates/extension.js +322 -248
- package/dist/templates/mobile.d.ts +2 -0
- package/dist/templates/mobile.js +720 -0
- package/dist/templates/shared.d.ts +2 -0
- package/dist/templates/shared.js +520 -0
- package/dist/templates/web.d.ts +2 -0
- package/dist/templates/web.js +412 -0
- package/dist/toolchain.d.ts +0 -8
- package/dist/toolchain.js +0 -21
- package/dist/transaction.d.ts +22 -0
- package/dist/transaction.js +822 -0
- package/dist/ui.d.ts +33 -0
- package/dist/ui.js +199 -0
- package/dist/util.d.ts +1 -5
- package/dist/util.js +70 -159
- package/dist/version.d.ts +2 -0
- package/dist/version.js +2 -0
- package/dist/workspace-config.d.ts +11 -0
- package/dist/workspace-config.js +372 -0
- package/docs/conventions.md +92 -0
- package/docs/master-stack-map.md +192 -0
- package/docs/output-tree-contract.md +482 -0
- package/docs/references/manual-scaffolding.md +403 -0
- package/docs/references/provisioning.md +220 -0
- package/package.json +57 -13
- package/templates/api-fastify/apps/api/tsconfig.json +20 -0
- package/templates/catalog.json +23 -0
- package/templates/db-drizzle/packages/db/tsconfig.json +14 -0
- package/templates/desktop-electron/apps/desktop/tsconfig.json +37 -0
- package/templates/extension-wxt/apps/extension/tsconfig.json +41 -0
- package/templates/mobile-expo/apps/mobile/expo-env.d.ts +1 -0
- package/templates/web-next/apps/web/next-env.d.ts +6 -0
- package/dist/branding.d.ts +0 -5
- package/dist/branding.js +0 -17
- package/dist/commands.d.ts +0 -7
- package/dist/commands.js +0 -24
- package/dist/templates/backend.d.ts +0 -2
- package/dist/templates/backend.js +0 -2115
- package/dist/templates/frontend.d.ts +0 -2
- package/dist/templates/frontend.js +0 -1268
package/dist/scaffold.js
CHANGED
|
@@ -1,1305 +1,1881 @@
|
|
|
1
|
-
import { readdirSync } from 'node:fs';
|
|
1
|
+
import { chmodSync, copyFileSync, lstatSync, mkdirSync, readFileSync, readdirSync, rmdirSync, rmSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { env } from 'node:process';
|
|
4
4
|
import { anhedralPrint } from './print.js';
|
|
5
|
-
import { appendGitignore, writeFile } from './util.js';
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
5
|
+
import { appendGitignore, execFile, writeFile } from './util.js';
|
|
6
|
+
import { MOBILE_NODE_ENGINE, NODE_ENGINE, PACKAGE_MANAGER, ROOT_DEPENDENCIES, TOOLCHAIN_DEPENDENCIES } from './dependencies.js';
|
|
7
|
+
import { resolveToolchainChannel } from './toolchain.js';
|
|
8
|
+
import { scaffoldApi } from './templates/api.js';
|
|
9
|
+
import { r2BucketName, scaffoldAssetsPrivateProxy } from './templates/assets-private-proxy.js';
|
|
10
|
+
import { scaffoldDesktop } from './templates/desktop.js';
|
|
11
|
+
import { desktopUpdatesBucketName, scaffoldElectronUpdater, } from './templates/electron-updater.js';
|
|
10
12
|
import { scaffoldExtension } from './templates/extension.js';
|
|
11
|
-
import {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
];
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
13
|
+
import { scaffoldMobile } from './templates/mobile.js';
|
|
14
|
+
import { scaffoldSharedPackages } from './templates/shared.js';
|
|
15
|
+
import { scaffoldWeb } from './templates/web.js';
|
|
16
|
+
import { assertPackageName, markdownHeading } from './render.js';
|
|
17
|
+
import { runStagedTransaction } from './transaction.js';
|
|
18
|
+
import { buildGenerationPlan, createManifest, hashContent, readManifest, resolveModules, serializeManifest, } from './architecture/index.js';
|
|
19
|
+
import { GENERATOR_VERSION } from './version.js';
|
|
20
|
+
import { collectModuleContributions } from './architecture/contributions.js';
|
|
21
|
+
import { composeRootEnvironment, composeVercelCrons } from './composers/root.js';
|
|
22
|
+
import { assertTemplateProvenance, materializeTemplates, } from './template-source.js';
|
|
23
|
+
import { desiredWorkspacePolicy, mergeWorkspaceFile, } from './workspace-config.js';
|
|
24
|
+
import { UI_TARGETS, installUiComponents, mergeUiInstalls, resolveUiInstalls, uiInstallKey, } from './ui.js';
|
|
25
|
+
export function supportsMobileInstallNode(version) {
|
|
26
|
+
const match = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(version);
|
|
27
|
+
if (!match)
|
|
28
|
+
return false;
|
|
29
|
+
const major = Number(match[1]);
|
|
30
|
+
const minor = Number(match[2]);
|
|
31
|
+
if (major === 22)
|
|
32
|
+
return minor >= 13;
|
|
33
|
+
if (major === 24)
|
|
34
|
+
return minor >= 3;
|
|
35
|
+
return major >= 25;
|
|
36
|
+
}
|
|
37
|
+
function assertInstallNodeCompatibility(options) {
|
|
38
|
+
if (options.apps.mobile
|
|
39
|
+
&& !options.skipInstall
|
|
40
|
+
&& !options.dryRun
|
|
41
|
+
&& !supportsMobileInstallNode(process.versions.node)) {
|
|
42
|
+
throw new Error(`Installing an Expo mobile workspace requires Node ${MOBILE_NODE_ENGINE}; current Node is ${process.versions.node}. `
|
|
43
|
+
+ 'Switch Node versions or rerun with --skip-install.');
|
|
39
44
|
}
|
|
40
|
-
console.log('');
|
|
41
45
|
}
|
|
42
|
-
|
|
46
|
+
const ACTIONS = {
|
|
47
|
+
checkout: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10',
|
|
48
|
+
pnpm: 'pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271',
|
|
49
|
+
node: 'actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38',
|
|
50
|
+
};
|
|
51
|
+
const ROOT_MERGEABLE_FILES = new Set([
|
|
52
|
+
'.env.example',
|
|
53
|
+
'.gitignore',
|
|
54
|
+
'.vercelignore',
|
|
55
|
+
'package.json',
|
|
56
|
+
'pnpm-workspace.yaml',
|
|
57
|
+
'turbo.json',
|
|
58
|
+
'vercel.json',
|
|
59
|
+
]);
|
|
60
|
+
const GENERATED_CACHE_NAMES = new Set([
|
|
61
|
+
'.git', '.next', '.output', '.turbo', '.wxt', 'dist', 'node_modules', 'release',
|
|
62
|
+
]);
|
|
63
|
+
function lstatIfPresent(target) {
|
|
64
|
+
try {
|
|
65
|
+
return lstatSync(target);
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
if (error.code === 'ENOENT')
|
|
69
|
+
return null;
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
function pathEntryExists(target) {
|
|
74
|
+
return lstatIfPresent(target) !== null;
|
|
75
|
+
}
|
|
76
|
+
function normalizedFileMode(mode) {
|
|
77
|
+
// Windows exposes a synthetic subset of stat mode bits that cannot represent
|
|
78
|
+
// Unix permissions reliably. Keep the manifest portable by treating those
|
|
79
|
+
// permissions as unknown and disabling ownership checks on that platform.
|
|
80
|
+
return process.platform === 'win32' ? null : Number(mode) & 0o777;
|
|
81
|
+
}
|
|
82
|
+
function displayFileMode(mode) {
|
|
83
|
+
return `0o${mode.toString(8).padStart(3, '0')}`;
|
|
84
|
+
}
|
|
85
|
+
function assertManagedFileMode(relativePath, record, actualStat) {
|
|
86
|
+
if (record.ownership !== 'managed' || record.mode === null)
|
|
87
|
+
return;
|
|
88
|
+
const actualMode = normalizedFileMode(actualStat.mode);
|
|
89
|
+
if (actualMode === null || actualMode === record.mode)
|
|
90
|
+
return;
|
|
91
|
+
throw new Error(`Managed file mode has user modifications: ${relativePath} `
|
|
92
|
+
+ `(expected ${displayFileMode(record.mode)}, found ${displayFileMode(actualMode)}).`);
|
|
93
|
+
}
|
|
94
|
+
function assertManagedFileModes(root, manifest) {
|
|
95
|
+
for (const [relativePath, record] of Object.entries(manifest.files)) {
|
|
96
|
+
if (record.ownership !== 'managed' || record.mode === null)
|
|
97
|
+
continue;
|
|
98
|
+
const actualStat = lstatIfPresent(path.join(root, relativePath));
|
|
99
|
+
if (!actualStat || !actualStat.isFile() || actualStat.isSymbolicLink())
|
|
100
|
+
continue;
|
|
101
|
+
assertManagedFileMode(relativePath, record, actualStat);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
function isTransactionMetadata(relativePath) {
|
|
105
|
+
if (relativePath.includes('/'))
|
|
106
|
+
return false;
|
|
107
|
+
return relativePath === '.anhedral-txn'
|
|
108
|
+
|| relativePath === '.anhedral.lock'
|
|
109
|
+
|| relativePath === '.anhedral.lock.reclaim'
|
|
110
|
+
|| /^\.anhedral\.lock\..+\.tmp$/.test(relativePath)
|
|
111
|
+
|| relativePath === '.anhedral-journal.json'
|
|
112
|
+
|| /^\.anhedral-journal\.json\.tmp-.+$/.test(relativePath);
|
|
113
|
+
}
|
|
114
|
+
function projectOptions(options) {
|
|
43
115
|
return {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
116
|
+
projectName: options.projectName,
|
|
117
|
+
displayName: options.displayName,
|
|
118
|
+
apps: options.apps,
|
|
119
|
+
features: options.features,
|
|
120
|
+
skipInstall: options.skipInstall || options.dryRun,
|
|
121
|
+
nativeStyling: options.nativeStyling,
|
|
48
122
|
};
|
|
49
123
|
}
|
|
50
|
-
function
|
|
51
|
-
const
|
|
124
|
+
function selectionsFromModules(modules) {
|
|
125
|
+
const selected = new Set(modules);
|
|
52
126
|
return {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
backend: 'fastify',
|
|
60
|
-
auth: options.auth,
|
|
61
|
-
payments: options.payments,
|
|
62
|
-
storage: 'cloudflare_r2_via_aws_s3_sdk',
|
|
63
|
-
database: 'neon_plus_drizzle',
|
|
64
|
-
skills: getSkillCommands(),
|
|
65
|
-
outputs: {
|
|
66
|
-
monorepo: true,
|
|
67
|
-
package_manager: 'pnpm',
|
|
68
|
-
dependency_manifest: dependencyManifest(),
|
|
69
|
-
toolchain_channel: options.toolchainChannel,
|
|
70
|
-
toolchain: getUsedToolchain(options, toolchain),
|
|
71
|
-
generated_paths: generatedPaths,
|
|
127
|
+
apps: {
|
|
128
|
+
web: selected.has('web'),
|
|
129
|
+
mobile: selected.has('mobile'),
|
|
130
|
+
api: selected.has('api'),
|
|
131
|
+
desktop: selected.has('desktop'),
|
|
132
|
+
extension: selected.has('extension'),
|
|
72
133
|
},
|
|
134
|
+
features: {
|
|
135
|
+
database: selected.has('db'),
|
|
136
|
+
auth: selected.has('auth'),
|
|
137
|
+
billing: selected.has('billing'),
|
|
138
|
+
storage: selected.has('storage'),
|
|
139
|
+
nativeSubscriptions: selected.has('native-subscriptions'),
|
|
140
|
+
electronUpdater: selected.has('electron-updater'),
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
function canonicalInitOptions(options) {
|
|
145
|
+
assertPackageName(options.projectName);
|
|
146
|
+
const resolution = resolveModules(options.modules);
|
|
147
|
+
return {
|
|
148
|
+
...options,
|
|
149
|
+
uiComponents: options.uiComponents ?? [],
|
|
150
|
+
nativeStyling: options.nativeStyling ?? 'nativewind',
|
|
151
|
+
modules: [...resolution.requestedModules],
|
|
152
|
+
...selectionsFromModules(resolution.resolvedModules),
|
|
73
153
|
};
|
|
74
154
|
}
|
|
75
|
-
function
|
|
76
|
-
|
|
155
|
+
function optionsFromManifest(manifest, addOptions, modules) {
|
|
156
|
+
const resolution = resolveModules(modules);
|
|
157
|
+
return {
|
|
158
|
+
projectName: manifest.project.name,
|
|
159
|
+
displayName: manifest.project.displayName,
|
|
160
|
+
modules: [...resolution.requestedModules],
|
|
161
|
+
...selectionsFromModules(resolution.resolvedModules),
|
|
162
|
+
skipInstall: addOptions.skipInstall,
|
|
163
|
+
dryRun: addOptions.dryRun,
|
|
164
|
+
json: addOptions.json,
|
|
165
|
+
toolchainChannel: addOptions.toolchainChannel ?? toolchainChannelFromManifest(manifest),
|
|
166
|
+
uiComponents: [],
|
|
167
|
+
nativeStyling: manifest.ui.nativeStyling,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
function toolchainChannelFromManifest(manifest) {
|
|
171
|
+
return resolveToolchainChannel(manifest.toolchain);
|
|
172
|
+
}
|
|
173
|
+
export function isSupportedProjectUpgrade(fromVersion, toVersion) {
|
|
174
|
+
return fromVersion === '0.3.0' && toVersion === '0.4.0';
|
|
77
175
|
}
|
|
78
176
|
function ensureScaffoldRoot(root) {
|
|
79
|
-
const
|
|
80
|
-
|
|
177
|
+
const rootStat = lstatIfPresent(root);
|
|
178
|
+
if (!rootStat)
|
|
179
|
+
throw new Error(`Scaffold destination is not a directory: ${root}`);
|
|
180
|
+
if (rootStat.isSymbolicLink())
|
|
181
|
+
throw new Error(`Refusing scaffold destination that is a symbolic link: ${root}`);
|
|
182
|
+
if (!rootStat.isDirectory())
|
|
183
|
+
throw new Error(`Scaffold destination is not a directory: ${root}`);
|
|
184
|
+
const allowed = new Set(['.git', '.gitignore', '.DS_Store', '.anhedral.lock']);
|
|
185
|
+
const unexpected = readdirSync(root).filter((entry) => !allowed.has(entry));
|
|
81
186
|
if (unexpected.length > 0) {
|
|
82
|
-
throw new Error(`Current directory is not empty.
|
|
187
|
+
throw new Error(`Current directory is not empty. Found: ${unexpected.join(', ')}`);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
function selectedAppFilters(apps) {
|
|
191
|
+
return Object.entries(apps)
|
|
192
|
+
.filter(([, enabled]) => enabled)
|
|
193
|
+
.map(([name]) => `./apps/${name}`);
|
|
194
|
+
}
|
|
195
|
+
function rootScripts(options) {
|
|
196
|
+
const filters = selectedAppFilters(options.apps);
|
|
197
|
+
const primaryClient = filters.find((entry) => entry !== './apps/api');
|
|
198
|
+
const primaryFilters = primaryClient
|
|
199
|
+
? [primaryClient, ...(options.apps.api ? ['./apps/api'] : [])]
|
|
200
|
+
: filters.slice(0, 1);
|
|
201
|
+
const scripts = {
|
|
202
|
+
dev: primaryFilters.length ? `turbo dev --parallel ${primaryFilters.map((entry) => `--filter=${entry}`).join(' ')}` : 'echo "No app surfaces selected."',
|
|
203
|
+
...(filters.length > primaryFilters.length
|
|
204
|
+
? { 'dev:all': `turbo dev --parallel ${filters.map((entry) => `--filter=${entry}`).join(' ')}` }
|
|
205
|
+
: {}),
|
|
206
|
+
build: 'turbo build',
|
|
207
|
+
typecheck: 'turbo typecheck',
|
|
208
|
+
};
|
|
209
|
+
const verify = [];
|
|
210
|
+
if (options.apps.web) {
|
|
211
|
+
scripts['dev:web'] = 'pnpm --filter ./apps/web dev';
|
|
212
|
+
scripts['verify:web'] = 'pnpm --filter ./apps/web typecheck && pnpm --filter ./apps/web build';
|
|
213
|
+
verify.push('pnpm verify:web');
|
|
214
|
+
}
|
|
215
|
+
if (options.apps.web || options.apps.api) {
|
|
216
|
+
scripts['deploy:vercel:link'] = `pnpm dlx vercel@${TOOLCHAIN_DEPENDENCIES.vercel} link`;
|
|
217
|
+
scripts['deploy:vercel:preview'] = `pnpm dlx vercel@${TOOLCHAIN_DEPENDENCIES.vercel} deploy`;
|
|
218
|
+
scripts['deploy:vercel:production'] = `pnpm dlx vercel@${TOOLCHAIN_DEPENDENCIES.vercel} deploy --prod`;
|
|
219
|
+
scripts['deploy:vercel:inspect'] = `pnpm dlx vercel@${TOOLCHAIN_DEPENDENCIES.vercel} inspect`;
|
|
220
|
+
scripts['deploy:vercel:domain:inspect'] = `pnpm dlx vercel@${TOOLCHAIN_DEPENDENCIES.vercel} domains inspect`;
|
|
221
|
+
}
|
|
222
|
+
if (options.apps.mobile) {
|
|
223
|
+
scripts['dev:mobile'] = 'pnpm --filter ./apps/mobile dev';
|
|
224
|
+
scripts['verify:mobile'] = 'pnpm --filter ./apps/mobile typecheck && pnpm --filter ./apps/mobile build:web';
|
|
225
|
+
verify.push('pnpm verify:mobile');
|
|
226
|
+
const eas = `pnpm --dir apps/mobile dlx eas-cli@${TOOLCHAIN_DEPENDENCIES['eas-cli']}`;
|
|
227
|
+
scripts['mobile:eas:login'] = `${eas} login`;
|
|
228
|
+
scripts['mobile:eas:init'] = `${eas} init`;
|
|
229
|
+
scripts['mobile:build:internal:ios'] = `${eas} build --platform ios --profile preview`;
|
|
230
|
+
scripts['mobile:build:internal:android'] = `${eas} build --platform android --profile preview`;
|
|
231
|
+
scripts['mobile:build:production:ios'] = `${eas} build --platform ios --profile production`;
|
|
232
|
+
scripts['mobile:build:production:android'] = `${eas} build --platform android --profile production`;
|
|
233
|
+
scripts['mobile:submit:ios'] = `${eas} submit --platform ios --profile production --latest`;
|
|
234
|
+
scripts['mobile:submit:android'] = `${eas} submit --platform android --profile production --latest`;
|
|
235
|
+
}
|
|
236
|
+
if (options.apps.api) {
|
|
237
|
+
scripts['dev:api'] = 'pnpm --filter ./apps/api dev';
|
|
238
|
+
scripts['verify:api'] = 'pnpm --filter ./apps/api test:coverage && pnpm --filter ./apps/api build';
|
|
239
|
+
verify.push('pnpm verify:api');
|
|
83
240
|
}
|
|
241
|
+
if (options.apps.desktop) {
|
|
242
|
+
scripts['dev:desktop'] = 'pnpm --filter ./apps/desktop dev';
|
|
243
|
+
scripts['desktop:build'] = 'pnpm --filter ./apps/desktop package';
|
|
244
|
+
scripts['verify:desktop'] = 'pnpm --filter ./apps/desktop typecheck && pnpm --filter ./apps/desktop build';
|
|
245
|
+
verify.push('pnpm verify:desktop');
|
|
246
|
+
}
|
|
247
|
+
if (options.features.electronUpdater) {
|
|
248
|
+
const bucketName = desktopUpdatesBucketName(options.projectName);
|
|
249
|
+
const wrangler = `pnpm dlx wrangler@${TOOLCHAIN_DEPENDENCIES.wrangler}`;
|
|
250
|
+
scripts['desktop:updates:cloudflare:login'] = `${wrangler} login`;
|
|
251
|
+
scripts['desktop:updates:bucket:create'] = `${wrangler} r2 bucket create ${bucketName}`;
|
|
252
|
+
scripts['desktop:updates:worker:check'] = 'pnpm --filter ./apps/desktop-updater-worker check';
|
|
253
|
+
scripts['desktop:updates:worker:dev'] = 'pnpm --filter ./apps/desktop-updater-worker dev';
|
|
254
|
+
scripts['desktop:updates:worker:deploy'] = 'pnpm --filter ./apps/desktop-updater-worker deploy';
|
|
255
|
+
scripts['desktop:updates:first-provision'] = 'pnpm desktop:updates:bucket:create && pnpm desktop:updates:worker:deploy';
|
|
256
|
+
scripts['desktop:updates:worker:types'] = 'pnpm --filter ./apps/desktop-updater-worker types';
|
|
257
|
+
scripts['desktop:updates:build:mac'] = 'pnpm --filter ./apps/desktop updates:build:mac';
|
|
258
|
+
scripts['desktop:updates:build:win'] = 'pnpm --filter ./apps/desktop updates:build:win';
|
|
259
|
+
scripts['desktop:updates:build:linux'] = 'pnpm --filter ./apps/desktop updates:build:linux';
|
|
260
|
+
scripts['desktop:updates:publish'] = 'node apps/desktop/scripts/publish-updates.mjs';
|
|
261
|
+
scripts['verify:desktop-updates'] = 'pnpm desktop:updates:worker:check && node --check apps/desktop/scripts/publish-updates.mjs';
|
|
262
|
+
verify.push('pnpm verify:desktop-updates');
|
|
263
|
+
}
|
|
264
|
+
if (options.apps.extension) {
|
|
265
|
+
scripts['dev:extension'] = 'pnpm --filter ./apps/extension dev';
|
|
266
|
+
scripts['extension:zip'] = 'pnpm --filter ./apps/extension zip';
|
|
267
|
+
scripts['verify:extension'] = 'pnpm --filter ./apps/extension typecheck && pnpm --filter ./apps/extension zip';
|
|
268
|
+
verify.push('pnpm verify:extension');
|
|
269
|
+
}
|
|
270
|
+
if (options.features.database) {
|
|
271
|
+
scripts['neon:login'] = `pnpm dlx neonctl@${TOOLCHAIN_DEPENDENCIES.neonctl} auth`;
|
|
272
|
+
scripts['neon:project:create'] = `pnpm dlx neonctl@${TOOLCHAIN_DEPENDENCIES.neonctl} projects create`;
|
|
273
|
+
scripts['db:generate'] = 'pnpm --filter @shared/db db:generate';
|
|
274
|
+
scripts['db:migrate'] = 'pnpm --filter @shared/db db:migrate';
|
|
275
|
+
scripts['db:check'] = 'pnpm --filter @shared/db db:check';
|
|
276
|
+
scripts['db:studio'] = 'pnpm --filter @shared/db db:studio';
|
|
277
|
+
scripts['verify:db'] = 'node scripts/verify-db-migrations.mjs && pnpm db:check';
|
|
278
|
+
verify.push('pnpm verify:db');
|
|
279
|
+
}
|
|
280
|
+
if (options.features.storage) {
|
|
281
|
+
const bucketName = r2BucketName(options.projectName);
|
|
282
|
+
scripts['r2:login'] = `pnpm dlx wrangler@${TOOLCHAIN_DEPENDENCIES.wrangler} login`;
|
|
283
|
+
scripts['r2:bucket:create'] = `pnpm dlx wrangler@${TOOLCHAIN_DEPENDENCIES.wrangler} r2 bucket create ${bucketName}`;
|
|
284
|
+
scripts['r2:cors:list'] = `pnpm dlx wrangler@${TOOLCHAIN_DEPENDENCIES.wrangler} r2 bucket cors list ${bucketName}`;
|
|
285
|
+
scripts['r2:cors:set'] = `pnpm dlx wrangler@${TOOLCHAIN_DEPENDENCIES.wrangler} r2 bucket cors set ${bucketName} --file cloudflare/r2-cors.template.json`;
|
|
286
|
+
scripts['assets:proxy:check'] = 'pnpm --filter ./apps/assets-private-proxy check';
|
|
287
|
+
scripts['assets:proxy:dev'] = 'pnpm --filter ./apps/assets-private-proxy dev';
|
|
288
|
+
scripts['assets:proxy:deploy'] = 'pnpm --filter ./apps/assets-private-proxy deploy';
|
|
289
|
+
scripts['verify:assets-proxy'] = 'pnpm assets:proxy:check';
|
|
290
|
+
verify.push('pnpm verify:assets-proxy');
|
|
291
|
+
}
|
|
292
|
+
if (!Object.values(options.apps).some(Boolean))
|
|
293
|
+
verify.unshift('pnpm typecheck');
|
|
294
|
+
scripts.verify = verify.join(' && ');
|
|
295
|
+
return scripts;
|
|
84
296
|
}
|
|
85
|
-
function
|
|
86
|
-
const
|
|
87
|
-
|
|
297
|
+
function desiredRootPackage(options) {
|
|
298
|
+
const workspaces = [
|
|
299
|
+
Object.values(options.apps).some(Boolean) ? 'apps/*' : null,
|
|
300
|
+
options.apps.api || options.features.database ? 'packages/*' : null,
|
|
301
|
+
].filter((value) => value !== null);
|
|
302
|
+
return {
|
|
303
|
+
name: options.projectName,
|
|
88
304
|
private: true,
|
|
89
305
|
version: '0.1.0',
|
|
90
306
|
packageManager: PACKAGE_MANAGER,
|
|
91
|
-
|
|
92
|
-
|
|
307
|
+
engines: { node: options.apps.mobile ? MOBILE_NODE_ENGINE : NODE_ENGINE },
|
|
308
|
+
scripts: rootScripts(options),
|
|
309
|
+
workspaces,
|
|
310
|
+
devDependencies: ROOT_DEPENDENCIES.devDependencies,
|
|
93
311
|
};
|
|
94
|
-
|
|
95
|
-
|
|
312
|
+
}
|
|
313
|
+
function sameJson(left, right) {
|
|
314
|
+
if (Object.is(left, right))
|
|
315
|
+
return true;
|
|
316
|
+
if (Array.isArray(left) || Array.isArray(right)) {
|
|
317
|
+
return Array.isArray(left)
|
|
318
|
+
&& Array.isArray(right)
|
|
319
|
+
&& left.length === right.length
|
|
320
|
+
&& left.every((entry, index) => sameJson(entry, right[index]));
|
|
96
321
|
}
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
function writeSkillsGuide(root, commands) {
|
|
104
|
-
const commandBlock = commands.map((command) => `${command}\n`).join('\n');
|
|
105
|
-
writeFile(path.join(root, 'install-skills.sh'), `#!/usr/bin/env bash
|
|
106
|
-
# Manual skill installation guide for this project.
|
|
107
|
-
#
|
|
108
|
-
# Run the commands below after setup. The skills CLI will prompt you to:
|
|
109
|
-
# - choose which agents to install to
|
|
110
|
-
# - choose whether the install should be project-scoped or global
|
|
111
|
-
#
|
|
112
|
-
# If you want the skill files tracked with this project, choose the project scope.
|
|
113
|
-
# Run each command one at a time if you want to review each prompt separately.
|
|
114
|
-
|
|
115
|
-
${commandBlock}`);
|
|
116
|
-
}
|
|
117
|
-
function writeRootDocs(root, options, stack) {
|
|
118
|
-
const toolchainLine = stack.outputs.toolchain.verifiedAt
|
|
119
|
-
? `${options.toolchainChannel} (verified ${stack.outputs.toolchain.verifiedAt})`
|
|
120
|
-
: `${options.toolchainChannel} (floating latest)`;
|
|
121
|
-
const generatedPaths = stack.outputs.generated_paths.map((entry) => `- \`${entry}\``).join('\n') || '- `.`';
|
|
122
|
-
writeFile(path.join(root, 'README.md'), `# ${options.displayName}
|
|
123
|
-
|
|
124
|
-
Generated by anhedral.
|
|
125
|
-
|
|
126
|
-
## Stack
|
|
127
|
-
|
|
128
|
-
- Mode: fullstack
|
|
129
|
-
- Frontend: Expo + React Native Reusables
|
|
130
|
-
- Backend: Fastify
|
|
131
|
-
- Extension: WXT Chrome extension
|
|
132
|
-
- Shared packages: \`packages/*\`
|
|
133
|
-
- Auth: ${options.auth}
|
|
134
|
-
- Payments: ${options.payments}
|
|
135
|
-
- Database: ${options.db} + ${options.orm}
|
|
136
|
-
- Storage: ${options.storage}
|
|
137
|
-
- API: ${options.api ?? 'framework-native'}
|
|
138
|
-
- Toolchain: ${toolchainLine}
|
|
139
|
-
- Dependency manifest: recorded in \`stack.json\`
|
|
140
|
-
|
|
141
|
-
## Generated paths
|
|
142
|
-
|
|
143
|
-
${generatedPaths}
|
|
144
|
-
|
|
145
|
-
## First Run
|
|
146
|
-
|
|
147
|
-
\`\`\`bash
|
|
148
|
-
pnpm install
|
|
149
|
-
cp .env.example .env
|
|
150
|
-
# Frontend/.env is generated for local Expo development
|
|
151
|
-
cp Extension/.env.example Extension/.env
|
|
152
|
-
pnpm db:generate
|
|
153
|
-
pnpm db:migrate
|
|
154
|
-
pnpm verify
|
|
155
|
-
pnpm dev:backend
|
|
156
|
-
pnpm dev:frontend
|
|
157
|
-
pnpm dev:extension
|
|
158
|
-
\`\`\`
|
|
159
|
-
|
|
160
|
-
The generated env files intentionally contain placeholder provider values. That is enough to inspect the project structure and run backend smoke tests, but the full UI needs real provider keys before auth, billing, uploads, and extension sign-in behave like production.
|
|
161
|
-
|
|
162
|
-
For a provider-free backend smoke test, keep \`ANHEDRAL_DEMO=true\` in \`Backend/.env\`. Demo mode returns a signed-in sample user and active subscription responses without Clerk, RevenueCat, or Stripe credentials. It is for local development only.
|
|
163
|
-
|
|
164
|
-
## Provider Setup
|
|
165
|
-
|
|
166
|
-
Use this order so each service has the domains and callback URLs it needs:
|
|
167
|
-
|
|
168
|
-
1. Neon database
|
|
169
|
-
- Create a Neon project and database: https://neon.com/docs/get-started-with-neon/connect-neon
|
|
170
|
-
- Put the pooled connection string in \`DATABASE_URL\` in \`.env\`, \`Backend/.env\`, and the Backend Vercel project.
|
|
171
|
-
- Run \`pnpm db:generate\` and \`pnpm db:migrate\` after \`DATABASE_URL\` is real.
|
|
172
|
-
|
|
173
|
-
2. Clerk auth
|
|
174
|
-
- Expo setup: https://docs.expo.dev/guides/using-clerk/
|
|
175
|
-
- Clerk Expo quickstart: https://clerk.com/docs/quickstarts/get-started-with-expo
|
|
176
|
-
- Set \`EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY\` in \`Frontend/.env\`, Vercel Frontend, and EAS.
|
|
177
|
-
- Set \`CLERK_PUBLISHABLE_KEY\` and \`CLERK_SECRET_KEY\` in \`Backend/.env\` and Vercel Backend.
|
|
178
|
-
- Set \`VITE_CLERK_PUBLISHABLE_KEY\` in \`Extension/.env\`.
|
|
179
|
-
- Add allowed origins for \`http://localhost:8081\`, the Frontend Vercel domain, and the extension origin after Chrome assigns an extension id.
|
|
180
|
-
|
|
181
|
-
3. RevenueCat + Stripe
|
|
182
|
-
- RevenueCat Web overview: https://www.revenuecat.com/docs/web/overview
|
|
183
|
-
- RevenueCat Stripe Billing integration: https://www.revenuecat.com/docs/web/integrations/stripe
|
|
184
|
-
- Stripe API keys: https://docs.stripe.com/keys
|
|
185
|
-
- Create an entitlement named \`pro\`.
|
|
186
|
-
- Create iOS, Android, and Web apps in RevenueCat and copy their public SDK keys into \`EXPO_PUBLIC_RC_API_KEY_IOS\`, \`EXPO_PUBLIC_RC_API_KEY_ANDROID\`, and \`EXPO_PUBLIC_RC_WEB_API_KEY\`.
|
|
187
|
-
- Set \`EXPO_PUBLIC_RC_ENTITLEMENT_ID=pro\`.
|
|
188
|
-
- Set \`RC_SECRET_API_KEY\` and \`RC_WEBHOOK_SECRET\` only in Backend envs.
|
|
189
|
-
- Point the RevenueCat webhook to \`https://<backend-domain>/webhooks/revenuecat\`.
|
|
190
|
-
|
|
191
|
-
4. Cloudflare R2/CDN
|
|
192
|
-
- R2 S3-compatible setup: https://developers.cloudflare.com/r2/get-started/s3/
|
|
193
|
-
- R2 API tokens: https://developers.cloudflare.com/r2/api/tokens/
|
|
194
|
-
- Create a bucket and least-privilege API token.
|
|
195
|
-
- Set \`R2_ACCOUNT_ID\`, \`R2_ACCESS_KEY_ID\`, \`R2_SECRET_ACCESS_KEY\`, and \`R2_BUCKET\` only in Backend envs.
|
|
196
|
-
- If you use a public CDN/custom domain for uploaded files, add that URL to your own app config before exposing uploads broadly.
|
|
197
|
-
|
|
198
|
-
5. Vercel web/API deploys
|
|
199
|
-
- Monorepo docs: https://vercel.com/docs/monorepos
|
|
200
|
-
- Monorepo FAQ for source outside root: https://vercel.com/docs/monorepos/monorepo-faq
|
|
201
|
-
- Import this same Git repository twice. Use \`Frontend\` as the root directory for the Expo web project and \`Backend\` as the root directory for the Fastify API project.
|
|
202
|
-
- Enable access to source files outside each project root so workspace packages under \`packages/*\` are available during builds.
|
|
203
|
-
- Frontend build command: \`pnpm build:web\`; output directory: \`dist\`.
|
|
204
|
-
- Backend build command: \`pnpm build\`; Vercel detects \`Backend/src/index.ts\` as the Fastify entrypoint.
|
|
205
|
-
|
|
206
|
-
6. EAS native app builds
|
|
207
|
-
- EAS docs: https://docs.expo.dev/eas/
|
|
208
|
-
- Store submission docs: https://docs.expo.dev/deploy/submit-to-app-stores/
|
|
209
|
-
- Use \`Frontend\` as the Expo project root for iOS and Android builds. Vercel deploys the web export from the same source.
|
|
210
|
-
|
|
211
|
-
7. Chrome Web Store
|
|
212
|
-
- WXT publishing: https://wxt.dev/guide/essentials/publishing.html
|
|
213
|
-
- Chrome publishing: https://developer.chrome.com/docs/webstore/publish
|
|
214
|
-
- Build with \`pnpm extension:zip\`, then upload \`Extension/.output/*-chrome.zip\` in the Chrome Web Store Developer Dashboard.
|
|
215
|
-
|
|
216
|
-
## Setup Notes
|
|
217
|
-
|
|
218
|
-
1. Copy \`.env.example\` into the runtime env files your apps need and fill in provider values.
|
|
219
|
-
2. Review \`install-skills.sh\` and run the listed skill commands manually so you can choose scope and agent targets.
|
|
220
|
-
3. Add the real provider configuration for Clerk, RevenueCat, Stripe web billing, Neon, and R2 where the scaffolded helper files indicate.
|
|
221
|
-
4. Use \`stack.json\` when debugging generated projects; it records the verified dependency manifest used by this init run.
|
|
222
|
-
5. Review \`PRODUCTION.md\` before connecting live provider projects.
|
|
223
|
-
6. Run database generation and migrations from the shared DB package:
|
|
224
|
-
|
|
225
|
-
\`\`\`bash
|
|
226
|
-
pnpm db:generate
|
|
227
|
-
pnpm db:migrate
|
|
228
|
-
\`\`\`
|
|
229
|
-
|
|
230
|
-
Run deployment readiness checks before pushing:
|
|
231
|
-
|
|
232
|
-
\`\`\`bash
|
|
233
|
-
pnpm verify
|
|
234
|
-
pnpm verify:frontend
|
|
235
|
-
pnpm verify:backend
|
|
236
|
-
pnpm verify:extension
|
|
237
|
-
\`\`\`
|
|
238
|
-
|
|
239
|
-
7. Build or run the extension separately. It is not deployed by Vercel:
|
|
240
|
-
|
|
241
|
-
\`\`\`bash
|
|
242
|
-
pnpm dev:extension
|
|
243
|
-
pnpm extension:zip
|
|
244
|
-
\`\`\`
|
|
245
|
-
`);
|
|
322
|
+
if (!isJsonRecord(left) || !isJsonRecord(right))
|
|
323
|
+
return false;
|
|
324
|
+
const leftKeys = Object.keys(left).sort();
|
|
325
|
+
const rightKeys = Object.keys(right).sort();
|
|
326
|
+
return leftKeys.length === rightKeys.length
|
|
327
|
+
&& leftKeys.every((key, index) => key === rightKeys[index] && sameJson(left[key], right[key]));
|
|
246
328
|
}
|
|
247
|
-
function
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
329
|
+
function isJsonRecord(value) {
|
|
330
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
331
|
+
}
|
|
332
|
+
const MISSING_JSON_VALUE = Symbol('missing-json-value');
|
|
333
|
+
function includesJson(values, candidate) {
|
|
334
|
+
return values.some((value) => sameJson(value, candidate));
|
|
335
|
+
}
|
|
336
|
+
function mergeChangedArrays(current, desired, previous) {
|
|
337
|
+
const merged = [];
|
|
338
|
+
for (const entry of desired) {
|
|
339
|
+
if ((!includesJson(previous, entry) || includesJson(current, entry)) && !includesJson(merged, entry)) {
|
|
340
|
+
merged.push(entry);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
for (const entry of current) {
|
|
344
|
+
if (!includesJson(previous, entry) && !includesJson(merged, entry))
|
|
345
|
+
merged.push(entry);
|
|
346
|
+
}
|
|
347
|
+
return merged;
|
|
348
|
+
}
|
|
349
|
+
function mergeGeneratedThreeWay(current, desired, previous, fieldPath) {
|
|
350
|
+
if (current === MISSING_JSON_VALUE) {
|
|
351
|
+
if (desired === MISSING_JSON_VALUE)
|
|
352
|
+
return MISSING_JSON_VALUE;
|
|
353
|
+
if (previous === MISSING_JSON_VALUE)
|
|
354
|
+
return desired;
|
|
355
|
+
if (sameJson(desired, previous))
|
|
356
|
+
return MISSING_JSON_VALUE;
|
|
357
|
+
throw new Error(`Conflict in ${fieldPath}: a generated value changed after the user removed it.`);
|
|
358
|
+
}
|
|
359
|
+
if (desired === MISSING_JSON_VALUE) {
|
|
360
|
+
if (previous === MISSING_JSON_VALUE)
|
|
361
|
+
return current;
|
|
362
|
+
if (sameJson(current, previous))
|
|
363
|
+
return MISSING_JSON_VALUE;
|
|
364
|
+
return current;
|
|
365
|
+
}
|
|
366
|
+
if (previous === MISSING_JSON_VALUE) {
|
|
367
|
+
if (sameJson(current, desired))
|
|
368
|
+
return desired;
|
|
369
|
+
if (isJsonRecord(current) && isJsonRecord(desired)) {
|
|
370
|
+
return mergeGeneratedRecords(current, desired, {}, fieldPath, true);
|
|
371
|
+
}
|
|
372
|
+
if (Array.isArray(current) && Array.isArray(desired)) {
|
|
373
|
+
return [...desired, ...current.filter((entry) => !includesJson(desired, entry))];
|
|
374
|
+
}
|
|
375
|
+
throw new Error(`Conflict in ${fieldPath}: a newly generated value collides with a user-defined value.`);
|
|
376
|
+
}
|
|
377
|
+
if (sameJson(current, desired))
|
|
378
|
+
return current;
|
|
379
|
+
if (sameJson(current, previous))
|
|
380
|
+
return desired;
|
|
381
|
+
if (sameJson(desired, previous))
|
|
382
|
+
return current;
|
|
383
|
+
if (isJsonRecord(current) && isJsonRecord(desired) && isJsonRecord(previous)) {
|
|
384
|
+
return mergeGeneratedRecords(current, desired, previous, fieldPath, true);
|
|
385
|
+
}
|
|
386
|
+
if (Array.isArray(current) && Array.isArray(desired) && Array.isArray(previous)) {
|
|
387
|
+
return mergeChangedArrays(current, desired, previous);
|
|
388
|
+
}
|
|
389
|
+
throw new Error(`Conflict in ${fieldPath}: both the generator and the user changed the value.`);
|
|
390
|
+
}
|
|
391
|
+
function mergeGeneratedOwned(current, desired, fieldPath) {
|
|
392
|
+
if (desired === MISSING_JSON_VALUE)
|
|
393
|
+
return current;
|
|
394
|
+
if (current === MISSING_JSON_VALUE)
|
|
395
|
+
return desired;
|
|
396
|
+
if (isJsonRecord(current) && isJsonRecord(desired)) {
|
|
397
|
+
return mergeGeneratedRecords(current, desired, {}, fieldPath, false);
|
|
398
|
+
}
|
|
399
|
+
if (Array.isArray(current) && Array.isArray(desired)) {
|
|
400
|
+
return [...desired, ...current.filter((entry) => !includesJson(desired, entry))];
|
|
401
|
+
}
|
|
402
|
+
if (isJsonRecord(current)
|
|
403
|
+
|| isJsonRecord(desired)
|
|
404
|
+
|| Array.isArray(current)
|
|
405
|
+
|| Array.isArray(desired)) {
|
|
406
|
+
throw new Error(`Conflict in ${fieldPath}: the generator version changed and the value shape is ambiguous.`);
|
|
407
|
+
}
|
|
408
|
+
// Without a trustworthy previous-generator snapshot, an existing scalar may
|
|
409
|
+
// be user-authored. Preserve it instead of silently resetting it to today's
|
|
410
|
+
// default. Newly introduced keys still take the desired value above.
|
|
411
|
+
return current;
|
|
412
|
+
}
|
|
413
|
+
function mergeGeneratedRecords(current, desired, previous, fieldPath, trustPrevious) {
|
|
414
|
+
const keys = [...new Set([...Object.keys(desired), ...Object.keys(current), ...Object.keys(previous)])];
|
|
415
|
+
const entries = [];
|
|
416
|
+
for (const key of keys) {
|
|
417
|
+
const currentValue = Object.hasOwn(current, key) ? current[key] : MISSING_JSON_VALUE;
|
|
418
|
+
const desiredValue = Object.hasOwn(desired, key) ? desired[key] : MISSING_JSON_VALUE;
|
|
419
|
+
const previousValue = Object.hasOwn(previous, key) ? previous[key] : MISSING_JSON_VALUE;
|
|
420
|
+
const value = trustPrevious
|
|
421
|
+
? mergeGeneratedThreeWay(currentValue, desiredValue, previousValue, `${fieldPath}.${key}`)
|
|
422
|
+
: mergeGeneratedOwned(currentValue, desiredValue, `${fieldPath}.${key}`);
|
|
423
|
+
if (value !== MISSING_JSON_VALUE)
|
|
424
|
+
entries.push([key, value]);
|
|
425
|
+
}
|
|
426
|
+
return Object.fromEntries(entries);
|
|
427
|
+
}
|
|
428
|
+
function mergeGeneratedStructure(current, desired, previous, fieldPath, trustPrevious) {
|
|
429
|
+
if (isJsonRecord(current) && isJsonRecord(desired)) {
|
|
430
|
+
return mergeGeneratedRecords(current, desired, isJsonRecord(previous) ? previous : {}, fieldPath, trustPrevious);
|
|
431
|
+
}
|
|
432
|
+
return trustPrevious
|
|
433
|
+
? mergeGeneratedThreeWay(current, desired, previous, fieldPath)
|
|
434
|
+
: mergeGeneratedOwned(current, desired, fieldPath);
|
|
435
|
+
}
|
|
436
|
+
function mergeStringMap(current, desired, previousGenerated, filePath, allowGeneratedUpdates) {
|
|
437
|
+
const existing = current && typeof current === 'object' ? current : {};
|
|
438
|
+
const generated = desired && typeof desired === 'object' ? desired : {};
|
|
439
|
+
const previous = previousGenerated && typeof previousGenerated === 'object'
|
|
440
|
+
? previousGenerated
|
|
441
|
+
: {};
|
|
442
|
+
const merged = { ...existing };
|
|
443
|
+
for (const [key, value] of Object.entries(generated)) {
|
|
444
|
+
if (merged[key] === undefined || merged[key] === value || allowGeneratedUpdates || merged[key] === previous[key])
|
|
445
|
+
merged[key] = value;
|
|
446
|
+
else
|
|
447
|
+
throw new Error(`Conflict in ${filePath}: key ${key} has a user-defined value.`);
|
|
448
|
+
}
|
|
449
|
+
return Object.fromEntries(Object.entries(merged).sort(([left], [right]) => left.localeCompare(right)));
|
|
450
|
+
}
|
|
451
|
+
function writeRootPackage(root, options, mode, allowGeneratedUpdates, previousGenerated) {
|
|
452
|
+
const filePath = path.join(root, 'package.json');
|
|
453
|
+
const desired = desiredRootPackage(options);
|
|
454
|
+
if (mode === 'init' || !pathEntryExists(filePath)) {
|
|
455
|
+
writeFile(filePath, JSON.stringify(desired, null, 2) + '\n');
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
const current = JSON.parse(readFileSync(filePath, 'utf8'));
|
|
459
|
+
const desiredWorkspaces = desired.workspaces;
|
|
460
|
+
const currentWorkspaces = Array.isArray(current.workspaces) ? current.workspaces.filter((value) => typeof value === 'string') : [];
|
|
461
|
+
const mergeMetadataField = (field) => {
|
|
462
|
+
const currentValue = current[field];
|
|
463
|
+
const desiredValue = desired[field];
|
|
464
|
+
const previousValue = previousGenerated?.[field];
|
|
465
|
+
if (allowGeneratedUpdates
|
|
466
|
+
|| currentValue === undefined
|
|
467
|
+
|| (previousValue !== undefined && sameJson(currentValue, previousValue))) {
|
|
468
|
+
return desiredValue;
|
|
469
|
+
}
|
|
470
|
+
if (previousValue !== undefined && !sameJson(previousValue, desiredValue)) {
|
|
471
|
+
throw new Error(`Conflict in package.json: ${field} was user-modified and must satisfy the newly selected modules.`);
|
|
472
|
+
}
|
|
473
|
+
return currentValue;
|
|
474
|
+
};
|
|
475
|
+
const merged = {
|
|
476
|
+
...desired,
|
|
477
|
+
...current,
|
|
478
|
+
private: true,
|
|
479
|
+
packageManager: mergeMetadataField('packageManager'),
|
|
480
|
+
engines: mergeMetadataField('engines'),
|
|
481
|
+
workspaces: [...new Set([...currentWorkspaces, ...desiredWorkspaces])].sort(),
|
|
482
|
+
scripts: mergeStringMap(current.scripts, desired.scripts, previousGenerated?.scripts, 'package.json scripts', allowGeneratedUpdates),
|
|
483
|
+
devDependencies: mergeStringMap(current.devDependencies, desired.devDependencies, previousGenerated?.devDependencies, 'package.json devDependencies', allowGeneratedUpdates),
|
|
484
|
+
};
|
|
485
|
+
writeFile(filePath, JSON.stringify(merged, null, 2) + '\n');
|
|
486
|
+
}
|
|
487
|
+
function mergeJsonConfig(root, relativePath, desired, previousGenerated, trustPreviousGenerated) {
|
|
488
|
+
const filePath = path.join(root, relativePath);
|
|
489
|
+
if (!pathEntryExists(filePath)) {
|
|
490
|
+
writeFile(filePath, JSON.stringify(desired, null, 2) + '\n');
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
const current = JSON.parse(readFileSync(filePath, 'utf8'));
|
|
494
|
+
const merged = mergeGeneratedStructure(current, desired, previousGenerated, relativePath, trustPreviousGenerated);
|
|
495
|
+
writeFile(filePath, JSON.stringify(merged, null, 2) + '\n');
|
|
496
|
+
}
|
|
497
|
+
function writeRootEnv(root, options) {
|
|
498
|
+
const corsOrigins = [
|
|
499
|
+
options.apps.web ? 'http://localhost:3000' : null,
|
|
500
|
+
options.apps.mobile ? 'http://localhost:8081' : null,
|
|
501
|
+
options.apps.desktop ? 'http://127.0.0.1:5173' : null,
|
|
502
|
+
options.apps.desktop ? 'null' : null,
|
|
503
|
+
].filter((value) => value !== null);
|
|
504
|
+
const lines = composeRootEnvironment(collectModuleContributions(options.modules), { corsOrigins });
|
|
505
|
+
const filePath = path.join(root, '.env.example');
|
|
506
|
+
const current = pathEntryExists(filePath) ? readFileSync(filePath, 'utf8') : '';
|
|
507
|
+
const existingKeys = new Set([...current.matchAll(/^([A-Z][A-Z0-9_]*)=/gm)].map((match) => match[1]));
|
|
508
|
+
const additions = lines.filter((line) => !existingKeys.has(line.slice(0, line.indexOf('='))));
|
|
509
|
+
const separator = current && additions.length ? (current.endsWith('\n') ? '\n' : '\n\n') : '';
|
|
510
|
+
writeFile(filePath, `${current}${separator}${additions.join('\n')}${additions.length ? '\n' : ''}`);
|
|
511
|
+
}
|
|
512
|
+
function desiredTurboConfig() {
|
|
513
|
+
return {
|
|
514
|
+
$schema: 'https://turborepo.dev/schema.json',
|
|
515
|
+
tasks: {
|
|
516
|
+
build: {
|
|
517
|
+
dependsOn: ['^build'],
|
|
518
|
+
outputs: ['.next/**', '!.next/cache/**', '.output/**', 'dist/**'],
|
|
519
|
+
},
|
|
520
|
+
typecheck: { dependsOn: ['^build'] },
|
|
521
|
+
dev: { cache: false, persistent: true },
|
|
522
|
+
},
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
function desiredVercelConfig(options) {
|
|
526
|
+
const services = {};
|
|
527
|
+
if (options.apps.api) {
|
|
528
|
+
services.api = { root: 'apps/api' };
|
|
529
|
+
}
|
|
530
|
+
if (options.apps.web) {
|
|
531
|
+
services.web = { root: 'apps/web', framework: 'nextjs' };
|
|
532
|
+
}
|
|
533
|
+
const rewrites = [
|
|
534
|
+
options.apps.api ? { source: '/api/(.*)', destination: { service: 'api' } } : null,
|
|
535
|
+
options.apps.web ? { source: '/(.*)', destination: { service: 'web' } } : null,
|
|
536
|
+
].filter((rewrite) => rewrite !== null);
|
|
537
|
+
const crons = composeVercelCrons(collectModuleContributions(options.modules));
|
|
538
|
+
return {
|
|
539
|
+
$schema: 'https://openapi.vercel.sh/vercel.json',
|
|
540
|
+
...(Object.keys(services).length > 0 ? { services } : {}),
|
|
541
|
+
...(rewrites.length > 0 ? { rewrites } : {}),
|
|
542
|
+
...(crons.length > 0 ? { crons } : {}),
|
|
543
|
+
};
|
|
544
|
+
}
|
|
545
|
+
function generatedCi(options) {
|
|
546
|
+
const nodeVersion = options.apps.mobile ? '22.13.0' : '20.19.0';
|
|
547
|
+
const commands = [
|
|
548
|
+
'pnpm typecheck',
|
|
549
|
+
options.apps.api ? 'pnpm --filter ./apps/api test:coverage' : null,
|
|
550
|
+
'pnpm build',
|
|
551
|
+
].filter((value) => value !== null).join('\n ');
|
|
552
|
+
const databaseSteps = options.features.database ? `
|
|
553
|
+
- name: Verify committed database migration history
|
|
554
|
+
run: pnpm verify:db
|
|
555
|
+
- name: Verify database schema and migration parity
|
|
556
|
+
run: |
|
|
557
|
+
pnpm db:generate
|
|
558
|
+
git diff --exit-code -- packages/db/migrations
|
|
559
|
+
if [[ -n "$(git status --porcelain --untracked-files=all -- packages/db/migrations)" ]]; then
|
|
560
|
+
echo "drizzle-kit generated uncommitted migration artifacts:"
|
|
561
|
+
git status --short --untracked-files=all -- packages/db/migrations
|
|
562
|
+
exit 1
|
|
563
|
+
fi
|
|
564
|
+
` : '';
|
|
565
|
+
return `name: Anhedral CI
|
|
269
566
|
|
|
270
|
-
|
|
567
|
+
on:
|
|
568
|
+
pull_request:
|
|
569
|
+
push:
|
|
570
|
+
branches: [main]
|
|
271
571
|
|
|
272
|
-
|
|
572
|
+
permissions:
|
|
573
|
+
contents: read
|
|
273
574
|
|
|
274
|
-
|
|
275
|
-
-
|
|
276
|
-
-
|
|
575
|
+
concurrency:
|
|
576
|
+
group: anhedral-ci-\${{ github.ref }}
|
|
577
|
+
cancel-in-progress: true
|
|
277
578
|
|
|
278
|
-
|
|
579
|
+
jobs:
|
|
580
|
+
verify:
|
|
581
|
+
runs-on: ubuntu-latest
|
|
582
|
+
timeout-minutes: 30
|
|
583
|
+
steps:
|
|
584
|
+
- uses: ${ACTIONS.checkout} # v6
|
|
585
|
+
with:
|
|
586
|
+
persist-credentials: false
|
|
587
|
+
- uses: ${ACTIONS.pnpm} # v6
|
|
588
|
+
with:
|
|
589
|
+
version: ${PACKAGE_MANAGER.replace('pnpm@', '')}
|
|
590
|
+
- uses: ${ACTIONS.node} # v6
|
|
591
|
+
with:
|
|
592
|
+
node-version: ${nodeVersion}
|
|
593
|
+
cache: pnpm
|
|
594
|
+
- run: pnpm install --frozen-lockfile
|
|
595
|
+
${databaseSteps} - name: Verify generated workspace
|
|
596
|
+
run: |
|
|
597
|
+
${commands}
|
|
598
|
+
`;
|
|
599
|
+
}
|
|
600
|
+
function writeDatabaseVerificationScript(root, options) {
|
|
601
|
+
if (!options.features.database)
|
|
602
|
+
return;
|
|
603
|
+
writeFile(path.join(root, 'scripts/verify-db-migrations.mjs'), `import { readdirSync } from 'node:fs';
|
|
604
|
+
import path from 'node:path';
|
|
605
|
+
import { spawnSync } from 'node:child_process';
|
|
279
606
|
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
- \`Frontend/eas.json\`: native build profiles
|
|
283
|
-
- \`Frontend/app\`: Expo Router screens
|
|
607
|
+
const projectRoot = process.cwd();
|
|
608
|
+
const migrationsRoot = path.join(projectRoot, 'packages/db/migrations');
|
|
284
609
|
|
|
285
|
-
|
|
610
|
+
function collectSqlFiles(directory, relativeDirectory = '') {
|
|
611
|
+
const files = [];
|
|
612
|
+
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
613
|
+
const relativePath = path.posix.join(relativeDirectory, entry.name);
|
|
614
|
+
if (entry.isDirectory()) files.push(...collectSqlFiles(path.join(directory, entry.name), relativePath));
|
|
615
|
+
else if (entry.isFile() && entry.name.endsWith('.sql')) files.push(path.posix.join('packages/db/migrations', relativePath));
|
|
616
|
+
}
|
|
617
|
+
return files.sort();
|
|
618
|
+
}
|
|
286
619
|
|
|
287
|
-
|
|
620
|
+
const sqlFiles = collectSqlFiles(migrationsRoot);
|
|
621
|
+
if (sqlFiles.length === 0) {
|
|
622
|
+
throw new Error('No database migration SQL is committed. Run \`pnpm db:generate\`, review packages/db/migrations, and commit the generated migration before verification.');
|
|
623
|
+
}
|
|
288
624
|
|
|
289
|
-
|
|
625
|
+
const gitRoot = spawnSync('git', ['rev-parse', '--show-toplevel'], {
|
|
626
|
+
cwd: projectRoot,
|
|
627
|
+
encoding: 'utf8',
|
|
628
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
629
|
+
});
|
|
290
630
|
|
|
291
|
-
|
|
631
|
+
if (gitRoot.status === 0) {
|
|
632
|
+
const repositoryRoot = String(gitRoot.stdout).trim();
|
|
633
|
+
const tracked = spawnSync('git', ['ls-files', '--full-name', '-z', '--', 'packages/db/migrations'], {
|
|
634
|
+
cwd: projectRoot,
|
|
635
|
+
encoding: 'utf8',
|
|
636
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
637
|
+
});
|
|
638
|
+
if (tracked.status !== 0) {
|
|
639
|
+
throw new Error('Unable to inspect committed database migrations: ' + String(tracked.stderr).trim());
|
|
640
|
+
}
|
|
641
|
+
const trackedFiles = new Set(String(tracked.stdout)
|
|
642
|
+
.split('\\0')
|
|
643
|
+
.filter(Boolean)
|
|
644
|
+
.map((entry) => path.resolve(repositoryRoot, entry)));
|
|
645
|
+
const untrackedSql = sqlFiles.filter((entry) => !trackedFiles.has(path.resolve(projectRoot, entry)));
|
|
646
|
+
if (untrackedSql.length > 0) {
|
|
647
|
+
throw new Error('Database migration SQL must be tracked by Git before verification: ' + untrackedSql.join(', '));
|
|
648
|
+
}
|
|
649
|
+
} else if (process.env.CI) {
|
|
650
|
+
throw new Error('CI must run database migration verification inside a Git worktree.');
|
|
651
|
+
}
|
|
292
652
|
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
653
|
+
console.log('Database migration baseline is present' + (gitRoot.status === 0 ? ' and tracked by Git.' : '.'));
|
|
654
|
+
`);
|
|
655
|
+
}
|
|
656
|
+
function isRecordedFileUnmodified(root, manifest, relativePath) {
|
|
657
|
+
if (!manifest)
|
|
658
|
+
return true;
|
|
659
|
+
const record = manifest.files[relativePath];
|
|
660
|
+
if (!record?.hash)
|
|
661
|
+
return false;
|
|
662
|
+
const filePath = path.join(root, relativePath);
|
|
663
|
+
return pathEntryExists(filePath) && hashContent(readFileSync(filePath)) === record.hash;
|
|
664
|
+
}
|
|
665
|
+
function writeRootFiles(root, options, mode, currentRoot, manifest) {
|
|
666
|
+
const packageUnmodified = isRecordedFileUnmodified(currentRoot, manifest, 'package.json');
|
|
667
|
+
const workspaceUnmodified = manifest !== undefined
|
|
668
|
+
&& isRecordedFileUnmodified(currentRoot, manifest, 'pnpm-workspace.yaml');
|
|
669
|
+
const previousOptions = manifest
|
|
670
|
+
? { ...options, ...selectionsFromModules(manifest.modules), modules: [...manifest.modules] }
|
|
671
|
+
: undefined;
|
|
672
|
+
const previousPackage = previousOptions ? desiredRootPackage(previousOptions) : undefined;
|
|
673
|
+
writeRootPackage(root, options, mode, packageUnmodified, previousPackage);
|
|
674
|
+
const workspacePackages = [
|
|
675
|
+
Object.values(options.apps).some(Boolean) ? 'apps/*' : null,
|
|
676
|
+
options.apps.api || options.features.database ? 'packages/*' : null,
|
|
677
|
+
].filter((value) => value !== null);
|
|
678
|
+
mergeWorkspaceFile(root, workspacePackages, desiredWorkspacePolicy(), workspaceUnmodified);
|
|
679
|
+
mergeJsonConfig(root, 'turbo.json', desiredTurboConfig(), desiredTurboConfig(), true);
|
|
680
|
+
mergeJsonConfig(root, 'vercel.json', desiredVercelConfig(options), previousOptions ? desiredVercelConfig(previousOptions) : {}, true);
|
|
681
|
+
appendGitignore(root, [
|
|
682
|
+
'node_modules',
|
|
683
|
+
'.turbo',
|
|
684
|
+
'.next',
|
|
685
|
+
'.output',
|
|
686
|
+
'.wxt',
|
|
687
|
+
'.expo',
|
|
688
|
+
'coverage',
|
|
689
|
+
'dist',
|
|
690
|
+
'release',
|
|
691
|
+
'.env',
|
|
692
|
+
'.env.*',
|
|
693
|
+
'!.env.example',
|
|
694
|
+
'*.tsbuildinfo',
|
|
695
|
+
options.features.electronUpdater ? 'electron-builder.env' : null,
|
|
696
|
+
].filter((value) => value !== null));
|
|
697
|
+
writeRootEnv(root, options);
|
|
698
|
+
writeDatabaseVerificationScript(root, options);
|
|
699
|
+
const vercelIgnore = path.join(root, '.vercelignore');
|
|
700
|
+
const currentIgnore = pathEntryExists(vercelIgnore) ? readFileSync(vercelIgnore, 'utf8') : '';
|
|
701
|
+
const ignoreLines = [...new Set([
|
|
702
|
+
...currentIgnore.split('\n').filter(Boolean),
|
|
703
|
+
'apps/extension/.output',
|
|
704
|
+
'apps/mobile/dist',
|
|
705
|
+
'apps/desktop/release',
|
|
706
|
+
'apps/assets-private-proxy',
|
|
707
|
+
'apps/desktop-updater-worker',
|
|
708
|
+
])];
|
|
709
|
+
writeFile(vercelIgnore, ignoreLines.join('\n') + '\n');
|
|
710
|
+
writeFile(path.join(root, '.github/workflows/anhedral-ci.yml'), generatedCi(options));
|
|
711
|
+
}
|
|
712
|
+
function enabledModuleNames(options) {
|
|
713
|
+
return resolveModules(options.modules).resolvedModules;
|
|
714
|
+
}
|
|
715
|
+
function writeProjectDocs(root, options, includeUserDocs) {
|
|
716
|
+
const modules = enabledModuleNames(options);
|
|
717
|
+
const storageBucketName = r2BucketName(options.projectName);
|
|
718
|
+
const environmentSetupCommands = [
|
|
719
|
+
'pnpm install',
|
|
720
|
+
options.apps.api ? 'cp apps/api/.env.example apps/api/.env' : null,
|
|
721
|
+
options.features.database ? 'cp packages/db/.env.example packages/db/.env' : null,
|
|
722
|
+
options.apps.web ? 'cp apps/web/.env.example apps/web/.env.local' : null,
|
|
723
|
+
options.apps.mobile ? 'cp apps/mobile/.env.example apps/mobile/.env' : null,
|
|
724
|
+
options.apps.desktop ? 'cp apps/desktop/.env.example apps/desktop/.env' : null,
|
|
725
|
+
options.features.electronUpdater ? 'cp apps/desktop/electron-builder.env.example apps/desktop/electron-builder.env' : null,
|
|
726
|
+
options.apps.extension ? 'cp apps/extension/.env.example apps/extension/.env' : null,
|
|
727
|
+
].filter((value) => value !== null).join('\n');
|
|
728
|
+
const firstVerificationCommands = [
|
|
729
|
+
options.features.database ? 'pnpm db:generate' : null,
|
|
730
|
+
options.features.database ? 'git add packages/db/migrations' : null,
|
|
731
|
+
'pnpm verify',
|
|
732
|
+
options.features.database ? 'pnpm db:migrate' : null,
|
|
733
|
+
].filter((value) => value !== null).join('\n');
|
|
734
|
+
const deploymentRows = [
|
|
735
|
+
options.apps.web || options.apps.api ? '| Web/API | Vercel Git integration | Import this repository once; branch pushes create previews and the production branch deploys automatically. |' : null,
|
|
736
|
+
options.apps.mobile ? '| Mobile | EAS Build + App Store Connect + Google Play Console | EAS creates signed binaries; Apple TestFlight/App Review and Google testing tracks/store review control release. |' : null,
|
|
737
|
+
options.features.database ? '| Database | Neon | Provision the project, set `DATABASE_URL`, review/apply Drizzle migrations, and use separate branches or projects for preview and production. |' : null,
|
|
738
|
+
options.features.storage ? '| Object storage | Private Cloudflare R2 + `assets-private-proxy` Worker | Authenticated uploads use presigned R2 URLs; the Worker streams known-key GET/HEAD downloads through `assets.<domain>` while direct bucket access stays disabled. |' : null,
|
|
739
|
+
options.features.electronUpdater ? '| Desktop updates | Private Cloudflare R2 + `desktop-updater` Worker | Signed native artifacts are uploaded to a private bucket; a custom-domain Worker supplies metadata, ranges, and downloads to `electron-updater`. |' : null,
|
|
740
|
+
options.apps.extension ? '| Chrome extension | Chrome Web Store | Build the production ZIP, test it as an unpublished/trusted-tester item, complete privacy disclosures, and submit it for review. |' : null,
|
|
741
|
+
options.apps.desktop ? `| Desktop | electron-builder artifacts${options.features.electronUpdater ? ' + automatic update channel' : ''} | Build and sign on each target OS, then publish through the release channel chosen for the product. |` : null,
|
|
742
|
+
].filter((value) => value !== null).join('\n');
|
|
743
|
+
const deploymentCommands = [
|
|
744
|
+
options.apps.web || options.apps.api ? 'pnpm deploy:vercel:link' : null,
|
|
745
|
+
options.apps.web || options.apps.api ? 'pnpm deploy:vercel:preview' : null,
|
|
746
|
+
options.apps.web || options.apps.api ? 'pnpm deploy:vercel:production' : null,
|
|
747
|
+
options.apps.mobile ? 'pnpm mobile:eas:login' : null,
|
|
748
|
+
options.apps.mobile ? 'pnpm mobile:build:internal:ios' : null,
|
|
749
|
+
options.apps.mobile ? 'pnpm mobile:build:internal:android' : null,
|
|
750
|
+
options.features.database ? `pnpm neon:project:create -- --name ${options.projectName}` : null,
|
|
751
|
+
options.features.storage ? 'pnpm r2:bucket:create' : null,
|
|
752
|
+
options.features.storage ? 'pnpm assets:proxy:check' : null,
|
|
753
|
+
options.features.storage ? 'pnpm assets:proxy:deploy' : null,
|
|
754
|
+
options.features.electronUpdater ? 'pnpm desktop:updates:cloudflare:login' : null,
|
|
755
|
+
options.features.electronUpdater ? 'pnpm desktop:updates:first-provision' : null,
|
|
756
|
+
options.features.electronUpdater ? 'pnpm desktop:updates:build:mac' : null,
|
|
757
|
+
options.features.electronUpdater ? 'pnpm desktop:updates:publish -- --platform mac --arch arm64' : null,
|
|
758
|
+
options.apps.extension ? 'pnpm extension:zip' : null,
|
|
759
|
+
].filter((value) => value !== null).join('\n');
|
|
760
|
+
const sourceRows = [
|
|
761
|
+
options.apps.web ? '| Web frontend | `apps/web/app/`, `apps/web/components/`, `apps/web/lib/` | Next.js App Router pages, layouts, components, and web utilities. |' : null,
|
|
762
|
+
options.apps.mobile ? '| Mobile frontend | `apps/mobile/app/`, `apps/mobile/components/` | Expo Router screens, layouts, and native UI. |' : null,
|
|
763
|
+
options.apps.api ? '| Backend API | `apps/api/src/routes/app.ts`, `apps/api/src/<feature>.ts` | User-owned Fastify routes and server-only product modules; Anhedral keeps provider routes in managed wiring. |' : null,
|
|
764
|
+
options.apps.desktop ? '| Desktop app | `apps/desktop/src/renderer/`, `apps/desktop/src/main/` | React UI and privileged Electron main-process code. |' : null,
|
|
765
|
+
options.features.electronUpdater ? '| Desktop update edge | `apps/desktop-updater-worker/`, `apps/desktop/scripts/publish-updates.mjs` | Private R2 delivery Worker, custom domain, and ordered release publisher. |' : null,
|
|
766
|
+
options.apps.extension ? '| Browser extension | `apps/extension/src/entrypoints/`, `apps/extension/src/components/` | WXT entrypoints and extension UI. |' : null,
|
|
767
|
+
options.apps.api ? '| Shared API contracts | `packages/contracts/src/app.ts` | User-owned Zod request and response schemas exported beside managed provider contracts. |' : null,
|
|
768
|
+
options.apps.api ? '| Typed API client | `packages/api-client/src/app.ts` | User-owned client-safe methods exported beside the managed fetch client. |' : null,
|
|
769
|
+
options.features.database ? '| Database | `packages/db/src/app-schema.ts`, `packages/db/migrations/` | User-owned Drizzle tables plus reviewed SQL migrations; provider tables remain generated. |' : null,
|
|
770
|
+
].filter((value) => value !== null).join('\n');
|
|
771
|
+
const moduleCommands = [
|
|
772
|
+
options.apps.web ? '- Web: `pnpm dev:web`' : null,
|
|
773
|
+
options.apps.mobile ? '- Mobile: `pnpm dev:mobile`' : null,
|
|
774
|
+
options.apps.api ? '- API: `pnpm dev:api`' : null,
|
|
775
|
+
options.apps.desktop ? '- Desktop: `pnpm dev:desktop`' : null,
|
|
776
|
+
options.apps.extension ? '- Extension: `pnpm dev:extension`' : null,
|
|
777
|
+
].filter((value) => value !== null).join('\n');
|
|
778
|
+
const toolRows = [
|
|
779
|
+
options.apps.web ? '| Next.js | Web routes and rendering | `apps/web` | https://nextjs.org/docs/app/getting-started |' : null,
|
|
780
|
+
options.apps.mobile ? '| Expo Router | iOS and Android app | `apps/mobile` | https://docs.expo.dev/router/introduction/ |' : null,
|
|
781
|
+
options.apps.api ? '| Fastify | HTTP API | `apps/api` | https://fastify.dev/docs/latest/ |' : null,
|
|
782
|
+
options.features.database ? '| Neon | Managed Postgres; no local Postgres | `DATABASE_URL` | https://neon.com/docs/introduction |' : null,
|
|
783
|
+
options.features.database ? '| Drizzle | SQL schema, queries, migrations | `packages/db` | https://orm.drizzle.team/docs/get-started |' : null,
|
|
784
|
+
options.features.auth ? '| Clerk | Identity and sessions | generated auth files | https://clerk.com/docs/getting-started/quickstart/overview |' : null,
|
|
785
|
+
options.features.storage ? '| Cloudflare R2 | Private object storage | upload API and `apps/assets-private-proxy` | https://developers.cloudflare.com/r2/ |' : null,
|
|
786
|
+
options.features.billing ? '| RevenueCat | Subscription authority | API and native client | https://www.revenuecat.com/docs |' : null,
|
|
787
|
+
options.features.billing ? '| Ably | Realtime invalidation | `packages/realtime` | https://ably.com/docs |' : null,
|
|
788
|
+
options.apps.desktop ? '| Electron | Desktop runtime | `apps/desktop` | https://www.electronjs.org/docs/latest/ |' : null,
|
|
789
|
+
options.features.electronUpdater ? '| electron-updater | Packaged-app update checks and installation | `apps/desktop/src/main/main.ts` | https://www.electron.build/auto-update.html |' : null,
|
|
790
|
+
options.features.electronUpdater ? '| Cloudflare Workers + R2 | Private update storage and custom-domain delivery | `apps/desktop-updater-worker` | https://developers.cloudflare.com/r2/api/workers/workers-api-usage/ |' : null,
|
|
791
|
+
options.apps.extension ? '| WXT | Browser extension tooling | `apps/extension` | https://wxt.dev/guide/installation.html |' : null,
|
|
792
|
+
options.apps.web || options.apps.desktop || options.apps.extension ? '| shadcn/ui | Source-owned DOM UI | each DOM app\'s `components/ui` | https://ui.shadcn.com/docs |' : null,
|
|
793
|
+
'| Turborepo | Workspace tasks | root `turbo.json` | https://turborepo.com/docs |',
|
|
794
|
+
].filter((value) => value !== null).join('\n');
|
|
795
|
+
const developmentSteps = [
|
|
796
|
+
options.apps.api ? 'Define shared Zod schemas in user-owned `packages/contracts/src/app.ts` so the network boundary has one definition.' : null,
|
|
797
|
+
options.features.database ? 'Define persistent product state in user-owned `packages/db/src/app-schema.ts`. Run `pnpm db:generate`, review the SQL, and commit it. This project uses managed Neon—do not add a local Postgres container.' : null,
|
|
798
|
+
options.apps.api ? 'Add server-only behavior in a focused `apps/api/src/<feature>.ts` module and register its validated HTTP boundary in user-owned `apps/api/src/routes/app.ts`.' : null,
|
|
799
|
+
options.apps.api ? 'Expose client-safe methods from user-owned `packages/api-client/src/app.ts`; never import API implementation files into a frontend.' : null,
|
|
800
|
+
options.apps.web ? 'Add web routes in `apps/web/app/<route>/page.tsx` and reusable UI in `apps/web/components/`. Use a client component only for browser state, events, or hooks.' : null,
|
|
801
|
+
options.apps.mobile ? 'Add mobile routes in `apps/mobile/app/` and reusable native UI in `apps/mobile/components/`. Keep platform-only APIs in platform-specific modules.' : null,
|
|
802
|
+
'Test the smallest affected package first, then run `pnpm verify`.',
|
|
803
|
+
].filter((value) => value !== null).map((value, index) => `${index + 1}. ${value}`).join('\n');
|
|
804
|
+
const commonTaskSections = [
|
|
805
|
+
options.apps.web ? `### Add a web page
|
|
806
|
+
|
|
807
|
+
Create \`apps/web/app/<route>/page.tsx\`. Follow Next.js App Router conventions directly. Put reusable components under \`apps/web/components/<feature>/\` and web-only helpers under \`apps/web/lib/\`.` : null,
|
|
808
|
+
options.apps.api ? `### Add an API endpoint
|
|
809
|
+
|
|
810
|
+
Define its Zod contract in \`packages/contracts/src/app.ts\`, implement server-only behavior in \`apps/api/src/<feature>.ts\`, and register the Fastify route in \`apps/api/src/routes/app.ts\`. Authenticate on the server and derive the user ID from the verified session.` : null,
|
|
811
|
+
options.features.database ? `### Change the database
|
|
812
|
+
|
|
813
|
+
Edit \`packages/db/src/app-schema.ts\`, then run:
|
|
297
814
|
|
|
298
|
-
|
|
815
|
+
\`\`\`sh
|
|
816
|
+
pnpm db:generate
|
|
817
|
+
pnpm verify:db
|
|
818
|
+
pnpm db:migrate
|
|
819
|
+
\`\`\`
|
|
299
820
|
|
|
300
|
-
\`
|
|
821
|
+
Review generated SQL before applying it. \`DATABASE_URL\` points to a managed Neon branch or project; there is intentionally no local Postgres service.` : null,
|
|
822
|
+
options.features.auth ? `### Use authentication
|
|
301
823
|
|
|
302
|
-
|
|
824
|
+
Frontend applications use their generated Clerk provider and hooks. The API verifies Clerk sessions. Public keys may use a framework public environment prefix; \`CLERK_SECRET_KEY\` remains in the API environment only.` : null,
|
|
825
|
+
options.features.storage ? `### Upload a file
|
|
303
826
|
|
|
304
|
-
-
|
|
305
|
-
|
|
306
|
-
|
|
827
|
+
Use \`@shared/api-client\`. The API authorizes the user and creates a short-lived signed R2 upload; the client uploads directly and confirms through the API. Never put R2 credentials in a client or make the bucket public.` : null,
|
|
828
|
+
].filter((value) => value !== null).join('\n\n');
|
|
829
|
+
const verticalSliceExample = options.apps.api && options.features.database ? `## Copy-paste example: a projects feature
|
|
307
830
|
|
|
308
|
-
|
|
831
|
+
These files are user-owned extension seams. Anhedral preserves them during \`add\`, \`ui add\`, and \`upgrade\`.
|
|
309
832
|
|
|
310
|
-
|
|
833
|
+
\`packages/contracts/src/app.ts\`:
|
|
311
834
|
|
|
312
|
-
|
|
835
|
+
\`\`\`ts
|
|
836
|
+
import { z } from 'zod';
|
|
313
837
|
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
pnpm dev
|
|
838
|
+
export const ProjectSchema = z.object({ id: z.string(), name: z.string() });
|
|
839
|
+
export const ProjectListSchema = z.array(ProjectSchema);
|
|
317
840
|
\`\`\`
|
|
318
841
|
|
|
319
|
-
|
|
842
|
+
\`packages/db/src/app-schema.ts\`:
|
|
320
843
|
|
|
321
|
-
\`\`\`
|
|
322
|
-
|
|
323
|
-
pnpm dev:backend
|
|
324
|
-
pnpm dev:extension
|
|
325
|
-
\`\`\`
|
|
844
|
+
\`\`\`ts
|
|
845
|
+
import { pgTable, text } from 'drizzle-orm/pg-core';
|
|
326
846
|
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
pnpm verify:frontend
|
|
332
|
-
pnpm verify:backend
|
|
333
|
-
pnpm verify:extension
|
|
847
|
+
export const projects = pgTable('projects', {
|
|
848
|
+
id: text('id').primaryKey(),
|
|
849
|
+
name: text('name').notNull(),
|
|
850
|
+
});
|
|
334
851
|
\`\`\`
|
|
335
852
|
|
|
336
|
-
|
|
853
|
+
\`apps/api/src/routes/app.ts\`:
|
|
337
854
|
|
|
338
|
-
\`\`\`
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
855
|
+
\`\`\`ts
|
|
856
|
+
import type { FastifyPluginAsync } from 'fastify';
|
|
857
|
+
import { ProjectListSchema } from '@shared/contracts';
|
|
858
|
+
import { db } from '@shared/db';
|
|
859
|
+
import { projects } from '@shared/db/schema';
|
|
860
|
+
|
|
861
|
+
export const appRoutes: FastifyPluginAsync = async (app) => {
|
|
862
|
+
app.get('/projects', async () => ProjectListSchema.parse(await db.select().from(projects)));
|
|
863
|
+
};
|
|
343
864
|
\`\`\`
|
|
344
865
|
|
|
345
|
-
|
|
866
|
+
${options.apps.web || options.apps.mobile || options.apps.desktop || options.apps.extension ? `\`packages/api-client/src/app.ts\`:
|
|
346
867
|
|
|
347
|
-
|
|
868
|
+
\`\`\`ts
|
|
869
|
+
import { ProjectListSchema } from '@shared/contracts';
|
|
870
|
+
import { ApiClient } from './generated';
|
|
348
871
|
|
|
349
|
-
|
|
872
|
+
export function listProjects(client: ApiClient) {
|
|
873
|
+
return client.request('/projects', { method: 'GET' }, ProjectListSchema);
|
|
874
|
+
}
|
|
875
|
+
\`\`\`
|
|
350
876
|
|
|
351
|
-
|
|
352
|
-
- Frontend/Vercel Frontend: only \`EXPO_PUBLIC_*\` values.
|
|
353
|
-
- Backend/Vercel Backend: server secrets such as \`DATABASE_URL\`, \`CLERK_SECRET_KEY\`, \`RC_SECRET_API_KEY\`, \`RC_WEBHOOK_SECRET\`, and R2 credentials.
|
|
354
|
-
- Extension: \`VITE_API_URL\`, \`VITE_CLERK_PUBLISHABLE_KEY\`, and optionally \`VITE_CRX_PUBLIC_KEY\`.
|
|
355
|
-
- EAS: add native app public keys with \`eas secret:create\` or the EAS dashboard.
|
|
877
|
+
Import \`listProjects\` and the generated client factory in the frontend surface you are building.` : ''}
|
|
356
878
|
|
|
357
|
-
|
|
879
|
+
Then run \`pnpm db:generate\`, review and commit the migration, run \`pnpm verify\`, apply it to the intended Neon branch with \`pnpm db:migrate\`, and start the relevant app surfaces.` : '';
|
|
880
|
+
const dependencyLines = [
|
|
881
|
+
options.apps.api ? 'frontend apps -> @shared/api-client -> HTTP -> apps/api' : null,
|
|
882
|
+
options.apps.api ? 'frontend apps -> @shared/contracts <- apps/api' : null,
|
|
883
|
+
options.apps.api && options.features.database ? 'apps/api -> @shared/db -> managed Neon Postgres' : null,
|
|
884
|
+
options.features.electronUpdater ? 'apps/desktop -> updates.<domain> Worker -> private R2 bucket' : null,
|
|
885
|
+
].filter((value) => value !== null).join('\n');
|
|
886
|
+
const uiTaskSection = Object.values(options.apps).some(Boolean) && (options.apps.web || options.apps.mobile || options.apps.desktop || options.apps.extension)
|
|
887
|
+
? `### Add UI primitives
|
|
358
888
|
|
|
359
|
-
|
|
889
|
+
\`\`\`sh
|
|
890
|
+
anhedral ui add dialog
|
|
891
|
+
${options.apps.web ? 'anhedral ui add data-table --target web\n' : ''}\`\`\`
|
|
360
892
|
|
|
361
|
-
|
|
893
|
+
DOM apps receive source-owned shadcn/ui files. Mobile receives React Native Reusables files. Customize those files normally.`
|
|
894
|
+
: '';
|
|
895
|
+
if (includeUserDocs) {
|
|
896
|
+
writeFile(path.join(root, 'README.md'), `# ${markdownHeading(options.displayName)}
|
|
362
897
|
|
|
363
|
-
|
|
364
|
-
- https://orm.drizzle.team/docs/tutorials/drizzle-with-neon
|
|
898
|
+
This is a complete, readable TypeScript product stack generated by Anhedral ${GENERATOR_VERSION}. Anhedral assembled and connected the tools; application code remains normal framework code.
|
|
365
899
|
|
|
366
|
-
|
|
900
|
+
## Start here
|
|
367
901
|
|
|
368
|
-
|
|
369
|
-
2. Copy the pooled connection string.
|
|
370
|
-
3. Set \`DATABASE_URL\` in the root \`.env\`, \`Backend/.env\`, and the Backend Vercel project.
|
|
371
|
-
4. Run:
|
|
902
|
+
Anhedral installs workspace dependencies during generation unless you used \`--skip-install\`. On a fresh clone, install them and copy the selected environment examples:
|
|
372
903
|
|
|
373
904
|
\`\`\`sh
|
|
374
|
-
|
|
375
|
-
pnpm db:migrate
|
|
905
|
+
${environmentSetupCommands}
|
|
376
906
|
\`\`\`
|
|
377
907
|
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
Docs:
|
|
381
|
-
|
|
382
|
-
- https://docs.expo.dev/guides/using-clerk/
|
|
383
|
-
- https://clerk.com/docs/quickstarts/get-started-with-expo
|
|
384
|
-
|
|
385
|
-
Steps:
|
|
386
|
-
|
|
387
|
-
1. Create a Clerk application.
|
|
388
|
-
2. Set \`EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY\` in \`Frontend/.env\`, the Frontend Vercel project, and EAS.
|
|
389
|
-
3. Set \`CLERK_PUBLISHABLE_KEY\` and \`CLERK_SECRET_KEY\` in \`Backend/.env\` and the Backend Vercel project.
|
|
390
|
-
4. Set \`VITE_CLERK_PUBLISHABLE_KEY\` in \`Extension/.env\`.
|
|
391
|
-
5. Add allowed origins for local Expo web, the deployed Frontend Vercel domain, and the Chrome extension origin after the extension id is stable.
|
|
392
|
-
6. Configure native redirect/deep-link settings for the Expo scheme in \`Frontend/app.json\`.
|
|
393
|
-
|
|
394
|
-
### RevenueCat + Stripe
|
|
395
|
-
|
|
396
|
-
Docs:
|
|
908
|
+
Stop here and replace every required placeholder in those uncommitted files. In particular, \`DATABASE_URL\` must be the pooled connection string for the intended managed Neon branch when the database module is selected. Then generate the initial migration and verify the workspace:
|
|
397
909
|
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
910
|
+
\`\`\`sh
|
|
911
|
+
${firstVerificationCommands}
|
|
912
|
+
\`\`\`
|
|
401
913
|
|
|
402
|
-
|
|
914
|
+
After verification succeeds, run \`pnpm dev\`. In a full stack this starts the primary web + API development loop, so opening the project does not also launch Expo, Electron, and WXT. Use \`pnpm dev:all\` when you intentionally want every selected surface, or run one surface:
|
|
403
915
|
|
|
404
|
-
|
|
405
|
-
2. Create an entitlement named \`pro\`.
|
|
406
|
-
3. Create iOS, Android, and Web apps in RevenueCat.
|
|
407
|
-
4. Copy the public SDK keys into \`EXPO_PUBLIC_RC_API_KEY_IOS\`, \`EXPO_PUBLIC_RC_API_KEY_ANDROID\`, and \`EXPO_PUBLIC_RC_WEB_API_KEY\`.
|
|
408
|
-
5. Set \`EXPO_PUBLIC_RC_ENTITLEMENT_ID=pro\`.
|
|
409
|
-
6. Connect Stripe as the RevenueCat web billing source.
|
|
410
|
-
7. Set \`RC_SECRET_API_KEY\` and \`RC_WEBHOOK_SECRET\` only in Backend envs.
|
|
411
|
-
8. Configure the RevenueCat webhook URL as \`https://<backend-domain>/webhooks/revenuecat\`.
|
|
916
|
+
${moduleCommands}
|
|
412
917
|
|
|
413
|
-
|
|
918
|
+
There is no hidden Anhedral application runtime. Open the framework directory you want and write ordinary TypeScript there.
|
|
414
919
|
|
|
415
|
-
|
|
920
|
+
## Where to write code
|
|
416
921
|
|
|
417
|
-
|
|
418
|
-
|
|
922
|
+
| Concern | Location | What belongs there |
|
|
923
|
+
| --- | --- | --- |
|
|
924
|
+
${sourceRows}
|
|
419
925
|
|
|
420
|
-
|
|
926
|
+
${options.apps.api ? 'For an end-to-end feature, define shared contracts first, implement server and persistence behavior, expose it through the typed client, then build each frontend.' : 'Build product features directly inside each selected application using its native framework conventions.'} Read [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for concrete recipes and [docs/STACK.md](docs/STACK.md) for every selected tool and its official documentation.
|
|
421
927
|
|
|
422
|
-
|
|
423
|
-
2. Create a least-privilege API token for that bucket.
|
|
424
|
-
3. Set \`R2_ACCOUNT_ID\`, \`R2_ACCESS_KEY_ID\`, \`R2_SECRET_ACCESS_KEY\`, and \`R2_BUCKET\` only in Backend envs.
|
|
425
|
-
4. Use \`POST /storage/uploads\` to create signed upload URLs, then \`GET /storage/files/:key\` or \`DELETE /storage/files/:key\` for user-owned objects.
|
|
426
|
-
5. Configure a public bucket URL or custom domain if uploaded assets need public CDN delivery.
|
|
928
|
+
## Selected modules
|
|
427
929
|
|
|
428
|
-
|
|
930
|
+
${modules.map((moduleName) => `- \`${moduleName}\``).join('\n')}
|
|
429
931
|
|
|
430
|
-
|
|
932
|
+
## UI and customization
|
|
431
933
|
|
|
432
|
-
|
|
433
|
-
- Root Directory: \`Frontend\`
|
|
434
|
-
- Build command: \`pnpm build:web\`
|
|
435
|
-
- Output directory: \`dist\`
|
|
934
|
+
DOM clients use source-owned shadcn/ui. Expo uses React Native Reusables with ${options.nativeStyling}. Add components with \`anhedral ui add <component>\`; use \`--target\` when a component belongs to one client. Customize the resulting source normally.
|
|
436
935
|
|
|
437
|
-
|
|
438
|
-
- Root Directory: \`Backend\`
|
|
439
|
-
- Build command: \`pnpm build\`
|
|
440
|
-
- Fastify entrypoint: \`src/index.ts\`
|
|
936
|
+
## Environment and first verification
|
|
441
937
|
|
|
442
|
-
|
|
938
|
+
The setup blocks above are the first-run checklist. Provider secrets stay in their package-local environment files; none belong in client code. This project uses managed Neon and intentionally does not generate or start local Postgres.
|
|
443
939
|
|
|
444
|
-
|
|
940
|
+
## Deployment
|
|
445
941
|
|
|
446
|
-
|
|
447
|
-
cd Frontend
|
|
448
|
-
pnpm dlx eas-cli@latest login
|
|
449
|
-
pnpm dlx eas-cli@latest init
|
|
450
|
-
pnpm dlx eas-cli@latest build --platform all --profile production
|
|
451
|
-
pnpm dlx eas-cli@latest submit --platform all --latest --profile production
|
|
452
|
-
\`\`\`
|
|
942
|
+
Only selected surfaces and providers contribute deployment scripts. Exact-version deployment CLIs run on demand instead of inflating the application lockfile.
|
|
453
943
|
|
|
454
|
-
|
|
944
|
+
| Selection | Delivery target | Strategy |
|
|
945
|
+
| --- | --- | --- |
|
|
946
|
+
${deploymentRows}
|
|
455
947
|
|
|
456
948
|
\`\`\`sh
|
|
457
|
-
|
|
949
|
+
${deploymentCommands}
|
|
458
950
|
\`\`\`
|
|
459
951
|
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
## Production Checklist
|
|
463
|
-
|
|
464
|
-
- Replace every \`*_placeholder\` value before production deploy.
|
|
465
|
-
- Keep server secrets out of Frontend, Extension, and EAS public envs.
|
|
466
|
-
- Confirm Clerk works on local web, Vercel web, iOS, Android, and the Chrome extension.
|
|
467
|
-
- Confirm RevenueCat returns the \`pro\` entitlement after Stripe web purchase and native store purchase.
|
|
468
|
-
- Confirm \`pnpm db:migrate\` has run against the production Neon database.
|
|
469
|
-
- Confirm R2 upload, signed URL retrieval, and deletion work from the deployed Backend domain.
|
|
470
|
-
- Confirm both Vercel projects can resolve \`packages/*\` workspace packages.
|
|
471
|
-
- Confirm the Chrome extension ZIP is tested locally with \`chrome://extensions\` before Chrome Web Store upload.
|
|
472
|
-
|
|
473
|
-
## Relevant Docs
|
|
474
|
-
|
|
475
|
-
- [Vercel monorepos](https://vercel.com/docs/monorepos)
|
|
476
|
-
- [Vercel monorepo FAQ](https://vercel.com/docs/monorepos/monorepo-faq)
|
|
477
|
-
- [Expo web publishing](https://docs.expo.dev/guides/publishing-websites/)
|
|
478
|
-
- [Expo EAS](https://docs.expo.dev/eas/)
|
|
479
|
-
- [Expo store submissions](https://docs.expo.dev/deploy/submit-to-app-stores/)
|
|
480
|
-
- [Clerk with Expo](https://docs.expo.dev/guides/using-clerk/)
|
|
481
|
-
- [Clerk Expo quickstart](https://clerk.com/docs/quickstarts/get-started-with-expo)
|
|
482
|
-
- [Neon connection strings](https://neon.com/docs/get-started-with-neon/connect-neon)
|
|
483
|
-
- [Drizzle with Neon](https://orm.drizzle.team/docs/tutorials/drizzle-with-neon)
|
|
484
|
-
- [RevenueCat Web](https://www.revenuecat.com/docs/web/overview)
|
|
485
|
-
- [RevenueCat Stripe Billing](https://www.revenuecat.com/docs/web/integrations/stripe)
|
|
486
|
-
- [Stripe API keys](https://docs.stripe.com/keys)
|
|
487
|
-
- [Cloudflare R2 S3 API](https://developers.cloudflare.com/r2/get-started/s3/)
|
|
488
|
-
- [Cloudflare R2 API tokens](https://developers.cloudflare.com/r2/api/tokens/)
|
|
489
|
-
- [WXT publishing](https://wxt.dev/guide/essentials/publishing.html)
|
|
490
|
-
- [Chrome Web Store publishing](https://developer.chrome.com/docs/webstore/publish)
|
|
491
|
-
`);
|
|
492
|
-
}
|
|
493
|
-
function writeStackFile(root, stack) {
|
|
494
|
-
writeFile(path.join(root, 'stack.json'), JSON.stringify(stack, null, 2) + '\n');
|
|
495
|
-
}
|
|
496
|
-
function writeSharedPackages(root) {
|
|
497
|
-
const dbRoot = path.join(root, 'packages/db');
|
|
498
|
-
writeFile(path.join(dbRoot, 'package.json'), JSON.stringify({
|
|
499
|
-
name: '@shared/db',
|
|
500
|
-
version: '0.1.0',
|
|
501
|
-
private: true,
|
|
502
|
-
type: 'module',
|
|
503
|
-
exports: {
|
|
504
|
-
'.': './src/index.ts',
|
|
505
|
-
'./schema': './src/schema.ts',
|
|
506
|
-
'./queries': './src/queries/index.ts',
|
|
507
|
-
'./migrate': './src/migrate.ts',
|
|
508
|
-
},
|
|
509
|
-
scripts: {
|
|
510
|
-
build: 'pnpm typecheck',
|
|
511
|
-
typecheck: 'tsc --noEmit',
|
|
512
|
-
'db:generate': 'drizzle-kit generate',
|
|
513
|
-
'db:migrate': 'tsx --env-file=../../.env src/migrate.ts',
|
|
514
|
-
'db:studio': 'drizzle-kit studio',
|
|
515
|
-
'db:check': 'drizzle-kit check',
|
|
516
|
-
},
|
|
517
|
-
dependencies: SHARED_DB_DEPENDENCIES.dependencies,
|
|
518
|
-
devDependencies: SHARED_DB_DEPENDENCIES.devDependencies,
|
|
519
|
-
}, null, 2) + '\n');
|
|
520
|
-
writeFile(path.join(dbRoot, 'tsconfig.json'), JSON.stringify({
|
|
521
|
-
compilerOptions: {
|
|
522
|
-
target: 'ESNext',
|
|
523
|
-
module: 'ESNext',
|
|
524
|
-
moduleResolution: 'Bundler',
|
|
525
|
-
strict: true,
|
|
526
|
-
skipLibCheck: true,
|
|
527
|
-
noEmit: true,
|
|
528
|
-
types: ['node'],
|
|
529
|
-
},
|
|
530
|
-
include: ['src/**/*', 'drizzle.config.ts'],
|
|
531
|
-
}, null, 2) + '\n');
|
|
532
|
-
writeFile(path.join(dbRoot, 'drizzle.config.ts'), `import type { Config } from 'drizzle-kit';
|
|
533
|
-
|
|
534
|
-
export default {
|
|
535
|
-
schema: './src/schema.ts',
|
|
536
|
-
out: './migrations',
|
|
537
|
-
dialect: 'postgresql',
|
|
538
|
-
dbCredentials: {
|
|
539
|
-
url: process.env.DATABASE_URL!,
|
|
540
|
-
},
|
|
541
|
-
} satisfies Config;
|
|
542
|
-
`);
|
|
543
|
-
writeFile(path.join(dbRoot, 'src/index.ts'), `import { drizzle } from 'drizzle-orm/neon-http';
|
|
544
|
-
import { neon } from '@neondatabase/serverless';
|
|
545
|
-
import * as schema from './schema';
|
|
546
|
-
|
|
547
|
-
export function createDb(databaseUrl = process.env.DATABASE_URL) {
|
|
548
|
-
if (!databaseUrl) {
|
|
549
|
-
throw new Error('DATABASE_URL is not defined');
|
|
550
|
-
}
|
|
551
|
-
|
|
552
|
-
const sql = neon(databaseUrl);
|
|
553
|
-
return drizzle(sql, { schema });
|
|
554
|
-
}
|
|
555
|
-
|
|
556
|
-
export type Database = ReturnType<typeof createDb>;
|
|
557
|
-
|
|
558
|
-
let cachedDb: Database | undefined;
|
|
559
|
-
|
|
560
|
-
function getDb() {
|
|
561
|
-
cachedDb ??= createDb();
|
|
562
|
-
return cachedDb;
|
|
563
|
-
}
|
|
564
|
-
|
|
565
|
-
export const db = new Proxy({} as Database, {
|
|
566
|
-
get(_target, prop, receiver) {
|
|
567
|
-
return Reflect.get(getDb(), prop, receiver);
|
|
568
|
-
},
|
|
569
|
-
});
|
|
570
|
-
|
|
571
|
-
export * from './schema';
|
|
952
|
+
Read \`PRODUCTION.md\` before creating accounts or production resources. Generated ownership and tool versions are recorded in \`anhedral.json\`. Run \`anhedral doctor\` before structural changes and preview them with \`--dry-run\`.
|
|
572
953
|
`);
|
|
573
|
-
|
|
574
|
-
|
|
954
|
+
}
|
|
955
|
+
writeFile(path.join(root, 'docs/DEVELOPMENT.md'), `# Developing ${markdownHeading(options.displayName)}
|
|
575
956
|
|
|
576
|
-
|
|
577
|
-
export type SubscriptionTier = (typeof SUBSCRIPTION_TIERS)[number];
|
|
957
|
+
Anhedral generated this stack; it does not replace the frameworks inside it. Use official framework APIs and keep product logic in ordinary source files.
|
|
578
958
|
|
|
579
|
-
|
|
580
|
-
export type SubscriptionStatus = (typeof SUBSCRIPTION_STATUSES)[number];
|
|
959
|
+
## End-to-end feature loop
|
|
581
960
|
|
|
582
|
-
|
|
583
|
-
export type SubscriptionMethod = (typeof SUBSCRIPTION_METHODS)[number];
|
|
961
|
+
${developmentSteps}
|
|
584
962
|
|
|
585
|
-
|
|
586
|
-
export type SubscriptionOrigin = (typeof SUBSCRIPTION_ORIGINS)[number];
|
|
963
|
+
${verticalSliceExample}
|
|
587
964
|
|
|
588
|
-
|
|
589
|
-
'trial_started', 'trial_converted', 'trial_expired',
|
|
590
|
-
'initial_purchase', 'renewal', 'product_change',
|
|
591
|
-
'cancellation_scheduled', 'cancellation_unscheduled', 'subscription_expired', 'subscription_canceled',
|
|
592
|
-
'billing_issue', 'billing_recovered',
|
|
593
|
-
] as const;
|
|
594
|
-
export type SubscriptionEventType = (typeof SUBSCRIPTION_EVENT_TYPES)[number];
|
|
965
|
+
## Common tasks
|
|
595
966
|
|
|
596
|
-
|
|
597
|
-
revenueCatProductId?: string;
|
|
598
|
-
lastWebhookUpdate?: string;
|
|
599
|
-
cancelReason?: string;
|
|
600
|
-
};
|
|
967
|
+
${commonTaskSections}
|
|
601
968
|
|
|
602
|
-
|
|
603
|
-
revenueCatEventType?: string;
|
|
604
|
-
revenueCatProductId?: string;
|
|
605
|
-
billingPeriod?: string;
|
|
606
|
-
price?: { amount: number; currency: string };
|
|
607
|
-
store?: string;
|
|
608
|
-
transactionId?: string;
|
|
609
|
-
reason?: string;
|
|
610
|
-
};
|
|
969
|
+
${uiTaskSection}
|
|
611
970
|
|
|
612
|
-
|
|
613
|
-
id: text('id').primaryKey(),
|
|
614
|
-
clerkUserId: text('clerk_user_id').unique(),
|
|
615
|
-
email: text('email').notNull().unique(),
|
|
616
|
-
displayName: text('display_name'),
|
|
617
|
-
lastLoginAt: timestamp('last_login_at'),
|
|
618
|
-
profileImageUrl: text('profile_image_url'),
|
|
619
|
-
subscriptionTier: text('subscription_tier').notNull().default('free'),
|
|
620
|
-
subscriptionStatus: text('subscription_status').notNull().default('active'),
|
|
621
|
-
createdAt: timestamp('created_at').$defaultFn(() => new Date()).notNull(),
|
|
622
|
-
updatedAt: timestamp('updated_at').$defaultFn(() => new Date()).notNull(),
|
|
623
|
-
}, (t) => [
|
|
624
|
-
index('users_clerk_user_id_idx').on(t.clerkUserId),
|
|
625
|
-
index('users_email_idx').on(t.email),
|
|
626
|
-
]);
|
|
971
|
+
### Add another app surface
|
|
627
972
|
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
bucket: text('bucket').notNull(),
|
|
633
|
-
contentType: text('content_type'),
|
|
634
|
-
createdAt: timestamp('created_at').$defaultFn(() => new Date()).notNull(),
|
|
635
|
-
});
|
|
973
|
+
\`\`\`sh
|
|
974
|
+
anhedral add mobile --dry-run
|
|
975
|
+
anhedral add mobile
|
|
976
|
+
\`\`\`
|
|
636
977
|
|
|
637
|
-
|
|
638
|
-
id: text('id').primaryKey(),
|
|
639
|
-
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }).unique(),
|
|
640
|
-
tier: text('tier').$type<SubscriptionTier>().notNull().default('free'),
|
|
641
|
-
status: text('status').$type<SubscriptionStatus>().notNull().default('active'),
|
|
642
|
-
method: text('method').$type<SubscriptionMethod>(),
|
|
643
|
-
origin: text('origin').$type<SubscriptionOrigin>(),
|
|
644
|
-
billingPeriod: text('billing_period'),
|
|
645
|
-
currentPeriodStart: timestamp('current_period_start'),
|
|
646
|
-
currentPeriodEnd: timestamp('current_period_end'),
|
|
647
|
-
canceledAt: timestamp('canceled_at'),
|
|
648
|
-
cancelAtPeriodEnd: boolean('cancel_at_period_end').notNull().default(false),
|
|
649
|
-
trialStart: timestamp('trial_start'),
|
|
650
|
-
trialEnd: timestamp('trial_end'),
|
|
651
|
-
dailyLimit: integer('daily_limit'),
|
|
652
|
-
metadata: jsonb('metadata').$type<SubscriptionMetadata>(),
|
|
653
|
-
createdAt: timestamp('created_at').$defaultFn(() => new Date()).notNull(),
|
|
654
|
-
updatedAt: timestamp('updated_at').$defaultFn(() => new Date()).notNull(),
|
|
655
|
-
}, (t) => [
|
|
656
|
-
index('subscriptions_user_idx').on(t.userId),
|
|
657
|
-
index('subscriptions_status_idx').on(t.status),
|
|
658
|
-
index('subscriptions_tier_idx').on(t.tier),
|
|
659
|
-
index('subscriptions_period_end_idx').on(t.currentPeriodEnd),
|
|
660
|
-
]);
|
|
978
|
+
Anhedral refuses ownership conflicts instead of overwriting product changes. Run \`anhedral doctor\` when an add cannot proceed.
|
|
661
979
|
|
|
662
|
-
|
|
663
|
-
id: text('id').primaryKey(),
|
|
664
|
-
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
|
665
|
-
subscriptionId: text('subscription_id').references(() => subscriptions.id, { onDelete: 'set null' }),
|
|
666
|
-
eventType: text('event_type').$type<SubscriptionEventType>().notNull(),
|
|
667
|
-
previousTier: text('previous_tier').$type<SubscriptionTier>(),
|
|
668
|
-
previousStatus: text('previous_status').$type<SubscriptionStatus>(),
|
|
669
|
-
previousMethod: text('previous_method').$type<SubscriptionMethod>(),
|
|
670
|
-
newTier: text('new_tier').$type<SubscriptionTier>(),
|
|
671
|
-
newStatus: text('new_status').$type<SubscriptionStatus>(),
|
|
672
|
-
newMethod: text('new_method').$type<SubscriptionMethod>(),
|
|
673
|
-
revenueCatEventType: text('revenuecat_event_type'),
|
|
674
|
-
revenueCatProductId: text('revenuecat_product_id'),
|
|
675
|
-
origin: text('origin').$type<SubscriptionOrigin>(),
|
|
676
|
-
periodStart: timestamp('period_start'),
|
|
677
|
-
periodEnd: timestamp('period_end'),
|
|
678
|
-
metadata: jsonb('metadata').$type<SubscriptionEventMetadata>(),
|
|
679
|
-
createdAt: timestamp('created_at').$defaultFn(() => new Date()).notNull(),
|
|
680
|
-
}, (t) => [
|
|
681
|
-
index('sub_events_user_idx').on(t.userId),
|
|
682
|
-
index('sub_events_user_created_idx').on(t.userId, t.createdAt),
|
|
683
|
-
index('sub_events_type_idx').on(t.eventType),
|
|
684
|
-
]);
|
|
980
|
+
## Verification
|
|
685
981
|
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
claimedAt: timestamp('claimed_at').$defaultFn(() => new Date()).notNull(),
|
|
691
|
-
}, (t) => [index('trial_claims_email_idx').on(t.email)]);
|
|
692
|
-
|
|
693
|
-
export type Users = InferSelectModel<typeof users>;
|
|
694
|
-
export type NewUsers = InferInsertModel<typeof users>;
|
|
695
|
-
export type Uploads = InferSelectModel<typeof uploads>;
|
|
696
|
-
export type NewUploads = InferInsertModel<typeof uploads>;
|
|
697
|
-
export type Subscriptions = InferSelectModel<typeof subscriptions>;
|
|
698
|
-
export type NewSubscriptions = InferInsertModel<typeof subscriptions>;
|
|
699
|
-
export type SubscriptionEvents = InferSelectModel<typeof subscriptionEvents>;
|
|
700
|
-
export type NewSubscriptionEvents = InferInsertModel<typeof subscriptionEvents>;
|
|
701
|
-
export type TrialClaims = InferSelectModel<typeof trialClaims>;
|
|
702
|
-
export type NewTrialClaims = InferInsertModel<typeof trialClaims>;
|
|
982
|
+
- While iterating, run the affected package's typecheck or tests.
|
|
983
|
+
- Before handoff, run \`pnpm verify\`.
|
|
984
|
+
- Before generation, run \`anhedral doctor\` and use \`--dry-run\`.
|
|
985
|
+
- For schema changes, commit reviewed migration SQL and run \`pnpm verify:db\`.
|
|
703
986
|
`);
|
|
704
|
-
writeFile(path.join(
|
|
705
|
-
import { db } from '../index';
|
|
706
|
-
import { uploads, users } from '../schema';
|
|
707
|
-
|
|
708
|
-
type SyncUserInput = {
|
|
709
|
-
clerkUserId: string;
|
|
710
|
-
email: string;
|
|
711
|
-
displayName: string | null;
|
|
712
|
-
};
|
|
987
|
+
writeFile(path.join(root, 'docs/STACK.md'), `# Stack guide
|
|
713
988
|
|
|
714
|
-
|
|
715
|
-
return db.query.users.findFirst({
|
|
716
|
-
where: eq(users.clerkUserId, clerkUserId),
|
|
717
|
-
});
|
|
718
|
-
}
|
|
989
|
+
This maps generated source to upstream documentation. Anhedral owns initial integration and safe incremental generation. Each framework owns its runtime and programming model.
|
|
719
990
|
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
.values({
|
|
724
|
-
id: input.clerkUserId,
|
|
725
|
-
clerkUserId: input.clerkUserId,
|
|
726
|
-
email: input.email,
|
|
727
|
-
displayName: input.displayName,
|
|
728
|
-
})
|
|
729
|
-
.onConflictDoUpdate({
|
|
730
|
-
target: users.clerkUserId,
|
|
731
|
-
set: {
|
|
732
|
-
email: input.email,
|
|
733
|
-
displayName: input.displayName,
|
|
734
|
-
updatedAt: new Date(),
|
|
735
|
-
},
|
|
736
|
-
});
|
|
991
|
+
| Tool | Responsibility | Generated location | Official documentation |
|
|
992
|
+
| --- | --- | --- | --- |
|
|
993
|
+
${toolRows}
|
|
737
994
|
|
|
738
|
-
|
|
739
|
-
}
|
|
995
|
+
## Dependency direction
|
|
740
996
|
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
) {
|
|
745
|
-
await db.insert(uploads).values({
|
|
746
|
-
id: crypto.randomUUID(),
|
|
747
|
-
userId: clerkUserId,
|
|
748
|
-
objectKey: input.objectKey,
|
|
749
|
-
bucket: input.bucket,
|
|
750
|
-
contentType: input.contentType,
|
|
751
|
-
});
|
|
752
|
-
}
|
|
753
|
-
`);
|
|
754
|
-
writeFile(path.join(dbRoot, 'src/queries/uploads.ts'), `import { eq } from 'drizzle-orm';
|
|
755
|
-
import { db } from '../index';
|
|
756
|
-
import { uploads } from '../schema';
|
|
997
|
+
\`\`\`text
|
|
998
|
+
${dependencyLines || 'selected applications are independent framework projects'}
|
|
999
|
+
\`\`\`
|
|
757
1000
|
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
});
|
|
762
|
-
}
|
|
1001
|
+
Clients may import contracts and the API client. They must not import API services, database connections, or server environment modules. Provider secrets terminate at the API or provider-specific Worker.
|
|
1002
|
+
|
|
1003
|
+
\`anhedral add\` adds modules and integration files. \`anhedral ui add\` adds source-owned UI. \`anhedral doctor\` checks recorded ownership. Product features, models, pages, routes, and services remain developer-owned TypeScript.
|
|
763
1004
|
`);
|
|
764
|
-
|
|
765
|
-
|
|
1005
|
+
if (includeUserDocs) {
|
|
1006
|
+
const productionItems = [
|
|
1007
|
+
options.apps.api ? '- Keep `ANHEDRAL_DEMO=false` in production.' : null,
|
|
1008
|
+
options.apps.api ? '- Set `TRUST_PROXY_HOPS` only to the number of trusted reverse-proxy hops.' : null,
|
|
1009
|
+
options.apps.api ? '- Store server secrets only in the API deployment environment.' : null,
|
|
1010
|
+
options.apps.api || options.apps.web ? '- Select the Vercel Services framework preset and deploy from the repository root.' : null,
|
|
1011
|
+
options.apps.api && options.apps.web ? '- Keep the top-level `/api/(.*)` service rewrite before the web catch-all rewrite.' : null,
|
|
1012
|
+
options.apps.api && options.apps.web ? '- The web client defaults to same-origin `/api` in production; override `NEXT_PUBLIC_API_URL` only when the API uses another origin.' : null,
|
|
1013
|
+
options.features.storage ? '- Set a strong `CRON_SECRET` and verify Vercel invokes `/api/internal/storage/cleanup` on schedule.' : null,
|
|
1014
|
+
options.features.billing ? '- Configure `ABLY_API_KEY`, set a strong `CRON_SECRET`, and verify Vercel invokes `/api/internal/realtime/flush` every five minutes to retry the transactional outbox.' : null,
|
|
1015
|
+
options.features.billing ? '- Use a dedicated RevenueCat secret REST API key with customer-read access; keep it server-only and rotate it independently from the 32+ character webhook authorization secret.' : null,
|
|
1016
|
+
options.features.storage ? '- List the live R2 CORS policy, merge every exact browser origin into `cloudflare/r2-cors.template.json`, then apply the complete replacement policy and verify preflight plus signed PUT.' : null,
|
|
1017
|
+
options.features.storage ? '- Configure an R2 lifecycle rule for the `storage/staging/` prefix as a backstop, with an age longer than the application cleanup grace period.' : null,
|
|
1018
|
+
options.features.database ? '- Commit every reviewed Drizzle SQL migration and its metadata with the schema change; `pnpm verify:db` rejects a missing or untracked SQL baseline and validates migration history.' : null,
|
|
1019
|
+
options.features.database ? '- Generated CI runs `pnpm db:generate` and fails when `packages/db/migrations` changes, preventing schema changes without a matching migration.' : null,
|
|
1020
|
+
'- Run `pnpm verify` before deployment.',
|
|
1021
|
+
'- Run `anhedral doctor` before incremental generation.',
|
|
1022
|
+
].filter((item) => item !== null);
|
|
1023
|
+
const productionSections = [
|
|
1024
|
+
options.apps.web || options.apps.api || options.features.storage ? `## Domain registration and Cloudflare control plane
|
|
1025
|
+
|
|
1026
|
+
Registration and DNS hosting are separate jobs. You can buy the domain at GoDaddy while Cloudflare becomes authoritative DNS immediately; transferring the registrar later is optional.
|
|
1027
|
+
|
|
1028
|
+
1. Buy the domain at GoDaddy under an organization-controlled account. Enable auto-renewal, MFA, accurate registrant contact details, and record renewal ownership.
|
|
1029
|
+
2. Add the domain as a website/zone in Cloudflare. Before changing nameservers, copy every existing DNS record—especially MX, TXT, DKIM, SPF, DMARC, verification, and subdomain records—and disable DNSSEC/DS records at GoDaddy if enabled.
|
|
1030
|
+
3. Cloudflare assigns two authoritative nameservers. Replace the GoDaddy nameservers with those exact values, then wait until Cloudflare marks the zone **Active**. Recreate and verify mail and other records before considering the migration complete. Re-enable DNSSEC in Cloudflare after activation.
|
|
1031
|
+
4. Optional registrar transfer: after the domain is eligible (commonly not within 60 days of registration, a prior transfer, or certain registrant changes), unlock it at GoDaddy, request its EPP/authorization code, and start **Transfer Domains** in Cloudflare. Approve the transfer and payment. DNS can remain live throughout because Cloudflare was already authoritative.
|
|
1032
|
+
5. Keep Cloudflare as the shared domain control plane: DNS and DNSSEC at the zone; Workers and R2 for the asset hostname; and optional Queues, Workflows, Durable Objects, WAF, Turnstile, or other compute/security products when the application actually needs them. Vercel remains the web/API runtime.
|
|
1033
|
+
|
|
1034
|
+
Use organization-owned Cloudflare and GoDaddy accounts with MFA and least-privilege member roles. Do not confuse changing nameservers (DNS delegation) with transferring the registration (registrar/billing ownership). Follow the current [GoDaddy nameserver instructions](https://help.dc-aws.godaddy.com/help/edit-my-domain-nameservers-664), [GoDaddy transfer-away sequence](https://www.godaddy.com/en-ca/help/transfer-my-domain-away-from-godaddy-3560), [Cloudflare nameserver guidance](https://developers.cloudflare.com/dns/nameservers/update-nameservers/), and [Cloudflare registrar-transfer sequence](https://developers.cloudflare.com/registrar/get-started/transfer-domain-to-cloudflare/).` : null,
|
|
1035
|
+
options.apps.web || options.apps.api ? `## Vercel: web and API
|
|
1036
|
+
|
|
1037
|
+
1. Push the repository to GitHub and import it in Vercel. Choose the **Services** framework preset and keep the repository root as the project root; \`vercel.json\` maps the selected \`apps/web\` and \`apps/api\` services.
|
|
1038
|
+
2. Add every required variable from the package-local \`.env.example\` files to Vercel's Development, Preview, and Production environments. Server secrets belong only to the API service. Never put a secret in a \`NEXT_PUBLIC_*\` variable.
|
|
1039
|
+
3. Run \`pnpm deploy:vercel:link\` for local CLI access. The normal release path is Git: every non-production branch/PR receives a preview deployment, and merging to the configured production branch creates production automatically. The explicit \`deploy:vercel:preview\` and \`deploy:vercel:production\` scripts are escape hatches for manual releases.
|
|
1040
|
+
4. Run \`pnpm deploy:vercel:inspect -- <deployment-url>\` when diagnosing a deployment. Verify health, authentication, scheduled jobs, and provider callbacks in Preview before merging.
|
|
1041
|
+
5. Add \`app.example.com\` in Vercel **Project → Settings → Domains** before changing DNS. Inspect it with \`pnpm deploy:vercel:domain:inspect -- app.example.com\`, then create the exact A/CNAME/TXT records Vercel reports in Cloudflare DNS. Leave the Vercel A/CNAME record **DNS only** (gray cloud), because placing Cloudflare's reverse proxy in front of Vercel hides traffic signals, adds another CDN hop, and can interfere with Vercel caching/firewall behavior. Ownership-verification TXT records are also DNS only. Vercel provisions TLS after validation.
|
|
1042
|
+
|
|
1043
|
+
When web and API are both selected, keep the generated \`/api/(.*)\` route before the web catch-all. The browser uses same-origin \`/api\`; mobile, desktop, and extension builds need the absolute production API URL.` : null,
|
|
1044
|
+
options.features.auth ? `## Clerk: production identity
|
|
1045
|
+
|
|
1046
|
+
1. Create the Clerk application for development, then create its separate **Production** instance. Copy \`pk_live_*\` and \`sk_live_*\` keys into the production environments; keep \`CLERK_SECRET_KEY\` server-only.
|
|
1047
|
+
2. Add the production root domain in Clerk and publish the DNS records shown on **Domains**. Configure a subdomain allowlist, production OAuth credentials, allowlisted redirect URLs, webhook URLs/signing secrets, and native application identifiers that apply to the selected clients.
|
|
1048
|
+
3. Use development keys locally and production keys only for production builds. Vercel Preview deployments should use a separate Clerk application/domain when stable preview auth is required; do not point previews at live user data.
|
|
1049
|
+
4. Redeploy every selected client after changing public Clerk keys. Test sign-in, sign-out, token refresh, deep links, and physical-device behavior before release.${options.apps.extension ? '\n5. For the extension, create a stable CRX ID, configure Clerk Chrome Extension deployment for that ID, and set `VITE_CLERK_FRONTEND_API_URL` plus `VITE_CLERK_SYNC_HOST` when using web-to-extension session sync. OAuth and email-link flows require Sync Host.' : ''}` : null,
|
|
1050
|
+
options.features.database ? `## Neon and Drizzle: database
|
|
1051
|
+
|
|
1052
|
+
1. Create a Neon account, then run \`pnpm neon:login\` and \`pnpm neon:project:create -- --name ${options.projectName}\`, or create the project in the Neon console.
|
|
1053
|
+
2. Copy the pooled Postgres connection string to \`DATABASE_URL\` in \`packages/db/.env\` locally and the API's Vercel environment in production. Keep preview and production databases isolated with Neon branches or separate projects.
|
|
1054
|
+
3. Change the Drizzle schema, run \`pnpm db:generate\`, review and commit the SQL and metadata, then run \`pnpm verify:db\`.
|
|
1055
|
+
4. Apply \`pnpm db:migrate\` against the intended database as a controlled release step before sending production traffic to code that requires the new schema. Backward-compatible migrations make rollback safer; never run unreviewed migration generation during a Vercel build.
|
|
1056
|
+
5. Enable Neon backups/restore controls appropriate to the plan, restrict credentials, and rotate a leaked connection string immediately.` : null,
|
|
1057
|
+
options.features.storage ? `## Cloudflare R2: private bucket and generated Worker
|
|
1058
|
+
|
|
1059
|
+
1. Run \`pnpm r2:login\` and \`pnpm r2:bucket:create\` to create \`${storageBucketName}\`, or create that bucket in **R2 → Overview**. Keep both the \`r2.dev\` development URL and R2 bucket custom-domain access disabled; the bucket itself remains private.
|
|
1060
|
+
2. Create an Object Read & Write S3 API token scoped to this bucket. Put \`BASE_URL\`, \`R2_ACCOUNT_ID\`, \`R2_ACCESS_KEY_ID\`, \`R2_SECRET_ACCESS_KEY\`, \`R2_BUCKET_NAME=${storageBucketName}\`, \`R2_PREFIX=storage\`, and \`R2_PROXY_READ_URL_TTL_SECONDS=600\` in the API deployment. The Worker uses the in-process \`ASSETS\` binding instead of these S3 credentials. Keep \`CLOUDFLARE_API_TOKEN\` operations/CI-only for Wrangler.
|
|
1061
|
+
3. Direct browser uploads continue to use short-lived presigned PUT URLs on \`<ACCOUNT_ID>.r2.cloudflarestorage.com\`. Presigned URLs do **not** work on custom domains. Run \`pnpm r2:cors:list\`, edit the user-owned \`cloudflare/r2-cors.template.json\`, then run \`pnpm r2:cors:set\`. The set command replaces the complete live policy, so retain every exact uploading origin.
|
|
1062
|
+
4. Open \`apps/assets-private-proxy/wrangler.jsonc\`. Replace both \`assets.example.com\` values with \`assets.yourdomain.com\`. The generated config names the Worker \`assets-private-proxy\`, disables \`workers.dev\` and preview URLs, binds \`${storageBucketName}\` as \`ASSETS\`, sets \`R2_PREFIX=storage\`, enables logs, and declares the hostname as a Worker Custom Domain.
|
|
1063
|
+
5. Run \`pnpm assets:proxy:check\`, then \`pnpm assets:proxy:deploy\`. Wrangler creates or updates the Worker, R2 binding, managed proxied DNS record, TLS certificate, and custom domain. The dashboard equivalent is **Workers & Pages → assets-private-proxy → Settings → Bindings** (R2, variable \`ASSETS\`, bucket \`${storageBucketName}\`) and **Domains & Routes → Add → Custom domain**.
|
|
1064
|
+
6. The Worker streams GET/HEAD bodies, preserves metadata/ranges/conditionals, caches complete public responses, rejects other methods and unexpected hosts, and never lists the bucket. It exposes only \`storage/confirmed/\` by unguessable key; \`storage/staging/\` and \`generation-inputs\` return 404. Authenticated private reads use \`GET /api/storage/uploads/:uploadId/read-url\`, which verifies ownership before returning a short-lived S3 URL.
|
|
1065
|
+
7. Add an R2 lifecycle rule for \`storage/staging/\` longer than the API cleanup grace period. Set a strong \`CRON_SECRET\` in Vercel and verify cleanup. Test missing keys, invalid encodings, GET, HEAD, range, conditional, cache HIT/MISS, forbidden methods, private-prefix rejection, authenticated read authorization, TTL bounds, and direct bucket inaccessibility.
|
|
1066
|
+
|
|
1067
|
+
\`app.yourdomain.com\` is a Cloudflare DNS-only record targeting Vercel. \`assets.yourdomain.com\` is a Cloudflare-managed Worker Custom Domain and stays proxied. Never connect the asset hostname directly to R2, never CNAME it to \`r2.dev\`, and never expose S3 credentials in a client bundle.` : null,
|
|
1068
|
+
options.apps.mobile ? `## Expo, TestFlight, and app stores
|
|
1069
|
+
|
|
1070
|
+
Accounts required: an Expo/EAS account, an Apple Developer Program membership with App Store Connect access for iOS, and a Google Play Console developer account for Android. Create unique iOS bundle and Android package identifiers before the first store build.
|
|
1071
|
+
|
|
1072
|
+
1. Run \`pnpm mobile:eas:login\`, then \`pnpm mobile:eas:init\` from the repository root to create/link the EAS project. Review and commit the project ID/configuration that EAS adds.
|
|
1073
|
+
2. Put public build-time values in the matching EAS environment/profile; keep server secrets out of Expo. Configure Apple/Google credentials with EAS and test sign-in/deep links on physical devices.
|
|
1074
|
+
3. Internal EAS distribution: run \`pnpm mobile:build:internal:ios\` and/or \`pnpm mobile:build:internal:android\`. iOS ad hoc distribution requires registered device UDIDs; Android internal builds can be installed from the EAS share URL. These builds do not require a development server.
|
|
1075
|
+
4. TestFlight: run \`pnpm mobile:build:production:ios\`, then \`pnpm mobile:submit:ios\`. After App Store Connect processes the build, assign it to internal testers. External testers require TestFlight Beta App Review. TestFlight approval is not App Store production approval.
|
|
1076
|
+
5. Google Play: create the app/listing and required policy declarations in Play Console. Run \`pnpm mobile:build:production:android\`, then \`pnpm mobile:submit:android\`. Start with the internal testing track, promote through closed/open testing as appropriate, and only then roll out production. A brand-new Play app may require its first binary to be uploaded manually before API-based submissions work.
|
|
1077
|
+
6. Complete store metadata, screenshots, privacy/data-safety declarations, content ratings, export compliance, pricing/availability, review credentials, and release notes. Submit for store review and use staged/phased rollout where appropriate.` : null,
|
|
1078
|
+
options.apps.extension ? `## Chrome Web Store
|
|
1079
|
+
|
|
1080
|
+
1. Register a Chrome Web Store developer account, verify its contact email, accept the agreement, and pay Google's one-time registration fee.
|
|
1081
|
+
2. Configure production extension environment values and a stable CRX public key/ID, then run \`pnpm verify:extension\` and \`pnpm extension:zip\`. Load the unpacked production build in Chrome first and test install, update, permissions, authentication, API calls, and sign-out.
|
|
1082
|
+
3. In the Developer Dashboard, create an item and upload the ZIP from \`apps/extension/.output\`. Complete the listing (description, icons, screenshots, category, regions) and keep the manifest version higher for every update.
|
|
1083
|
+
4. State one narrow purpose, justify every permission/host permission, disclose all handled data, certify limited use, and provide a public privacy-policy URL consistent with actual behavior. Clerk login counts as handling authentication data. Supply reviewer test instructions and credentials when features are gated.
|
|
1084
|
+
5. Publish first to trusted testers/unlisted visibility, verify the store-installed CRX ID matches Clerk and backend allowlists, then submit the production listing for review. Store review and rollout are separate from GitHub/Vercel deployment.` : null,
|
|
1085
|
+
options.features.electronUpdater ? `## Electron desktop and automatic updates
|
|
1086
|
+
|
|
1087
|
+
The generated update path is \`electron-updater -> updates.<domain> Worker -> private ${desktopUpdatesBucketName(options.projectName)} R2 bucket\`. The application never receives Cloudflare credentials, the Worker exposes reads only, and direct bucket access stays disabled.
|
|
1088
|
+
|
|
1089
|
+
1. In \`apps/desktop-updater-worker/wrangler.jsonc\`, replace both \`updates.example.com\` values with a hostname in an active Cloudflare zone. Run \`pnpm desktop:updates:cloudflare:login\`, then run \`pnpm desktop:updates:first-provision\` exactly once to create the private bucket and deploy the bound Worker, managed DNS record, TLS certificate, logs, and custom domain. For repeat deployments use \`pnpm desktop:updates:worker:deploy\`; bucket creation is intentionally not repeated. Keep the bucket's \`r2.dev\` URL and R2 bucket custom-domain access disabled.
|
|
1090
|
+
2. Copy \`apps/desktop/electron-builder.env.example\` to \`apps/desktop/electron-builder.env\` and set \`DESKTOP_UPDATE_BASE_URL\` to that exact HTTPS origin with no trailing slash. electron-builder writes the platform/architecture URL into the packaged app's update configuration; no update secret is embedded.
|
|
1091
|
+
3. Increase \`apps/desktop/package.json\`'s version for every release. Build on each target OS using \`pnpm desktop:updates:build:mac\`, \`pnpm desktop:updates:build:win\`, or \`pnpm desktop:updates:build:linux\`. Configure macOS signing/notarization and Windows code-signing credentials in CI, never in the repository.
|
|
1092
|
+
4. From the repository root, upload one architecture at a time, for example \`pnpm desktop:updates:publish -- --platform mac --arch arm64\`. The publisher accepts only known platform/architecture values, uploads immutable installers and blockmaps first, then uploads mutable \`latest*.yml\` metadata last. Run it only after signing succeeds.
|
|
1093
|
+
5. Install the previous signed version, publish a newer version, and verify update discovery, range downloads, signature validation, restart/install, rollback behavior, and logs on macOS, Windows, and the supported Linux package path. Test clean install, deep links, Clerk return flow, and OS security warnings too.
|
|
1094
|
+
|
|
1095
|
+
The Worker accepts GET/HEAD only, validates the configured hostname and \`releases/\` prefix, supports conditionals and single-range downloads, serves metadata with \`no-store\`, and serves versioned artifacts as immutable. See \`cloudflare/desktop-updates.md\` for the generated resource contract.` : options.apps.desktop ? `## Electron desktop
|
|
1096
|
+
|
|
1097
|
+
Builds are platform-specific: produce macOS, Windows, and Linux artifacts on their matching CI runners with the package's \`build:mac\`, \`build:win\`, and \`build:linux\` scripts. Configure signing/notarization credentials in CI, never in the repository. Test clean install, upgrade, deep links, Clerk return flow, auto-update strategy, and OS security warnings before publishing artifacts to the chosen release channel.` : null,
|
|
1098
|
+
].filter((section) => section !== null);
|
|
1099
|
+
writeFile(path.join(root, 'PRODUCTION.md'), `# ${markdownHeading(options.displayName)} production guide
|
|
1100
|
+
|
|
1101
|
+
This guide includes only the surfaces and providers selected when the project was generated. Provision separate Preview and Production resources, use least-privilege credentials, and keep account ownership with the organization rather than an individual developer.
|
|
1102
|
+
|
|
1103
|
+
## Release gate
|
|
1104
|
+
|
|
1105
|
+
${productionItems.join('\n')}
|
|
1106
|
+
|
|
1107
|
+
${productionSections.join('\n\n')}
|
|
1108
|
+
|
|
1109
|
+
## Final release order
|
|
1110
|
+
|
|
1111
|
+
- Run \`pnpm verify\` and \`anhedral doctor\`; review the exact production diff.
|
|
1112
|
+
${options.features.database ? '- Apply reviewed, backward-compatible database migrations.' : ''}
|
|
1113
|
+
${options.apps.web || options.apps.api ? '- Deploy provider infrastructure and secrets, then deploy API/web.' : ''}
|
|
1114
|
+
${options.apps.mobile || options.apps.extension || options.apps.desktop ? '- Build immutable client artifacts only after production URLs and public keys are final.\n- Release to internal testers, verify telemetry and rollback, then promote through each store review/rollout.' : ''}
|
|
766
1115
|
`);
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
import { neon } from '@neondatabase/serverless';
|
|
770
|
-
import dotenv from 'dotenv';
|
|
771
|
-
|
|
772
|
-
dotenv.config({ path: '../../.env', quiet: true });
|
|
773
|
-
dotenv.config({ quiet: true });
|
|
1116
|
+
}
|
|
1117
|
+
writeFile(path.join(root, 'ANHEDRAL.md'), `# Anhedral-managed project information
|
|
774
1118
|
|
|
775
|
-
|
|
776
|
-
throw new Error('DATABASE_URL is not defined');
|
|
777
|
-
}
|
|
1119
|
+
Generator: ${GENERATOR_VERSION}
|
|
778
1120
|
|
|
779
|
-
|
|
780
|
-
const database = drizzle(client);
|
|
1121
|
+
Resolved modules: ${modules.join(', ')}
|
|
781
1122
|
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
1123
|
+
- \`anhedral add <module> --dry-run\` previews incremental changes.
|
|
1124
|
+
- \`anhedral upgrade --dry-run\` previews a supported generator migration.
|
|
1125
|
+
- \`anhedral ui add <component> --dry-run\` previews platform-routed component additions.
|
|
1126
|
+
- \`anhedral doctor\` reports manifest and filesystem drift before incremental changes.
|
|
1127
|
+
- README and existing user workflows are never rewritten by \`anhedral add\`.
|
|
786
1128
|
`);
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
});
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
tier: z.enum(['free', 'pro']),
|
|
818
|
-
displayName: z.string(),
|
|
819
|
-
description: z.string(),
|
|
820
|
-
priceMonthly: z.number().nullable(),
|
|
821
|
-
priceYearly: z.number().nullable(),
|
|
822
|
-
currency: z.string(),
|
|
823
|
-
limits: z.object({
|
|
824
|
-
dailyLimit: z.number().nullable(),
|
|
825
|
-
}),
|
|
826
|
-
paymentInfo: z.object({
|
|
827
|
-
revenueCatEntitlementId: z.string(),
|
|
828
|
-
revenueCatOfferingId: z.string(),
|
|
829
|
-
}).optional(),
|
|
830
|
-
})),
|
|
831
|
-
});
|
|
832
|
-
export type PricingResponse = z.infer<typeof PricingResponseSchema>;
|
|
833
|
-
|
|
834
|
-
export const SubscriptionEntitlementsSchema = z.object({
|
|
835
|
-
pro: z.boolean(),
|
|
836
|
-
inTrial: z.boolean(),
|
|
837
|
-
trialEndsAt: z.string().optional(),
|
|
838
|
-
expiresAt: z.string().optional(),
|
|
839
|
-
periodStart: z.string().optional(),
|
|
840
|
-
periodEnd: z.string().optional(),
|
|
841
|
-
method: z.enum(['trialing', 'redeemed', 'paid']).nullable().optional(),
|
|
842
|
-
managementUrl: z.string().url().optional(),
|
|
843
|
-
cancelAtPeriodEnd: z.boolean().optional(),
|
|
844
|
-
});
|
|
845
|
-
export type SubscriptionEntitlements = z.infer<typeof SubscriptionEntitlementsSchema>;
|
|
846
|
-
|
|
847
|
-
export const SignOutResponseSchema = z.object({
|
|
848
|
-
success: z.boolean(),
|
|
849
|
-
});
|
|
850
|
-
export type SignOutResponse = z.infer<typeof SignOutResponseSchema>;
|
|
851
|
-
|
|
852
|
-
export const CreateUploadRequestSchema = z.object({
|
|
853
|
-
fileName: z.string().min(1).max(200).optional(),
|
|
854
|
-
contentType: z.string().min(1).max(200),
|
|
855
|
-
});
|
|
856
|
-
export type CreateUploadRequest = z.infer<typeof CreateUploadRequestSchema>;
|
|
1129
|
+
const surfaceGuidance = [
|
|
1130
|
+
options.apps.web ? '- Web lives in `apps/web`: use Next.js App Router, server-first rendering, and shadcn/ui source components.' : null,
|
|
1131
|
+
options.apps.mobile ? `- Mobile lives in \`apps/mobile\`: use Expo Router and React Native Reusables with ${options.nativeStyling}. Keep native-only APIs behind platform-safe modules.` : null,
|
|
1132
|
+
options.apps.api ? '- HTTP and provider integration lives in `apps/api`: keep Fastify routes thin, validate boundaries, and put secrets only in server environment files.' : null,
|
|
1133
|
+
options.apps.desktop ? '- Desktop lives in `apps/desktop`: keep Electron main/preload privileges minimal and application UI in the sandboxed React renderer.' : null,
|
|
1134
|
+
options.features.electronUpdater ? '- Desktop update delivery lives in `apps/desktop-updater-worker`: keep the R2 bucket private, the Worker read-only, and the Worker Custom Domain aligned with `DESKTOP_UPDATE_BASE_URL`.' : null,
|
|
1135
|
+
options.apps.extension ? '- Browser extension code lives in `apps/extension`: use WXT entrypoints and request only permissions required by the feature.' : null,
|
|
1136
|
+
options.features.storage ? '- Private-bucket asset delivery lives in `apps/assets-private-proxy`: preserve the `ASSETS` R2 binding, streaming responses, method/host restrictions, and Cloudflare-managed custom domain.' : null,
|
|
1137
|
+
].filter((value) => value !== null).join('\n');
|
|
1138
|
+
const featureGuidance = [
|
|
1139
|
+
options.apps.api ? '- Reuse `@shared/contracts` at every network boundary and call the API through `@shared/api-client`; clients must not import server implementation modules.' : null,
|
|
1140
|
+
options.features.database ? '- Neon/Drizzle state is authoritative. Change user-owned `packages/db/src/app-schema.ts`, generate SQL with `pnpm db:generate`, review it, and Git-track the migration.' : null,
|
|
1141
|
+
options.features.auth ? '- Clerk owns identity and sessions. Never trust a client-supplied user ID; derive identity from verified server authentication.' : null,
|
|
1142
|
+
options.features.billing ? '- RevenueCat events reconcile into Neon before Ably publishes an invalidation. Clients refetch entitlements instead of treating realtime payloads as authority.' : null,
|
|
1143
|
+
options.features.storage ? '- R2 credentials and signed-upload policy stay in the API. The `assets-private-proxy` Worker uses its binding instead of S3 credentials and publicly serves GET/HEAD only by known unguessable key.' : null,
|
|
1144
|
+
options.features.electronUpdater ? '- Electron releases use `electron-updater` through the generated private R2 + Worker channel. Build signed native artifacts first and publish mutable channel metadata last.' : null,
|
|
1145
|
+
].filter((value) => value !== null).join('\n');
|
|
1146
|
+
const skillFeatureSteps = [
|
|
1147
|
+
options.apps.api ? 'Define shared network schemas in user-owned `packages/contracts/src/app.ts`.' : null,
|
|
1148
|
+
options.features.database ? 'Define persistent state in user-owned `packages/db/src/app-schema.ts`; generate, review, and commit Drizzle SQL. Use managed Neon, never a generated local Postgres service.' : null,
|
|
1149
|
+
options.apps.api ? 'Put backend behavior in focused `apps/api/src/<feature>.ts` modules and register product routes in user-owned `apps/api/src/routes/app.ts`.' : null,
|
|
1150
|
+
options.apps.api ? 'Add client-safe calls to user-owned `packages/api-client/src/app.ts`; frontends must not import server implementations.' : null,
|
|
1151
|
+
options.apps.web ? 'Write web product code with normal Next.js App Router conventions in `apps/web`.' : null,
|
|
1152
|
+
options.apps.mobile ? 'Write native product code with normal Expo Router conventions in `apps/mobile`.' : null,
|
|
1153
|
+
'Verify the affected package, then run the root verification before handoff.',
|
|
1154
|
+
].filter((value) => value !== null).map((value, index) => `${index + 1}. ${value}`).join('\n');
|
|
1155
|
+
writeFile(path.join(root, 'SKILL.md'), `---
|
|
1156
|
+
name: anhedral-project
|
|
1157
|
+
description: Build and extend this Anhedral-generated ${modules.join(', ')} TypeScript application while preserving its framework, ownership, security, and verification conventions.
|
|
1158
|
+
---
|
|
857
1159
|
|
|
858
|
-
|
|
859
|
-
objectKey: z.string(),
|
|
860
|
-
uploadUrl: z.string().url(),
|
|
861
|
-
expiresIn: z.number(),
|
|
862
|
-
headers: z.record(z.string(), z.string()),
|
|
863
|
-
});
|
|
864
|
-
export type CreateUploadResponse = z.infer<typeof CreateUploadResponseSchema>;
|
|
865
|
-
|
|
866
|
-
export const StorageFileResponseSchema = z.object({
|
|
867
|
-
objectKey: z.string(),
|
|
868
|
-
downloadUrl: z.string().url(),
|
|
869
|
-
expiresIn: z.number(),
|
|
870
|
-
});
|
|
871
|
-
export type StorageFileResponse = z.infer<typeof StorageFileResponseSchema>;
|
|
872
|
-
`],
|
|
873
|
-
['types', {
|
|
874
|
-
exports: { '.': './src/index.ts' },
|
|
875
|
-
dependencies: { '@shared/contracts': 'workspace:*' },
|
|
876
|
-
}, `export type ApiEnvelope<T> = { data: T } | { error: string; message: string };
|
|
877
|
-
export type {
|
|
878
|
-
AuthMeResponse,
|
|
879
|
-
ClientPlatform,
|
|
880
|
-
CreateUploadRequest,
|
|
881
|
-
CreateUploadResponse,
|
|
882
|
-
PricingResponse,
|
|
883
|
-
SignOutResponse,
|
|
884
|
-
StorageFileResponse,
|
|
885
|
-
SubscriptionEntitlements,
|
|
886
|
-
} from '@shared/contracts';
|
|
887
|
-
`],
|
|
888
|
-
['config', { exports: { '.': './src/index.ts' } }, `export const DEFAULT_API_PATH_PREFIX = '/api';
|
|
889
|
-
export const DEFAULT_LOCAL_API_URL = 'http://localhost:8787';
|
|
890
|
-
export const DEFAULT_FRONTEND_URL = 'http://localhost:8081';
|
|
891
|
-
export const DEFAULT_EXTENSION_URL = 'chrome-extension://';
|
|
892
|
-
|
|
893
|
-
export function joinApiUrl(baseUrl: string, path: string) {
|
|
894
|
-
const base = baseUrl.replace(/\\/$/, '');
|
|
895
|
-
const suffix = path.startsWith('/') ? path : \`/\${path}\`;
|
|
896
|
-
return \`\${base}\${suffix}\`;
|
|
897
|
-
}
|
|
898
|
-
`],
|
|
899
|
-
['api-client', {
|
|
900
|
-
exports: { '.': './src/index.ts' },
|
|
901
|
-
dependencies: API_CLIENT_DEPENDENCIES.dependencies,
|
|
902
|
-
}, `import type { ZodType } from 'zod';
|
|
903
|
-
import { joinApiUrl } from '@shared/config';
|
|
904
|
-
import {
|
|
905
|
-
AuthMeResponseSchema,
|
|
906
|
-
CreateUploadResponseSchema,
|
|
907
|
-
PricingResponseSchema,
|
|
908
|
-
SignOutResponseSchema,
|
|
909
|
-
StorageFileResponseSchema,
|
|
910
|
-
SubscriptionEntitlementsSchema,
|
|
911
|
-
type AuthMeResponse,
|
|
912
|
-
type ClientPlatform,
|
|
913
|
-
type CreateUploadRequest,
|
|
914
|
-
type CreateUploadResponse,
|
|
915
|
-
type PricingResponse,
|
|
916
|
-
type SignOutResponse,
|
|
917
|
-
type StorageFileResponse,
|
|
918
|
-
type SubscriptionEntitlements,
|
|
919
|
-
} from '@shared/contracts';
|
|
920
|
-
|
|
921
|
-
export class APIRequestError extends Error {
|
|
922
|
-
constructor(
|
|
923
|
-
public statusCode: number,
|
|
924
|
-
message: string,
|
|
925
|
-
public errorCode?: string,
|
|
926
|
-
) {
|
|
927
|
-
super(message);
|
|
928
|
-
this.name = 'APIRequestError';
|
|
929
|
-
}
|
|
930
|
-
}
|
|
931
|
-
|
|
932
|
-
export type ApiClientOptions = {
|
|
933
|
-
baseUrl: string;
|
|
934
|
-
platform: ClientPlatform;
|
|
935
|
-
getToken?: () => Promise<string | null>;
|
|
936
|
-
};
|
|
1160
|
+
# Anhedral project
|
|
937
1161
|
|
|
938
|
-
|
|
939
|
-
constructor(private readonly options: ApiClientOptions) {}
|
|
1162
|
+
Use this skill whenever you plan, implement, review, or diagnose work in this repository.
|
|
940
1163
|
|
|
941
|
-
|
|
942
|
-
const token = await this.options.getToken?.();
|
|
943
|
-
const headers = new Headers(init.headers);
|
|
944
|
-
if (init.body != null) {
|
|
945
|
-
headers.set('Content-Type', headers.get('Content-Type') ?? 'application/json');
|
|
946
|
-
}
|
|
947
|
-
headers.set('X-Platform', this.options.platform);
|
|
948
|
-
if (token) headers.set('Authorization', \`Bearer \${token}\`);
|
|
1164
|
+
## Product model
|
|
949
1165
|
|
|
950
|
-
|
|
951
|
-
...init,
|
|
952
|
-
headers,
|
|
953
|
-
});
|
|
1166
|
+
This is a standard TypeScript monorepo assembled by Anhedral, not an application running inside an Anhedral framework. Write normal Next.js, Expo Router, Fastify, Drizzle, Electron, or WXT code in the generated workspace. Use Anhedral only for structural operations such as adding a module, adding source-owned UI, and checking generated-file ownership.
|
|
954
1167
|
|
|
955
|
-
|
|
956
|
-
let error: { error?: string; message?: string } = {};
|
|
957
|
-
try { error = await response.json(); } catch {}
|
|
958
|
-
throw new APIRequestError(
|
|
959
|
-
response.status,
|
|
960
|
-
error.message || \`API request failed: \${response.status}\`,
|
|
961
|
-
error.error,
|
|
962
|
-
);
|
|
963
|
-
}
|
|
1168
|
+
Before implementing a feature, read \`README.md\` for the source map, \`docs/DEVELOPMENT.md\` for task recipes, and \`docs/STACK.md\` when you need the selected tools' responsibility boundaries and official documentation.
|
|
964
1169
|
|
|
965
|
-
|
|
966
|
-
const data = await response.json();
|
|
967
|
-
return schema ? schema.parse(data) : data as T;
|
|
968
|
-
}
|
|
1170
|
+
## Start safely
|
|
969
1171
|
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
1172
|
+
1. Read \`anhedral.json\` for the selected modules, native styling provider, installed UI components, and file ownership.
|
|
1173
|
+
2. Run \`pnpm dlx anhedral@${GENERATOR_VERSION} doctor\` before generator operations. If it reports a supported older generator, preview and apply \`anhedral upgrade\` before continuing.
|
|
1174
|
+
3. Preview structural changes with \`anhedral upgrade --dry-run\`, \`anhedral add <module> --dry-run\`, or \`anhedral ui add <component> --dry-run\` as applicable.
|
|
1175
|
+
4. Never hand-edit \`anhedral.json\`, ownership hashes, or bundled-template provenance.
|
|
973
1176
|
|
|
974
|
-
|
|
975
|
-
return this.request<PricingResponse>('/subscriptions/pricing', {}, PricingResponseSchema);
|
|
976
|
-
}
|
|
1177
|
+
Resolved modules: ${modules.map((moduleName) => `\`${moduleName}\``).join(', ')}.
|
|
977
1178
|
|
|
978
|
-
|
|
979
|
-
return this.getPricing();
|
|
980
|
-
}
|
|
1179
|
+
## Repository boundaries
|
|
981
1180
|
|
|
982
|
-
|
|
983
|
-
return this.request<SubscriptionEntitlements>(
|
|
984
|
-
options?.refresh ? '/subscriptions/entitlements/me?refresh=true' : '/subscriptions/entitlements/me',
|
|
985
|
-
{},
|
|
986
|
-
SubscriptionEntitlementsSchema,
|
|
987
|
-
);
|
|
988
|
-
}
|
|
1181
|
+
${surfaceGuidance}
|
|
989
1182
|
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
method: 'POST',
|
|
993
|
-
}, SignOutResponseSchema);
|
|
994
|
-
}
|
|
1183
|
+
- Shared source belongs in focused packages under \`packages/\`; do not create a second workspace, lockfile, or nested dependency island.
|
|
1184
|
+
${featureGuidance}
|
|
995
1185
|
|
|
996
|
-
|
|
997
|
-
return this.request<void>('/auth/account', {
|
|
998
|
-
method: 'DELETE',
|
|
999
|
-
});
|
|
1000
|
-
}
|
|
1186
|
+
## Feature workflow
|
|
1001
1187
|
|
|
1002
|
-
|
|
1003
|
-
return this.request<CreateUploadResponse>('/storage/uploads', {
|
|
1004
|
-
method: 'POST',
|
|
1005
|
-
body: JSON.stringify(input),
|
|
1006
|
-
}, CreateUploadResponseSchema);
|
|
1007
|
-
}
|
|
1188
|
+
${skillFeatureSteps}
|
|
1008
1189
|
|
|
1009
|
-
|
|
1010
|
-
return this.request<StorageFileResponse>(
|
|
1011
|
-
\`/storage/files/\${encodeURIComponent(objectKey)}\`,
|
|
1012
|
-
{},
|
|
1013
|
-
StorageFileResponseSchema,
|
|
1014
|
-
);
|
|
1015
|
-
}
|
|
1190
|
+
## UI conventions
|
|
1016
1191
|
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
}
|
|
1022
|
-
}
|
|
1023
|
-
`],
|
|
1024
|
-
];
|
|
1025
|
-
for (const [name, packageFields, source] of simplePackages) {
|
|
1026
|
-
const packageRoot = path.join(root, `packages/${name}`);
|
|
1027
|
-
writeFile(path.join(packageRoot, 'package.json'), JSON.stringify({
|
|
1028
|
-
name: `@shared/${name}`,
|
|
1029
|
-
version: '0.1.0',
|
|
1030
|
-
private: true,
|
|
1031
|
-
type: 'module',
|
|
1032
|
-
scripts: {
|
|
1033
|
-
build: 'pnpm typecheck',
|
|
1034
|
-
typecheck: 'tsc --noEmit',
|
|
1035
|
-
},
|
|
1036
|
-
devDependencies: SHARED_PACKAGE_DEPENDENCIES.devDependencies,
|
|
1037
|
-
...packageFields,
|
|
1038
|
-
}, null, 2) + '\n');
|
|
1039
|
-
writeFile(path.join(packageRoot, 'tsconfig.json'), JSON.stringify({
|
|
1040
|
-
compilerOptions: {
|
|
1041
|
-
target: 'ESNext',
|
|
1042
|
-
module: 'ESNext',
|
|
1043
|
-
moduleResolution: 'Bundler',
|
|
1044
|
-
strict: true,
|
|
1045
|
-
skipLibCheck: true,
|
|
1046
|
-
noEmit: true,
|
|
1047
|
-
},
|
|
1048
|
-
include: ['src/**/*'],
|
|
1049
|
-
}, null, 2) + '\n');
|
|
1050
|
-
writeFile(path.join(packageRoot, 'src/index.ts'), source);
|
|
1051
|
-
}
|
|
1052
|
-
}
|
|
1053
|
-
function writeRootEnvExample(root) {
|
|
1054
|
-
const frontendUrl = 'http://localhost:8081';
|
|
1055
|
-
const expoEnv = `EXPO_PUBLIC_API_URL=http://localhost:8787
|
|
1056
|
-
EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_***
|
|
1057
|
-
EXPO_PUBLIC_RC_ENTITLEMENT_ID=pro
|
|
1058
|
-
EXPO_PUBLIC_RC_API_KEY_IOS=
|
|
1059
|
-
EXPO_PUBLIC_RC_API_KEY_ANDROID=
|
|
1060
|
-
EXPO_PUBLIC_RC_WEB_API_KEY=
|
|
1061
|
-
`;
|
|
1062
|
-
const extensionEnv = `VITE_API_URL=http://localhost:8787
|
|
1063
|
-
VITE_WEBSITE_URL=${frontendUrl}
|
|
1064
|
-
VITE_CLERK_PUBLISHABLE_KEY=pk_test_***
|
|
1065
|
-
VITE_CRX_PUBLIC_KEY=
|
|
1066
|
-
VITE_RC_BILLING_URL=
|
|
1067
|
-
`;
|
|
1068
|
-
const paymentsEnv = `# RevenueCat
|
|
1069
|
-
RC_SECRET_API_KEY=
|
|
1070
|
-
RC_WEBHOOK_SECRET=
|
|
1071
|
-
RC_ENTITLEMENT_ID=pro
|
|
1072
|
-
RC_OFFERING_ID=default
|
|
1073
|
-
`;
|
|
1074
|
-
writeFile(path.join(root, '.env.example'), `# Apps
|
|
1075
|
-
FRONTEND_URL=${frontendUrl}
|
|
1076
|
-
ANHEDRAL_DEMO=false
|
|
1077
|
-
${expoEnv}${extensionEnv}
|
|
1078
|
-
|
|
1079
|
-
# Server
|
|
1080
|
-
PORT=8787
|
|
1081
|
-
DATABASE_URL=postgresql://user:pass@ep-xxx.us-east-1.aws.neon.tech/dbname?sslmode=require
|
|
1082
|
-
|
|
1083
|
-
# Clerk
|
|
1084
|
-
CLERK_PUBLISHABLE_KEY=pk_test_***
|
|
1085
|
-
CLERK_SECRET_KEY=sk_test_***
|
|
1086
|
-
|
|
1087
|
-
# Cloudflare R2
|
|
1088
|
-
R2_ACCOUNT_ID=
|
|
1089
|
-
R2_ACCESS_KEY_ID=
|
|
1090
|
-
R2_SECRET_ACCESS_KEY=
|
|
1091
|
-
R2_BUCKET=
|
|
1092
|
-
|
|
1093
|
-
${paymentsEnv}`);
|
|
1094
|
-
}
|
|
1095
|
-
function writeRootVercelFiles(root) {
|
|
1096
|
-
writeFile(path.join(root, '.vercelignore'), `Extension/.output
|
|
1097
|
-
Extension/.wxt
|
|
1098
|
-
Extension/dist
|
|
1099
|
-
`);
|
|
1100
|
-
}
|
|
1101
|
-
function writeGeneratedCiWorkflow(root) {
|
|
1102
|
-
writeFile(path.join(root, '.github/workflows/ci.yml'), `name: CI
|
|
1192
|
+
- Add source-owned components with \`pnpm dlx anhedral@${GENERATOR_VERSION} ui add <component>\`; use \`--target\` only when the component should not exist in every selected client.
|
|
1193
|
+
- Web, desktop, and extension components come from shadcn/ui. Mobile components come from React Native Reusables using ${options.nativeStyling}.
|
|
1194
|
+
- Compose application-specific UI around installed primitives. Preserve accessibility, keyboard behavior, focus handling, and platform conventions.
|
|
1195
|
+
- Use lowercase kebab-case filenames, PascalCase component exports, and \`use-\` prefixes for hooks.
|
|
1103
1196
|
|
|
1104
|
-
|
|
1105
|
-
pull_request:
|
|
1106
|
-
push:
|
|
1107
|
-
branches:
|
|
1108
|
-
- main
|
|
1197
|
+
## Ownership rules
|
|
1109
1198
|
|
|
1110
|
-
|
|
1111
|
-
|
|
1199
|
+
- \`README.md\`, \`PRODUCTION.md\`, product UI, and the backend \`app.ts\`/\`app-schema.ts\` extension seams are user-owned. Root JSON/YAML configuration is mergeable. Other recorded files are generator-managed unless \`anhedral.json\` says otherwise.
|
|
1200
|
+
- Prefer new application files and narrow composition points over editing managed substrate files. If a managed file must change, understand that future \`anhedral add\` operations will stop until the project is reconciled.
|
|
1201
|
+
- Never overwrite user changes, bypass a generator conflict, or replace a failed ownership check with fabricated hashes.
|
|
1112
1202
|
|
|
1113
|
-
|
|
1114
|
-
group: ci-\${{ github.workflow }}-\${{ github.ref }}
|
|
1115
|
-
cancel-in-progress: true
|
|
1203
|
+
## Environment and security
|
|
1116
1204
|
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
timeout-minutes: 45
|
|
1121
|
-
steps:
|
|
1122
|
-
- name: Checkout
|
|
1123
|
-
uses: actions/checkout@v4
|
|
1205
|
+
- The root \`.env.example\` is an inventory, not a runtime environment file. Copy package-local examples and keep real environment files uncommitted.
|
|
1206
|
+
- Keep server secrets out of browser, Expo, Electron renderer, and extension bundles. Only variables with the framework's explicit public prefix may enter client code.
|
|
1207
|
+
- Validate external input at the API boundary, use parameterized Drizzle queries, and keep privileged Electron functionality behind a narrow context-isolated preload bridge.
|
|
1124
1208
|
|
|
1125
|
-
|
|
1126
|
-
uses: pnpm/action-setup@v4
|
|
1127
|
-
with:
|
|
1128
|
-
version: 10.15.1
|
|
1209
|
+
## Deployment conventions
|
|
1129
1210
|
|
|
1130
|
-
|
|
1131
|
-
uses: actions/setup-node@v4
|
|
1132
|
-
with:
|
|
1133
|
-
node-version: 20
|
|
1134
|
-
cache: pnpm
|
|
1211
|
+
### Agent-assisted provisioning
|
|
1135
1212
|
|
|
1136
|
-
|
|
1137
|
-
run: pnpm install --frozen-lockfile
|
|
1213
|
+
When the user asks you to provision this project:
|
|
1138
1214
|
|
|
1139
|
-
|
|
1140
|
-
|
|
1215
|
+
1. Ask for the exact environment and custom domain first. Record canonical resource names and selected provider teams without recording secrets.
|
|
1216
|
+
2. Inspect whether Computer Use/browser control and subagents are available. With Computer Use, navigate provider dashboards and complete approved non-secret setup. Stop for the user at sign-in, password, passkey, MFA, CAPTCHA, paid purchase, destructive replacement, secret generation/reveal/rotation, and final release/store submission.
|
|
1217
|
+
3. If subagents are available, keep one lead agent responsible for mutations. Delegate bounded independent tasks only: one subagent reads this project's docs and environment examples, one performs read-only DNS/hostname checks, and one performs final read-only verification. Never send credentials, cookies, or secret values to a subagent, and never let two agents mutate the same provider or DNS zone.
|
|
1218
|
+
4. At every secret-generation screen, stop before the final button. Tell the user the exact button, variable name, and uncommitted package-local environment file. The user clicks and pastes the value directly into that file or the provider's protected field, never into chat. Validate presence without printing populated environment files.
|
|
1219
|
+
5. Read \`PRODUCTION.md\` completely and follow its selection-specific order. Do not claim completion until cloud resources, environment names, DNS, TLS, application health, and the verification commands below pass.
|
|
1141
1220
|
|
|
1142
|
-
|
|
1143
|
-
run: pnpm --filter ./Frontend build:web
|
|
1221
|
+
Domain topology: Cloudflare is authoritative DNS; Vercel hosts web/API; the Vercel A/CNAME/TXT records are created in Cloudflare DNS and normally remain DNS-only. Generated Worker hostnames such as \`assets.<domain>\` or \`updates.<domain>\` are Cloudflare Worker Custom Domains created by Wrangler, not CNAMEs to Vercel or public R2 endpoints.
|
|
1144
1222
|
|
|
1145
|
-
|
|
1146
|
-
|
|
1223
|
+
- Read \`PRODUCTION.md\` before provisioning or changing production resources. It is tailored to this project's selected surfaces and providers.
|
|
1224
|
+
${options.apps.web || options.apps.api ? '- Prefer GitHub-triggered Vercel Preview and Production deployments. Keep the generated Services routing intact; use manual `deploy:vercel:*` scripts only when explicitly required.' : ''}
|
|
1225
|
+
${options.features.database ? '- Treat reviewed, committed Drizzle SQL as the release artifact. Apply migrations as a controlled step against the intended Neon environment; never generate migrations during an application build.' : ''}
|
|
1226
|
+
${options.features.storage ? '- Keep the R2 bucket private and deploy `assets-private-proxy` at the Cloudflare Worker custom domain. Expose only `storage/confirmed/` publicly; use the owner-authorized read-URL endpoint for private objects and keep `CLOUDFLARE_API_TOKEN` operations-only.' : ''}
|
|
1227
|
+
${options.features.electronUpdater ? '- Provision the private desktop-update R2 bucket and deploy `desktop-updater` at `updates.<domain>`. Keep the update origin identical in Wrangler and `electron-builder.env`, then publish signed per-platform artifacts before channel metadata.' : ''}
|
|
1228
|
+
${options.apps.mobile ? '- Promote mobile artifacts through EAS internal distribution, TestFlight/Google Play testing, then store review. Build-time public variables are not secrets.' : ''}
|
|
1229
|
+
${options.apps.extension ? '- Publish the exact verified WXT ZIP through trusted/unlisted Chrome Web Store testing before production review; preserve a stable CRX ID and least-privilege permissions.' : ''}
|
|
1147
1230
|
|
|
1148
|
-
|
|
1149
|
-
run: pnpm --filter ./Backend build
|
|
1231
|
+
## Verify changes
|
|
1150
1232
|
|
|
1151
|
-
|
|
1152
|
-
run: pnpm --filter ./Extension build
|
|
1233
|
+
Run the strongest applicable checks before handing work back:
|
|
1153
1234
|
|
|
1154
|
-
|
|
1155
|
-
|
|
1235
|
+
\`\`\`sh
|
|
1236
|
+
pnpm typecheck
|
|
1237
|
+
pnpm verify
|
|
1238
|
+
pnpm build
|
|
1239
|
+
pnpm dlx anhedral@${GENERATOR_VERSION} doctor
|
|
1240
|
+
\`\`\`
|
|
1156
1241
|
|
|
1157
|
-
|
|
1158
|
-
run: pnpm build
|
|
1242
|
+
Use the package-specific \`verify:*\` scripts while iterating. A database schema change is incomplete until its reviewed migration is Git-tracked. Report exact failing packages and commands; do not weaken checks to make a change pass.
|
|
1159
1243
|
`);
|
|
1160
1244
|
}
|
|
1161
|
-
function
|
|
1162
|
-
const
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
'
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1245
|
+
function collectFiles(root, relativeRoot = '') {
|
|
1246
|
+
const absoluteRoot = path.join(root, relativeRoot);
|
|
1247
|
+
if (!pathEntryExists(absoluteRoot))
|
|
1248
|
+
return [];
|
|
1249
|
+
const files = [];
|
|
1250
|
+
for (const entry of readdirSync(absoluteRoot, { withFileTypes: true })) {
|
|
1251
|
+
if (GENERATED_CACHE_NAMES.has(entry.name))
|
|
1252
|
+
continue;
|
|
1253
|
+
const relativePath = relativeRoot ? `${relativeRoot}/${entry.name}` : entry.name;
|
|
1254
|
+
if (relativePath === 'anhedral.json' || isTransactionMetadata(relativePath))
|
|
1255
|
+
continue;
|
|
1256
|
+
if (entry.isDirectory())
|
|
1257
|
+
files.push(...collectFiles(root, relativePath));
|
|
1258
|
+
else if (entry.isFile())
|
|
1259
|
+
files.push(relativePath);
|
|
1260
|
+
}
|
|
1261
|
+
return files.sort();
|
|
1262
|
+
}
|
|
1263
|
+
function ownerForPath(relativePath) {
|
|
1264
|
+
if (relativePath.startsWith('apps/desktop-updater-worker/'))
|
|
1265
|
+
return 'electron-updater';
|
|
1266
|
+
if (relativePath === 'apps/desktop/scripts/publish-updates.mjs')
|
|
1267
|
+
return 'electron-updater';
|
|
1268
|
+
if (relativePath === 'apps/desktop/electron-builder.env.example')
|
|
1269
|
+
return 'electron-updater';
|
|
1270
|
+
if (relativePath === 'cloudflare/desktop-updates.md')
|
|
1271
|
+
return 'electron-updater';
|
|
1272
|
+
if (relativePath.startsWith('apps/assets-private-proxy/'))
|
|
1273
|
+
return 'storage';
|
|
1274
|
+
if (relativePath.startsWith('cloudflare/'))
|
|
1275
|
+
return 'storage';
|
|
1276
|
+
const app = /^apps\/(web|mobile|api|desktop|extension)(?:\/|$)/.exec(relativePath);
|
|
1277
|
+
if (app)
|
|
1278
|
+
return app[1];
|
|
1279
|
+
if (relativePath.startsWith('packages/db/'))
|
|
1280
|
+
return 'db';
|
|
1281
|
+
return 'root';
|
|
1282
|
+
}
|
|
1283
|
+
function defaultOwnership(relativePath) {
|
|
1284
|
+
if (relativePath === 'README.md' || relativePath === 'PRODUCTION.md')
|
|
1285
|
+
return 'user';
|
|
1286
|
+
if (relativePath === 'apps/assets-private-proxy/wrangler.jsonc')
|
|
1287
|
+
return 'user';
|
|
1288
|
+
if (relativePath === 'apps/desktop-updater-worker/wrangler.jsonc')
|
|
1289
|
+
return 'user';
|
|
1290
|
+
if (relativePath === 'cloudflare/r2-cors.template.json')
|
|
1291
|
+
return 'user';
|
|
1292
|
+
if (relativePath === 'apps/web/app/page.tsx'
|
|
1293
|
+
|| /^apps\/web\/components\//.test(relativePath)
|
|
1294
|
+
|| relativePath === 'apps/mobile/app/index.tsx'
|
|
1295
|
+
|| /^apps\/mobile\/components\//.test(relativePath)
|
|
1296
|
+
|| relativePath === 'apps/desktop/src/main/app-window.ts'
|
|
1297
|
+
|| /^apps\/desktop\/src\/renderer\/components\//.test(relativePath)
|
|
1298
|
+
|| /^apps\/extension\/src\/components\//.test(relativePath)
|
|
1299
|
+
|| relativePath === 'apps/api/src/routes/app.ts'
|
|
1300
|
+
|| /^apps\/api\/src\/services\//.test(relativePath)
|
|
1301
|
+
|| relativePath === 'packages/contracts/src/app.ts'
|
|
1302
|
+
|| relativePath === 'packages/api-client/src/app.ts'
|
|
1303
|
+
|| relativePath === 'packages/db/src/app-schema.ts')
|
|
1304
|
+
return 'user';
|
|
1305
|
+
if (ROOT_MERGEABLE_FILES.has(relativePath))
|
|
1306
|
+
return 'mergeable';
|
|
1307
|
+
return 'managed';
|
|
1308
|
+
}
|
|
1309
|
+
function applyCurrentOwnership(manifest) {
|
|
1310
|
+
const files = Object.freeze(Object.fromEntries(Object.entries(manifest.files).map(([relativePath, record]) => [
|
|
1311
|
+
relativePath,
|
|
1312
|
+
record.ownership === 'managed' && defaultOwnership(relativePath) === 'user'
|
|
1313
|
+
? Object.freeze({ ...record, ownership: 'user' })
|
|
1314
|
+
: record,
|
|
1315
|
+
])));
|
|
1316
|
+
return Object.freeze({ ...manifest, files });
|
|
1317
|
+
}
|
|
1318
|
+
function preserveUserOwnedFiles(root, stageRoot, manifest) {
|
|
1319
|
+
for (const [relativePath, record] of Object.entries(manifest.files)) {
|
|
1320
|
+
if (record.ownership !== 'user')
|
|
1321
|
+
continue;
|
|
1322
|
+
const current = path.join(root, relativePath);
|
|
1323
|
+
const staged = path.join(stageRoot, relativePath);
|
|
1324
|
+
const currentStat = lstatIfPresent(current);
|
|
1325
|
+
const stagedStat = lstatIfPresent(staged);
|
|
1326
|
+
if (!currentStat?.isFile())
|
|
1327
|
+
continue;
|
|
1328
|
+
if (stagedStat?.isSymbolicLink() || (stagedStat && !stagedStat.isFile())) {
|
|
1329
|
+
throw new Error(`Refusing to replace user-owned path with a non-file: ${relativePath}`);
|
|
1330
|
+
}
|
|
1331
|
+
if (stagedStat?.isFile() && readFileSync(current).equals(readFileSync(staged)))
|
|
1332
|
+
continue;
|
|
1333
|
+
mkdirSync(path.dirname(staged), { recursive: true });
|
|
1334
|
+
copyFileSync(current, staged);
|
|
1335
|
+
chmodSync(staged, Number(currentStat.mode) & 0o777);
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
function createProjectManifest(root, options, operation, templates, previous, components = previous?.ui.components ?? []) {
|
|
1339
|
+
const contributions = new Map();
|
|
1340
|
+
for (const relativePath of collectFiles(root)) {
|
|
1341
|
+
const previousRecord = previous?.files[relativePath];
|
|
1342
|
+
const ownership = previousRecord?.ownership ?? defaultOwnership(relativePath);
|
|
1343
|
+
const owner = previousRecord?.owner ?? ownerForPath(relativePath);
|
|
1344
|
+
const files = contributions.get(owner) ?? [];
|
|
1345
|
+
files.push({
|
|
1346
|
+
path: relativePath,
|
|
1347
|
+
ownership,
|
|
1348
|
+
content: readFileSync(path.join(root, relativePath), 'utf8'),
|
|
1349
|
+
});
|
|
1350
|
+
contributions.set(owner, files);
|
|
1351
|
+
}
|
|
1352
|
+
const plan = buildGenerationPlan({
|
|
1353
|
+
operation,
|
|
1354
|
+
requestedModules: options.modules,
|
|
1355
|
+
contributions: [...contributions].map(([module, files]) => ({ module, files })),
|
|
1187
1356
|
});
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1357
|
+
const manifest = createManifest({
|
|
1358
|
+
generatorVersion: GENERATOR_VERSION,
|
|
1359
|
+
project: { name: options.projectName, displayName: options.displayName },
|
|
1360
|
+
plan,
|
|
1361
|
+
toolchain: options.toolchainChannel,
|
|
1362
|
+
templates,
|
|
1363
|
+
nativeStyling: options.nativeStyling,
|
|
1364
|
+
components,
|
|
1365
|
+
});
|
|
1366
|
+
const files = Object.freeze(Object.fromEntries(Object.entries(manifest.files).map(([relativePath, record]) => [
|
|
1367
|
+
relativePath,
|
|
1368
|
+
Object.freeze({
|
|
1369
|
+
...record,
|
|
1370
|
+
hash: hashContent(readFileSync(path.join(root, relativePath))),
|
|
1371
|
+
mode: normalizedFileMode(lstatSync(path.join(root, relativePath)).mode),
|
|
1372
|
+
}),
|
|
1373
|
+
])));
|
|
1374
|
+
return Object.freeze({ ...manifest, files });
|
|
1375
|
+
}
|
|
1376
|
+
function writeManifest(root, manifest) {
|
|
1377
|
+
writeFile(path.join(root, 'anhedral.json'), serializeManifest(manifest));
|
|
1378
|
+
}
|
|
1379
|
+
function readProjectManifest(root, options = {}) {
|
|
1380
|
+
const filePath = path.join(root, 'anhedral.json');
|
|
1381
|
+
if (!pathEntryExists(filePath))
|
|
1382
|
+
throw new Error('anhedral.json was not found. Run anhedral init first.');
|
|
1383
|
+
const manifest = readManifest(readFileSync(filePath, 'utf8'));
|
|
1384
|
+
if (manifest.generatorVersion !== GENERATOR_VERSION) {
|
|
1385
|
+
if (options.allowSupportedUpgrade && isSupportedProjectUpgrade(manifest.generatorVersion, GENERATOR_VERSION)) {
|
|
1386
|
+
return manifest;
|
|
1387
|
+
}
|
|
1388
|
+
const nextStep = isSupportedProjectUpgrade(manifest.generatorVersion, GENERATOR_VERSION)
|
|
1389
|
+
? 'Run anhedral upgrade before adding modules.'
|
|
1390
|
+
: 'Regenerate the project with the current CLI before adding modules.';
|
|
1391
|
+
throw new Error(`This project was generated by Anhedral ${manifest.generatorVersion}; current CLI ${GENERATOR_VERSION} only supports exact-current projects. `
|
|
1392
|
+
+ nextStep);
|
|
1393
|
+
}
|
|
1394
|
+
assertTemplateProvenance(manifest.modules, manifest.templates);
|
|
1395
|
+
return manifest;
|
|
1396
|
+
}
|
|
1397
|
+
function cleanNestedArtifacts(root) {
|
|
1398
|
+
rmSync(path.join(root, 'node_modules'), { recursive: true, force: true });
|
|
1399
|
+
const appsRoot = path.join(root, 'apps');
|
|
1400
|
+
if (!pathEntryExists(appsRoot))
|
|
1401
|
+
return;
|
|
1402
|
+
for (const app of readdirSync(appsRoot, { withFileTypes: true })) {
|
|
1403
|
+
if (!app.isDirectory())
|
|
1404
|
+
continue;
|
|
1405
|
+
for (const artifact of ['.git', 'node_modules', 'package-lock.json', 'pnpm-lock.yaml', 'pnpm-workspace.yaml', 'yarn.lock']) {
|
|
1406
|
+
rmSync(path.join(appsRoot, app.name, artifact), { recursive: true, force: true });
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
function selectedUiTargets(options) {
|
|
1411
|
+
return UI_TARGETS.filter((target) => options.apps[target]);
|
|
1412
|
+
}
|
|
1413
|
+
function installSelectedUiComponents(root, options) {
|
|
1414
|
+
const installs = resolveUiInstalls(options.uiComponents, selectedUiTargets(options), options.nativeStyling);
|
|
1415
|
+
if (installs.length > 0 && !options.dryRun)
|
|
1416
|
+
installUiComponents(root, installs);
|
|
1417
|
+
return installs;
|
|
1418
|
+
}
|
|
1419
|
+
function installWorkspaceDependencies(root) {
|
|
1420
|
+
const executable = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm';
|
|
1421
|
+
execFile(executable, ['install', '--no-frozen-lockfile'], root);
|
|
1422
|
+
}
|
|
1423
|
+
async function writeSelectedModules(root, options) {
|
|
1424
|
+
const templates = materializeTemplates(root, resolveModules(options.modules).resolvedModules);
|
|
1425
|
+
const shared = projectOptions(options);
|
|
1426
|
+
scaffoldSharedPackages(root, shared);
|
|
1427
|
+
if (options.apps.api)
|
|
1428
|
+
await scaffoldApi(root, shared);
|
|
1429
|
+
if (options.apps.mobile)
|
|
1430
|
+
await scaffoldMobile(root, shared);
|
|
1431
|
+
if (options.apps.web)
|
|
1432
|
+
await scaffoldWeb(root, shared);
|
|
1433
|
+
if (options.apps.desktop)
|
|
1434
|
+
await scaffoldDesktop(root, shared);
|
|
1435
|
+
if (options.apps.extension)
|
|
1436
|
+
await scaffoldExtension(root, shared);
|
|
1437
|
+
if (options.features.storage)
|
|
1438
|
+
scaffoldAssetsPrivateProxy(root, shared);
|
|
1439
|
+
if (options.features.electronUpdater)
|
|
1440
|
+
scaffoldElectronUpdater(root, shared);
|
|
1441
|
+
return templates;
|
|
1442
|
+
}
|
|
1443
|
+
function printPlan(operation, paths, json) {
|
|
1444
|
+
if (json) {
|
|
1445
|
+
console.log(JSON.stringify({ operation, paths }, null, 2));
|
|
1446
|
+
return;
|
|
1447
|
+
}
|
|
1448
|
+
anhedralPrint.info(`${operation} plan: ${paths.length} path${paths.length === 1 ? '' : 's'}`);
|
|
1449
|
+
for (const relativePath of paths)
|
|
1450
|
+
console.log(` ${relativePath}`);
|
|
1451
|
+
}
|
|
1452
|
+
function assertSafeChangedPaths(root, stageRoot, manifest, paths, deleting = new Set()) {
|
|
1453
|
+
for (const relativePath of paths) {
|
|
1454
|
+
if (relativePath === 'anhedral.json')
|
|
1455
|
+
continue;
|
|
1456
|
+
const target = path.join(root, relativePath);
|
|
1457
|
+
const record = manifest.files[relativePath];
|
|
1458
|
+
const targetStat = lstatIfPresent(target);
|
|
1459
|
+
if (targetStat?.isSymbolicLink())
|
|
1460
|
+
throw new Error(`Refusing to modify symbolic-link path: ${relativePath}`);
|
|
1461
|
+
if (targetStat && !record) {
|
|
1462
|
+
throw new Error(`Refusing to overwrite unowned path: ${relativePath}`);
|
|
1463
|
+
}
|
|
1464
|
+
if (!record || !targetStat)
|
|
1465
|
+
continue;
|
|
1466
|
+
assertManagedFileMode(relativePath, record, targetStat);
|
|
1467
|
+
const actualHash = hashContent(readFileSync(target));
|
|
1468
|
+
if (record.ownership === 'user' && actualHash !== record.hash) {
|
|
1469
|
+
throw new Error(`Refusing to modify user-owned path: ${relativePath}`);
|
|
1470
|
+
}
|
|
1471
|
+
if (record.ownership === 'managed' && actualHash !== record.hash) {
|
|
1472
|
+
throw new Error(`Managed file has user modifications: ${relativePath}`);
|
|
1473
|
+
}
|
|
1474
|
+
if (!deleting.has(relativePath) && !pathEntryExists(path.join(stageRoot, relativePath))) {
|
|
1475
|
+
throw new Error(`Internal plan error: staged path is missing: ${relativePath}`);
|
|
1476
|
+
}
|
|
1477
|
+
}
|
|
1478
|
+
}
|
|
1479
|
+
function stagedFileDiff(root, stageRoot, manifest) {
|
|
1480
|
+
const changed = [...collectFiles(stageRoot), 'anhedral.json'].filter((relativePath) => {
|
|
1481
|
+
const target = path.join(root, relativePath);
|
|
1482
|
+
const targetStat = lstatIfPresent(target);
|
|
1483
|
+
if (!targetStat)
|
|
1484
|
+
return true;
|
|
1485
|
+
if (targetStat.isSymbolicLink())
|
|
1486
|
+
throw new Error(`Refusing to compare symbolic-link path: ${relativePath}`);
|
|
1487
|
+
const stagedTarget = path.join(stageRoot, relativePath);
|
|
1488
|
+
const stagedStat = lstatSync(stagedTarget);
|
|
1489
|
+
const targetMode = normalizedFileMode(targetStat.mode);
|
|
1490
|
+
const stagedMode = normalizedFileMode(stagedStat.mode);
|
|
1491
|
+
return !readFileSync(target).equals(readFileSync(stagedTarget))
|
|
1492
|
+
|| (targetMode !== null && stagedMode !== null && targetMode !== stagedMode);
|
|
1493
|
+
});
|
|
1494
|
+
const deleted = Object.keys(manifest?.files ?? {}).filter((relativePath) => pathEntryExists(path.join(root, relativePath)) && !pathEntryExists(path.join(stageRoot, relativePath)));
|
|
1495
|
+
return { changed, deleted };
|
|
1496
|
+
}
|
|
1497
|
+
export async function scaffoldProject(inputOptions) {
|
|
1498
|
+
const options = canonicalInitOptions(inputOptions);
|
|
1499
|
+
assertInstallNodeCompatibility(options);
|
|
1500
|
+
const root = path.resolve(options.rootDirectory ?? process.cwd());
|
|
1501
|
+
let createdRoot = false;
|
|
1502
|
+
if (!pathEntryExists(root)) {
|
|
1503
|
+
mkdirSync(root);
|
|
1504
|
+
createdRoot = true;
|
|
1505
|
+
}
|
|
1506
|
+
const previousChannel = env.ANHEDRAL_TOOLCHAIN;
|
|
1507
|
+
env.ANHEDRAL_TOOLCHAIN = options.toolchainChannel;
|
|
1508
|
+
const commitPaths = [];
|
|
1509
|
+
const seedPaths = [];
|
|
1510
|
+
try {
|
|
1511
|
+
anhedralPrint.banner(`Initializing ${options.displayName}`);
|
|
1512
|
+
const planned = await runStagedTransaction(root, {
|
|
1513
|
+
commitPaths,
|
|
1514
|
+
seedPaths,
|
|
1515
|
+
dryRun: options.dryRun,
|
|
1516
|
+
prepare: () => {
|
|
1517
|
+
ensureScaffoldRoot(root);
|
|
1518
|
+
if (pathEntryExists(path.join(root, '.gitignore')))
|
|
1519
|
+
seedPaths.push('.gitignore');
|
|
1195
1520
|
},
|
|
1196
|
-
|
|
1197
|
-
|
|
1521
|
+
build: async (stageRoot) => {
|
|
1522
|
+
writeRootFiles(stageRoot, options, 'init', root);
|
|
1523
|
+
const templates = await writeSelectedModules(stageRoot, options);
|
|
1524
|
+
const components = installSelectedUiComponents(stageRoot, options);
|
|
1525
|
+
writeProjectDocs(stageRoot, options, true);
|
|
1526
|
+
cleanNestedArtifacts(stageRoot);
|
|
1527
|
+
rmSync(path.join(stageRoot, 'pnpm-lock.yaml'), { force: true });
|
|
1528
|
+
writeManifest(stageRoot, createProjectManifest(stageRoot, options, 'init', templates, undefined, components));
|
|
1529
|
+
commitPaths.push(...readdirSync(stageRoot));
|
|
1198
1530
|
},
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1531
|
+
afterCommit: () => {
|
|
1532
|
+
if (!options.skipInstall) {
|
|
1533
|
+
anhedralPrint.section('Workspace install');
|
|
1534
|
+
installWorkspaceDependencies(root);
|
|
1535
|
+
}
|
|
1202
1536
|
},
|
|
1203
|
-
}
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
}
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
anhedralPrint.step('Writing root config (package.json, pnpm workspace, env, vercel)');
|
|
1222
|
-
writeFullstackRootFiles(root, options);
|
|
1223
|
-
anhedralPrint.done('Root config written');
|
|
1224
|
-
anhedralPrint.section('Shared packages');
|
|
1225
|
-
anhedralPrint.step('Writing @shared/db, types, config, api-client');
|
|
1226
|
-
writeSharedPackages(root);
|
|
1227
|
-
anhedralPrint.done('Shared packages written');
|
|
1228
|
-
const frontendUrl = 'http://localhost:8081';
|
|
1229
|
-
await scaffoldBackend(root, {
|
|
1230
|
-
projectName: options.projectName,
|
|
1231
|
-
displayName: options.displayName,
|
|
1232
|
-
githubOrg: null,
|
|
1233
|
-
frontendUrl,
|
|
1234
|
-
skipInstall: options.skipInstall,
|
|
1235
|
-
});
|
|
1236
|
-
await scaffoldFrontend(root, {
|
|
1237
|
-
projectName: options.projectName,
|
|
1238
|
-
displayName: options.displayName,
|
|
1239
|
-
githubOrg: null,
|
|
1240
|
-
frontendUrl,
|
|
1241
|
-
skipInstall: options.skipInstall,
|
|
1242
|
-
});
|
|
1243
|
-
await scaffoldExtension(root, {
|
|
1244
|
-
projectName: options.projectName,
|
|
1245
|
-
displayName: options.displayName,
|
|
1246
|
-
githubOrg: null,
|
|
1247
|
-
frontendUrl,
|
|
1248
|
-
skipInstall: options.skipInstall,
|
|
1249
|
-
});
|
|
1250
|
-
return [
|
|
1251
|
-
'.github/workflows/ci.yml',
|
|
1252
|
-
'PRODUCTION.md',
|
|
1253
|
-
'Frontend',
|
|
1254
|
-
'Backend',
|
|
1255
|
-
'Extension',
|
|
1256
|
-
'packages/db',
|
|
1257
|
-
'packages/types',
|
|
1258
|
-
'packages/config',
|
|
1259
|
-
'packages/api-client',
|
|
1260
|
-
];
|
|
1261
|
-
}
|
|
1262
|
-
export async function scaffoldProject(options) {
|
|
1537
|
+
});
|
|
1538
|
+
if (options.dryRun)
|
|
1539
|
+
printPlan('init', planned, options.json);
|
|
1540
|
+
if (!options.dryRun)
|
|
1541
|
+
anhedralPrint.done(`Committed ${planned.length} paths`);
|
|
1542
|
+
if (!options.dryRun && options.json)
|
|
1543
|
+
printPlan('init', planned, true);
|
|
1544
|
+
}
|
|
1545
|
+
finally {
|
|
1546
|
+
if (previousChannel == null)
|
|
1547
|
+
delete env.ANHEDRAL_TOOLCHAIN;
|
|
1548
|
+
else
|
|
1549
|
+
env.ANHEDRAL_TOOLCHAIN = previousChannel;
|
|
1550
|
+
if (createdRoot && pathEntryExists(root) && readdirSync(root).length === 0)
|
|
1551
|
+
rmdirSync(root);
|
|
1552
|
+
}
|
|
1553
|
+
}
|
|
1554
|
+
export async function scaffoldUiComponents(uiOptions) {
|
|
1263
1555
|
const root = path.resolve(process.cwd());
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1556
|
+
let manifest;
|
|
1557
|
+
let options;
|
|
1558
|
+
let additions = [];
|
|
1559
|
+
let components = [];
|
|
1560
|
+
let noOp = false;
|
|
1561
|
+
const seedPaths = [];
|
|
1562
|
+
const commitPaths = [];
|
|
1563
|
+
const previousChannel = env.ANHEDRAL_TOOLCHAIN;
|
|
1564
|
+
try {
|
|
1565
|
+
const planned = await runStagedTransaction(root, {
|
|
1566
|
+
seedPaths,
|
|
1567
|
+
commitPaths,
|
|
1568
|
+
dryRun: uiOptions.dryRun,
|
|
1569
|
+
prepare: () => {
|
|
1570
|
+
manifest = applyCurrentOwnership(readProjectManifest(root));
|
|
1571
|
+
assertManagedFileModes(root, manifest);
|
|
1572
|
+
const installedTargets = UI_TARGETS.filter((target) => manifest.modules.includes(target));
|
|
1573
|
+
const targets = uiOptions.targets.length > 0 ? uiOptions.targets : installedTargets;
|
|
1574
|
+
if (targets.length === 0)
|
|
1575
|
+
throw new Error('This project has no UI client. Add web, mobile, desktop, or extension first.');
|
|
1576
|
+
for (const target of targets) {
|
|
1577
|
+
if (!manifest.modules.includes(target))
|
|
1578
|
+
throw new Error(`UI target is not installed: ${target}`);
|
|
1579
|
+
}
|
|
1580
|
+
const requested = resolveUiInstalls(uiOptions.components, targets, manifest.ui.nativeStyling);
|
|
1581
|
+
const installedKeys = new Set(manifest.ui.components.map(uiInstallKey));
|
|
1582
|
+
additions = requested.filter((entry) => !installedKeys.has(uiInstallKey(entry)));
|
|
1583
|
+
components = mergeUiInstalls(manifest.ui.components, additions);
|
|
1584
|
+
if (additions.length === 0) {
|
|
1585
|
+
noOp = true;
|
|
1586
|
+
return false;
|
|
1587
|
+
}
|
|
1588
|
+
options = {
|
|
1589
|
+
projectName: manifest.project.name,
|
|
1590
|
+
displayName: manifest.project.displayName,
|
|
1591
|
+
modules: [...manifest.modules],
|
|
1592
|
+
...selectionsFromModules(manifest.modules),
|
|
1593
|
+
skipInstall: uiOptions.skipInstall,
|
|
1594
|
+
dryRun: uiOptions.dryRun,
|
|
1595
|
+
json: uiOptions.json,
|
|
1596
|
+
toolchainChannel: toolchainChannelFromManifest(manifest),
|
|
1597
|
+
uiComponents: [],
|
|
1598
|
+
nativeStyling: manifest.ui.nativeStyling,
|
|
1599
|
+
};
|
|
1600
|
+
env.ANHEDRAL_TOOLCHAIN = options.toolchainChannel;
|
|
1601
|
+
seedPaths.push(...new Set([
|
|
1602
|
+
...Object.keys(manifest.files).filter((relativePath) => pathEntryExists(path.join(root, relativePath))),
|
|
1603
|
+
'anhedral.json',
|
|
1604
|
+
]));
|
|
1605
|
+
anhedralPrint.banner(`Adding UI components`);
|
|
1606
|
+
},
|
|
1607
|
+
build: async (stageRoot) => {
|
|
1608
|
+
if (!manifest || !options)
|
|
1609
|
+
throw new Error('Internal UI plan was not prepared.');
|
|
1610
|
+
if (!uiOptions.dryRun)
|
|
1611
|
+
installUiComponents(stageRoot, additions);
|
|
1612
|
+
cleanNestedArtifacts(stageRoot);
|
|
1613
|
+
rmSync(path.join(stageRoot, 'pnpm-lock.yaml'), { force: true });
|
|
1614
|
+
preserveUserOwnedFiles(root, stageRoot, manifest);
|
|
1615
|
+
writeManifest(stageRoot, createProjectManifest(stageRoot, options, 'add', manifest.templates, manifest, components));
|
|
1616
|
+
const diff = stagedFileDiff(root, stageRoot, manifest);
|
|
1617
|
+
assertSafeChangedPaths(root, stageRoot, manifest, [...diff.changed, ...diff.deleted], new Set(diff.deleted));
|
|
1618
|
+
commitPaths.push(...diff.changed);
|
|
1619
|
+
},
|
|
1620
|
+
afterCommit: () => {
|
|
1621
|
+
if (!uiOptions.skipInstall)
|
|
1622
|
+
installWorkspaceDependencies(root);
|
|
1623
|
+
},
|
|
1624
|
+
});
|
|
1625
|
+
if (noOp) {
|
|
1626
|
+
if (uiOptions.json)
|
|
1627
|
+
console.log(JSON.stringify({ operation: 'ui-add', paths: [], components: [] }, null, 2));
|
|
1628
|
+
else
|
|
1629
|
+
anhedralPrint.info('All requested UI components are already installed in the selected clients.');
|
|
1630
|
+
return;
|
|
1631
|
+
}
|
|
1632
|
+
const componentPlan = additions.map((entry) => ({
|
|
1633
|
+
component: entry.name,
|
|
1634
|
+
target: entry.target,
|
|
1635
|
+
provider: entry.provider,
|
|
1636
|
+
source: entry.source,
|
|
1637
|
+
}));
|
|
1638
|
+
if (uiOptions.json) {
|
|
1639
|
+
console.log(JSON.stringify({ operation: 'ui-add', paths: planned, components: componentPlan }, null, 2));
|
|
1640
|
+
}
|
|
1641
|
+
else {
|
|
1642
|
+
anhedralPrint.info(`ui-add plan: ${componentPlan.length} installation${componentPlan.length === 1 ? '' : 's'}`);
|
|
1643
|
+
for (const entry of componentPlan)
|
|
1644
|
+
console.log(` ${entry.target}: ${entry.component} (${entry.provider})`);
|
|
1645
|
+
for (const relativePath of planned)
|
|
1646
|
+
console.log(` ${relativePath}`);
|
|
1647
|
+
}
|
|
1648
|
+
}
|
|
1649
|
+
finally {
|
|
1650
|
+
if (previousChannel == null)
|
|
1651
|
+
delete env.ANHEDRAL_TOOLCHAIN;
|
|
1652
|
+
else
|
|
1653
|
+
env.ANHEDRAL_TOOLCHAIN = previousChannel;
|
|
1654
|
+
}
|
|
1655
|
+
}
|
|
1656
|
+
export async function scaffoldUpgradeProject(upgradeOptions) {
|
|
1657
|
+
const root = path.resolve(process.cwd());
|
|
1658
|
+
let manifest;
|
|
1659
|
+
let options;
|
|
1660
|
+
let noOp = false;
|
|
1661
|
+
const seedPaths = [];
|
|
1662
|
+
const commitPaths = [];
|
|
1663
|
+
const deletePaths = [];
|
|
1664
|
+
const previousChannel = env.ANHEDRAL_TOOLCHAIN;
|
|
1665
|
+
try {
|
|
1666
|
+
const planned = await runStagedTransaction(root, {
|
|
1667
|
+
commitPaths,
|
|
1668
|
+
deletePaths,
|
|
1669
|
+
seedPaths,
|
|
1670
|
+
dryRun: upgradeOptions.dryRun,
|
|
1671
|
+
prepare: () => {
|
|
1672
|
+
manifest = applyCurrentOwnership(readProjectManifest(root, { allowSupportedUpgrade: true }));
|
|
1673
|
+
if (manifest.generatorVersion === GENERATOR_VERSION) {
|
|
1674
|
+
noOp = true;
|
|
1675
|
+
return false;
|
|
1676
|
+
}
|
|
1677
|
+
assertManagedFileModes(root, manifest);
|
|
1678
|
+
options = optionsFromManifest(manifest, {
|
|
1679
|
+
modules: [],
|
|
1680
|
+
skipInstall: upgradeOptions.skipInstall,
|
|
1681
|
+
dryRun: upgradeOptions.dryRun,
|
|
1682
|
+
json: upgradeOptions.json,
|
|
1683
|
+
}, manifest.modules);
|
|
1684
|
+
assertInstallNodeCompatibility(options);
|
|
1685
|
+
env.ANHEDRAL_TOOLCHAIN = options.toolchainChannel;
|
|
1686
|
+
seedPaths.push(...new Set([
|
|
1687
|
+
...Object.keys(manifest.files).filter((relativePath) => pathEntryExists(path.join(root, relativePath))),
|
|
1688
|
+
'anhedral.json',
|
|
1689
|
+
]));
|
|
1690
|
+
anhedralPrint.banner(`Upgrading ${manifest.generatorVersion} to ${GENERATOR_VERSION}`);
|
|
1691
|
+
},
|
|
1692
|
+
build: async (stageRoot) => {
|
|
1693
|
+
if (!manifest || !options)
|
|
1694
|
+
throw new Error('Internal upgrade plan was not prepared.');
|
|
1695
|
+
writeRootFiles(stageRoot, options, 'add', root, manifest);
|
|
1696
|
+
const templates = await writeSelectedModules(stageRoot, options);
|
|
1697
|
+
writeProjectDocs(stageRoot, options, false);
|
|
1698
|
+
cleanNestedArtifacts(stageRoot);
|
|
1699
|
+
preserveUserOwnedFiles(root, stageRoot, manifest);
|
|
1700
|
+
writeManifest(stageRoot, createProjectManifest(stageRoot, options, 'add', templates, manifest, manifest.ui.components));
|
|
1701
|
+
const diff = stagedFileDiff(root, stageRoot, manifest);
|
|
1702
|
+
assertSafeChangedPaths(root, stageRoot, manifest, [...diff.changed, ...diff.deleted], new Set(diff.deleted));
|
|
1703
|
+
commitPaths.push(...diff.changed);
|
|
1704
|
+
deletePaths.push(...diff.deleted);
|
|
1705
|
+
},
|
|
1706
|
+
afterCommit: () => {
|
|
1707
|
+
if (!upgradeOptions.skipInstall)
|
|
1708
|
+
installWorkspaceDependencies(root);
|
|
1709
|
+
},
|
|
1710
|
+
});
|
|
1711
|
+
if (noOp) {
|
|
1712
|
+
if (upgradeOptions.json)
|
|
1713
|
+
console.log(JSON.stringify({ operation: 'upgrade', paths: [] }, null, 2));
|
|
1714
|
+
else
|
|
1715
|
+
anhedralPrint.info(`Project is already on Anhedral ${GENERATOR_VERSION}.`);
|
|
1716
|
+
return;
|
|
1717
|
+
}
|
|
1718
|
+
printPlan('upgrade', planned, upgradeOptions.json);
|
|
1719
|
+
}
|
|
1720
|
+
finally {
|
|
1721
|
+
if (previousChannel == null)
|
|
1722
|
+
delete env.ANHEDRAL_TOOLCHAIN;
|
|
1723
|
+
else
|
|
1724
|
+
env.ANHEDRAL_TOOLCHAIN = previousChannel;
|
|
1725
|
+
}
|
|
1726
|
+
}
|
|
1727
|
+
export async function scaffoldAddModules(addOptions) {
|
|
1728
|
+
const root = path.resolve(process.cwd());
|
|
1729
|
+
let manifest;
|
|
1730
|
+
let options;
|
|
1731
|
+
let missing = [];
|
|
1732
|
+
let noOp = false;
|
|
1733
|
+
const seedPaths = [];
|
|
1734
|
+
const commitPaths = [];
|
|
1735
|
+
const deletePaths = [];
|
|
1736
|
+
const previousChannel = env.ANHEDRAL_TOOLCHAIN;
|
|
1267
1737
|
try {
|
|
1268
|
-
await
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1738
|
+
const planned = await runStagedTransaction(root, {
|
|
1739
|
+
commitPaths,
|
|
1740
|
+
deletePaths,
|
|
1741
|
+
seedPaths,
|
|
1742
|
+
dryRun: addOptions.dryRun,
|
|
1743
|
+
prepare: () => {
|
|
1744
|
+
manifest = applyCurrentOwnership(readProjectManifest(root));
|
|
1745
|
+
assertManagedFileModes(root, manifest);
|
|
1746
|
+
const desiredRequested = [...new Set([...manifest.modules, ...addOptions.modules])];
|
|
1747
|
+
const resolution = resolveModules(desiredRequested);
|
|
1748
|
+
const installed = new Set(manifest.modules);
|
|
1749
|
+
missing = resolution.resolvedModules.filter((moduleId) => !installed.has(moduleId));
|
|
1750
|
+
if (!missing.length) {
|
|
1751
|
+
noOp = true;
|
|
1752
|
+
return false;
|
|
1753
|
+
}
|
|
1754
|
+
options = optionsFromManifest(manifest, addOptions, resolution.requestedModules);
|
|
1755
|
+
assertInstallNodeCompatibility(options);
|
|
1756
|
+
env.ANHEDRAL_TOOLCHAIN = options.toolchainChannel;
|
|
1757
|
+
seedPaths.push(...new Set([
|
|
1758
|
+
...Object.keys(manifest.files).filter((relativePath) => pathEntryExists(path.join(root, relativePath))),
|
|
1759
|
+
'anhedral.json',
|
|
1760
|
+
]));
|
|
1761
|
+
anhedralPrint.banner(`Adding ${missing.join(', ')}`);
|
|
1762
|
+
},
|
|
1763
|
+
build: async (stageRoot) => {
|
|
1764
|
+
if (!manifest || !options)
|
|
1765
|
+
throw new Error('Internal add plan was not prepared.');
|
|
1766
|
+
writeRootFiles(stageRoot, options, 'add', root, manifest);
|
|
1767
|
+
const templates = await writeSelectedModules(stageRoot, options);
|
|
1768
|
+
writeProjectDocs(stageRoot, options, false);
|
|
1769
|
+
cleanNestedArtifacts(stageRoot);
|
|
1770
|
+
preserveUserOwnedFiles(root, stageRoot, manifest);
|
|
1771
|
+
writeManifest(stageRoot, createProjectManifest(stageRoot, options, 'add', templates, manifest));
|
|
1772
|
+
const diff = stagedFileDiff(root, stageRoot, manifest);
|
|
1773
|
+
assertSafeChangedPaths(root, stageRoot, manifest, [...diff.changed, ...diff.deleted], new Set(diff.deleted));
|
|
1774
|
+
commitPaths.push(...diff.changed);
|
|
1775
|
+
deletePaths.push(...diff.deleted);
|
|
1776
|
+
},
|
|
1777
|
+
afterCommit: () => {
|
|
1778
|
+
if (!addOptions.skipInstall)
|
|
1779
|
+
installWorkspaceDependencies(root);
|
|
1780
|
+
},
|
|
1781
|
+
});
|
|
1782
|
+
if (noOp) {
|
|
1783
|
+
if (addOptions.json)
|
|
1784
|
+
console.log(JSON.stringify({ operation: 'add', paths: [] }, null, 2));
|
|
1785
|
+
else
|
|
1786
|
+
anhedralPrint.info('All requested modules are already installed.');
|
|
1787
|
+
return;
|
|
1284
1788
|
}
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
console.log(' cp Extension/.env.example Extension/.env');
|
|
1290
|
-
console.log(' pnpm db:generate && pnpm db:migrate');
|
|
1291
|
-
console.log(' pnpm verify');
|
|
1292
|
-
console.log(' pnpm dev:backend');
|
|
1293
|
-
console.log(' pnpm dev:frontend');
|
|
1294
|
-
console.log(' pnpm dev:extension');
|
|
1295
|
-
console.log('');
|
|
1789
|
+
if (addOptions.dryRun || !addOptions.json)
|
|
1790
|
+
printPlan('add', planned, addOptions.json);
|
|
1791
|
+
if (!addOptions.dryRun && addOptions.json)
|
|
1792
|
+
printPlan('add', planned, true);
|
|
1296
1793
|
}
|
|
1297
1794
|
finally {
|
|
1298
|
-
if (
|
|
1795
|
+
if (previousChannel == null)
|
|
1299
1796
|
delete env.ANHEDRAL_TOOLCHAIN;
|
|
1797
|
+
else
|
|
1798
|
+
env.ANHEDRAL_TOOLCHAIN = previousChannel;
|
|
1799
|
+
}
|
|
1800
|
+
}
|
|
1801
|
+
export function doctorProject() {
|
|
1802
|
+
const root = path.resolve(process.cwd());
|
|
1803
|
+
const filePath = path.join(root, 'anhedral.json');
|
|
1804
|
+
if (!pathEntryExists(filePath))
|
|
1805
|
+
throw new Error('anhedral.json was not found. Run anhedral init first.');
|
|
1806
|
+
const manifest = readManifest(readFileSync(filePath, 'utf8'));
|
|
1807
|
+
const issues = [];
|
|
1808
|
+
for (const entry of readdirSync(root, { withFileTypes: true })) {
|
|
1809
|
+
if (!isTransactionMetadata(entry.name))
|
|
1810
|
+
continue;
|
|
1811
|
+
if (entry.name === '.anhedral-journal.json') {
|
|
1812
|
+
issues.push({ path: entry.name, severity: 'error', message: 'An interrupted transaction requires recovery by init or add.' });
|
|
1813
|
+
}
|
|
1814
|
+
else if (entry.name.startsWith('.anhedral-journal.json.tmp-')) {
|
|
1815
|
+
issues.push({ path: entry.name, severity: 'error', message: 'An incomplete transaction journal artifact requires manual inspection.' });
|
|
1300
1816
|
}
|
|
1301
1817
|
else {
|
|
1302
|
-
|
|
1818
|
+
issues.push({ path: entry.name, severity: 'error', message: 'An Anhedral operation is running or left a transaction-lock artifact.' });
|
|
1819
|
+
}
|
|
1820
|
+
}
|
|
1821
|
+
if (manifest.generatorVersion !== GENERATOR_VERSION) {
|
|
1822
|
+
issues.push({
|
|
1823
|
+
path: 'anhedral.json',
|
|
1824
|
+
severity: 'error',
|
|
1825
|
+
message: isSupportedProjectUpgrade(manifest.generatorVersion, GENERATOR_VERSION)
|
|
1826
|
+
? `Project generator ${manifest.generatorVersion} differs from CLI ${GENERATOR_VERSION}; run anhedral upgrade.`
|
|
1827
|
+
: `Project generator ${manifest.generatorVersion} differs from CLI ${GENERATOR_VERSION}; regenerate with the current CLI.`,
|
|
1828
|
+
});
|
|
1829
|
+
}
|
|
1830
|
+
try {
|
|
1831
|
+
assertTemplateProvenance(manifest.modules, manifest.templates);
|
|
1832
|
+
}
|
|
1833
|
+
catch (error) {
|
|
1834
|
+
issues.push({
|
|
1835
|
+
path: 'anhedral.json',
|
|
1836
|
+
severity: 'error',
|
|
1837
|
+
message: error instanceof Error ? error.message : 'Template provenance is invalid.',
|
|
1838
|
+
});
|
|
1839
|
+
}
|
|
1840
|
+
for (const [relativePath, record] of Object.entries(manifest.files)) {
|
|
1841
|
+
const target = path.join(root, relativePath);
|
|
1842
|
+
const targetStat = lstatIfPresent(target);
|
|
1843
|
+
if (!targetStat) {
|
|
1844
|
+
issues.push({ path: relativePath, severity: 'error', message: 'Recorded file is missing.' });
|
|
1845
|
+
continue;
|
|
1846
|
+
}
|
|
1847
|
+
if (targetStat.isSymbolicLink()) {
|
|
1848
|
+
issues.push({ path: relativePath, severity: 'error', message: 'Recorded path is a symbolic link.' });
|
|
1849
|
+
continue;
|
|
1850
|
+
}
|
|
1851
|
+
if (!targetStat.isFile()) {
|
|
1852
|
+
issues.push({ path: relativePath, severity: 'error', message: 'Recorded path is not a regular file.' });
|
|
1853
|
+
continue;
|
|
1854
|
+
}
|
|
1855
|
+
const actual = hashContent(readFileSync(target));
|
|
1856
|
+
if (actual !== record.hash) {
|
|
1857
|
+
issues.push({
|
|
1858
|
+
path: relativePath,
|
|
1859
|
+
severity: record.ownership === 'managed' ? 'error' : 'warning',
|
|
1860
|
+
message: `${record.ownership} file differs from its recorded hash.`,
|
|
1861
|
+
});
|
|
1862
|
+
}
|
|
1863
|
+
if (record.mode !== null) {
|
|
1864
|
+
const actualMode = normalizedFileMode(targetStat.mode);
|
|
1865
|
+
if (actualMode !== null && actualMode !== record.mode) {
|
|
1866
|
+
issues.push({
|
|
1867
|
+
path: relativePath,
|
|
1868
|
+
severity: record.ownership === 'managed' ? 'error' : 'warning',
|
|
1869
|
+
message: `${record.ownership} file mode differs from its recorded mode `
|
|
1870
|
+
+ `(${displayFileMode(record.mode)} expected, ${displayFileMode(actualMode)} found).`,
|
|
1871
|
+
});
|
|
1872
|
+
}
|
|
1303
1873
|
}
|
|
1304
1874
|
}
|
|
1875
|
+
return {
|
|
1876
|
+
ok: !issues.some((issue) => issue.severity === 'error'),
|
|
1877
|
+
schemaVersion: manifest.schemaVersion,
|
|
1878
|
+
generatorVersion: manifest.generatorVersion,
|
|
1879
|
+
issues,
|
|
1880
|
+
};
|
|
1305
1881
|
}
|