anhedral 0.4.2 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/README.md +29 -269
  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 -191
  5. package/dist/dependencies.d.ts +7 -57
  6. package/dist/dependencies.js +96 -308
  7. package/dist/index.d.ts +2 -7
  8. package/dist/index.js +1 -4
  9. package/dist/platforms/desktop.js +401 -0
  10. package/dist/platforms/extension.js +401 -0
  11. package/dist/platforms/mobile.js +232 -0
  12. package/dist/project.d.ts +0 -26
  13. package/dist/standard-products.d.ts +2 -0
  14. package/dist/standard-products.js +6 -0
  15. package/dist/standard.d.ts +18 -0
  16. package/dist/standard.js +578 -0
  17. package/dist/version.d.ts +1 -1
  18. package/dist/version.js +1 -1
  19. package/docs/application-stack-standard.md +700 -0
  20. package/package.json +34 -51
  21. package/CHANGELOG.md +0 -178
  22. package/CONTRIBUTING.md +0 -77
  23. package/SECURITY.md +0 -37
  24. package/SKILL.md +0 -339
  25. package/assets/images/svg/ably-symbol-color.svg +0 -8
  26. package/bin/anhedral.js +0 -2
  27. package/dist/architecture/contributions.d.ts +0 -28
  28. package/dist/architecture/contributions.js +0 -125
  29. package/dist/architecture/index.d.ts +0 -12
  30. package/dist/architecture/index.js +0 -6
  31. package/dist/architecture/manifest.d.ts +0 -53
  32. package/dist/architecture/manifest.js +0 -283
  33. package/dist/architecture/modules.d.ts +0 -41
  34. package/dist/architecture/modules.js +0 -187
  35. package/dist/architecture/plan.d.ts +0 -58
  36. package/dist/architecture/plan.js +0 -145
  37. package/dist/architecture/templates.d.ts +0 -10
  38. package/dist/architecture/templates.js +0 -23
  39. package/dist/cli.d.ts +0 -32
  40. package/dist/cli.js +0 -281
  41. package/dist/composers/root.d.ts +0 -6
  42. package/dist/composers/root.js +0 -35
  43. package/dist/print.d.ts +0 -8
  44. package/dist/print.js +0 -48
  45. package/dist/prompts.d.ts +0 -6
  46. package/dist/prompts.js +0 -41
  47. package/dist/scaffold.d.ts +0 -64
  48. package/dist/scaffold.js +0 -2102
  49. package/dist/template-source.d.ts +0 -20
  50. package/dist/template-source.js +0 -180
  51. package/dist/templates/api.d.ts +0 -2
  52. package/dist/templates/api.js +0 -3187
  53. package/dist/templates/assets-private-proxy.d.ts +0 -3
  54. package/dist/templates/assets-private-proxy.js +0 -277
  55. package/dist/templates/desktop.js +0 -792
  56. package/dist/templates/electron-updater.d.ts +0 -3
  57. package/dist/templates/electron-updater.js +0 -358
  58. package/dist/templates/extension.js +0 -856
  59. package/dist/templates/first-run.d.ts +0 -2
  60. package/dist/templates/first-run.js +0 -167
  61. package/dist/templates/mobile.js +0 -861
  62. package/dist/templates/shared.d.ts +0 -2
  63. package/dist/templates/shared.js +0 -577
  64. package/dist/templates/web.d.ts +0 -2
  65. package/dist/templates/web.js +0 -569
  66. package/dist/toolchain.d.ts +0 -3
  67. package/dist/toolchain.js +0 -8
  68. package/dist/ui.d.ts +0 -33
  69. package/dist/ui.js +0 -199
  70. package/dist/workspace-config.d.ts +0 -11
  71. package/dist/workspace-config.js +0 -372
  72. package/docs/NORTHSTAR.md +0 -114
  73. package/docs/conventions.md +0 -94
  74. package/docs/engineering-principles.md +0 -88
  75. package/docs/master-stack-map.md +0 -199
  76. package/docs/output-tree-contract.md +0 -493
  77. package/docs/references/manual-scaffolding.md +0 -421
  78. package/docs/references/provisioning.md +0 -577
  79. package/scripts/render-master-stack-map.mjs +0 -510
  80. package/templates/api-fastify/apps/api/tsconfig.json +0 -20
  81. package/templates/catalog.json +0 -23
  82. package/templates/db-drizzle/packages/db/tsconfig.json +0 -14
  83. package/templates/desktop-electron/apps/desktop/tsconfig.json +0 -37
  84. package/templates/extension-wxt/apps/extension/tsconfig.json +0 -41
  85. package/templates/mobile-expo/apps/mobile/expo-env.d.ts +0 -1
  86. package/templates/web-next/apps/web/next-env.d.ts +0 -6
  87. /package/dist/{templates → platforms}/desktop.d.ts +0 -0
  88. /package/dist/{templates → platforms}/extension.d.ts +0 -0
  89. /package/dist/{templates → platforms}/mobile.d.ts +0 -0
