anhedral 0.4.2 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/README.md +29 -269
  2. package/assets/anhedral-cli-init-technical.svg +322 -0
  3. package/assets/anhedral-cli-init.svg +257 -173
  4. package/dist/bin.js +28 -191
  5. package/dist/dependencies.d.ts +7 -57
  6. package/dist/dependencies.js +96 -308
  7. package/dist/index.d.ts +2 -7
  8. package/dist/index.js +1 -4
  9. package/dist/platforms/desktop.js +401 -0
  10. package/dist/platforms/extension.js +401 -0
  11. package/dist/platforms/mobile.js +232 -0
  12. package/dist/project.d.ts +0 -26
  13. package/dist/standard-products.d.ts +2 -0
  14. package/dist/standard-products.js +6 -0
  15. package/dist/standard.d.ts +18 -0
  16. package/dist/standard.js +578 -0
  17. package/dist/version.d.ts +1 -1
  18. package/dist/version.js +1 -1
  19. package/docs/application-stack-standard.md +700 -0
  20. package/package.json +34 -51
  21. package/CHANGELOG.md +0 -178
  22. package/CONTRIBUTING.md +0 -77
  23. package/SECURITY.md +0 -37
  24. package/SKILL.md +0 -339
  25. package/assets/images/svg/ably-symbol-color.svg +0 -8
  26. package/bin/anhedral.js +0 -2
  27. package/dist/architecture/contributions.d.ts +0 -28
  28. package/dist/architecture/contributions.js +0 -125
  29. package/dist/architecture/index.d.ts +0 -12
  30. package/dist/architecture/index.js +0 -6
  31. package/dist/architecture/manifest.d.ts +0 -53
  32. package/dist/architecture/manifest.js +0 -283
  33. package/dist/architecture/modules.d.ts +0 -41
  34. package/dist/architecture/modules.js +0 -187
  35. package/dist/architecture/plan.d.ts +0 -58
  36. package/dist/architecture/plan.js +0 -145
  37. package/dist/architecture/templates.d.ts +0 -10
  38. package/dist/architecture/templates.js +0 -23
  39. package/dist/cli.d.ts +0 -32
  40. package/dist/cli.js +0 -281
  41. package/dist/composers/root.d.ts +0 -6
  42. package/dist/composers/root.js +0 -35
  43. package/dist/print.d.ts +0 -8
  44. package/dist/print.js +0 -48
  45. package/dist/prompts.d.ts +0 -6
  46. package/dist/prompts.js +0 -41
  47. package/dist/scaffold.d.ts +0 -64
  48. package/dist/scaffold.js +0 -2102
  49. package/dist/template-source.d.ts +0 -20
  50. package/dist/template-source.js +0 -180
  51. package/dist/templates/api.d.ts +0 -2
  52. package/dist/templates/api.js +0 -3187
  53. package/dist/templates/assets-private-proxy.d.ts +0 -3
  54. package/dist/templates/assets-private-proxy.js +0 -277
  55. package/dist/templates/desktop.js +0 -792
  56. package/dist/templates/electron-updater.d.ts +0 -3
  57. package/dist/templates/electron-updater.js +0 -358
  58. package/dist/templates/extension.js +0 -856
  59. package/dist/templates/first-run.d.ts +0 -2
  60. package/dist/templates/first-run.js +0 -167
  61. package/dist/templates/mobile.js +0 -861
  62. package/dist/templates/shared.d.ts +0 -2
  63. package/dist/templates/shared.js +0 -577
  64. package/dist/templates/web.d.ts +0 -2
  65. package/dist/templates/web.js +0 -569
  66. package/dist/toolchain.d.ts +0 -3
  67. package/dist/toolchain.js +0 -8
  68. package/dist/ui.d.ts +0 -33
  69. package/dist/ui.js +0 -199
  70. package/dist/workspace-config.d.ts +0 -11
  71. package/dist/workspace-config.js +0 -372
  72. package/docs/NORTHSTAR.md +0 -114
  73. package/docs/conventions.md +0 -94
  74. package/docs/engineering-principles.md +0 -88
  75. package/docs/master-stack-map.md +0 -199
  76. package/docs/output-tree-contract.md +0 -493
  77. package/docs/references/manual-scaffolding.md +0 -421
  78. package/docs/references/provisioning.md +0 -577
  79. package/scripts/render-master-stack-map.mjs +0 -510
  80. package/templates/api-fastify/apps/api/tsconfig.json +0 -20
  81. package/templates/catalog.json +0 -23
  82. package/templates/db-drizzle/packages/db/tsconfig.json +0 -14
  83. package/templates/desktop-electron/apps/desktop/tsconfig.json +0 -37
  84. package/templates/extension-wxt/apps/extension/tsconfig.json +0 -41
  85. package/templates/mobile-expo/apps/mobile/expo-env.d.ts +0 -1
  86. package/templates/web-next/apps/web/next-env.d.ts +0 -6
  87. /package/dist/{templates → platforms}/desktop.d.ts +0 -0
  88. /package/dist/{templates → platforms}/extension.d.ts +0 -0
  89. /package/dist/{templates → platforms}/mobile.d.ts +0 -0
