anhedral 0.4.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/README.md +29 -256
  2. package/assets/anhedral-cli-init-technical.svg +322 -0
  3. package/assets/anhedral-cli-init.svg +257 -173
  4. package/dist/bin.js +28 -162
  5. package/dist/dependencies.d.ts +7 -56
  6. package/dist/dependencies.js +96 -305
  7. package/dist/index.d.ts +2 -7
  8. package/dist/index.js +1 -4
  9. package/dist/{templates → platforms}/desktop.d.ts +1 -1
  10. package/dist/{templates → platforms}/desktop.js +28 -266
  11. package/dist/{templates → platforms}/extension.d.ts +1 -1
  12. package/dist/platforms/extension.js +401 -0
  13. package/dist/{templates → platforms}/mobile.d.ts +1 -1
  14. package/dist/platforms/mobile.js +232 -0
  15. package/dist/project.d.ts +4 -0
  16. package/dist/project.js +1 -0
  17. package/dist/standard-products.d.ts +2 -0
  18. package/dist/standard-products.js +6 -0
  19. package/dist/standard.d.ts +18 -0
  20. package/dist/standard.js +578 -0
  21. package/dist/version.d.ts +1 -1
  22. package/dist/version.js +1 -1
  23. package/docs/application-stack-standard.md +700 -0
  24. package/package.json +34 -49
  25. package/CHANGELOG.md +0 -132
  26. package/CONTRIBUTING.md +0 -73
  27. package/SECURITY.md +0 -37
  28. package/SKILL.md +0 -244
  29. package/assets/images/svg/ably-symbol-color.svg +0 -8
  30. package/bin/anhedral.js +0 -2
  31. package/dist/architecture/contributions.d.ts +0 -28
  32. package/dist/architecture/contributions.js +0 -125
  33. package/dist/architecture/index.d.ts +0 -12
  34. package/dist/architecture/index.js +0 -6
  35. package/dist/architecture/manifest.d.ts +0 -53
  36. package/dist/architecture/manifest.js +0 -283
  37. package/dist/architecture/modules.d.ts +0 -37
  38. package/dist/architecture/modules.js +0 -184
  39. package/dist/architecture/plan.d.ts +0 -58
  40. package/dist/architecture/plan.js +0 -145
  41. package/dist/architecture/templates.d.ts +0 -10
  42. package/dist/architecture/templates.js +0 -23
  43. package/dist/cli.d.ts +0 -32
  44. package/dist/cli.js +0 -235
  45. package/dist/composers/root.d.ts +0 -6
  46. package/dist/composers/root.js +0 -35
  47. package/dist/print.d.ts +0 -8
  48. package/dist/print.js +0 -48
  49. package/dist/prompts.d.ts +0 -5
  50. package/dist/prompts.js +0 -19
  51. package/dist/scaffold.d.ts +0 -76
  52. package/dist/scaffold.js +0 -1895
  53. package/dist/template-source.d.ts +0 -20
  54. package/dist/template-source.js +0 -180
  55. package/dist/templates/api.d.ts +0 -2
  56. package/dist/templates/api.js +0 -3122
  57. package/dist/templates/assets-private-proxy.d.ts +0 -3
  58. package/dist/templates/assets-private-proxy.js +0 -277
  59. package/dist/templates/electron-updater.d.ts +0 -3
  60. package/dist/templates/electron-updater.js +0 -358
  61. package/dist/templates/extension.js +0 -732
  62. package/dist/templates/mobile.js +0 -720
  63. package/dist/templates/shared.d.ts +0 -2
  64. package/dist/templates/shared.js +0 -520
  65. package/dist/templates/web.d.ts +0 -2
  66. package/dist/templates/web.js +0 -412
  67. package/dist/toolchain.d.ts +0 -3
  68. package/dist/toolchain.js +0 -8
  69. package/dist/ui.d.ts +0 -33
  70. package/dist/ui.js +0 -199
  71. package/dist/workspace-config.d.ts +0 -11
  72. package/dist/workspace-config.js +0 -372
  73. package/docs/conventions.md +0 -92
  74. package/docs/master-stack-map.md +0 -199
  75. package/docs/output-tree-contract.md +0 -482
  76. package/docs/references/manual-scaffolding.md +0 -403
  77. package/docs/references/provisioning.md +0 -220
  78. package/scripts/render-master-stack-map.mjs +0 -510
  79. package/templates/api-fastify/apps/api/tsconfig.json +0 -20
  80. package/templates/catalog.json +0 -23
  81. package/templates/db-drizzle/packages/db/tsconfig.json +0 -14
  82. package/templates/desktop-electron/apps/desktop/tsconfig.json +0 -37
  83. package/templates/extension-wxt/apps/extension/tsconfig.json +0 -41
  84. package/templates/mobile-expo/apps/mobile/expo-env.d.ts +0 -1
  85. package/templates/web-next/apps/web/next-env.d.ts +0 -6