@@ -1,3 +0,0 @@
1
- import type { ProjectOptions } from '../project.js';
2
- export declare function desktopUpdatesBucketName(projectName: string): string;
3
- export declare function scaffoldElectronUpdater(root: string, options: ProjectOptions): void;
@@ -1,358 +0,0 @@
1
- import path from 'node:path';
2
- import { TOOLCHAIN_DEPENDENCIES } from '../dependencies.js';
3
- import { anhedralPrint } from '../print.js';
4
- import { childPackageName } from '../render.js';
5
- import { writeFile } from '../util.js';
6
- function normalizedResourceName(projectName) {
7
- const unscoped = projectName.replace(/^@[^/]+\//, '');
8
- const normalized = unscoped.toLowerCase()
9
- .replace(/[^a-z0-9-]+/g, '-')
10
- .replace(/-+/g, '-')
11
- .replace(/^-+|-+$/g, '') || 'anhedral';
12
- return normalized.slice(0, 48).replace(/-+$/g, '') || 'anhedral';
13
- }
14
- export function desktopUpdatesBucketName(projectName) {
15
- return `${normalizedResourceName(projectName)}-desktop-updates`;
16
- }
17
- export function scaffoldElectronUpdater(root, options) {
18
- const workerDir = path.join(root, 'apps/desktop-updater-worker');
19
- const desktopDir = path.join(root, 'apps/desktop');
20
- const wrangler = `pnpm dlx wrangler@${TOOLCHAIN_DEPENDENCIES.wrangler}`;
21
- const bucketName = desktopUpdatesBucketName(options.projectName);
22
- anhedralPrint.section('Electron updater (private R2 + Cloudflare Worker)');
23
- anhedralPrint.step('Writing desktop update channel');
24
- writeFile(path.join(workerDir, 'package.json'), JSON.stringify({
25
- name: childPackageName(options.projectName, 'desktop-updater-worker'),
26
- version: '0.1.0',
27
- private: true,
28
- type: 'module',
29
- scripts: {
30
- check: 'node --check src/index.js && node --test tests/*.test.js',
31
- test: 'node --test tests/*.test.js',
32
- dev: `${wrangler} dev`,
33
- deploy: `${wrangler} deploy`,
34
- types: `${wrangler} types`,
35
- },
36
- }, null, 2) + '\n');
37
- writeFile(path.join(workerDir, 'wrangler.jsonc'), `{
38
- // Replace both updates.example.com values with a hostname in your active Cloudflare zone.
39
- "name": "desktop-updater",
40
- "main": "src/index.js",
41
- "compatibility_date": "2026-07-22",
42
- "compatibility_flags": ["nodejs_compat"],
43
- "workers_dev": false,
44
- "preview_urls": false,
45
- "routes": [{ "pattern": "updates.example.com", "custom_domain": true }],
46
- "vars": { "UPDATE_HOSTNAME": "updates.example.com", "UPDATE_PREFIX": "releases" },
47
- "r2_buckets": [{ "binding": "UPDATES", "bucket_name": "${bucketName}" }],
48
- "observability": {
49
- "enabled": true,
50
- "logs": { "enabled": true, "head_sampling_rate": 1 }
51
- }
52
- }
53
- `);
54
- writeFile(path.join(workerDir, 'src/index.js'), `/**
55
- * Electron update delivery from a private R2 bucket.
56
- *
57
- * The bucket has no public r2.dev or bucket custom domain. This Worker is the
58
- * only public read path. Release CI uploads signed installers, blockmaps, and
59
- * update metadata with Wrangler; the Worker never permits writes or listings.
60
- */
61
- const ARTIFACT_CACHE_CONTROL = "public, max-age=31536000, immutable";
62
- const METADATA_CACHE_CONTROL = "no-store";
63
-
64
- export default {
65
- async fetch(request, env) {
66
- const method = request.method.toUpperCase();
67
- if (method !== "GET" && method !== "HEAD") {
68
- return textResponse("Method Not Allowed", 405, { allow: "GET, HEAD" });
69
- }
70
-
71
- const url = new URL(request.url);
72
- if (url.hostname !== env.UPDATE_HOSTNAME) return textResponse("Forbidden", 403);
73
- const key = decodeObjectKey(url.pathname);
74
- const prefix = env.UPDATE_PREFIX.replace(/^\\/+|\\/+$/g, "") + "/";
75
- if (!key || !key.startsWith(prefix)) return textResponse("Not Found", 404);
76
-
77
- try {
78
- if (method === "HEAD") {
79
- const object = await env.UPDATES.head(key);
80
- if (!object) return textResponse("Not Found", 404);
81
- return new Response(null, { status: 200, headers: objectHeaders(object, key) });
82
- }
83
-
84
- const object = await env.UPDATES.get(key, {
85
- onlyIf: request.headers,
86
- range: request.headers,
87
- });
88
- if (!object) return textResponse("Not Found", 404);
89
-
90
- const headers = objectHeaders(object, key);
91
- if (!("body" in object)) {
92
- const status = failedConditionalStatus(request.headers);
93
- headers.delete("content-length");
94
- return new Response(null, { status, headers });
95
- }
96
-
97
- const isRange = request.headers.has("range") && object.range;
98
- if (isRange) {
99
- const returned = normalizeRange(object.range, object.size);
100
- headers.set("content-range", "bytes " + returned.offset + "-" + (returned.offset + returned.length - 1) + "/" + object.size);
101
- headers.set("content-length", String(returned.length));
102
- }
103
- return new Response(object.body, { status: isRange ? 206 : 200, headers });
104
- } catch (error) {
105
- console.error(JSON.stringify({
106
- message: "desktop update delivery failed",
107
- key,
108
- error: error instanceof Error ? error.message : String(error),
109
- }));
110
- return textResponse("Update delivery failed", 500);
111
- }
112
- },
113
- };
114
-
115
- function decodeObjectKey(pathname) {
116
- const parts = [];
117
- for (const rawPart of pathname.replace(/^\\/+/, "").split("/")) {
118
- if (!rawPart) continue;
119
- let part;
120
- try {
121
- part = decodeURIComponent(rawPart);
122
- } catch {
123
- return null;
124
- }
125
- if (!part || part === "." || part === ".." || part.includes("/") || part.includes("\\\\")) return null;
126
- parts.push(part);
127
- }
128
- return parts.join("/") || null;
129
- }
130
-
131
- function objectHeaders(object, key) {
132
- const headers = new Headers();
133
- object.writeHttpMetadata(headers);
134
- headers.set("accept-ranges", "bytes");
135
- headers.set("cache-control", key.endsWith(".yml") ? METADATA_CACHE_CONTROL : ARTIFACT_CACHE_CONTROL);
136
- headers.set("content-length", String(object.size));
137
- headers.set("etag", object.httpEtag);
138
- headers.set("x-content-type-options", "nosniff");
139
- headers.set("content-security-policy", "default-src 'none'");
140
- if (object.uploaded instanceof Date) headers.set("last-modified", object.uploaded.toUTCString());
141
- headers.delete("set-cookie");
142
- return headers;
143
- }
144
-
145
- function failedConditionalStatus(headers) {
146
- if (headers.has("if-none-match")) return 304;
147
- if (!headers.has("if-match") && !headers.has("if-unmodified-since") && headers.has("if-modified-since")) return 304;
148
- return 412;
149
- }
150
-
151
- function normalizeRange(range, objectSize) {
152
- if (typeof range.suffix === "number") {
153
- const length = Math.min(Math.max(0, range.suffix), objectSize);
154
- return { offset: objectSize - length, length };
155
- }
156
- const offset = Math.min(Math.max(0, range.offset ?? 0), objectSize);
157
- const available = objectSize - offset;
158
- const length = typeof range.length === "number"
159
- ? Math.min(Math.max(0, range.length), available)
160
- : available;
161
- return { offset, length };
162
- }
163
-
164
- function textResponse(body, status, extraHeaders = {}) {
165
- return new Response(body, {
166
- status,
167
- headers: { "cache-control": "no-store", "content-type": "text/plain; charset=utf-8", ...extraHeaders },
168
- });
169
- }
170
- `);
171
- writeFile(path.join(workerDir, 'tests/worker.test.js'), `import assert from 'node:assert/strict';
172
- import test from 'node:test';
173
- import worker from '../src/index.js';
174
-
175
- function storedObject(body = new TextEncoder().encode('release-bytes'), overrides = {}) {
176
- return {
177
- body,
178
- size: body.byteLength,
179
- httpEtag: '"synthetic-etag"',
180
- uploaded: new Date('2026-07-22T00:00:00.000Z'),
181
- writeHttpMetadata(headers) { headers.set('content-type', 'application/octet-stream'); },
182
- ...overrides,
183
- };
184
- }
185
-
186
- function environment(overrides = {}) {
187
- const object = storedObject();
188
- return {
189
- UPDATE_HOSTNAME: 'updates.example.com',
190
- UPDATE_PREFIX: 'releases',
191
- UPDATES: {
192
- async head() { return object; },
193
- async get() { return object; },
194
- },
195
- ...overrides,
196
- };
197
- }
198
-
199
- test('allows only read methods on the configured host and release prefix', async () => {
200
- const env = environment();
201
- assert.equal((await worker.fetch(new Request('https://updates.example.com/releases/mac/arm64/app.zip', { method: 'POST' }), env)).status, 405);
202
- assert.equal((await worker.fetch(new Request('https://other.example.com/releases/mac/arm64/app.zip'), env)).status, 403);
203
- assert.equal((await worker.fetch(new Request('https://updates.example.com/private/app.zip'), env)).status, 404);
204
- assert.equal((await worker.fetch(new Request('https://updates.example.com/releases/%2Fprivate.zip'), env)).status, 404);
205
- });
206
-
207
- test('streams artifacts and handles HEAD without exposing a body', async () => {
208
- const env = environment();
209
- const response = await worker.fetch(new Request('https://updates.example.com/releases/mac/arm64/app.zip'), env);
210
- assert.equal(response.status, 200);
211
- assert.equal(await response.text(), 'release-bytes');
212
- assert.equal(response.headers.get('cache-control'), 'public, max-age=31536000, immutable');
213
- assert.equal(response.headers.get('etag'), '"synthetic-etag"');
214
-
215
- const head = await worker.fetch(new Request('https://updates.example.com/releases/mac/arm64/app.zip', { method: 'HEAD' }), env);
216
- assert.equal(head.status, 200);
217
- assert.equal(await head.text(), '');
218
- });
219
-
220
- test('returns ranges and conditional responses with safe cache policy', async () => {
221
- const ranged = storedObject(new TextEncoder().encode('le'), { size: 13, range: { offset: 2, length: 2 } });
222
- const rangeResponse = await worker.fetch(new Request('https://updates.example.com/releases/mac/arm64/app.zip', {
223
- headers: { range: 'bytes=2-3' },
224
- }), environment({ UPDATES: { async head() { return ranged; }, async get() { return ranged; } } }));
225
- assert.equal(rangeResponse.status, 206);
226
- assert.equal(rangeResponse.headers.get('content-range'), 'bytes 2-3/13');
227
- assert.equal(rangeResponse.headers.get('content-length'), '2');
228
-
229
- const notModified = storedObject(new Uint8Array(), { size: 13 });
230
- delete notModified.body;
231
- const conditional = await worker.fetch(new Request('https://updates.example.com/releases/mac/arm64/latest-mac.yml', {
232
- headers: { 'if-none-match': '"synthetic-etag"' },
233
- }), environment({ UPDATES: { async head() { return notModified; }, async get() { return notModified; } } }));
234
- assert.equal(conditional.status, 304);
235
- assert.equal(conditional.headers.get('cache-control'), 'no-store');
236
- assert.equal(conditional.headers.has('content-length'), false);
237
- });
238
- `);
239
- writeFile(path.join(desktopDir, 'scripts/publish-updates.mjs'), `import { readdirSync, statSync } from 'node:fs';
240
- import path from 'node:path';
241
- import { spawnSync } from 'node:child_process';
242
-
243
- const WRANGLER_VERSION = ${JSON.stringify(TOOLCHAIN_DEPENDENCIES.wrangler)};
244
- const BUCKET_NAME = ${JSON.stringify(bucketName)};
245
- const RELEASE_DIRECTORY = path.resolve('apps/desktop/release');
246
- const ALLOWED_PLATFORMS = new Set(['mac', 'win', 'linux']);
247
- const ALLOWED_ARCHITECTURES = new Set(['x64', 'arm64']);
248
- const UPLOAD_COMMAND = process.env.ANHEDRAL_DESKTOP_UPDATE_UPLOAD_COMMAND || (process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm');
249
- const UPLOAD_ARGS_PREFIX = process.env.ANHEDRAL_DESKTOP_UPDATE_UPLOAD_ARGS_PREFIX
250
- ? JSON.parse(process.env.ANHEDRAL_DESKTOP_UPDATE_UPLOAD_ARGS_PREFIX)
251
- : [];
252
- if (!Array.isArray(UPLOAD_ARGS_PREFIX) || !UPLOAD_ARGS_PREFIX.every((value) => typeof value === 'string')) {
253
- throw new Error('ANHEDRAL_DESKTOP_UPDATE_UPLOAD_ARGS_PREFIX must be a JSON string array');
254
- }
255
-
256
- function option(name) {
257
- const index = process.argv.indexOf('--' + name);
258
- return index >= 0 ? process.argv[index + 1] : undefined;
259
- }
260
-
261
- const platform = option('platform');
262
- const arch = option('arch');
263
- if (!platform || !ALLOWED_PLATFORMS.has(platform)) {
264
- throw new Error('--platform must be one of: mac, win, linux');
265
- }
266
- if (!arch || !ALLOWED_ARCHITECTURES.has(arch)) {
267
- throw new Error('--arch must be one of: x64, arm64');
268
- }
269
-
270
- const metadataName = platform === 'mac' ? 'latest-mac.yml' : platform === 'linux' ? 'latest-linux.yml' : 'latest.yml';
271
- const artifactExtensions = platform === 'mac'
272
- ? ['.dmg', '.zip', '.blockmap']
273
- : platform === 'win'
274
- ? ['.exe', '.zip', '.blockmap']
275
- : ['.AppImage', '.deb', '.blockmap'];
276
- const files = readdirSync(RELEASE_DIRECTORY)
277
- .filter((name) => name === metadataName || artifactExtensions.some((extension) => name.endsWith(extension)))
278
- .filter((name) => name === metadataName || name.includes('-' + arch + '.'))
279
- .filter((name) => statSync(path.join(RELEASE_DIRECTORY, name)).isFile());
280
- if (!files.includes(metadataName)) throw new Error('Missing electron-updater metadata: ' + metadataName);
281
- if (!files.some((name) => name !== metadataName && !name.endsWith('.blockmap'))) {
282
- throw new Error('No desktop installer artifact was found for ' + platform + '/' + arch);
283
- }
284
-
285
- // Publish immutable artifacts first and the mutable channel metadata last.
286
- files.sort((left, right) => Number(left === metadataName) - Number(right === metadataName) || left.localeCompare(right));
287
- for (const name of files) {
288
- const source = path.join(RELEASE_DIRECTORY, name);
289
- const objectPath = BUCKET_NAME + '/releases/' + platform + '/' + arch + '/' + name;
290
- const metadata = name.endsWith('.yml');
291
- const args = [
292
- 'dlx',
293
- 'wrangler@' + WRANGLER_VERSION,
294
- 'r2',
295
- 'object',
296
- 'put',
297
- objectPath,
298
- '--remote',
299
- '--file=' + source,
300
- '--content-type=' + contentType(name),
301
- '--cache-control=' + (metadata ? 'no-store' : 'public, max-age=31536000, immutable'),
302
- ];
303
- const result = spawnSync(UPLOAD_COMMAND, [...UPLOAD_ARGS_PREFIX, ...args], {
304
- cwd: process.cwd(),
305
- stdio: 'inherit',
306
- });
307
- if (result.error) throw result.error;
308
- if (result.status !== 0) throw new Error('Unable to upload desktop update object: ' + objectPath);
309
- }
310
-
311
- function contentType(name) {
312
- if (name.endsWith('.yml')) return 'text/yaml; charset=utf-8';
313
- if (name.endsWith('.blockmap')) return 'application/octet-stream';
314
- if (name.endsWith('.zip')) return 'application/zip';
315
- if (name.endsWith('.dmg')) return 'application/x-apple-diskimage';
316
- if (name.endsWith('.exe')) return 'application/vnd.microsoft.portable-executable';
317
- if (name.endsWith('.deb')) return 'application/vnd.debian.binary-package';
318
- return 'application/octet-stream';
319
- }
320
-
321
- console.log('Published ' + files.length + ' desktop update files to ' + BUCKET_NAME + '.');
322
- `);
323
- writeFile(path.join(root, 'cloudflare/desktop-updates.md'), `# Electron desktop update channel
324
-
325
- The \`${bucketName}\` R2 bucket stays private. The generated
326
- \`desktop-updater\` Worker is the only public read path and serves signed
327
- installers, blockmaps, and electron-updater channel metadata through a
328
- Cloudflare-managed custom domain such as \`updates.example.com\`.
329
-
330
- Set the same HTTPS origin in \`apps/desktop/electron-builder.env\` as
331
- \`DESKTOP_UPDATE_BASE_URL\`. electron-builder expands platform and architecture
332
- macros below \`/releases/<os>/<arch>\`, and the packaged application reads that
333
- URL from its generated \`app-update.yml\` file. Keep the bucket's \`r2.dev\` URL
334
- and R2 bucket custom-domain access disabled.
335
-
336
- Publish signed artifacts before channel metadata. The generated publisher does
337
- this ordering automatically so an update check cannot discover a release whose
338
- installer or blockmap is not yet available.
339
-
340
- Provision and release from the repository root:
341
-
342
- \`\`\`sh
343
- pnpm desktop:updates:cloudflare:login
344
- # First replace updates.example.com in apps/desktop-updater-worker/wrangler.jsonc.
345
- pnpm desktop:updates:first-provision
346
- cp apps/desktop/electron-builder.env.example apps/desktop/electron-builder.env
347
- pnpm desktop:updates:build:mac
348
- pnpm desktop:updates:publish --platform mac --arch arm64
349
- \`\`\`
350
-
351
- Build each operating system on its native, signing-enabled CI runner. Increase
352
- the desktop package version for every release and upload each architecture from
353
- a clean \`apps/desktop/release\` directory. The publisher rejects unknown
354
- platforms/architectures and selects only artifacts carrying the requested
355
- architecture in the generated filename.
356
- `);
357
- anhedralPrint.done('Electron desktop update channel written');
358
- }