@@ -1,792 +0,0 @@
1
- import path from 'node:path';
2
- import { writeFile } from '../util.js';
3
- import { anhedralPrint } from '../print.js';
4
- import { DESKTOP_DEPENDENCIES, ELECTRON_UPDATER_DEPENDENCIES } from '../dependencies.js';
5
- import { childPackageName, htmlText, identifierSegment, jsString } from '../render.js';
6
- function selectedDependencies(options) {
7
- const dependencies = { ...(DESKTOP_DEPENDENCIES.dependencies ?? {}) };
8
- if (!options.apps.api)
9
- delete dependencies['@shared/api-client'];
10
- if (!options.features.billing)
11
- delete dependencies['@shared/realtime'];
12
- if (!options.features.auth) {
13
- delete dependencies['@clerk/clerk-js'];
14
- delete dependencies['@clerk/ui'];
15
- delete dependencies['@solana/web3.js'];
16
- delete dependencies.bs58;
17
- }
18
- if (options.features.electronUpdater) {
19
- Object.assign(dependencies, ELECTRON_UPDATER_DEPENDENCIES.dependencies);
20
- }
21
- return dependencies;
22
- }
23
- export async function scaffoldDesktop(root, options) {
24
- const { projectName, displayName } = options;
25
- const dir = path.join(root, 'apps/desktop');
26
- anhedralPrint.section('Desktop (Electron + shadcn/ui)');
27
- anhedralPrint.step('Writing Electron desktop app');
28
- writePackageJson(dir, projectName, options);
29
- writeTsConfig(dir);
30
- writeViteConfig(dir);
31
- writePostcssConfig(dir);
32
- writeDevScript(dir);
33
- writeShadcnConfig(dir);
34
- writeEnvExample(dir, options);
35
- writeSourceFiles(dir, displayName, options);
36
- anhedralPrint.done('Electron desktop app written');
37
- }
38
- function writePackageJson(dir, projectName, options) {
39
- const updatePublish = options.features.electronUpdater ? {
40
- artifactName: '${productName}-${version}-${os}-${arch}.${ext}',
41
- publish: [{
42
- provider: 'generic',
43
- url: '${env.DESKTOP_UPDATE_BASE_URL}/releases/${os}/${arch}',
44
- useMultipleRangeRequest: false,
45
- }],
46
- } : {};
47
- writeFile(path.join(dir, 'package.json'), JSON.stringify({
48
- name: childPackageName(projectName, 'desktop'),
49
- version: '0.1.0',
50
- private: true,
51
- type: 'module',
52
- main: 'dist/main/main.js',
53
- scripts: {
54
- dev: 'tsc -p tsconfig.main.json && node scripts/dev.mjs',
55
- build: 'tsc --noEmit && tsc -p tsconfig.main.json && vite build',
56
- typecheck: 'tsc --noEmit',
57
- 'build:mac': `pnpm build && electron-builder --mac${options.features.electronUpdater ? ' --publish never' : ''}`,
58
- 'build:win': `pnpm build && electron-builder --win${options.features.electronUpdater ? ' --publish never' : ''}`,
59
- 'build:linux': `pnpm build && electron-builder --linux${options.features.electronUpdater ? ' --publish never' : ''}`,
60
- package: `pnpm build && electron-builder${options.features.electronUpdater ? ' --publish never' : ''}`,
61
- ...(options.features.electronUpdater ? {
62
- 'updates:build:mac': 'pnpm build && electron-builder --mac --publish never',
63
- 'updates:build:win': 'pnpm build && electron-builder --win --publish never',
64
- 'updates:build:linux': 'pnpm build && electron-builder --linux --publish never',
65
- } : {}),
66
- },
67
- build: {
68
- appId: `dev.anhedral.${identifierSegment(projectName)}`,
69
- productName: projectName,
70
- directories: {
71
- output: 'release',
72
- },
73
- files: [
74
- 'dist/**/*',
75
- 'package.json',
76
- ],
77
- mac: {
78
- target: ['dmg', 'zip'],
79
- },
80
- win: {
81
- target: ['nsis', 'zip'],
82
- },
83
- linux: {
84
- executableName: identifierSegment(projectName),
85
- target: ['AppImage', 'deb'],
86
- },
87
- ...updatePublish,
88
- },
89
- dependencies: selectedDependencies(options),
90
- devDependencies: DESKTOP_DEPENDENCIES.devDependencies,
91
- }, null, 2) + '\n');
92
- }
93
- function writeTsConfig(dir) {
94
- writeFile(path.join(dir, 'tsconfig.main.json'), JSON.stringify({
95
- compilerOptions: {
96
- target: 'ES2022',
97
- module: 'NodeNext',
98
- moduleResolution: 'NodeNext',
99
- strict: true,
100
- skipLibCheck: true,
101
- esModuleInterop: true,
102
- outDir: 'dist/main',
103
- rootDir: 'src/main',
104
- types: ['node'],
105
- },
106
- include: ['src/main/**/*'],
107
- }, null, 2) + '\n');
108
- }
109
- function writeViteConfig(dir) {
110
- writeFile(path.join(dir, 'vite.config.ts'), `import { defineConfig } from 'vite';
111
- import react from '@vitejs/plugin-react';
112
- import path from 'node:path';
113
-
114
- export default defineConfig({
115
- base: './',
116
- plugins: [react()],
117
- resolve: {
118
- alias: {
119
- '@': path.resolve(__dirname, 'src/renderer'),
120
- },
121
- },
122
- build: {
123
- outDir: 'dist/renderer',
124
- emptyOutDir: true,
125
- },
126
- });
127
- `);
128
- }
129
- function writePostcssConfig(dir) {
130
- writeFile(path.join(dir, 'postcss.config.mjs'), `const config = {
131
- plugins: {
132
- '@tailwindcss/postcss': {},
133
- },
134
- };
135
-
136
- export default config;
137
- `);
138
- }
139
- function writeDevScript(dir) {
140
- writeFile(path.join(dir, 'scripts/dev.mjs'), `import { spawn } from 'node:child_process';
141
- import { once } from 'node:events';
142
-
143
- const host = '127.0.0.1';
144
- const port = '5173';
145
- const devServerUrl = 'http://' + host + ':' + port;
146
- const viteCommand = process.platform === 'win32' ? 'vite.cmd' : 'vite';
147
- const electronCommand = process.platform === 'win32' ? 'electron.cmd' : 'electron';
148
-
149
- function start(command, args, options = {}) {
150
- const invocation = process.platform === 'win32'
151
- ? {
152
- command: process.env.ComSpec || 'cmd.exe',
153
- args: ['/d', '/s', '/c', 'call', command, ...args],
154
- }
155
- : { command, args };
156
- return spawn(invocation.command, invocation.args, {
157
- cwd: process.cwd(),
158
- stdio: 'inherit',
159
- ...options,
160
- });
161
- }
162
-
163
- function stop(child) {
164
- if (child && child.exitCode === null && !child.killed) child.kill('SIGTERM');
165
- }
166
-
167
- async function waitForServer(server, url, timeoutMs = 30_000) {
168
- const deadline = Date.now() + timeoutMs;
169
- while (Date.now() < deadline) {
170
- if (server.exitCode !== null) throw new Error('Vite exited before the dev server was ready.');
171
- try {
172
- const response = await fetch(url);
173
- if (response.ok) return;
174
- } catch {
175
- // The server is still starting.
176
- }
177
- await new Promise((resolve) => setTimeout(resolve, 150));
178
- }
179
- throw new Error('Timed out waiting for Vite at ' + url + '.');
180
- }
181
-
182
- const vite = start(viteCommand, ['--host', host, '--port', port, '--strictPort']);
183
- let electron;
184
- let shuttingDown = false;
185
-
186
- for (const signal of ['SIGINT', 'SIGTERM']) {
187
- process.once(signal, () => {
188
- shuttingDown = true;
189
- stop(electron);
190
- stop(vite);
191
- });
192
- }
193
-
194
- try {
195
- await waitForServer(vite, devServerUrl);
196
- electron = start(electronCommand, ['.'], {
197
- env: { ...process.env, VITE_DEV_SERVER_URL: devServerUrl },
198
- });
199
- vite.once('exit', (code) => {
200
- if (!shuttingDown) {
201
- console.error('Vite exited while Electron was running.');
202
- process.exitCode = code ?? 1;
203
- stop(electron);
204
- }
205
- });
206
- const [code] = await once(electron, 'exit');
207
- if (process.exitCode === undefined) process.exitCode = typeof code === 'number' ? code : 1;
208
- } catch (error) {
209
- console.error(error instanceof Error ? error.message : error);
210
- process.exitCode = 1;
211
- } finally {
212
- shuttingDown = true;
213
- stop(electron);
214
- stop(vite);
215
- }
216
- `);
217
- }
218
- function writeShadcnConfig(dir) {
219
- writeFile(path.join(dir, 'components.json'), JSON.stringify({
220
- $schema: 'https://ui.shadcn.com/schema.json',
221
- style: 'new-york',
222
- rsc: false,
223
- tsx: true,
224
- tailwind: {
225
- config: '',
226
- css: 'src/renderer/styles.css',
227
- baseColor: 'neutral',
228
- cssVariables: true,
229
- prefix: '',
230
- },
231
- aliases: {
232
- components: '@/components',
233
- utils: '@/lib/utils',
234
- ui: '@/components/ui',
235
- lib: '@/lib',
236
- hooks: '@/hooks',
237
- },
238
- iconLibrary: 'lucide',
239
- }, null, 2) + '\n');
240
- }
241
- function writeEnvExample(dir, options) {
242
- const lines = [
243
- options.apps.api ? 'VITE_API_URL=http://localhost:8787/api' : null,
244
- options.features.auth ? 'VITE_CLERK_PUBLISHABLE_KEY=pk_test_***' : null,
245
- ].filter((value) => value !== null);
246
- writeFile(path.join(dir, '.env.example'), lines.join('\n') + (lines.length > 0 ? '\n' : ''));
247
- if (options.features.electronUpdater) {
248
- writeFile(path.join(dir, 'electron-builder.env.example'), 'DESKTOP_UPDATE_BASE_URL=https://updates.example.com\n');
249
- }
250
- }
251
- function writeSourceFiles(dir, displayName, options) {
252
- const displayNameLiteral = jsString(displayName);
253
- const displayNameHtml = htmlText(displayName);
254
- const description = options.apps.api
255
- ? 'Electron + shadcn/ui desktop client using the same shared API client as web, mobile, and extension.'
256
- : 'Electron + shadcn/ui desktop client ready for your application.';
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';
297
- import path from 'node:path';
298
- import { fileURLToPath, pathToFileURL } from 'node:url';
299
-
300
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
301
- const rendererIndex = path.join(__dirname, '../renderer/index.html');
302
-
303
- function isSafeExternalUrl(value: string): boolean {
304
- try {
305
- const url = new URL(value);
306
- return url.protocol === 'https:' && !url.username && !url.password;
307
- } catch {
308
- return false;
309
- }
310
- }
311
-
312
- function isAllowedAppNavigation(value: string): boolean {
313
- try {
314
- const target = new URL(value);
315
- if (process.env.VITE_DEV_SERVER_URL) {
316
- return target.origin === new URL(process.env.VITE_DEV_SERVER_URL).origin;
317
- }
318
- return target.href === pathToFileURL(rendererIndex).href;
319
- } catch {
320
- return false;
321
- }
322
- }
323
-
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 {
327
- const window = new BrowserWindow({
328
- width: 1180,
329
- height: 760,
330
- minWidth: 860,
331
- minHeight: 560,
332
- title: ${displayNameLiteral},
333
- webPreferences: {
334
- preload: path.join(__dirname, 'preload.cjs'),
335
- contextIsolation: true,
336
- nodeIntegration: false,
337
- sandbox: true,
338
- webSecurity: true,
339
- },
340
- });
341
-
342
- window.webContents.on('will-navigate', (event, url) => {
343
- if (!isAllowedAppNavigation(url)) event.preventDefault();
344
- });
345
- window.webContents.setWindowOpenHandler(({ url }) => {
346
- if (isSafeExternalUrl(url)) {
347
- void shell.openExternal(url).catch((error) => {
348
- console.error('Unable to open external URL:', error);
349
- });
350
- }
351
- return { action: 'deny' };
352
- });
353
-
354
- if (process.env.VITE_DEV_SERVER_URL) {
355
- void window.loadURL(process.env.VITE_DEV_SERVER_URL).catch((error) => {
356
- console.error('Unable to load the development renderer:', error);
357
- });
358
- } else {
359
- void window.loadFile(rendererIndex).catch((error) => {
360
- console.error('Unable to load the packaged renderer:', error);
361
- });
362
- }
363
-
364
- return window;
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}
370
-
371
- app.whenReady().then(() => {
372
- session.defaultSession.setPermissionCheckHandler(() => false);
373
- session.defaultSession.setPermissionRequestHandler((_webContents, _permission, callback) => callback(false));
374
- createAppWindow();
375
- ${updaterStart}});
376
- app.on('window-all-closed', () => {
377
- if (process.platform !== 'darwin') app.quit();
378
- });
379
- app.on('activate', () => {
380
- if (BrowserWindow.getAllWindows().length === 0) createAppWindow();
381
- });
382
- ${updaterStop}`);
383
- writeFile(path.join(dir, 'src/main/preload.cts'), `import { contextBridge } from 'electron';
384
-
385
- contextBridge.exposeInMainWorld('anhedral', {
386
- platform: process.platform,
387
- });
388
- `);
389
- writeFile(path.join(dir, 'index.html'), `<!doctype html>
390
- <html lang="en">
391
- <head>
392
- <meta charset="UTF-8" />
393
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
394
- <meta http-equiv="Content-Security-Policy" content="default-src 'self'; base-uri 'none'; object-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self' https: ws://127.0.0.1:* wss:; img-src 'self' data: https:; font-src 'self' data: https:; frame-src https:; form-action 'self' https:" />
395
- <title>${displayNameHtml}</title>
396
- </head>
397
- <body>
398
- <div id="root"></div>
399
- <script type="module" src="/src/renderer/main.tsx"></script>
400
- </body>
401
- </html>
402
- `);
403
- writeFile(path.join(dir, 'src/renderer/lib/utils.ts'), `import { clsx, type ClassValue } from 'clsx';
404
- import { twMerge } from 'tailwind-merge';
405
-
406
- export function cn(...inputs: ClassValue[]) {
407
- return twMerge(clsx(inputs));
408
- }
409
- `);
410
- if (options.apps.api) {
411
- const authImport = options.features.auth ? "import { getAuthToken } from './auth';\n" : '';
412
- const getToken = options.features.auth ? 'getAuthToken' : 'async () => null';
413
- writeFile(path.join(dir, 'src/renderer/lib/api.ts'), `import { ApiClient, normalizeApiBaseUrl } from '@shared/api-client';
414
- ${authImport}
415
-
416
- function apiBaseUrl(): string {
417
- const configured = import.meta.env.VITE_API_URL?.trim();
418
- const candidate = configured || (import.meta.env.DEV ? 'http://localhost:8787/api' : '');
419
- if (!candidate) throw new Error('VITE_API_URL is required in production builds');
420
- const normalized = normalizeApiBaseUrl(candidate, 'VITE_API_URL');
421
- const url = new URL(normalized);
422
- if (import.meta.env.PROD && url.protocol !== 'https:') {
423
- throw new Error('VITE_API_URL must use https: in production');
424
- }
425
- return normalized;
426
- }
427
-
428
- export const api = new ApiClient({
429
- baseUrl: apiBaseUrl(),
430
- getToken: ${getToken},
431
- });
432
- `);
433
- }
434
- if (options.features.auth) {
435
- writeFile(path.join(dir, 'src/renderer/lib/auth.ts'), `import type { Clerk as ClerkInstance } from '@clerk/clerk-js';
436
-
437
- const publishableKey = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY || '';
438
- let initialization: Promise<ClerkInstance | null> | null = null;
439
-
440
- export function isClerkConfigured(): boolean {
441
- return publishableKey.length > 0 && !publishableKey.includes('***');
442
- }
443
-
444
- export function initializeClerk(): Promise<ClerkInstance | null> {
445
- if (!isClerkConfigured()) return Promise.resolve(null);
446
- if (!initialization) {
447
- initialization = Promise.all([
448
- import('@clerk/clerk-js'),
449
- import('@clerk/ui'),
450
- ]).then(async ([{ Clerk }, { ui }]) => {
451
- const clerk = new Clerk(publishableKey);
452
- await clerk.load({ ui });
453
- return clerk;
454
- }).catch((error) => {
455
- initialization = null;
456
- throw error;
457
- });
458
- }
459
- return initialization;
460
- }
461
-
462
- export async function getAuthToken(): Promise<string | null> {
463
- const clerk = await initializeClerk();
464
- return clerk?.session?.getToken() ?? null;
465
- }
466
-
467
- export async function getAuthUserId(): Promise<string | null> {
468
- const clerk = await initializeClerk();
469
- return clerk?.user?.id ?? null;
470
- }
471
-
472
- export async function subscribeToAuthState(listener: (userId: string | null) => void): Promise<() => void> {
473
- const clerk = await initializeClerk();
474
- if (!clerk) {
475
- listener(null);
476
- return () => undefined;
477
- }
478
- const synchronize = () => listener(clerk.user?.id ?? null);
479
- synchronize();
480
- return clerk.addListener(synchronize);
481
- }
482
-
483
- export async function openAccount(): Promise<boolean> {
484
- const clerk = await initializeClerk();
485
- if (!clerk) return false;
486
-
487
- if (clerk.user) clerk.openUserProfile();
488
- else clerk.openSignIn();
489
- return true;
490
- }
491
- `);
492
- }
493
- if (options.features.billing) {
494
- writeFile(path.join(dir, 'src/renderer/hooks/use-entitlement.ts'), `import { subscribeToSubscriptionChanges } from '@shared/realtime';
495
- import * as React from 'react';
496
- import { api } from '../lib/api';
497
- export function useEntitlement(identity: string | null) {
498
- const [entitlement, setEntitlement] = React.useState<Awaited<ReturnType<typeof api.getEntitlement>> | null>(null);
499
- const [loadedIdentity, setLoadedIdentity] = React.useState<string | null>(null);
500
- const [error, setError] = React.useState<string | null>(null);
501
- const revision = React.useRef(0);
502
- const identityRef = React.useRef(identity);
503
- const refresh = React.useCallback(async () => {
504
- if (!identity) return;
505
- try {
506
- const next = await api.getEntitlement();
507
- if (identityRef.current !== identity) return;
508
- revision.current = Math.max(revision.current, next.revision);
509
- setEntitlement(next);
510
- setLoadedIdentity(identity);
511
- setError(null);
512
- } catch (cause) {
513
- if (identityRef.current !== identity) return;
514
- setLoadedIdentity(identity);
515
- setError(cause instanceof Error ? cause.message : 'Unable to load subscription');
516
- }
517
- }, [identity]);
518
- React.useEffect(() => {
519
- identityRef.current = identity;
520
- revision.current = 0;
521
- setEntitlement(null);
522
- setLoadedIdentity(null);
523
- setError(null);
524
- void refresh();
525
- }, [identity, refresh]);
526
- React.useEffect(() => {
527
- if (!identity) return;
528
- const onVisibility = () => { if (document.visibilityState === 'visible') void refresh(); };
529
- document.addEventListener('visibilitychange', onVisibility);
530
- return () => document.removeEventListener('visibilitychange', onVisibility);
531
- }, [identity, refresh]);
532
- React.useEffect(() => {
533
- if (!identity) return;
534
- return subscribeToSubscriptionChanges({
535
- userId: identity,
536
- getTokenRequest: () => api.getRealtimeToken(),
537
- onChange: (nextRevision) => { if (nextRevision > revision.current) void refresh(); },
538
- onError: (cause) => {
539
- if (identityRef.current === identity) {
540
- setLoadedIdentity(identity);
541
- setError(cause.message);
542
- }
543
- },
544
- });
545
- }, [identity, refresh]);
546
- return {
547
- entitlement: loadedIdentity === identity ? entitlement : null,
548
- error: loadedIdentity === identity ? error : null,
549
- };
550
- }
551
- `);
552
- }
553
- writeFile(path.join(dir, 'src/renderer/components/ui/button.tsx'), `import * as React from 'react';
554
- import { cn } from '@/lib/utils';
555
-
556
- export function Button({ className, type = 'button', ...props }: React.ButtonHTMLAttributes<HTMLButtonElement>) {
557
- return (
558
- <button
559
- type={type}
560
- className={cn('inline-flex h-9 items-center justify-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50', className)}
561
- {...props}
562
- />
563
- );
564
- }
565
- `);
566
- if (options.apps.api && options.features.database) {
567
- writeFile(path.join(dir, 'src/renderer/components/item-list.tsx'), `import { createItem, listItems, type Item } from '@shared/api-client';
568
- import * as React from 'react';
569
- import { api } from '@/lib/api';
570
- import { Button } from '@/components/ui/button';
571
-
572
- export function ItemList({ identity = 'public' }: { identity?: string | null }) {
573
- const [items, setItems] = React.useState<Item[]>([]);
574
- const [loadedIdentity, setLoadedIdentity] = React.useState<string | null>(null);
575
- const [name, setName] = React.useState('');
576
- const [status, setStatus] = React.useState<'idle' | 'loading' | 'ready' | 'saving'>('idle');
577
- const [error, setError] = React.useState<string | null>(null);
578
- const identityRef = React.useRef(identity);
579
- const visibleItems = loadedIdentity === identity ? items : [];
580
- const visibleError = loadedIdentity === identity ? error : null;
581
- const isLoading = status === 'loading' || loadedIdentity !== identity;
582
-
583
- React.useEffect(() => {
584
- identityRef.current = identity;
585
- if (!identity) return;
586
- let active = true;
587
- setLoadedIdentity(identity);
588
- setItems([]);
589
- setName('');
590
- setError(null);
591
- setStatus('loading');
592
- void listItems(api).then((nextItems) => {
593
- if (active) setItems(nextItems);
594
- }).catch((cause: unknown) => {
595
- if (active) setError(cause instanceof Error ? cause.message : 'Unable to load items');
596
- }).finally(() => {
597
- if (active) setStatus('ready');
598
- });
599
- return () => { active = false; };
600
- }, [identity]);
601
-
602
- async function submit(event: React.FormEvent<HTMLFormElement>) {
603
- event.preventDefault();
604
- const nextName = name.trim();
605
- if (!identity || !nextName || status !== 'ready' || loadedIdentity !== identity) return;
606
- const submittedIdentity = identity;
607
- setStatus('saving');
608
- try {
609
- const created = await createItem(api, { name: nextName });
610
- if (identityRef.current !== submittedIdentity) return;
611
- setItems((current) => [created, ...current]);
612
- setName('');
613
- setError(null);
614
- } catch (cause) {
615
- if (identityRef.current === submittedIdentity) {
616
- setError(cause instanceof Error ? cause.message : 'Unable to create item');
617
- }
618
- } finally {
619
- if (identityRef.current === submittedIdentity) setStatus('ready');
620
- }
621
- }
622
-
623
- if (!identity) {
624
- return <p className="text-muted-foreground">Open your account and sign in to use the working starter feature.</p>;
625
- }
626
-
627
- return (
628
- <section aria-labelledby="starter-feature-title" className="flex max-w-2xl flex-col gap-3 rounded-lg border border-slate-200 bg-white p-5">
629
- <h2 className="text-xl font-semibold" id="starter-feature-title">Working starter feature</h2>
630
- <form className="flex gap-2" onSubmit={(event) => void submit(event)}>
631
- <label className="sr-only" htmlFor="item-name">Item name</label>
632
- <input
633
- className="h-9 flex-1 rounded-md border border-slate-300 bg-background px-3 text-sm"
634
- id="item-name"
635
- maxLength={120}
636
- onChange={(event) => setName(event.target.value)}
637
- placeholder="Your first item"
638
- value={name}
639
- />
640
- <Button disabled={status !== 'ready' || loadedIdentity !== identity || !name.trim()} type="submit">
641
- {status === 'saving' ? 'Adding…' : 'Add item'}
642
- </Button>
643
- </form>
644
- {visibleError ? (
645
- <p className="text-sm text-red-700" role="alert">
646
- {visibleError}. Check DATABASE_URL, run pnpm db:migrate, and make sure the API is running.
647
- </p>
648
- ) : null}
649
- {isLoading ? <p className="text-sm text-muted-foreground">Loading items…</p> : null}
650
- {!isLoading && visibleItems.length === 0 && !visibleError
651
- ? <p className="text-sm text-muted-foreground">Your database is connected. Add the first item.</p>
652
- : null}
653
- {visibleItems.length > 0 ? (
654
- <ul className="divide-y rounded-md border border-slate-200">
655
- {visibleItems.map((item) => <li className="px-3 py-2 text-sm" key={item.id}>{item.name}</li>)}
656
- </ul>
657
- ) : null}
658
- </section>
659
- );
660
- }
661
- `);
662
- }
663
- const authImport = options.features.auth
664
- ? "import { getAuthUserId, isClerkConfigured, openAccount, subscribeToAuthState } from '@/lib/auth';\n"
665
- : '';
666
- const entitlementImport = options.features.billing ? "import { useEntitlement } from '@/hooks/use-entitlement';\n" : '';
667
- const itemListImport = options.apps.api && options.features.database ? "import { ItemList } from '@/components/item-list';\n" : '';
668
- const entitlementState = options.features.billing ? " const { entitlement, error: entitlementError } = useEntitlement(clerkState === 'ready' ? clerkUserId : null);\n" : '';
669
- const entitlementStatus = options.features.billing ? ` {entitlement ? <p className="text-muted-foreground">Plan: {entitlement.entitlement} ({entitlement.status})</p> : null}
670
- {entitlementError ? <p role="alert" className="text-red-700">{entitlementError}</p> : null}
671
- ` : '';
672
- const authState = options.features.auth ? ` const [clerkState, setClerkState] = React.useState<'unconfigured' | 'signed-out' | 'loading' | 'ready' | 'error'>(
673
- () => isClerkConfigured() ? 'loading' : 'unconfigured',
674
- );
675
- const [clerkUserId, setClerkUserId] = React.useState<string | null>(null);
676
-
677
- React.useEffect(() => {
678
- if (!isClerkConfigured()) return;
679
- let disposed = false;
680
- let unsubscribe: (() => void) | undefined;
681
- void subscribeToAuthState((userId) => {
682
- if (!disposed) {
683
- setClerkUserId(userId);
684
- setClerkState(userId ? 'ready' : 'signed-out');
685
- }
686
- }).then((stop) => {
687
- if (disposed) stop();
688
- else unsubscribe = stop;
689
- }).catch(() => {
690
- if (!disposed) setClerkState('error');
691
- });
692
- return () => {
693
- disposed = true;
694
- unsubscribe?.();
695
- };
696
- }, []);
697
-
698
- const handleAccount = async () => {
699
- setClerkState('loading');
700
- try {
701
- const opened = await openAccount();
702
- const userId = opened ? await getAuthUserId() : null;
703
- setClerkUserId(userId);
704
- setClerkState(opened ? (userId ? 'ready' : 'signed-out') : 'unconfigured');
705
- } catch {
706
- setClerkUserId(null);
707
- setClerkState('error');
708
- }
709
- };
710
-
711
- ` : '';
712
- const authStatus = options.features.auth ? ` <p
713
- role={clerkState === 'error' || clerkState === 'unconfigured' ? 'alert' : 'status'}
714
- aria-live={clerkState === 'error' || clerkState === 'unconfigured' ? 'assertive' : 'polite'}
715
- className={clerkState === 'error' || clerkState === 'unconfigured' ? 'text-red-700' : 'text-muted-foreground'}
716
- >
717
- {clerkState === 'unconfigured' ? 'Set VITE_CLERK_PUBLISHABLE_KEY to enable accounts.' : null}
718
- {clerkState === 'signed-out' ? 'Sign in to use account-backed features.' : null}
719
- {clerkState === 'loading' ? 'Loading account services…' : null}
720
- {clerkState === 'ready' ? 'Signed in. Account-backed features are ready.' : null}
721
- {clerkState === 'error' ? 'Clerk could not initialize. Check the publishable key and network connection.' : null}
722
- </p>
723
- ` : '';
724
- const buttonAction = options.features.auth
725
- ? " disabled={clerkState === 'loading' || clerkState === 'unconfigured'} onClick={() => void handleAccount()}"
726
- : '';
727
- const accountButton = options.features.auth
728
- ? ` <Button${buttonAction}>Open account</Button>\n`
729
- : '';
730
- const buttonImport = options.features.auth ? "import { Button } from '@/components/ui/button';\n" : '';
731
- const itemList = options.apps.api && options.features.database
732
- ? options.features.auth
733
- ? " <ItemList identity={clerkState === 'ready' ? clerkUserId : null} />\n"
734
- : " <ItemList />\n"
735
- : '';
736
- writeFile(path.join(dir, 'src/renderer/main.tsx'), `import React from 'react';
737
- import ReactDOM from 'react-dom/client';
738
- ${buttonImport}${authImport}${entitlementImport}${itemListImport}import './styles.css';
739
-
740
- function App() {
741
- ${authState}${entitlementState} return (
742
- <main className="flex min-h-screen flex-col gap-6 bg-background p-8 text-foreground">
743
- <section className="flex flex-col gap-3">
744
- <h1 className="text-3xl font-semibold">{${displayNameLiteral}}</h1>
745
- <p className="max-w-2xl text-muted-foreground">
746
- ${description}
747
- </p>
748
- </section>
749
- ${authStatus}
750
- ${entitlementStatus}
751
- ${accountButton}
752
- ${itemList}
753
- </main>
754
- );
755
- }
756
-
757
- ReactDOM.createRoot(document.getElementById('root')!).render(
758
- <React.StrictMode>
759
- <App />
760
- </React.StrictMode>,
761
- );
762
- `);
763
- writeFile(path.join(dir, 'src/renderer/styles.css'), `@import "tailwindcss";
764
-
765
- :root {
766
- --background: oklch(1 0 0);
767
- --foreground: oklch(0.145 0 0);
768
- --primary: oklch(0.205 0 0);
769
- --primary-foreground: oklch(0.985 0 0);
770
- --muted-foreground: oklch(0.556 0 0);
771
- }
772
-
773
- @theme inline {
774
- --color-background: var(--background);
775
- --color-foreground: var(--foreground);
776
- --color-primary: var(--primary);
777
- --color-primary-foreground: var(--primary-foreground);
778
- --color-muted-foreground: var(--muted-foreground);
779
- }
780
-
781
- body {
782
- margin: 0;
783
- background: var(--background);
784
- color: var(--foreground);
785
- font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
786
- }
787
-
788
- button {
789
- border: 0;
790
- }
791
- `);
792
- }