@@ -1,372 +0,0 @@
1
- import { lstatSync, readFileSync } from 'node:fs';
2
- import path from 'node:path';
3
- import { SECURITY_OVERRIDES } from './dependencies.js';
4
- import { writeFile } from './util.js';
5
- function pathEntryExists(target) {
6
- try {
7
- lstatSync(target);
8
- return true;
9
- }
10
- catch (error) {
11
- if (error.code === 'ENOENT')
12
- return false;
13
- throw error;
14
- }
15
- }
16
- export function desiredWorkspacePolicy() {
17
- return {
18
- overrides: { ...SECURITY_OVERRIDES },
19
- onlyBuiltDependencies: ['electron', 'esbuild', 'sharp'],
20
- ignoredBuiltDependencies: [
21
- 'browser-tabs-lock',
22
- 'bufferutil',
23
- 'core-js',
24
- 'electron-winstaller',
25
- 'spawn-sync',
26
- 'utf-8-validate',
27
- ],
28
- peerDependencyRules: {
29
- ignoreMissing: ['@solana/web3.js', 'bs58', 'react-native'],
30
- allowedVersions: {
31
- esbuild: '>=0.25.0',
32
- 'utf-8-validate': '>=5.0.2',
33
- },
34
- },
35
- };
36
- }
37
- // Managed pnpm policy intentionally supports only scalar maps and string lists. More complex YAML
38
- // is left untouched outside managed sections and rejected inside them instead of being guessed at.
39
- function sourceLines(content) {
40
- const lines = [];
41
- let start = 0;
42
- while (start < content.length) {
43
- let cursor = start;
44
- while (cursor < content.length && content[cursor] !== '\n' && content[cursor] !== '\r')
45
- cursor += 1;
46
- const text = content.slice(start, cursor);
47
- if (content[cursor] === '\r' && content[cursor + 1] === '\n')
48
- cursor += 2;
49
- else if (cursor < content.length)
50
- cursor += 1;
51
- lines.push({ text, start, end: cursor });
52
- start = cursor;
53
- }
54
- return lines;
55
- }
56
- function workspaceNewline(content) {
57
- return content.includes('\r\n') ? '\r\n' : content.includes('\r') ? '\r' : '\n';
58
- }
59
- function parseWorkspaceStringScalar(raw) {
60
- const leading = /^[ \t]*/.exec(raw)?.[0].length ?? 0;
61
- const valueAndComment = raw.slice(leading);
62
- if (!valueAndComment)
63
- return null;
64
- const singleQuoted = /^'((?:[^']|'')*)'([ \t]*(?:#.*)?)$/.exec(valueAndComment);
65
- if (singleQuoted) {
66
- return {
67
- value: singleQuoted[1].replaceAll("''", "'"),
68
- tokenStart: leading,
69
- tokenEnd: leading + singleQuoted[0].length - singleQuoted[2].length,
70
- };
71
- }
72
- if (valueAndComment.startsWith("'"))
73
- return null;
74
- const doubleQuoted = /^("(?:[^"\\]|\\.)*")([ \t]*(?:#.*)?)$/.exec(valueAndComment);
75
- if (doubleQuoted) {
76
- try {
77
- const value = JSON.parse(doubleQuoted[1]);
78
- return typeof value === 'string'
79
- ? { value, tokenStart: leading, tokenEnd: leading + doubleQuoted[1].length }
80
- : null;
81
- }
82
- catch {
83
- return null;
84
- }
85
- }
86
- if (valueAndComment.startsWith('"'))
87
- return null;
88
- const comment = /[ \t]+#/.exec(valueAndComment);
89
- const withoutComment = valueAndComment.slice(0, comment?.index ?? valueAndComment.length).trimEnd();
90
- if (!withoutComment || /^[\[{|>&*!]/.test(withoutComment))
91
- return null;
92
- if (/^(?:null|~|true|false|[-+]?(?:\.inf|\.nan|0x[\da-f]+|0o[0-7]+|\d+(?:\.\d+)?(?:e[-+]?\d+)?))$/i.test(withoutComment)) {
93
- return null;
94
- }
95
- return { value: withoutComment, tokenStart: leading, tokenEnd: leading + withoutComment.length };
96
- }
97
- function parseWorkspaceMapLine(line) {
98
- const indent = /^([ \t]*)/.exec(line)?.[1] ?? '';
99
- const body = line.slice(indent.length);
100
- let match = /^'((?:[^']|'')*)'[ \t]*:/.exec(body);
101
- let key;
102
- if (match)
103
- key = match[1].replaceAll("''", "'");
104
- else {
105
- match = /^("(?:[^"\\]|\\.)*")[ \t]*:/.exec(body);
106
- if (match) {
107
- try {
108
- const parsed = JSON.parse(match[1]);
109
- if (typeof parsed !== 'string')
110
- return null;
111
- key = parsed;
112
- }
113
- catch {
114
- return null;
115
- }
116
- }
117
- else {
118
- match = /^([^:#][^:]*?)[ \t]*:/.exec(body);
119
- if (!match)
120
- return null;
121
- key = match[1].trim();
122
- if (!key || key.includes('#'))
123
- return null;
124
- }
125
- }
126
- const restOffset = indent.length + match[0].length;
127
- return { indent, key, rest: line.slice(restOffset), restOffset };
128
- }
129
- function parseWorkspaceListLine(line) {
130
- const match = /^([ \t]+)-[ \t]+(.+)$/.exec(line);
131
- if (!match)
132
- return null;
133
- const scalar = parseWorkspaceStringScalar(match[2]);
134
- return scalar ? { indent: match[1], value: scalar.value } : null;
135
- }
136
- function quoteWorkspaceString(value) {
137
- return `'${value.replaceAll("'", "''")}'`;
138
- }
139
- function workspaceLineIndent(line) {
140
- if (!line.trim() || line.trimStart().startsWith('#'))
141
- return null;
142
- return /^([ \t]*)/.exec(line)?.[1] ?? '';
143
- }
144
- function workspaceBlockAt(lines, keyLineIndex) {
145
- const keyLine = lines[keyLineIndex];
146
- const key = parseWorkspaceMapLine(keyLine.text);
147
- if (!key)
148
- throw new Error('pnpm-workspace.yaml contains an unsupported mapping key.');
149
- if (key.indent.includes('\t'))
150
- throw new Error('pnpm-workspace.yaml managed sections must use spaces for indentation.');
151
- let sectionEndIndex = lines.length;
152
- for (let index = keyLineIndex + 1; index < lines.length; index += 1) {
153
- const indent = workspaceLineIndent(lines[index].text);
154
- if (indent !== null && indent.length <= key.indent.length) {
155
- sectionEndIndex = index;
156
- break;
157
- }
158
- }
159
- return { lines, keyLineIndex, sectionEndIndex, keyLine, key };
160
- }
161
- function findTopLevelWorkspaceBlock(content, key) {
162
- const lines = sourceLines(content);
163
- const matches = lines.flatMap((line, index) => {
164
- const parsed = parseWorkspaceMapLine(line.text);
165
- return parsed?.indent === '' && parsed.key === key ? [index] : [];
166
- });
167
- if (matches.length > 1)
168
- throw new Error(`pnpm-workspace.yaml contains duplicate top-level ${key} keys.`);
169
- return matches.length === 1 ? workspaceBlockAt(lines, matches[0]) : null;
170
- }
171
- function appendWorkspaceLines(content, offset, additions) {
172
- if (additions.length === 0)
173
- return content;
174
- const newline = workspaceNewline(content);
175
- const leading = offset > 0 && !/(?:\r\n|\n|\r)$/.test(content.slice(0, offset)) ? newline : '';
176
- return `${content.slice(0, offset)}${leading}${additions.join(newline)}${newline}${content.slice(offset)}`;
177
- }
178
- function appendTopLevelWorkspaceKey(content, key) {
179
- return appendWorkspaceLines(content, content.length, [`${key}:`]);
180
- }
181
- function requireWorkspaceBlockValue(block, fieldPath) {
182
- if (!/^[ \t]*(?:#.*)?$/.test(block.key.rest)) {
183
- throw new Error(`Conflict in ${fieldPath}: expected a block value that can be merged safely.`);
184
- }
185
- }
186
- function mergeWorkspaceListBlock(content, block, desiredValues, fieldPath) {
187
- requireWorkspaceBlockValue(block, fieldPath);
188
- const existing = new Set();
189
- let indent = `${block.key.indent} `;
190
- let insertionOffset = block.keyLine.end;
191
- let foundItem = false;
192
- for (let index = block.keyLineIndex + 1; index < block.sectionEndIndex; index += 1) {
193
- const line = block.lines[index];
194
- if (workspaceLineIndent(line.text) === null)
195
- continue;
196
- const parsed = parseWorkspaceListLine(line.text);
197
- if (!parsed || parsed.indent.includes('\t') || parsed.indent.length <= block.key.indent.length) {
198
- throw new Error(`Conflict in ${fieldPath}: expected a simple block list of strings.`);
199
- }
200
- if (foundItem && parsed.indent !== indent) {
201
- throw new Error(`Conflict in ${fieldPath}: list items must use consistent indentation.`);
202
- }
203
- foundItem = true;
204
- indent = parsed.indent;
205
- existing.add(parsed.value);
206
- insertionOffset = line.end;
207
- }
208
- const additions = [...new Set(desiredValues)].filter((value) => !existing.has(value));
209
- return appendWorkspaceLines(content, insertionOffset, additions.map((value) => `${indent}- ${quoteWorkspaceString(value)}`));
210
- }
211
- function ensureTopLevelWorkspaceBlock(content, key) {
212
- const existing = findTopLevelWorkspaceBlock(content, key);
213
- if (existing)
214
- return { content, block: existing };
215
- const appended = appendTopLevelWorkspaceKey(content, key);
216
- return { content: appended, block: findTopLevelWorkspaceBlock(appended, key) };
217
- }
218
- function mergeTopLevelWorkspaceList(content, key, desiredValues) {
219
- const ensured = ensureTopLevelWorkspaceBlock(content, key);
220
- return mergeWorkspaceListBlock(ensured.content, ensured.block, desiredValues, `pnpm-workspace.yaml ${key}`);
221
- }
222
- function mergeWorkspaceMapBlock(content, block, desiredValues, fieldPath, allowGeneratedUpdates) {
223
- requireWorkspaceBlockValue(block, fieldPath);
224
- const entries = new Map();
225
- let indent = `${block.key.indent} `;
226
- let insertionOffset = block.keyLine.end;
227
- let foundEntry = false;
228
- for (let index = block.keyLineIndex + 1; index < block.sectionEndIndex; index += 1) {
229
- const line = block.lines[index];
230
- if (workspaceLineIndent(line.text) === null)
231
- continue;
232
- const parsed = parseWorkspaceMapLine(line.text);
233
- if (!parsed || parsed.indent.includes('\t') || parsed.indent.length <= block.key.indent.length) {
234
- throw new Error(`Conflict in ${fieldPath}: expected a simple string-to-string mapping.`);
235
- }
236
- if (foundEntry && parsed.indent !== indent) {
237
- throw new Error(`Conflict in ${fieldPath}: mapping entries must use consistent indentation.`);
238
- }
239
- const scalar = parseWorkspaceStringScalar(parsed.rest);
240
- if (!scalar)
241
- throw new Error(`Conflict in ${fieldPath}: mapping values must be strings.`);
242
- if (entries.has(parsed.key))
243
- throw new Error(`Conflict in ${fieldPath}: duplicate mapping entry ${parsed.key}.`);
244
- foundEntry = true;
245
- indent = parsed.indent;
246
- insertionOffset = line.end;
247
- entries.set(parsed.key, { line, parsed, scalar });
248
- }
249
- const additions = [];
250
- const replacements = [];
251
- for (const [key, desiredValue] of Object.entries(desiredValues)) {
252
- const existing = entries.get(key);
253
- if (!existing) {
254
- additions.push(`${indent}${quoteWorkspaceString(key)}: ${quoteWorkspaceString(desiredValue)}`);
255
- continue;
256
- }
257
- if (existing.scalar.value === desiredValue)
258
- continue;
259
- if (!allowGeneratedUpdates) {
260
- throw new Error(`Conflict in ${fieldPath}: mapping entry ${key} differs from the generated value.`);
261
- }
262
- replacements.push({
263
- start: existing.line.start + existing.parsed.restOffset + existing.scalar.tokenStart,
264
- end: existing.line.start + existing.parsed.restOffset + existing.scalar.tokenEnd,
265
- value: quoteWorkspaceString(desiredValue),
266
- });
267
- }
268
- let merged = appendWorkspaceLines(content, insertionOffset, additions);
269
- for (const replacement of replacements.sort((left, right) => right.start - left.start)) {
270
- merged = `${merged.slice(0, replacement.start)}${replacement.value}${merged.slice(replacement.end)}`;
271
- }
272
- return merged;
273
- }
274
- function mergeTopLevelWorkspaceMap(content, key, desiredValues, allowGeneratedUpdates) {
275
- const ensured = ensureTopLevelWorkspaceBlock(content, key);
276
- return mergeWorkspaceMapBlock(ensured.content, ensured.block, desiredValues, `pnpm-workspace.yaml ${key}`, allowGeneratedUpdates);
277
- }
278
- function directChildIndent(block, fieldPath) {
279
- let childIndent = null;
280
- for (let index = block.keyLineIndex + 1; index < block.sectionEndIndex; index += 1) {
281
- const indent = workspaceLineIndent(block.lines[index].text);
282
- if (indent === null)
283
- continue;
284
- if (indent.includes('\t') || indent.length <= block.key.indent.length) {
285
- throw new Error(`Conflict in ${fieldPath}: expected an indented mapping.`);
286
- }
287
- if (childIndent === null || indent.length < childIndent.length)
288
- childIndent = indent;
289
- }
290
- return childIndent ?? `${block.key.indent} `;
291
- }
292
- function findDirectWorkspaceBlock(parent, key, fieldPath) {
293
- const childIndent = directChildIndent(parent, fieldPath);
294
- const matches = [];
295
- for (let index = parent.keyLineIndex + 1; index < parent.sectionEndIndex; index += 1) {
296
- const line = parent.lines[index];
297
- const indent = workspaceLineIndent(line.text);
298
- if (indent === null || indent.length !== childIndent.length)
299
- continue;
300
- const parsed = parseWorkspaceMapLine(line.text);
301
- if (!parsed || parsed.indent !== childIndent) {
302
- throw new Error(`Conflict in ${fieldPath}: expected a mapping of named policies.`);
303
- }
304
- if (parsed.key === key)
305
- matches.push(index);
306
- }
307
- if (matches.length > 1)
308
- throw new Error(`Conflict in ${fieldPath}: duplicate ${key} keys.`);
309
- return matches.length === 1 ? workspaceBlockAt(parent.lines, matches[0]) : null;
310
- }
311
- function ensureDirectWorkspaceBlock(content, parentKey, key, fieldPath) {
312
- let parent = findTopLevelWorkspaceBlock(content, parentKey);
313
- if (!parent) {
314
- content = appendTopLevelWorkspaceKey(content, parentKey);
315
- parent = findTopLevelWorkspaceBlock(content, parentKey);
316
- }
317
- requireWorkspaceBlockValue(parent, `pnpm-workspace.yaml ${parentKey}`);
318
- const existing = findDirectWorkspaceBlock(parent, key, fieldPath);
319
- if (existing)
320
- return { content, block: existing };
321
- const indent = directChildIndent(parent, fieldPath);
322
- let insertionOffset = parent.keyLine.end;
323
- for (let index = parent.keyLineIndex + 1; index < parent.sectionEndIndex; index += 1) {
324
- const line = parent.lines[index];
325
- if (workspaceLineIndent(line.text) !== null)
326
- insertionOffset = line.end;
327
- }
328
- content = appendWorkspaceLines(content, insertionOffset, [`${indent}${key}:`]);
329
- parent = findTopLevelWorkspaceBlock(content, parentKey);
330
- return { content, block: findDirectWorkspaceBlock(parent, key, fieldPath) };
331
- }
332
- function applyWorkspaceAutoInstallPeers(content, allowGeneratedUpdates) {
333
- const block = findTopLevelWorkspaceBlock(content, 'autoInstallPeers');
334
- if (!block) {
335
- const newline = workspaceNewline(content);
336
- const separator = content.length > 0 && !/(?:\r\n|\n|\r)$/.test(content) ? newline : '';
337
- return `${content}${separator}autoInstallPeers: false${newline}`;
338
- }
339
- const scalar = /^([ \t]*)(true|false)([ \t]*(?:#.*)?)$/i.exec(block.key.rest);
340
- if (!scalar)
341
- throw new Error('Conflict in pnpm-workspace.yaml: autoInstallPeers must be the boolean false.');
342
- for (let index = block.keyLineIndex + 1; index < block.sectionEndIndex; index += 1) {
343
- if (workspaceLineIndent(block.lines[index].text) !== null) {
344
- throw new Error('Conflict in pnpm-workspace.yaml: autoInstallPeers must be a scalar boolean.');
345
- }
346
- }
347
- if (scalar[2].toLowerCase() === 'false')
348
- return content;
349
- if (!allowGeneratedUpdates) {
350
- throw new Error('Conflict in pnpm-workspace.yaml: autoInstallPeers must be false to avoid unsafe optional peer resolution.');
351
- }
352
- const start = block.keyLine.start + block.key.restOffset + scalar[1].length;
353
- return `${content.slice(0, start)}false${content.slice(start + scalar[2].length)}`;
354
- }
355
- function applyWorkspacePolicy(content, policy, allowGeneratedUpdates) {
356
- let merged = applyWorkspaceAutoInstallPeers(content, allowGeneratedUpdates);
357
- merged = mergeTopLevelWorkspaceMap(merged, 'overrides', policy.overrides, allowGeneratedUpdates);
358
- merged = mergeTopLevelWorkspaceList(merged, 'onlyBuiltDependencies', policy.onlyBuiltDependencies);
359
- merged = mergeTopLevelWorkspaceList(merged, 'ignoredBuiltDependencies', policy.ignoredBuiltDependencies);
360
- const ignoreMissing = ensureDirectWorkspaceBlock(merged, 'peerDependencyRules', 'ignoreMissing', 'pnpm-workspace.yaml peerDependencyRules');
361
- merged = mergeWorkspaceListBlock(ignoreMissing.content, ignoreMissing.block, policy.peerDependencyRules.ignoreMissing, 'pnpm-workspace.yaml peerDependencyRules.ignoreMissing');
362
- const allowedVersions = ensureDirectWorkspaceBlock(merged, 'peerDependencyRules', 'allowedVersions', 'pnpm-workspace.yaml peerDependencyRules');
363
- return mergeWorkspaceMapBlock(allowedVersions.content, allowedVersions.block, policy.peerDependencyRules.allowedVersions, 'pnpm-workspace.yaml peerDependencyRules.allowedVersions', allowGeneratedUpdates);
364
- }
365
- export function mergeWorkspaceFile(root, desiredPackages, policy, allowGeneratedUpdates) {
366
- const filePath = path.join(root, 'pnpm-workspace.yaml');
367
- const current = pathEntryExists(filePath) ? readFileSync(filePath, 'utf8') : '';
368
- let merged = mergeTopLevelWorkspaceList(current, 'packages', desiredPackages);
369
- merged = applyWorkspacePolicy(merged, policy, allowGeneratedUpdates);
370
- if (merged !== current || !pathEntryExists(filePath))
371
- writeFile(filePath, merged);
372
- }
@@ -1,92 +0,0 @@
1
- # Anhedral generated-project conventions
2
-
3
- Anhedral is a deterministic stack generator. These conventions describe the readable workspace it produces; they are not a new application framework or runtime API.
4
-
5
- ## Core rule
6
-
7
- Use the underlying tool's normal programming model. Anhedral decides where integrations meet and generates safe defaults, but developers write ordinary Next.js, Expo Router, Fastify, Drizzle, Electron, and WXT code.
8
-
9
- ## Source ownership
10
-
11
- ```text
12
- apps/ deployable framework applications
13
- packages/ client-safe or server-shared TypeScript packages
14
- docs/ generated stack and development guidance
15
- ```
16
-
17
- - `apps/web`: Next.js App Router pages, layouts, components, hooks, and web utilities.
18
- - `apps/mobile`: Expo Router screens, layouts, native components, and hooks.
19
- - `apps/api`: Fastify routes, services, plugins, provider clients, and server environment validation.
20
- - `apps/desktop`: Electron main/preload code and sandboxed React renderer code.
21
- - `apps/desktop-updater-worker`: optional read-only Cloudflare Worker serving signed Electron updates from a bound private R2 bucket.
22
- - `apps/extension`: WXT entrypoints, extension components, background logic, and content scripts.
23
- - `packages/contracts`: Zod schemas for data crossing HTTP boundaries.
24
- - `packages/api-client`: client-safe HTTP behavior shared across frontends.
25
- - `packages/db`: Drizzle schema, database access, and reviewed SQL migrations.
26
- - `packages/realtime`: client-safe Ably subscription behavior when selected.
27
-
28
- ## End-to-end feature flow
29
-
30
- ```text
31
- Zod contract
32
- -> Drizzle schema/query or server service
33
- -> Fastify route
34
- -> shared API client
35
- -> Next.js / Expo / Electron / WXT UI
36
- ```
37
-
38
- Routes stay thin. They authenticate, validate, call a service, and return a declared contract. Business behavior and provider SDK calls live in server services. Frontends never import API services, database connections, or server environment modules.
39
-
40
- When `electron-updater` is selected, desktop releases follow a separate delivery path: the packaged Electron main process reads generated update metadata from an HTTPS custom domain, the Worker streams known keys from its private R2 binding, and release tooling uploads signed artifacts before mutable channel metadata. Neither Cloudflare credentials nor a public bucket URL enter the desktop application.
41
-
42
- ## Database
43
-
44
- Database-enabled projects use managed Neon Postgres. Anhedral does not generate local Postgres, Docker Compose, or an embedded database substitute.
45
-
46
- 1. Edit the user-owned `packages/db/src/app-schema.ts`; Anhedral keeps provider tables in `generated-schema.ts`.
47
- 2. Run `pnpm db:generate`.
48
- 3. Review and commit generated SQL and metadata.
49
- 4. Run `pnpm verify:db`.
50
- 5. Apply with `pnpm db:migrate` against the intended Neon branch or project.
51
-
52
- Schema generation must not happen during an application build. Migrations are reviewed release artifacts.
53
-
54
- ## Authentication and secrets
55
-
56
- Clerk owns identity and sessions. The API derives identity from a verified server session and never trusts a client-supplied user ID. Only variables with a framework's explicit public prefix may enter client bundles. Database, Clerk secret, R2, RevenueCat, Ably, cron, and provider credentials remain server-only.
57
-
58
- ## UI
59
-
60
- DOM clients use source-owned shadcn/ui components. Expo uses React Native Reusables with the selected NativeWind or Uniwind configuration.
61
-
62
- ```sh
63
- anhedral ui add button dialog
64
- anhedral ui add data-table --target web
65
- ```
66
-
67
- Generated UI files are application source and can be customized. Preserve accessibility, keyboard/focus behavior, and platform conventions.
68
-
69
- ## Naming
70
-
71
- - Directories and source files use lowercase kebab-case.
72
- - React component filenames use kebab-case and export PascalCase components.
73
- - Hooks start with `use-`.
74
- - Framework-required entrypoint names stay unchanged.
75
- - Shared packages use the `@shared/<name>` namespace.
76
-
77
- ## Structural changes
78
-
79
- Run `anhedral doctor` before changing generated structure. Preview new modules or UI with `--dry-run`. Anhedral refuses managed-file drift, symlinks, and unowned collisions instead of overwriting source.
80
-
81
- Application product files are developer-owned. `README.md` and `PRODUCTION.md` become user-owned after initialization. Root configuration is mergeable. Integration substrate and generated guidance are recorded in `anhedral.json`.
82
-
83
- ## Verification
84
-
85
- Run the smallest relevant package check while iterating, then the generated root verification before handoff:
86
-
87
- ```sh
88
- pnpm typecheck
89
- pnpm verify
90
- pnpm build
91
- anhedral doctor
92
- ```