anhedral 0.3.0 → 0.4.1
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 +35 -0
- package/CONTRIBUTING.md +4 -1
- package/LICENSE +201 -26
- package/README.md +79 -15
- package/SECURITY.md +4 -2
- package/SKILL.md +78 -11
- package/assets/anhedral-cli-init.svg +238 -0
- package/assets/images/svg/ably-symbol-color.svg +8 -0
- package/assets/images/svg/logo-white-subtract.svg +4 -0
- package/dist/architecture/contributions.js +7 -0
- package/dist/architecture/modules.d.ts +2 -2
- package/dist/architecture/modules.js +11 -3
- package/dist/bin.js +17 -5
- package/dist/cli.d.ts +2 -2
- package/dist/cli.js +4 -1
- package/dist/composers/root.js +1 -0
- package/dist/dependencies.d.ts +2 -0
- package/dist/dependencies.js +8 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/prompts.d.ts +1 -1
- package/dist/scaffold.d.ts +8 -0
- package/dist/scaffold.js +320 -38
- package/dist/templates/api.js +8 -0
- package/dist/templates/desktop.js +78 -11
- package/dist/templates/electron-updater.d.ts +3 -0
- package/dist/templates/electron-updater.js +358 -0
- package/dist/templates/shared.js +23 -6
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/docs/conventions.md +4 -1
- package/docs/master-stack-map.md +199 -0
- package/docs/output-tree-contract.md +45 -5
- package/{references → docs/references}/manual-scaffolding.md +24 -7
- package/docs/references/provisioning.md +220 -0
- package/package.json +12 -5
- package/scripts/render-master-stack-map.mjs +510 -0
package/dist/templates/api.js
CHANGED
|
@@ -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':
|
|
46
|
-
'build:win':
|
|
47
|
-
'build:linux':
|
|
48
|
-
package:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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,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
|
+
}
|
package/dist/templates/shared.js
CHANGED
|
@@ -24,7 +24,11 @@ function writeSharedDatabase(root, options) {
|
|
|
24
24
|
version: '0.1.0',
|
|
25
25
|
private: true,
|
|
26
26
|
type: 'module',
|
|
27
|
-
exports: {
|
|
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
|
-
|
|
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/
|
|
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.
|
|
2
|
+
export declare const GENERATOR_VERSION = "0.4.1";
|
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.
|
|
2
|
+
export const GENERATOR_VERSION = '0.4.1';
|
package/docs/conventions.md
CHANGED
|
@@ -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`.
|