@veloxts/cli 0.6.31 → 0.6.52

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.
@@ -0,0 +1,556 @@
1
+ /**
2
+ * Storage Template
3
+ *
4
+ * Generates storage configuration and upload handler files for VeloxTS applications.
5
+ */
6
+ // ============================================================================
7
+ // Path Helpers
8
+ // ============================================================================
9
+ /**
10
+ * Get the path for a storage configuration file
11
+ */
12
+ export function getStoragePath(entityName, _project, options) {
13
+ if (options.upload) {
14
+ return `src/handlers/${entityName.toLowerCase()}-upload.ts`;
15
+ }
16
+ return `src/config/storage/${entityName.toLowerCase()}.ts`;
17
+ }
18
+ // ============================================================================
19
+ // Templates
20
+ // ============================================================================
21
+ /**
22
+ * Generate local filesystem storage configuration
23
+ */
24
+ function generateLocalStorage(ctx) {
25
+ const { entity } = ctx;
26
+ return `/**
27
+ * ${entity.pascal} Storage Configuration (Local Filesystem)
28
+ *
29
+ * Local file storage configuration for ${entity.humanReadable}.
30
+ */
31
+
32
+ import { createLocalStorageDriver } from '@veloxts/storage';
33
+ import { join } from 'node:path';
34
+
35
+ // ============================================================================
36
+ // Configuration
37
+ // ============================================================================
38
+
39
+ /**
40
+ * ${entity.pascal} local storage driver
41
+ *
42
+ * Stores files in the local filesystem with organized directory structure.
43
+ *
44
+ * @example
45
+ * \`\`\`typescript
46
+ * import { ${entity.camel}Storage } from '@/config/storage/${entity.kebab}';
47
+ *
48
+ * // Store a file
49
+ * const filePath = await ${entity.camel}Storage.put('avatar.jpg', buffer);
50
+ *
51
+ * // Retrieve a file
52
+ * const fileBuffer = await ${entity.camel}Storage.get('avatar.jpg');
53
+ *
54
+ * // Delete a file
55
+ * await ${entity.camel}Storage.delete('avatar.jpg');
56
+ *
57
+ * // Check if file exists
58
+ * const exists = await ${entity.camel}Storage.exists('avatar.jpg');
59
+ * \`\`\`
60
+ */
61
+ export const ${entity.camel}Storage = createLocalStorageDriver({
62
+ // Root directory for stored files
63
+ root: join(process.cwd(), 'storage', '${entity.kebab}'),
64
+
65
+ // Base URL for serving files (optional)
66
+ baseUrl: process.env.STORAGE_BASE_URL ?? 'http://localhost:3030/storage/${entity.kebab}',
67
+
68
+ // Disk visibility
69
+ visibility: 'private', // 'public' | 'private'
70
+
71
+ // File permissions (Unix-style, e.g., 0o644 for rw-r--r--)
72
+ permissions: {
73
+ file: 0o644,
74
+ directory: 0o755,
75
+ },
76
+
77
+ // Optional: Organize files in subdirectories by date
78
+ pathGenerator: (filename: string) => {
79
+ const date = new Date();
80
+ const year = date.getFullYear();
81
+ const month = String(date.getMonth() + 1).padStart(2, '0');
82
+ const day = String(date.getDate()).padStart(2, '0');
83
+ return \`\${year}/\${month}/\${day}/\${filename}\`;
84
+ },
85
+ });
86
+
87
+ // ============================================================================
88
+ // Helper Functions
89
+ // ============================================================================
90
+
91
+ /**
92
+ * Generate a unique filename with timestamp
93
+ */
94
+ export function generateUniqueFilename(originalFilename: string): string {
95
+ const timestamp = Date.now();
96
+ const random = Math.random().toString(36).substring(2, 8);
97
+ const ext = originalFilename.split('.').pop();
98
+ const basename = originalFilename.replace(/\\.[^/.]+$/, '');
99
+ const sanitized = basename.replace(/[^a-z0-9]/gi, '-').toLowerCase();
100
+
101
+ return \`\${sanitized}-\${timestamp}-\${random}.\${ext}\`;
102
+ }
103
+
104
+ /**
105
+ * Validate file type and size
106
+ */
107
+ export function validate${entity.pascal}File(file: { mimetype: string; size: number }): {
108
+ valid: boolean;
109
+ error?: string;
110
+ } {
111
+ // Allowed MIME types
112
+ const allowedTypes = [
113
+ 'image/jpeg',
114
+ 'image/png',
115
+ 'image/gif',
116
+ 'image/webp',
117
+ 'application/pdf',
118
+ ];
119
+
120
+ // Max file size (10MB)
121
+ const maxSize = 10 * 1024 * 1024;
122
+
123
+ if (!allowedTypes.includes(file.mimetype)) {
124
+ return {
125
+ valid: false,
126
+ error: \`Invalid file type. Allowed: \${allowedTypes.join(', ')}\`,
127
+ };
128
+ }
129
+
130
+ if (file.size > maxSize) {
131
+ return {
132
+ valid: false,
133
+ error: \`File too large. Max size: \${maxSize / (1024 * 1024)}MB\`,
134
+ };
135
+ }
136
+
137
+ return { valid: true };
138
+ }
139
+ `;
140
+ }
141
+ /**
142
+ * Generate S3-compatible storage configuration
143
+ */
144
+ function generateS3Storage(ctx) {
145
+ const { entity } = ctx;
146
+ return `/**
147
+ * ${entity.pascal} Storage Configuration (S3/R2/MinIO)
148
+ *
149
+ * S3-compatible object storage configuration for ${entity.humanReadable}.
150
+ */
151
+
152
+ import { createS3StorageDriver } from '@veloxts/storage';
153
+
154
+ // ============================================================================
155
+ // Configuration
156
+ // ============================================================================
157
+
158
+ /**
159
+ * ${entity.pascal} S3 storage driver
160
+ *
161
+ * Stores files in S3-compatible object storage (AWS S3, Cloudflare R2, MinIO).
162
+ *
163
+ * @example
164
+ * \`\`\`typescript
165
+ * import { ${entity.camel}Storage } from '@/config/storage/${entity.kebab}';
166
+ *
167
+ * // Store a file
168
+ * const fileUrl = await ${entity.camel}Storage.put('avatar.jpg', buffer, {
169
+ * metadata: { userId: '123' },
170
+ * contentType: 'image/jpeg',
171
+ * });
172
+ *
173
+ * // Retrieve a file
174
+ * const fileBuffer = await ${entity.camel}Storage.get('avatar.jpg');
175
+ *
176
+ * // Get a signed URL (temporary access to private file)
177
+ * const signedUrl = await ${entity.camel}Storage.getSignedUrl('avatar.jpg', { expiresIn: 3600 });
178
+ *
179
+ * // Delete a file
180
+ * await ${entity.camel}Storage.delete('avatar.jpg');
181
+ * \`\`\`
182
+ */
183
+ export const ${entity.camel}Storage = createS3StorageDriver({
184
+ // S3 credentials
185
+ credentials: {
186
+ accessKeyId: process.env.S3_ACCESS_KEY_ID!,
187
+ secretAccessKey: process.env.S3_SECRET_ACCESS_KEY!,
188
+ },
189
+
190
+ // Bucket configuration
191
+ bucket: process.env.S3_BUCKET ?? '${entity.kebab}-files',
192
+ region: process.env.S3_REGION ?? 'us-east-1',
193
+
194
+ // Optional: Custom endpoint for R2/MinIO
195
+ endpoint: process.env.S3_ENDPOINT, // e.g., 'https://[account-id].r2.cloudflarestorage.com'
196
+
197
+ // Public URL base (for public files)
198
+ baseUrl: process.env.S3_PUBLIC_URL, // e.g., 'https://cdn.example.com'
199
+
200
+ // Default ACL for uploaded files
201
+ acl: 'private', // 'public-read' | 'private' | 'authenticated-read'
202
+
203
+ // Optional: Path prefix for all files
204
+ pathPrefix: '${entity.kebab}/',
205
+
206
+ // Cache control headers
207
+ cacheControl: 'public, max-age=31536000', // 1 year for immutable files
208
+ });
209
+
210
+ // ============================================================================
211
+ // Helper Functions
212
+ // ============================================================================
213
+
214
+ /**
215
+ * Generate a unique S3 key with organized path structure
216
+ */
217
+ export function generateS3Key(originalFilename: string, options?: { userId?: string }): string {
218
+ const timestamp = Date.now();
219
+ const random = Math.random().toString(36).substring(2, 8);
220
+ const ext = originalFilename.split('.').pop();
221
+ const basename = originalFilename.replace(/\\.[^/.]+$/, '');
222
+ const sanitized = basename.replace(/[^a-z0-9]/gi, '-').toLowerCase();
223
+
224
+ const date = new Date();
225
+ const year = date.getFullYear();
226
+ const month = String(date.getMonth() + 1).padStart(2, '0');
227
+
228
+ const userPath = options?.userId ? \`users/\${options.userId}/\` : '';
229
+ return \`\${userPath}\${year}/\${month}/\${sanitized}-\${timestamp}-\${random}.\${ext}\`;
230
+ }
231
+
232
+ /**
233
+ * Validate file for S3 upload
234
+ */
235
+ export function validate${entity.pascal}File(file: { mimetype: string; size: number }): {
236
+ valid: boolean;
237
+ error?: string;
238
+ } {
239
+ // Allowed MIME types
240
+ const allowedTypes = [
241
+ 'image/jpeg',
242
+ 'image/png',
243
+ 'image/gif',
244
+ 'image/webp',
245
+ 'image/svg+xml',
246
+ 'application/pdf',
247
+ 'video/mp4',
248
+ 'video/webm',
249
+ ];
250
+
251
+ // Max file size (50MB for S3)
252
+ const maxSize = 50 * 1024 * 1024;
253
+
254
+ if (!allowedTypes.includes(file.mimetype)) {
255
+ return {
256
+ valid: false,
257
+ error: \`Invalid file type. Allowed: \${allowedTypes.join(', ')}\`,
258
+ };
259
+ }
260
+
261
+ if (file.size > maxSize) {
262
+ return {
263
+ valid: false,
264
+ error: \`File too large. Max size: \${maxSize / (1024 * 1024)}MB\`,
265
+ };
266
+ }
267
+
268
+ return { valid: true };
269
+ }
270
+
271
+ /**
272
+ * Get content type from file extension
273
+ */
274
+ export function getContentType(filename: string): string {
275
+ const ext = filename.split('.').pop()?.toLowerCase();
276
+ const mimeTypes: Record<string, string> = {
277
+ jpg: 'image/jpeg',
278
+ jpeg: 'image/jpeg',
279
+ png: 'image/png',
280
+ gif: 'image/gif',
281
+ webp: 'image/webp',
282
+ svg: 'image/svg+xml',
283
+ pdf: 'application/pdf',
284
+ mp4: 'video/mp4',
285
+ webm: 'video/webm',
286
+ };
287
+
288
+ return mimeTypes[ext ?? ''] ?? 'application/octet-stream';
289
+ }
290
+ `;
291
+ }
292
+ /**
293
+ * Generate file upload handler with storage integration
294
+ */
295
+ function generateUploadHandler(ctx) {
296
+ const { entity } = ctx;
297
+ return `/**
298
+ * ${entity.pascal} Upload Handler
299
+ *
300
+ * File upload endpoint with storage integration for ${entity.humanReadable}.
301
+ */
302
+
303
+ import type { BaseContext } from '@veloxts/core';
304
+ import { procedure } from '@veloxts/router';
305
+ import { ${entity.camel}Storage, generateUniqueFilename, validate${entity.pascal}File } from '@/config/storage/${entity.kebab}';
306
+ import { z } from 'zod';
307
+
308
+ // ============================================================================
309
+ // Schema
310
+ // ============================================================================
311
+
312
+ const Upload${entity.pascal}Schema = z.object({
313
+ // File metadata will be provided via multipart form data
314
+ // Fastify @fastify/multipart plugin handles file parsing
315
+ });
316
+
317
+ const Upload${entity.pascal}ResponseSchema = z.object({
318
+ success: z.boolean(),
319
+ fileUrl: z.string().url(),
320
+ filename: z.string(),
321
+ size: z.number(),
322
+ mimetype: z.string(),
323
+ });
324
+
325
+ export type Upload${entity.pascal}Response = z.infer<typeof Upload${entity.pascal}ResponseSchema>;
326
+
327
+ // ============================================================================
328
+ // Upload Handler
329
+ // ============================================================================
330
+
331
+ /**
332
+ * Upload ${entity.humanReadable} file
333
+ *
334
+ * Handles multipart file uploads with validation and storage.
335
+ *
336
+ * @example
337
+ * \`\`\`typescript
338
+ * // Client-side (with fetch)
339
+ * const formData = new FormData();
340
+ * formData.append('file', fileInput.files[0]);
341
+ *
342
+ * const response = await fetch('/api/${entity.kebab}/upload', {
343
+ * method: 'POST',
344
+ * body: formData,
345
+ * });
346
+ *
347
+ * const result = await response.json();
348
+ * console.log('File uploaded:', result.fileUrl);
349
+ * \`\`\`
350
+ */
351
+ export const upload${entity.pascal} = procedure
352
+ .input(Upload${entity.pascal}Schema)
353
+ .output(Upload${entity.pascal}ResponseSchema)
354
+ .mutation(async ({ ctx }: { ctx: BaseContext }) => {
355
+ // Get multipart data from request
356
+ const data = await ctx.request.file();
357
+
358
+ if (!data) {
359
+ throw new Error('No file uploaded');
360
+ }
361
+
362
+ // Validate file
363
+ const validation = validate${entity.pascal}File({
364
+ mimetype: data.mimetype,
365
+ size: data.file.bytesRead,
366
+ });
367
+
368
+ if (!validation.valid) {
369
+ throw new Error(validation.error);
370
+ }
371
+
372
+ // Generate unique filename
373
+ const filename = generateUniqueFilename(data.filename);
374
+
375
+ // Convert stream to buffer
376
+ const buffer = await data.toBuffer();
377
+
378
+ // Store file
379
+ const filePath = await ${entity.camel}Storage.put(filename, buffer);
380
+
381
+ // Get public URL (if storage is configured for public access)
382
+ const fileUrl = await ${entity.camel}Storage.url(filePath);
383
+
384
+ // TODO: Save file metadata to database
385
+ // Example:
386
+ // await ctx.db.${entity.camel}File.create({
387
+ // data: {
388
+ // filename,
389
+ // path: filePath,
390
+ // url: fileUrl,
391
+ // mimetype: data.mimetype,
392
+ // size: buffer.length,
393
+ // userId: ctx.user?.id,
394
+ // },
395
+ // });
396
+
397
+ return {
398
+ success: true,
399
+ fileUrl,
400
+ filename,
401
+ size: buffer.length,
402
+ mimetype: data.mimetype,
403
+ };
404
+ });
405
+
406
+ // ============================================================================
407
+ // Delete Handler
408
+ // ============================================================================
409
+
410
+ const Delete${entity.pascal}Schema = z.object({
411
+ filename: z.string(),
412
+ });
413
+
414
+ /**
415
+ * Delete ${entity.humanReadable} file
416
+ */
417
+ export const delete${entity.pascal} = procedure
418
+ .input(Delete${entity.pascal}Schema)
419
+ .output(z.object({ success: z.boolean() }))
420
+ .mutation(async ({ input }) => {
421
+ // TODO: Verify user owns this file or has permission to delete
422
+ // const file = await ctx.db.${entity.camel}File.findUnique({
423
+ // where: { filename: input.filename },
424
+ // });
425
+ //
426
+ // if (!file || file.userId !== ctx.user?.id) {
427
+ // throw new Error('File not found or unauthorized');
428
+ // }
429
+
430
+ await ${entity.camel}Storage.delete(input.filename);
431
+
432
+ // TODO: Delete from database
433
+ // await ctx.db.${entity.camel}File.delete({
434
+ // where: { filename: input.filename },
435
+ // });
436
+
437
+ return { success: true };
438
+ });
439
+
440
+ // ============================================================================
441
+ // Download Handler
442
+ // ============================================================================
443
+
444
+ const Download${entity.pascal}Schema = z.object({
445
+ filename: z.string(),
446
+ });
447
+
448
+ /**
449
+ * Download ${entity.humanReadable} file
450
+ *
451
+ * Returns a temporary signed URL for secure file access.
452
+ */
453
+ export const download${entity.pascal} = procedure
454
+ .input(Download${entity.pascal}Schema)
455
+ .output(z.object({ downloadUrl: z.string().url() }))
456
+ .query(async ({ input }) => {
457
+ // TODO: Verify user has access to this file
458
+ // const file = await ctx.db.${entity.camel}File.findUnique({
459
+ // where: { filename: input.filename },
460
+ // });
461
+ //
462
+ // if (!file) {
463
+ // throw new Error('File not found');
464
+ // }
465
+
466
+ // Check if file exists
467
+ const exists = await ${entity.camel}Storage.exists(input.filename);
468
+ if (!exists) {
469
+ throw new Error('File not found in storage');
470
+ }
471
+
472
+ // Generate signed URL (expires in 1 hour)
473
+ const downloadUrl = await ${entity.camel}Storage.getSignedUrl(input.filename, {
474
+ expiresIn: 3600,
475
+ });
476
+
477
+ return { downloadUrl };
478
+ });
479
+ `;
480
+ }
481
+ // ============================================================================
482
+ // Main Template
483
+ // ============================================================================
484
+ /**
485
+ * Storage template function
486
+ */
487
+ export const storageTemplate = (ctx) => {
488
+ if (ctx.options.upload) {
489
+ return generateUploadHandler(ctx);
490
+ }
491
+ if (ctx.options.s3) {
492
+ return generateS3Storage(ctx);
493
+ }
494
+ // Default to local storage
495
+ return generateLocalStorage(ctx);
496
+ };
497
+ // ============================================================================
498
+ // Post-generation Instructions
499
+ // ============================================================================
500
+ export function getStorageInstructions(entityName, options) {
501
+ const lines = [];
502
+ if (options.upload) {
503
+ lines.push(`Your ${entityName} upload handler has been created.`, '', 'Next steps:');
504
+ lines.push(' 1. Register @fastify/multipart plugin in your app:');
505
+ lines.push('');
506
+ lines.push(" import multipart from '@fastify/multipart';");
507
+ lines.push(' await app.register(multipart);');
508
+ lines.push('');
509
+ lines.push(' 2. Create the storage configuration file:');
510
+ lines.push(` velox make storage ${entityName} --local # or --s3`);
511
+ lines.push('');
512
+ lines.push(' 3. Register upload procedures in your router');
513
+ lines.push(' 4. Optional: Create a Prisma model for file metadata');
514
+ }
515
+ else if (options.s3) {
516
+ lines.push(`Your ${entityName} S3 storage configuration has been created.`, '', 'Next steps:');
517
+ lines.push(' 1. Add environment variables to .env:');
518
+ lines.push('');
519
+ lines.push(' S3_ACCESS_KEY_ID=your-access-key');
520
+ lines.push(' S3_SECRET_ACCESS_KEY=your-secret-key');
521
+ lines.push(` S3_BUCKET=${entityName}-files`);
522
+ lines.push(' S3_REGION=us-east-1');
523
+ lines.push(' # Optional for R2/MinIO:');
524
+ lines.push(' # S3_ENDPOINT=https://[account-id].r2.cloudflarestorage.com');
525
+ lines.push(' # S3_PUBLIC_URL=https://cdn.example.com');
526
+ lines.push('');
527
+ lines.push(' 2. Install AWS SDK dependencies:');
528
+ lines.push('');
529
+ lines.push(' pnpm add @aws-sdk/client-s3 @aws-sdk/s3-request-presigner');
530
+ lines.push('');
531
+ lines.push(' 3. Import and use the storage driver in your procedures');
532
+ }
533
+ else {
534
+ lines.push(`Your ${entityName} local storage configuration has been created.`, '', 'Next steps:');
535
+ lines.push(' 1. Ensure the storage directory exists:');
536
+ lines.push('');
537
+ lines.push(` mkdir -p storage/${entityName.toLowerCase()}`);
538
+ lines.push('');
539
+ lines.push(' 2. Add storage directory to .gitignore:');
540
+ lines.push('');
541
+ lines.push(' echo "storage/" >> .gitignore');
542
+ lines.push('');
543
+ lines.push(' 3. Configure file serving in your app (for public access):');
544
+ lines.push('');
545
+ lines.push(" import fastifyStatic from '@fastify/static';");
546
+ lines.push(" import { join } from 'node:path';");
547
+ lines.push('');
548
+ lines.push(' await app.register(fastifyStatic, {');
549
+ lines.push(" root: join(process.cwd(), 'storage'),");
550
+ lines.push(" prefix: '/storage/',");
551
+ lines.push(' });');
552
+ lines.push('');
553
+ lines.push(' 4. Import and use the storage driver in your procedures');
554
+ }
555
+ return lines.join('\n');
556
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Task Template
3
+ *
4
+ * Template for generating scheduled task files.
5
+ */
6
+ import type { ProjectContext, TemplateContext } from '../types.js';
7
+ /**
8
+ * Options specific to task generation
9
+ */
10
+ export interface TaskOptions {
11
+ /** Generate task with callback handlers */
12
+ callbacks: boolean;
13
+ /** Generate task with constraints */
14
+ constraints: boolean;
15
+ /** Generate task with overlap prevention */
16
+ noOverlap: boolean;
17
+ }
18
+ /**
19
+ * Get the output path for a task file
20
+ */
21
+ export declare function getTaskPath(entityName: string, _project?: ProjectContext): string;
22
+ /**
23
+ * Generate a scheduled task file
24
+ */
25
+ export declare function taskTemplate(context: TemplateContext<TaskOptions>): string;
26
+ /**
27
+ * Generate a schedule file that imports and exports all tasks
28
+ */
29
+ export declare function scheduleFileTemplate(taskNames: string[]): string;
30
+ /**
31
+ * Get instructions to display after generating a task
32
+ */
33
+ export declare function getTaskInstructions(entityName: string, options: TaskOptions): string;