anhedral 0.1.9 → 0.3.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 +97 -0
- package/CONTRIBUTING.md +70 -0
- package/LICENSE +21 -17
- package/README.md +151 -152
- package/SECURITY.md +35 -0
- package/SKILL.md +177 -0
- package/bin/anhedral.js +1 -1
- package/dist/architecture/contributions.d.ts +28 -0
- package/dist/architecture/contributions.js +118 -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 +176 -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 +167 -0
- package/dist/cli.d.ts +24 -2
- package/dist/cli.js +186 -21
- package/dist/composers/root.d.ts +6 -0
- package/dist/composers/root.js +34 -0
- package/dist/dependencies.d.ts +29 -9
- package/dist/dependencies.js +220 -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 +56 -12
- package/dist/scaffold.js +1466 -1158
- 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 +3114 -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 +572 -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 +503 -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 +89 -0
- package/docs/output-tree-contract.md +442 -0
- package/package.json +56 -13
- package/references/manual-scaffolding.md +386 -0
- 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
|
@@ -0,0 +1,372 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
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/extension`: WXT entrypoints, extension components, background logic, and content scripts.
|
|
22
|
+
- `packages/contracts`: Zod schemas for data crossing HTTP boundaries.
|
|
23
|
+
- `packages/api-client`: client-safe HTTP behavior shared across frontends.
|
|
24
|
+
- `packages/db`: Drizzle schema, database access, and reviewed SQL migrations.
|
|
25
|
+
- `packages/realtime`: client-safe Ably subscription behavior when selected.
|
|
26
|
+
|
|
27
|
+
## End-to-end feature flow
|
|
28
|
+
|
|
29
|
+
```text
|
|
30
|
+
Zod contract
|
|
31
|
+
-> Drizzle schema/query or server service
|
|
32
|
+
-> Fastify route
|
|
33
|
+
-> shared API client
|
|
34
|
+
-> Next.js / Expo / Electron / WXT UI
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
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.
|
|
38
|
+
|
|
39
|
+
## Database
|
|
40
|
+
|
|
41
|
+
Database-enabled projects use managed Neon Postgres. Anhedral does not generate local Postgres, Docker Compose, or an embedded database substitute.
|
|
42
|
+
|
|
43
|
+
1. Edit `packages/db/src/schema.ts`.
|
|
44
|
+
2. Run `pnpm db:generate`.
|
|
45
|
+
3. Review and commit generated SQL and metadata.
|
|
46
|
+
4. Run `pnpm verify:db`.
|
|
47
|
+
5. Apply with `pnpm db:migrate` against the intended Neon branch or project.
|
|
48
|
+
|
|
49
|
+
Schema generation must not happen during an application build. Migrations are reviewed release artifacts.
|
|
50
|
+
|
|
51
|
+
## Authentication and secrets
|
|
52
|
+
|
|
53
|
+
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.
|
|
54
|
+
|
|
55
|
+
## UI
|
|
56
|
+
|
|
57
|
+
DOM clients use source-owned shadcn/ui components. Expo uses React Native Reusables with the selected NativeWind or Uniwind configuration.
|
|
58
|
+
|
|
59
|
+
```sh
|
|
60
|
+
anhedral ui add button dialog
|
|
61
|
+
anhedral ui add data-table --target web
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Generated UI files are application source and can be customized. Preserve accessibility, keyboard/focus behavior, and platform conventions.
|
|
65
|
+
|
|
66
|
+
## Naming
|
|
67
|
+
|
|
68
|
+
- Directories and source files use lowercase kebab-case.
|
|
69
|
+
- React component filenames use kebab-case and export PascalCase components.
|
|
70
|
+
- Hooks start with `use-`.
|
|
71
|
+
- Framework-required entrypoint names stay unchanged.
|
|
72
|
+
- Shared packages use the `@shared/<name>` namespace.
|
|
73
|
+
|
|
74
|
+
## Structural changes
|
|
75
|
+
|
|
76
|
+
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.
|
|
77
|
+
|
|
78
|
+
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`.
|
|
79
|
+
|
|
80
|
+
## Verification
|
|
81
|
+
|
|
82
|
+
Run the smallest relevant package check while iterating, then the generated root verification before handoff:
|
|
83
|
+
|
|
84
|
+
```sh
|
|
85
|
+
pnpm typecheck
|
|
86
|
+
pnpm verify
|
|
87
|
+
pnpm build
|
|
88
|
+
anhedral doctor
|
|
89
|
+
```
|