anhedral 0.4.2 → 0.6.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.
Files changed (89) hide show
  1. package/README.md +35 -265
  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 +595 -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 +29 -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,2 +0,0 @@
1
- import type { ProjectOptions } from '../project.js';
2
- export declare function scaffoldSharedPackages(root: string, options: ProjectOptions): void;
@@ -1,577 +0,0 @@
1
- import path from 'node:path';
2
- import { writeFile } from '../util.js';
3
- import { API_CLIENT_DEPENDENCIES, CONTRACTS_DEPENDENCIES, REALTIME_DEPENDENCIES, SHARED_DB_DEPENDENCIES, SHARED_PACKAGE_DEPENDENCIES, } from '../dependencies.js';
4
- function writeTsConfig(root) {
5
- writeFile(path.join(root, 'tsconfig.json'), JSON.stringify({
6
- compilerOptions: {
7
- target: 'ESNext',
8
- module: 'ESNext',
9
- moduleResolution: 'Bundler',
10
- strict: true,
11
- noUncheckedIndexedAccess: true,
12
- skipLibCheck: true,
13
- noEmit: true,
14
- },
15
- include: ['src/**/*'],
16
- }, null, 2) + '\n');
17
- }
18
- function writeSharedDatabase(root, options) {
19
- if (!options.features.database)
20
- return;
21
- const dir = path.join(root, 'packages/db');
22
- writeFile(path.join(dir, 'package.json'), JSON.stringify({
23
- name: '@shared/db',
24
- version: '0.1.0',
25
- private: true,
26
- type: 'module',
27
- exports: {
28
- '.': './src/index.ts',
29
- './schema': './src/schema.ts',
30
- './app-schema': './src/app-schema.ts',
31
- },
32
- scripts: {
33
- build: 'pnpm typecheck',
34
- typecheck: 'tsc --noEmit',
35
- 'db:generate': 'drizzle-kit generate',
36
- 'db:migrate': 'tsx --env-file=.env src/migrate.ts',
37
- 'db:check': 'drizzle-kit check',
38
- 'db:studio': 'drizzle-kit studio',
39
- },
40
- dependencies: SHARED_DB_DEPENDENCIES.dependencies,
41
- devDependencies: SHARED_DB_DEPENDENCIES.devDependencies,
42
- }, null, 2) + '\n');
43
- writeFile(path.join(dir, '.env.example'), 'DATABASE_URL=YOUR_NEON_POSTGRES_URL\n');
44
- writeFile(path.join(dir, 'drizzle.config.ts'), `import 'dotenv/config';
45
- import { defineConfig } from 'drizzle-kit';
46
-
47
- export default defineConfig({
48
- schema: ['./src/generated-schema.ts', './src/app-schema.ts'],
49
- out: './migrations',
50
- dialect: 'postgresql',
51
- dbCredentials: { url: process.env.DATABASE_URL || '' },
52
- });
53
- `);
54
- const storageTables = options.features.storage ? `
55
- export const uploads = pgTable('uploads', {
56
- id: text('id').primaryKey(),
57
- // Clerk IDs are authoritative until an application intentionally adds a user projection.
58
- userId: text('user_id').notNull(),
59
- stagingObjectKey: text('staging_object_key').notNull().unique(),
60
- objectKey: text('object_key').unique(),
61
- contentType: text('content_type').notNull(),
62
- expectedSize: integer('expected_size').notNull(),
63
- actualSize: integer('actual_size'),
64
- status: text('status').notNull().default('pending'),
65
- rejectionReason: text('rejection_reason'),
66
- uploadUrlExpiresAt: timestamp('upload_url_expires_at').notNull(),
67
- stagingDeletedAt: timestamp('staging_deleted_at'),
68
- confirmedAt: timestamp('confirmed_at'),
69
- createdAt: timestamp('created_at').defaultNow().notNull(),
70
- });
71
- ` : '';
72
- const billingTables = options.features.billing ? `
73
- export const subscriptions = pgTable('subscriptions', {
74
- id: text('id').primaryKey(),
75
- // RevenueCat App User IDs can be Clerk IDs before a local users row exists.
76
- userId: text('user_id').notNull().unique(),
77
- entitlement: text('entitlement').notNull().default('free'),
78
- status: text('status').notNull().default('active'),
79
- expiresAt: timestamp('expires_at'),
80
- eventTimestamp: timestamp('event_timestamp').notNull(),
81
- revision: integer('revision').notNull().default(1),
82
- updatedAt: timestamp('updated_at').defaultNow().notNull(),
83
- });
84
-
85
- export const webhookEvents = pgTable('webhook_events', {
86
- providerEventId: text('provider_event_id').primaryKey(),
87
- provider: text('provider').notNull(),
88
- payload: jsonb('payload').$type<Record<string, unknown>>().notNull(),
89
- status: text('status').notNull().default('pending'),
90
- attempts: integer('attempts').notNull().default(0),
91
- claimToken: text('claim_token'),
92
- claimedAt: timestamp('claimed_at'),
93
- processedAt: timestamp('processed_at'),
94
- lastError: text('last_error'),
95
- createdAt: timestamp('created_at').defaultNow().notNull(),
96
- });
97
-
98
- export const realtimeOutbox = pgTable('realtime_outbox', {
99
- id: text('id').primaryKey(),
100
- userId: text('user_id').notNull(),
101
- topic: text('topic').notNull(),
102
- revision: integer('revision').notNull(),
103
- attempts: integer('attempts').notNull().default(0),
104
- deliveredAt: timestamp('delivered_at'),
105
- lastError: text('last_error'),
106
- createdAt: timestamp('created_at').defaultNow().notNull(),
107
- });
108
- ` : '';
109
- writeFile(path.join(dir, 'src/generated-schema.ts'), `import { integer, jsonb, pgTable, text, timestamp } from 'drizzle-orm/pg-core';
110
- ${storageTables}${billingTables}`);
111
- const itemOwnerColumn = options.features.auth ? " userId: text('user_id').notNull(),\n" : '';
112
- const itemIndexes = options.features.auth
113
- ? "}, (table) => [\n index('items_user_created_at_idx').on(table.userId, table.createdAt),\n]"
114
- : '\n}';
115
- writeFile(path.join(dir, 'src/app-schema.ts'), `import { ${options.features.auth ? 'index, ' : ''}pgTable, text, timestamp } from 'drizzle-orm/pg-core';
116
-
117
- // Product-owned tables belong here. Anhedral never rewrites this file after creation.
118
- export const items = pgTable('items', {
119
- id: text('id').primaryKey(),
120
- ${itemOwnerColumn} name: text('name').notNull(),
121
- createdAt: timestamp('created_at').defaultNow().notNull(),
122
- ${itemIndexes});
123
- `);
124
- writeFile(path.join(dir, 'src/schema.ts'), `export * from './generated-schema';
125
- export * from './app-schema';
126
- `);
127
- writeFile(path.join(dir, 'src/index.ts'), `import { neon } from '@neondatabase/serverless';
128
- import { drizzle } from 'drizzle-orm/neon-http';
129
- import * as schema from './schema';
130
-
131
- const databaseUrl = process.env.DATABASE_URL;
132
- if (!databaseUrl) throw new Error('DATABASE_URL is required when the db module is enabled');
133
-
134
- export const sqlClient = neon(databaseUrl);
135
- export const db = drizzle(sqlClient, { schema });
136
- export type Database = typeof db;
137
- export * from './schema';
138
- `);
139
- writeFile(path.join(dir, 'src/migrate.ts'), `import { migrate } from 'drizzle-orm/neon-http/migrator';
140
- import { db } from './index';
141
-
142
- await migrate(db, { migrationsFolder: './migrations' });
143
- console.log('Database migrations complete.');
144
- `);
145
- writeFile(path.join(dir, 'migrations/.gitkeep'), '');
146
- }
147
- function contractSource(options) {
148
- const declarations = [
149
- `export const HealthResponseSchema = z.object({ ok: z.literal(true), service: z.string() });
150
- export type HealthResponse = z.infer<typeof HealthResponseSchema>;
151
- export const ReadinessResponseSchema = z.discriminatedUnion('status', [
152
- z.object({ ok: z.literal(true), service: z.string(), status: z.literal('ready') }),
153
- z.object({
154
- ok: z.literal(false),
155
- service: z.string(),
156
- status: z.enum(['unavailable', 'shutting_down']),
157
- }),
158
- ]);
159
- export type ReadinessResponse = z.infer<typeof ReadinessResponseSchema>;`,
160
- options.features.auth ? `export const AuthMeResponseSchema = z.object({ user: z.object({ id: z.string() }) });
161
- export type AuthMeResponse = z.infer<typeof AuthMeResponseSchema>;` : null,
162
- options.features.billing ? `export const EntitlementResponseSchema = z.object({
163
- entitlement: z.string(),
164
- status: z.enum(['active', 'expired', 'free']),
165
- expiresAt: z.string().datetime().nullable(),
166
- revision: z.number().int().nonnegative(),
167
- });
168
- export type EntitlementResponse = z.infer<typeof EntitlementResponseSchema>;
169
-
170
- export const SubscriptionChangedEventSchema = z.object({
171
- type: z.literal('subscription.changed'),
172
- revision: z.number().int().nonnegative(),
173
- });
174
- export type SubscriptionChangedEvent = z.infer<typeof SubscriptionChangedEventSchema>;
175
-
176
- export const RealtimeTokenRequestSchema = z.object({
177
- keyName: z.string().min(1),
178
- ttl: z.number().int().positive(),
179
- timestamp: z.number().int().nonnegative(),
180
- capability: z.string().min(1),
181
- clientId: z.string().min(1),
182
- nonce: z.string().min(1),
183
- mac: z.string().min(1),
184
- });
185
- export type RealtimeTokenRequest = z.infer<typeof RealtimeTokenRequestSchema>;` : null,
186
- options.features.storage ? `export const UPLOAD_ALLOWED_CONTENT_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'application/pdf'] as const;
187
- export const MAX_UPLOAD_SIZE_BYTES = 10 * 1024 * 1024;
188
-
189
- export const CreateUploadRequestSchema = z.object({
190
- fileName: z.string().trim().min(1).max(200),
191
- contentType: z.enum(UPLOAD_ALLOWED_CONTENT_TYPES),
192
- sizeBytes: z.number().int().positive().max(MAX_UPLOAD_SIZE_BYTES),
193
- }).strict();
194
- export const CreateUploadResponseSchema = z.object({
195
- uploadId: z.string().uuid(),
196
- stagingObjectKey: z.string(),
197
- uploadUrl: z.string().url(),
198
- expiresIn: z.number().int().positive(),
199
- requiredHeaders: z.object({ 'content-type': z.enum(UPLOAD_ALLOWED_CONTENT_TYPES) }),
200
- sizeBytes: z.number().int().positive().max(MAX_UPLOAD_SIZE_BYTES),
201
- signedContentLength: z.number().int().positive().max(MAX_UPLOAD_SIZE_BYTES),
202
- metadataValidationRequired: z.literal(true),
203
- });
204
- export const ConfirmUploadRequestSchema = z.object({ uploadId: z.string().uuid() }).strict();
205
- export const UploadParamsSchema = z.object({ uploadId: z.string().uuid() }).strict();
206
- export const UploadRecordSchema = z.object({
207
- id: z.string().uuid(),
208
- objectKey: z.string(),
209
- contentType: z.enum(UPLOAD_ALLOWED_CONTENT_TYPES),
210
- sizeBytes: z.number().int().nonnegative(),
211
- contentTrust: z.literal('untrusted'),
212
- status: z.enum(['pending', 'confirmed', 'rejected']),
213
- createdAt: z.string().datetime(),
214
- confirmedAt: z.string().datetime().nullable(),
215
- privateReadUrl: z.string().url(),
216
- });
217
- export const ConfirmUploadResponseSchema = z.object({ upload: UploadRecordSchema });
218
- export const GetUploadResponseSchema = z.object({ upload: UploadRecordSchema });
219
- export const GetUploadReadUrlResponseSchema = z.object({
220
- url: z.string().url(),
221
- expiresIn: z.number().int().min(60).max(604800),
222
- });
223
- export type CreateUploadRequest = z.infer<typeof CreateUploadRequestSchema>;
224
- export type CreateUploadResponse = z.infer<typeof CreateUploadResponseSchema>;
225
- export type ConfirmUploadRequest = z.infer<typeof ConfirmUploadRequestSchema>;
226
- export type UploadRecord = z.infer<typeof UploadRecordSchema>;` : null,
227
- ].filter((value) => value !== null);
228
- return `import { z } from 'zod';\n\n${declarations.join('\n\n')}\n`;
229
- }
230
- function apiClientSource(options) {
231
- const imports = [
232
- 'HealthResponseSchema',
233
- 'ReadinessResponseSchema',
234
- options.features.auth ? 'AuthMeResponseSchema' : null,
235
- options.features.billing ? 'EntitlementResponseSchema' : null,
236
- options.features.billing ? 'RealtimeTokenRequestSchema' : null,
237
- options.features.storage ? 'CreateUploadResponseSchema' : null,
238
- options.features.storage ? 'ConfirmUploadResponseSchema' : null,
239
- options.features.storage ? 'GetUploadResponseSchema' : null,
240
- options.features.storage ? 'GetUploadReadUrlResponseSchema' : null,
241
- options.features.storage ? 'type CreateUploadRequest' : null,
242
- options.features.storage ? 'type CreateUploadResponse' : null,
243
- options.features.storage ? 'type ConfirmUploadRequest' : null,
244
- ].filter((value) => value !== null);
245
- const methods = [
246
- `health(init: RequestInit = {}) { return this.request('/health', init, HealthResponseSchema); }`,
247
- `readiness(init: RequestInit = {}) { return this.request('/ready', init, ReadinessResponseSchema); }`,
248
- options.features.auth ? `getMe(init: RequestInit = {}) { return this.request('/auth/me', init, AuthMeResponseSchema); }` : null,
249
- options.features.billing ? `getEntitlement(init: RequestInit = {}) { return this.request('/subscriptions/me', init, EntitlementResponseSchema); }
250
-
251
- refreshEntitlement(init: RequestInit = {}) {
252
- return this.request('/subscriptions/refresh', { ...init, method: 'POST' }, EntitlementResponseSchema);
253
- }
254
-
255
- getRealtimeToken(init: RequestInit = {}) {
256
- return this.request('/realtime/token', { ...init, method: 'POST' }, RealtimeTokenRequestSchema);
257
- }` : null,
258
- options.features.storage ? `createUpload(input: CreateUploadRequest, init: RequestInit = {}) {
259
- return this.request('/storage/uploads', { ...init, method: 'POST', body: JSON.stringify(input) }, CreateUploadResponseSchema);
260
- }
261
-
262
- async putUpload(upload: CreateUploadResponse, body: Blob, init: Omit<RequestInit, 'body' | 'method'> = {}) {
263
- if (body.size !== upload.signedContentLength) {
264
- throw new ApiError('Upload body size does not match the signed content length', {
265
- status: 0,
266
- code: 'UPLOAD_SIZE_MISMATCH',
267
- details: { expected: upload.signedContentLength, actual: body.size },
268
- });
269
- }
270
- const headers = new Headers(init.headers);
271
- headers.set('content-type', upload.requiredHeaders['content-type']);
272
- // Browsers forbid setting Content-Length directly. Blob/File bodies emit it automatically,
273
- // and R2 verifies that runtime header because it is part of the presigned URL signature.
274
- const response = await fetch(upload.uploadUrl, { ...init, method: 'PUT', body, headers });
275
- if (!response.ok) {
276
- throw new ApiError(\`Object upload failed with status \${response.status}\`, {
277
- status: response.status,
278
- code: 'OBJECT_UPLOAD_FAILED',
279
- });
280
- }
281
- }
282
-
283
- confirmUpload(input: ConfirmUploadRequest, init: RequestInit = {}) {
284
- return this.request('/storage/uploads/confirm', { ...init, method: 'POST', body: JSON.stringify(input) }, ConfirmUploadResponseSchema);
285
- }
286
-
287
- getUpload(uploadId: string, init: RequestInit = {}) {
288
- return this.request(\`/storage/uploads/\${encodeURIComponent(uploadId)}\`, init, GetUploadResponseSchema);
289
- }
290
-
291
- getUploadReadUrl(uploadId: string, init: RequestInit = {}) {
292
- return this.request(\`/storage/uploads/\${encodeURIComponent(uploadId)}/read-url\`, init, GetUploadReadUrlResponseSchema);
293
- }` : null,
294
- ].filter((value) => value !== null);
295
- return `import type { ZodType } from 'zod';
296
- import { ${imports.join(', ')} } from '@shared/contracts';
297
-
298
- export function normalizeApiBaseUrl(value: string, label = 'API baseUrl'): string {
299
- const candidate = value.trim();
300
- let parsed: URL;
301
- try {
302
- parsed = new URL(candidate);
303
- } catch {
304
- throw new Error(\`\${label} must be a valid absolute URL\`);
305
- }
306
- if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
307
- throw new Error(\`\${label} must use http: or https:\`);
308
- }
309
- if (parsed.username || parsed.password) {
310
- throw new Error(\`\${label} must not include URL credentials\`);
311
- }
312
- if (parsed.search || candidate.includes('?')) {
313
- throw new Error(\`\${label} must not include a query string\`);
314
- }
315
- if (parsed.hash || candidate.includes('#')) {
316
- throw new Error(\`\${label} must not include a URL fragment\`);
317
- }
318
- let pathname = parsed.pathname;
319
- while (pathname.endsWith('/')) pathname = pathname.slice(0, -1);
320
- return parsed.origin + pathname;
321
- }
322
-
323
- export class ApiError extends Error {
324
- readonly status: number;
325
- readonly code: string;
326
- readonly details?: unknown;
327
-
328
- constructor(message: string, options: { status: number; code: string; details?: unknown; cause?: unknown }) {
329
- super(message, { cause: options.cause });
330
- this.name = 'ApiError';
331
- this.status = options.status;
332
- this.code = options.code;
333
- this.details = options.details;
334
- }
335
- }
336
-
337
- export class ApiClient {
338
- private readonly baseUrl: string;
339
-
340
- constructor(private readonly options: {
341
- baseUrl: string;
342
- getToken?: () => Promise<string | null>;
343
- timeoutMs?: number;
344
- }) {
345
- this.baseUrl = normalizeApiBaseUrl(options.baseUrl);
346
- }
347
-
348
- async request<T>(path: string, init: RequestInit, schema: ZodType<T>): Promise<T> {
349
- const timeoutMs = this.options.timeoutMs ?? 15_000;
350
- if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) throw new Error('API timeoutMs must be positive');
351
- const requestController = new AbortController();
352
- const callerSignal = init.signal;
353
- const abortFromCaller = () => requestController.abort(callerSignal?.reason);
354
- if (callerSignal?.aborted) abortFromCaller();
355
- else callerSignal?.addEventListener('abort', abortFromCaller, { once: true });
356
- const timer = setTimeout(() => requestController.abort(new Error('API request timed out')), timeoutMs);
357
- let rejectTokenWait: (() => void) | null = null;
358
- const tokenWaitAborted = new Promise<never>((_resolve, reject) => {
359
- rejectTokenWait = () => reject(requestController.signal.reason ?? new Error('API request aborted'));
360
- if (requestController.signal.aborted) rejectTokenWait();
361
- else requestController.signal.addEventListener('abort', rejectTokenWait, { once: true });
362
- });
363
-
364
- try {
365
- const token = await Promise.race([
366
- Promise.resolve(this.options.getToken?.()),
367
- tokenWaitAborted,
368
- ]);
369
- const hasBody = init.body !== undefined && init.body !== null;
370
- const headers = new Headers(init.headers);
371
- if (hasBody && !headers.has('content-type')) headers.set('content-type', 'application/json');
372
- if (token && !headers.has('authorization')) headers.set('authorization', \`Bearer \${token}\`);
373
- const response = await fetch(this.baseUrl + path, {
374
- ...init,
375
- signal: requestController.signal,
376
- headers,
377
- });
378
- if (!response.ok) {
379
- const payload = await response.json().catch(() => null) as {
380
- error?: unknown;
381
- message?: unknown;
382
- details?: unknown;
383
- } | null;
384
- const code = typeof payload?.error === 'string' ? payload.error : 'HTTP_ERROR';
385
- const message = typeof payload?.message === 'string'
386
- ? payload.message
387
- : \`API request failed with status \${response.status}\`;
388
- throw new ApiError(message, { status: response.status, code, details: payload?.details });
389
- }
390
- let body: unknown;
391
- try {
392
- body = await response.json();
393
- } catch (cause) {
394
- throw new ApiError('API returned invalid JSON', { status: response.status, code: 'INVALID_RESPONSE', cause });
395
- }
396
- const parsed = schema.safeParse(body);
397
- if (!parsed.success) {
398
- throw new ApiError('API response did not match its contract', {
399
- status: response.status,
400
- code: 'INVALID_RESPONSE',
401
- details: parsed.error.flatten(),
402
- });
403
- }
404
- return parsed.data;
405
- } catch (cause) {
406
- if (cause instanceof ApiError) throw cause;
407
- if (requestController.signal.aborted) {
408
- const callerAborted = Boolean(callerSignal?.aborted);
409
- throw new ApiError(callerAborted ? 'API request was cancelled' : 'API request timed out', {
410
- status: 0,
411
- code: callerAborted ? 'REQUEST_ABORTED' : 'REQUEST_TIMEOUT',
412
- cause,
413
- });
414
- }
415
- throw new ApiError('Unable to reach the API', { status: 0, code: 'NETWORK_ERROR', cause });
416
- } finally {
417
- clearTimeout(timer);
418
- callerSignal?.removeEventListener('abort', abortFromCaller);
419
- if (rejectTokenWait) requestController.signal.removeEventListener('abort', rejectTokenWait);
420
- }
421
- }
422
-
423
- ${methods.join('\n\n ')}
424
- }
425
- `;
426
- }
427
- function appContractSource(options) {
428
- if (!options.features.database) {
429
- return `// Product-owned network contracts belong here.
430
- // Anhedral never rewrites this file after creation.
431
- export {};
432
- `;
433
- }
434
- return `import { z } from 'zod';
435
-
436
- // A complete starter feature: edit or replace it with your product model.
437
- export const ItemSchema = z.object({
438
- id: z.string().uuid(),
439
- name: z.string().trim().min(1).max(120),
440
- createdAt: z.string().datetime(),
441
- });
442
- export const ItemListSchema = z.array(ItemSchema);
443
- export const CreateItemRequestSchema = z.object({
444
- name: z.string().trim().min(1).max(120),
445
- }).strict();
446
-
447
- export type Item = z.infer<typeof ItemSchema>;
448
- export type CreateItemRequest = z.infer<typeof CreateItemRequestSchema>;
449
- `;
450
- }
451
- function appApiClientSource(options) {
452
- if (!options.features.database) {
453
- return `// Product-owned API client helpers belong here.
454
- // Anhedral never rewrites this file after creation.
455
- export {};
456
- `;
457
- }
458
- return `import {
459
- CreateItemRequestSchema,
460
- ItemListSchema,
461
- ItemSchema,
462
- type CreateItemRequest,
463
- } from '@shared/contracts';
464
- import { ApiClient } from './generated';
465
-
466
- export type { Item } from '@shared/contracts';
467
-
468
- // These calls complete the starter feature across contracts, API, database, and clients.
469
- export function listItems(client: ApiClient, init: RequestInit = {}) {
470
- return client.request('/items', init, ItemListSchema);
471
- }
472
-
473
- export function createItem(client: ApiClient, input: CreateItemRequest, init: RequestInit = {}) {
474
- const body = CreateItemRequestSchema.parse(input);
475
- return client.request('/items', {
476
- ...init,
477
- method: 'POST',
478
- body: JSON.stringify(body),
479
- }, ItemSchema);
480
- }
481
- `;
482
- }
483
- function writeApiPackages(root, options) {
484
- if (!options.apps.api)
485
- return;
486
- const hasClientConsumer = options.apps.web || options.apps.mobile || options.apps.desktop || options.apps.extension;
487
- const packages = [
488
- ['contracts', { dependencies: CONTRACTS_DEPENDENCIES.dependencies }, contractSource(options)],
489
- ...(hasClientConsumer
490
- ? [['api-client', { dependencies: API_CLIENT_DEPENDENCIES.dependencies }, apiClientSource(options)]]
491
- : []),
492
- ];
493
- for (const [name, fields, source] of packages) {
494
- const dir = path.join(root, `packages/${name}`);
495
- writeFile(path.join(dir, 'package.json'), JSON.stringify({
496
- name: `@shared/${name}`,
497
- version: '0.1.0',
498
- private: true,
499
- type: 'module',
500
- exports: { '.': './src/index.ts', './app': './src/app.ts' },
501
- scripts: { build: 'pnpm typecheck', typecheck: 'tsc --noEmit' },
502
- devDependencies: SHARED_PACKAGE_DEPENDENCIES.devDependencies,
503
- ...fields,
504
- }, null, 2) + '\n');
505
- writeTsConfig(dir);
506
- writeFile(path.join(dir, 'src/generated.ts'), source);
507
- writeFile(path.join(dir, 'src/app.ts'), name === 'contracts' ? appContractSource(options) : appApiClientSource(options));
508
- writeFile(path.join(dir, 'src/index.ts'), `export * from './generated';
509
- export * from './app';
510
- `);
511
- }
512
- }
513
- function writeRealtimePackage(root, options) {
514
- const hasClientConsumer = options.apps.web || options.apps.mobile || options.apps.desktop || options.apps.extension;
515
- if (!options.features.billing || !hasClientConsumer)
516
- return;
517
- const dir = path.join(root, 'packages/realtime');
518
- writeFile(path.join(dir, 'package.json'), JSON.stringify({
519
- name: '@shared/realtime',
520
- version: '0.1.0',
521
- private: true,
522
- type: 'module',
523
- exports: { '.': './src/index.ts' },
524
- scripts: { build: 'pnpm typecheck', typecheck: 'tsc --noEmit' },
525
- dependencies: REALTIME_DEPENDENCIES.dependencies,
526
- devDependencies: REALTIME_DEPENDENCIES.devDependencies,
527
- }, null, 2) + '\n');
528
- writeTsConfig(dir);
529
- writeFile(path.join(dir, 'src/index.ts'), `import * as Ably from 'ably';
530
- import { SubscriptionChangedEventSchema, type RealtimeTokenRequest } from '@shared/contracts';
531
-
532
- export type SubscriptionRealtimeOptions = {
533
- userId: string;
534
- getTokenRequest: () => Promise<RealtimeTokenRequest>;
535
- onChange: (revision: number) => void;
536
- onError?: (error: Error) => void;
537
- };
538
-
539
- export function subscriptionChannelName(userId: string): string {
540
- return 'private:users:' + userId + ':subscriptions';
541
- }
542
-
543
- export function subscribeToSubscriptionChanges(options: SubscriptionRealtimeOptions): () => void {
544
- const client = new Ably.Realtime({
545
- clientId: options.userId,
546
- authCallback: (_params, callback) => {
547
- void options.getTokenRequest().then(
548
- (tokenRequest) => callback(null, tokenRequest),
549
- (error: unknown) => callback(error instanceof Error ? error.message : 'Realtime authorization failed', null),
550
- );
551
- },
552
- });
553
- const channel = client.channels.get(subscriptionChannelName(options.userId));
554
- const listener = (message: Ably.Message) => {
555
- const parsed = SubscriptionChangedEventSchema.safeParse(message.data);
556
- if (parsed.success) options.onChange(parsed.data.revision);
557
- };
558
- const stateListener = (change: Ably.ConnectionStateChange) => {
559
- if (change.current === 'failed' && change.reason) options.onError?.(change.reason);
560
- };
561
- client.connection.on(stateListener);
562
- void channel.subscribe('subscription.changed', listener).catch((error: unknown) => {
563
- options.onError?.(error instanceof Error ? error : new Error('Realtime subscription failed'));
564
- });
565
- return () => {
566
- channel.unsubscribe('subscription.changed', listener);
567
- client.connection.off(stateListener);
568
- client.close();
569
- };
570
- }
571
- `);
572
- }
573
- export function scaffoldSharedPackages(root, options) {
574
- writeSharedDatabase(root, options);
575
- writeApiPackages(root, options);
576
- writeRealtimePackage(root, options);
577
- }
@@ -1,2 +0,0 @@
1
- import type { ProjectOptions } from '../project.js';
2
- export declare function scaffoldWeb(root: string, options: ProjectOptions): Promise<void>;