anhedral 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1366,6 +1366,7 @@ export const revenueCatWebhookStore: RevenueCatWebhookStore = {
1366
1366
  function routesSource(options) {
1367
1367
  const imports = [
1368
1368
  "import type { FastifyPluginAsync } from 'fastify';",
1369
+ "import { appRoutes } from './routes/app';",
1369
1370
  options.features.billing || options.features.storage ? "import { timingSafeEqual } from 'node:crypto';" : null,
1370
1371
  options.features.auth ? "import { authenticatedUserId } from './auth';" : null,
1371
1372
  options.features.storage ? "import { ConfirmUploadRequestSchema, CreateUploadRequestSchema, UploadParamsSchema } from '@shared/contracts';" : null,
@@ -1666,6 +1667,8 @@ export function routes(env: AppEnv, dependencies: RouteDependencies = {}): Fasti
1666
1667
  return async function registerRoutes(app) {
1667
1668
  ${billingStore}${storageService} const serviceState = dependencies.serviceState ?? { readiness: async () => 'ready' as const };
1668
1669
  ${routes.join('\n\n ')}
1670
+
1671
+ await app.register(appRoutes);
1669
1672
  };
1670
1673
  }
1671
1674
  `;
@@ -2941,6 +2944,11 @@ export default defineConfig({
2941
2944
  writeFile(path.join(dir, 'src/storage.ts'), storageSource());
2942
2945
  if (options.features.billing)
2943
2946
  writeFile(path.join(dir, 'src/billing.ts'), billingSource());
2947
+ writeFile(path.join(dir, 'src/routes/app.ts'), `import type { FastifyPluginAsync } from 'fastify';
2948
+
2949
+ // Register product-owned routes here. Anhedral never rewrites this file after creation.
2950
+ export const appRoutes: FastifyPluginAsync = async (_app) => {};
2951
+ `);
2944
2952
  writeFile(path.join(dir, 'src/routes.ts'), routesSource(options));
2945
2953
  writeFile(path.join(dir, 'src/application.ts'), applicationSource(options, options.displayName));
2946
2954
  writeFile(path.join(dir, 'src/index.ts'), `import { buildApp } from './application';
@@ -1,7 +1,7 @@
1
1
  import path from 'node:path';
2
2
  import { writeFile } from '../util.js';
3
3
  import { anhedralPrint } from '../print.js';
4
- import { DESKTOP_DEPENDENCIES } from '../dependencies.js';
4
+ import { DESKTOP_DEPENDENCIES, ELECTRON_UPDATER_DEPENDENCIES } from '../dependencies.js';
5
5
  import { childPackageName, htmlText, identifierSegment, jsString } from '../render.js';
6
6
  function selectedDependencies(options) {
7
7
  const dependencies = { ...(DESKTOP_DEPENDENCIES.dependencies ?? {}) };
@@ -14,6 +14,9 @@ function selectedDependencies(options) {
14
14
  delete dependencies['@clerk/ui'];
15
15
  delete dependencies['@solana/web3.js'];
16
16
  }
17
+ if (options.features.electronUpdater) {
18
+ Object.assign(dependencies, ELECTRON_UPDATER_DEPENDENCIES.dependencies);
19
+ }
17
20
  return dependencies;
18
21
  }
19
22
  export async function scaffoldDesktop(root, options) {
@@ -32,6 +35,14 @@ export async function scaffoldDesktop(root, options) {
32
35
  anhedralPrint.done('Electron desktop app written');
33
36
  }
34
37
  function writePackageJson(dir, projectName, options) {
38
+ const updatePublish = options.features.electronUpdater ? {
39
+ artifactName: '${productName}-${version}-${os}-${arch}.${ext}',
40
+ publish: [{
41
+ provider: 'generic',
42
+ url: '${env.DESKTOP_UPDATE_BASE_URL}/releases/${os}/${arch}',
43
+ useMultipleRangeRequest: false,
44
+ }],
45
+ } : {};
35
46
  writeFile(path.join(dir, 'package.json'), JSON.stringify({
36
47
  name: childPackageName(projectName, 'desktop'),
37
48
  version: '0.1.0',
@@ -42,10 +53,15 @@ function writePackageJson(dir, projectName, options) {
42
53
  dev: 'tsc -p tsconfig.main.json && node scripts/dev.mjs',
43
54
  build: 'tsc --noEmit && tsc -p tsconfig.main.json && vite build',
44
55
  typecheck: 'tsc --noEmit',
45
- 'build:mac': 'pnpm build && electron-builder --mac',
46
- 'build:win': 'pnpm build && electron-builder --win',
47
- 'build:linux': 'pnpm build && electron-builder --linux',
48
- package: 'pnpm build && electron-builder',
56
+ 'build:mac': `pnpm build && electron-builder --mac${options.features.electronUpdater ? ' --publish never' : ''}`,
57
+ 'build:win': `pnpm build && electron-builder --win${options.features.electronUpdater ? ' --publish never' : ''}`,
58
+ 'build:linux': `pnpm build && electron-builder --linux${options.features.electronUpdater ? ' --publish never' : ''}`,
59
+ package: `pnpm build && electron-builder${options.features.electronUpdater ? ' --publish never' : ''}`,
60
+ ...(options.features.electronUpdater ? {
61
+ 'updates:build:mac': 'pnpm build && electron-builder --mac --publish never',
62
+ 'updates:build:win': 'pnpm build && electron-builder --win --publish never',
63
+ 'updates:build:linux': 'pnpm build && electron-builder --linux --publish never',
64
+ } : {}),
49
65
  },
50
66
  build: {
51
67
  appId: `dev.anhedral.${identifierSegment(projectName)}`,
@@ -67,6 +83,7 @@ function writePackageJson(dir, projectName, options) {
67
83
  executableName: identifierSegment(projectName),
68
84
  target: ['AppImage', 'deb'],
69
85
  },
86
+ ...updatePublish,
70
87
  },
71
88
  dependencies: selectedDependencies(options),
72
89
  devDependencies: DESKTOP_DEPENDENCIES.devDependencies,
@@ -226,6 +243,9 @@ function writeEnvExample(dir, options) {
226
243
  options.features.auth ? 'VITE_CLERK_PUBLISHABLE_KEY=pk_test_***' : null,
227
244
  ].filter((value) => value !== null);
228
245
  writeFile(path.join(dir, '.env.example'), lines.join('\n') + (lines.length > 0 ? '\n' : ''));
246
+ if (options.features.electronUpdater) {
247
+ writeFile(path.join(dir, 'electron-builder.env.example'), 'DESKTOP_UPDATE_BASE_URL=https://updates.example.com\n');
248
+ }
229
249
  }
230
250
  function writeSourceFiles(dir, displayName, options) {
231
251
  const displayNameLiteral = jsString(displayName);
@@ -234,7 +254,46 @@ function writeSourceFiles(dir, displayName, options) {
234
254
  ? 'Electron + shadcn/ui desktop client using the same shared API client as web, mobile, and extension.'
235
255
  : 'Electron + shadcn/ui desktop client ready for your application.';
236
256
  const buttonLabel = options.features.auth ? 'Open account' : options.apps.api ? 'Explore API' : 'Get started';
237
- writeFile(path.join(dir, 'src/main/main.ts'), `import { app, BrowserWindow, session, shell } from 'electron';
257
+ const updaterImport = options.features.electronUpdater
258
+ ? "import electronUpdater, { type AppUpdater } from 'electron-updater';\n"
259
+ : '';
260
+ const updaterRuntime = options.features.electronUpdater ? `
261
+ const UPDATE_CHECK_INTERVAL_MS = 4 * 60 * 60 * 1000;
262
+ let updateTimer: NodeJS.Timeout | undefined;
263
+
264
+ function getAutoUpdater(): AppUpdater {
265
+ const { autoUpdater } = electronUpdater;
266
+ return autoUpdater;
267
+ }
268
+
269
+ async function checkForDesktopUpdate(updater: AppUpdater): Promise<void> {
270
+ try {
271
+ await updater.checkForUpdatesAndNotify();
272
+ } catch (error) {
273
+ console.error('Desktop update check failed:', error);
274
+ }
275
+ }
276
+
277
+ function startDesktopUpdates(): void {
278
+ if (!app.isPackaged) return;
279
+ const updater = getAutoUpdater();
280
+ updater.autoDownload = true;
281
+ updater.autoInstallOnAppQuit = true;
282
+ updater.on('error', (error) => {
283
+ console.error('Desktop updater error:', error);
284
+ });
285
+ void checkForDesktopUpdate(updater);
286
+ updateTimer = setInterval(() => {
287
+ void checkForDesktopUpdate(updater);
288
+ }, UPDATE_CHECK_INTERVAL_MS);
289
+ updateTimer.unref();
290
+ }
291
+ ` : '';
292
+ const updaterStart = options.features.electronUpdater ? ' startDesktopUpdates();\n' : '';
293
+ const updaterStop = options.features.electronUpdater
294
+ ? "app.on('before-quit', () => { if (updateTimer) clearInterval(updateTimer); });\n"
295
+ : '';
296
+ writeFile(path.join(dir, 'src/main/app-window.ts'), `import { BrowserWindow, shell } from 'electron';
238
297
  import path from 'node:path';
239
298
  import { fileURLToPath, pathToFileURL } from 'node:url';
240
299
 
@@ -262,7 +321,9 @@ function isAllowedAppNavigation(value: string): boolean {
262
321
  }
263
322
  }
264
323
 
265
- function createWindow() {
324
+ // Product-owned window options and navigation policy belong in this file.
325
+ // Anhedral's managed main.ts imports this seam and wires lifecycle integrations around it.
326
+ export function createAppWindow(): BrowserWindow {
266
327
  const window = new BrowserWindow({
267
328
  width: 1180,
268
329
  height: 760,
@@ -299,20 +360,26 @@ function createWindow() {
299
360
  console.error('Unable to load the packaged renderer:', error);
300
361
  });
301
362
  }
363
+
364
+ return window;
302
365
  }
366
+ `);
367
+ writeFile(path.join(dir, 'src/main/main.ts'), `import { app, BrowserWindow, session } from 'electron';
368
+ import { createAppWindow } from './app-window.js';
369
+ ${updaterImport}${updaterRuntime}
303
370
 
304
371
  app.whenReady().then(() => {
305
372
  session.defaultSession.setPermissionCheckHandler(() => false);
306
373
  session.defaultSession.setPermissionRequestHandler((_webContents, _permission, callback) => callback(false));
307
- createWindow();
308
- });
374
+ createAppWindow();
375
+ ${updaterStart}});
309
376
  app.on('window-all-closed', () => {
310
377
  if (process.platform !== 'darwin') app.quit();
311
378
  });
312
379
  app.on('activate', () => {
313
- if (BrowserWindow.getAllWindows().length === 0) createWindow();
380
+ if (BrowserWindow.getAllWindows().length === 0) createAppWindow();
314
381
  });
315
- `);
382
+ ${updaterStop}`);
316
383
  writeFile(path.join(dir, 'src/main/preload.cts'), `import { contextBridge } from 'electron';
317
384
 
318
385
  contextBridge.exposeInMainWorld('anhedral', {
@@ -0,0 +1,3 @@
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;
@@ -0,0 +1,358 @@
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
+ }
@@ -24,7 +24,11 @@ function writeSharedDatabase(root, options) {
24
24
  version: '0.1.0',
25
25
  private: true,
26
26
  type: 'module',
27
- exports: { '.': './src/index.ts', './schema': './src/schema.ts' },
27
+ exports: {
28
+ '.': './src/index.ts',
29
+ './schema': './src/schema.ts',
30
+ './app-schema': './src/app-schema.ts',
31
+ },
28
32
  scripts: {
29
33
  build: 'pnpm typecheck',
30
34
  typecheck: 'tsc --noEmit',
@@ -41,7 +45,7 @@ function writeSharedDatabase(root, options) {
41
45
  import { defineConfig } from 'drizzle-kit';
42
46
 
43
47
  export default defineConfig({
44
- schema: './src/schema.ts',
48
+ schema: ['./src/generated-schema.ts', './src/app-schema.ts'],
45
49
  out: './migrations',
46
50
  dialect: 'postgresql',
47
51
  dbCredentials: { url: process.env.DATABASE_URL || '' },
@@ -102,14 +106,20 @@ export const realtimeOutbox = pgTable('realtime_outbox', {
102
106
  createdAt: timestamp('created_at').defaultNow().notNull(),
103
107
  });
104
108
  ` : '';
105
- writeFile(path.join(dir, 'src/schema.ts'), `import { integer, jsonb, pgTable, text, timestamp } from 'drizzle-orm/pg-core';
109
+ writeFile(path.join(dir, 'src/generated-schema.ts'), `import { integer, jsonb, pgTable, text, timestamp } from 'drizzle-orm/pg-core';
110
+ ${storageTables}${billingTables}`);
111
+ writeFile(path.join(dir, 'src/app-schema.ts'), `import { pgTable, text, timestamp } from 'drizzle-orm/pg-core';
106
112
 
113
+ // Product-owned tables belong here. Anhedral never rewrites this file after creation.
107
114
  export const items = pgTable('items', {
108
115
  id: text('id').primaryKey(),
109
116
  name: text('name').notNull(),
110
117
  createdAt: timestamp('created_at').defaultNow().notNull(),
111
118
  });
112
- ${storageTables}${billingTables}`);
119
+ `);
120
+ writeFile(path.join(dir, 'src/schema.ts'), `export * from './generated-schema';
121
+ export * from './app-schema';
122
+ `);
113
123
  writeFile(path.join(dir, 'src/index.ts'), `import { neon } from '@neondatabase/serverless';
114
124
  import { drizzle } from 'drizzle-orm/neon-http';
115
125
  import * as schema from './schema';
@@ -427,13 +437,20 @@ function writeApiPackages(root, options) {
427
437
  version: '0.1.0',
428
438
  private: true,
429
439
  type: 'module',
430
- exports: { '.': './src/index.ts' },
440
+ exports: { '.': './src/index.ts', './app': './src/app.ts' },
431
441
  scripts: { build: 'pnpm typecheck', typecheck: 'tsc --noEmit' },
432
442
  devDependencies: SHARED_PACKAGE_DEPENDENCIES.devDependencies,
433
443
  ...fields,
434
444
  }, null, 2) + '\n');
435
445
  writeTsConfig(dir);
436
- writeFile(path.join(dir, 'src/index.ts'), source);
446
+ writeFile(path.join(dir, 'src/generated.ts'), source);
447
+ writeFile(path.join(dir, 'src/app.ts'), `// Product-owned ${name === 'contracts' ? 'network contracts' : 'API client helpers'} belong here.
448
+ // Anhedral never rewrites this file after creation.
449
+ export {};
450
+ `);
451
+ writeFile(path.join(dir, 'src/index.ts'), `export * from './generated';
452
+ export * from './app';
453
+ `);
437
454
  }
438
455
  }
439
456
  function writeRealtimePackage(root, options) {
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  /** Kept in sync with package.json by tests/release checks. */
2
- export declare const GENERATOR_VERSION = "0.3.0";
2
+ export declare const GENERATOR_VERSION = "0.4.0";
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  /** Kept in sync with package.json by tests/release checks. */
2
- export const GENERATOR_VERSION = '0.3.0';
2
+ export const GENERATOR_VERSION = '0.4.0';
@@ -18,6 +18,7 @@ docs/ generated stack and development guidance
18
18
  - `apps/mobile`: Expo Router screens, layouts, native components, and hooks.
19
19
  - `apps/api`: Fastify routes, services, plugins, provider clients, and server environment validation.
20
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.
21
22
  - `apps/extension`: WXT entrypoints, extension components, background logic, and content scripts.
22
23
  - `packages/contracts`: Zod schemas for data crossing HTTP boundaries.
23
24
  - `packages/api-client`: client-safe HTTP behavior shared across frontends.
@@ -36,11 +37,13 @@ Zod contract
36
37
 
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.
38
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
+
39
42
  ## Database
40
43
 
41
44
  Database-enabled projects use managed Neon Postgres. Anhedral does not generate local Postgres, Docker Compose, or an embedded database substitute.
42
45
 
43
- 1. Edit `packages/db/src/schema.ts`.
46
+ 1. Edit the user-owned `packages/db/src/app-schema.ts`; Anhedral keeps provider tables in `generated-schema.ts`.
44
47
  2. Run `pnpm db:generate`.
45
48
  3. Review and commit generated SQL and metadata.
46
49
  4. Run `pnpm verify:db`.