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,3 +0,0 @@
1
- import type { ProjectOptions } from '../scaffold.js';
2
- export declare function r2BucketName(projectName: string): string;
3
- export declare function scaffoldAssetsPrivateProxy(root: string, options: ProjectOptions): void;
@@ -1,277 +0,0 @@
1
- import path from 'node:path';
2
- import { anhedralPrint } from '../print.js';
3
- import { childPackageName } from '../render.js';
4
- import { TOOLCHAIN_DEPENDENCIES } from '../dependencies.js';
5
- import { writeFile } from '../util.js';
6
- export function r2BucketName(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
- const base = normalized.slice(0, 56).replace(/-+$/g, '') || 'anhedral';
13
- return `${base}-assets`;
14
- }
15
- export function scaffoldAssetsPrivateProxy(root, options) {
16
- const dir = path.join(root, 'apps/assets-private-proxy');
17
- const wrangler = `pnpm dlx wrangler@${TOOLCHAIN_DEPENDENCIES.wrangler}`;
18
- const bucketName = r2BucketName(options.projectName);
19
- anhedralPrint.section('Private asset proxy (Cloudflare Worker + R2)');
20
- anhedralPrint.step('Writing private-bucket delivery Worker');
21
- writeFile(path.join(dir, 'package.json'), JSON.stringify({
22
- name: childPackageName(options.projectName, 'assets-private-proxy'),
23
- version: '0.1.0',
24
- private: true,
25
- type: 'module',
26
- scripts: {
27
- check: 'node --check src/index.js',
28
- dev: `${wrangler} dev`,
29
- deploy: `${wrangler} deploy`,
30
- types: `${wrangler} types`,
31
- },
32
- }, null, 2) + '\n');
33
- writeFile(path.join(dir, 'wrangler.jsonc'), `{
34
- // Replace both assets.example.com values with a hostname in your active Cloudflare zone before deployment.
35
- "name": "assets-private-proxy",
36
- "main": "src/index.js",
37
- "compatibility_date": "2026-07-16",
38
- "compatibility_flags": ["nodejs_compat"],
39
- "workers_dev": false,
40
- "preview_urls": false,
41
- "routes": [{ "pattern": "assets.example.com", "custom_domain": true }],
42
- "vars": { "ASSET_HOSTNAME": "assets.example.com", "R2_PREFIX": "storage" },
43
- "r2_buckets": [{ "binding": "ASSETS", "bucket_name": "${bucketName}" }],
44
- "observability": {
45
- "enabled": true,
46
- "logs": { "enabled": true, "head_sampling_rate": 1 }
47
- }
48
- }
49
- `);
50
- writeFile(path.join(dir, 'src/index.js'), `/**
51
- * Public asset delivery for a private Anhedral R2 bucket.
52
- *
53
- * Cloudflare configuration:
54
- * - Worker name: assets-private-proxy
55
- * - Custom domain: replace assets.example.com in wrangler.jsonc
56
- * - R2 binding name: ASSETS
57
- * - Public object prefix: storage/confirmed/
58
- *
59
- * Uploads remain authenticated through the Anhedral application. This Worker
60
- * only allows public GET and HEAD access to objects whose unguessable key is
61
- * known. Use authenticated or application-signed requests instead when object
62
- * keys are not sufficient authorization for your product.
63
- */
64
- const CACHE_CONTROL = "public, max-age=86400, s-maxage=2592000, immutable";
65
- const PRIVATE_GENERATION_INPUT_SEGMENT = "/generation-inputs/";
66
-
67
- const worker = {
68
- async fetch(request, env, ctx) {
69
- const method = request.method.toUpperCase();
70
- if (method !== "GET" && method !== "HEAD") {
71
- return textNoStore("Method Not Allowed", 405, { allow: "GET, HEAD" });
72
- }
73
-
74
- const url = new URL(request.url);
75
- if (url.hostname !== env.ASSET_HOSTNAME) return textNoStore("Forbidden", 403);
76
- const key = decodeObjectKey(url.pathname);
77
- const publicPrefix = env.R2_PREFIX + "/confirmed/";
78
- if (!key || !key.startsWith(publicPrefix) || key.includes(PRIVATE_GENERATION_INPUT_SEGMENT)) {
79
- return textNoStore("Not Found", 404);
80
- }
81
-
82
- try {
83
- const cache = caches.default;
84
- const cacheKey = buildCacheKey(url, key);
85
- const canUseBodyCache = isCacheableBodyRequest(request, method);
86
-
87
- if (method === "HEAD") {
88
- const cached = await cache.match(cacheKey);
89
- if (cached) return headFromResponse(cached, "HIT");
90
- const object = await env.ASSETS.head(key);
91
- if (!object) return textNoStore("Not Found", 404);
92
- return new Response(null, {
93
- status: 200,
94
- headers: withAssetDebugHeaders(buildObjectHeaders(object), "BYPASS"),
95
- });
96
- }
97
-
98
- if (canUseBodyCache) {
99
- const cached = await cache.match(cacheKey);
100
- if (cached) return withCacheStatus(cached, "HIT");
101
- }
102
-
103
- const object = await env.ASSETS.get(key, {
104
- onlyIf: request.headers,
105
- range: request.headers,
106
- });
107
- if (!object) return textNoStore("Not Found", 404);
108
-
109
- const hasBody = "body" in object;
110
- const headers = buildObjectHeaders(object);
111
- const isRangeResponse = hasBody && request.headers.has("range");
112
- if (isRangeResponse && object.range) {
113
- const returnedRange = normalizeReturnedRange(object.range, object.size);
114
- headers.set("content-range", "bytes " + returnedRange.offset + "-" + (returnedRange.offset + returnedRange.length - 1) + "/" + object.size);
115
- headers.set("content-length", String(returnedRange.length));
116
- }
117
-
118
- if (!hasBody) {
119
- const status = getFailedConditionalRequestStatus(request.headers);
120
- headers.delete("content-length");
121
- if (status === 412) headers.set("cache-control", "no-store");
122
- return new Response(null, {
123
- status,
124
- headers: withAssetDebugHeaders(headers, "BYPASS"),
125
- });
126
- }
127
-
128
- const response = new Response(object.body, {
129
- status: isRangeResponse ? 206 : 200,
130
- headers,
131
- });
132
- if (canUseBodyCache && response.status === 200) {
133
- ctx.waitUntil(cache.put(cacheKey, response.clone()).catch((error) => {
134
- console.error("Asset cache put failed", { key, error });
135
- }));
136
- return withCacheStatus(response, "MISS");
137
- }
138
- return withCacheStatus(response, "BYPASS");
139
- } catch (error) {
140
- console.error("Asset delivery failed", { key, error });
141
- return textNoStore("Asset delivery failed", 500);
142
- }
143
- },
144
- };
145
-
146
- export default worker;
147
-
148
- function isCacheableBodyRequest(request, method) {
149
- if (method !== "GET") return false;
150
- return !request.headers.has("range")
151
- && !request.headers.has("if-none-match")
152
- && !request.headers.has("if-match")
153
- && !request.headers.has("if-modified-since")
154
- && !request.headers.has("if-unmodified-since");
155
- }
156
-
157
- function buildCacheKey(url, key) {
158
- return new Request(url.origin + "/" + encodeObjectKey(key), { method: "GET" });
159
- }
160
-
161
- function decodeObjectKey(pathname) {
162
- const rawKey = pathname.replace(/^\\/+/, "");
163
- if (!rawKey) return null;
164
- const parts = [];
165
- for (const rawPart of rawKey.split("/")) {
166
- if (!rawPart) continue;
167
- let part;
168
- try {
169
- part = decodeURIComponent(rawPart);
170
- } catch {
171
- return null;
172
- }
173
- if (!part || part === "." || part === ".." || part.includes("/")) return null;
174
- parts.push(part);
175
- }
176
- return parts.join("/") || null;
177
- }
178
-
179
- function encodeObjectKey(key) {
180
- return key.split("/").map(encodeURIComponent).join("/");
181
- }
182
-
183
- function buildObjectHeaders(object) {
184
- const headers = new Headers();
185
- object.writeHttpMetadata(headers);
186
- headers.set("accept-ranges", "bytes");
187
- headers.set("cache-control", CACHE_CONTROL);
188
- headers.set("etag", object.httpEtag);
189
- headers.set("content-length", String(object.size));
190
- headers.set("x-assets-proxy", "anhedral-r2-direct-v1");
191
- if (object.uploaded instanceof Date) headers.set("last-modified", object.uploaded.toUTCString());
192
- headers.delete("set-cookie");
193
- if (headers.get("vary") === "*") headers.delete("vary");
194
- return headers;
195
- }
196
-
197
- function headFromResponse(response, cacheStatus) {
198
- const headers = new Headers(response.headers);
199
- headers.set("x-assets-cache", cacheStatus);
200
- return new Response(null, { status: response.status, headers });
201
- }
202
-
203
- function withCacheStatus(response, cacheStatus) {
204
- const headers = new Headers(response.headers);
205
- headers.set("x-assets-cache", cacheStatus);
206
- return new Response(response.body, {
207
- status: response.status,
208
- statusText: response.statusText,
209
- headers,
210
- });
211
- }
212
-
213
- function withAssetDebugHeaders(headers, cacheStatus) {
214
- const nextHeaders = new Headers(headers);
215
- nextHeaders.set("x-assets-cache", cacheStatus);
216
- return nextHeaders;
217
- }
218
-
219
- function textNoStore(body, status, extraHeaders = {}) {
220
- return new Response(body, {
221
- status,
222
- headers: { "cache-control": "no-store", ...extraHeaders },
223
- });
224
- }
225
-
226
- function getFailedConditionalRequestStatus(headers) {
227
- if (headers.has("if-none-match")) return 304;
228
- if (!headers.has("if-match") && !headers.has("if-unmodified-since") && headers.has("if-modified-since")) return 304;
229
- return 412;
230
- }
231
-
232
- function normalizeReturnedRange(range, objectSize) {
233
- if (typeof range.suffix === "number") {
234
- const length = Math.min(Math.max(0, range.suffix), objectSize);
235
- return { offset: objectSize - length, length };
236
- }
237
- const offset = Math.min(Math.max(0, range.offset ?? 0), objectSize);
238
- const availableLength = objectSize - offset;
239
- const length = typeof range.length === "number"
240
- ? Math.min(Math.max(0, range.length), availableLength)
241
- : availableLength;
242
- return { offset, length };
243
- }
244
- `);
245
- writeFile(path.join(root, 'cloudflare/r2-cors.template.json'), JSON.stringify({
246
- rules: [
247
- {
248
- id: 'anhedral-browser-uploads',
249
- allowed: {
250
- origins: ['https://app.example.com', 'http://localhost:3000'],
251
- methods: ['GET', 'HEAD', 'PUT'],
252
- headers: ['Content-Type'],
253
- },
254
- exposeHeaders: ['ETag'],
255
- maxAgeSeconds: 3600,
256
- },
257
- ],
258
- }, null, 2) + '\n');
259
- writeFile(path.join(root, 'cloudflare/README.md'), `# Anhedral Cloudflare resources
260
-
261
- The R2 bucket remains private. Browser uploads use short-lived, authenticated
262
- presigned URLs on the R2 S3 API hostname. Public downloads pass through the
263
- \`assets-private-proxy\` Worker, which exposes only keys below
264
- \`storage/confirmed/\` by default. Authenticated reads use the API's
265
- \`/api/storage/uploads/:uploadId/read-url\` endpoint instead.
266
-
267
- Before applying \`r2-cors.template.json\`, replace \`app.example.com\` and merge
268
- every exact web or extension origin that uploads to this bucket. Wrangler's
269
- \`r2 bucket cors set\` command replaces the complete live policy, so list and
270
- review the deployed policy before every update.
271
-
272
- \`CLOUDFLARE_API_TOKEN\` is an operations/CI credential used by Wrangler. It is
273
- not an application storage credential and must never be exposed to a browser,
274
- mobile build, desktop renderer, extension bundle, or Worker variable.
275
- `);
276
- anhedralPrint.done('Private R2 asset proxy written');
277
- }
@@ -1,3 +0,0 @@
1
- import type { ProjectOptions } from '../scaffold.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
- }