servcraft 0.1.7 → 0.3.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.
@@ -0,0 +1,110 @@
1
+ export function controllerTestTemplate(
2
+ name: string,
3
+ pascalName: string,
4
+ camelName: string
5
+ ): string {
6
+ return `import { describe, it, expect, beforeAll, afterAll } from 'vitest';
7
+ import { build } from '../../app.js';
8
+ import { FastifyInstance } from 'fastify';
9
+
10
+ describe('${pascalName}Controller', () => {
11
+ let app: FastifyInstance;
12
+
13
+ beforeAll(async () => {
14
+ app = await build();
15
+ await app.ready();
16
+ });
17
+
18
+ afterAll(async () => {
19
+ await app.close();
20
+ });
21
+
22
+ describe('GET /${name}', () => {
23
+ it('should return list of ${name}', async () => {
24
+ const response = await app.inject({
25
+ method: 'GET',
26
+ url: '/${name}',
27
+ });
28
+
29
+ expect(response.statusCode).toBe(200);
30
+ expect(response.json()).toHaveProperty('data');
31
+ });
32
+ });
33
+
34
+ describe('GET /${name}/:id', () => {
35
+ it('should return a single ${camelName}', async () => {
36
+ // TODO: Create test ${camelName} first
37
+ const response = await app.inject({
38
+ method: 'GET',
39
+ url: '/${name}/1',
40
+ });
41
+
42
+ expect(response.statusCode).toBe(200);
43
+ expect(response.json()).toHaveProperty('data');
44
+ });
45
+
46
+ it('should return 404 for non-existent ${camelName}', async () => {
47
+ const response = await app.inject({
48
+ method: 'GET',
49
+ url: '/${name}/999999',
50
+ });
51
+
52
+ expect(response.statusCode).toBe(404);
53
+ });
54
+ });
55
+
56
+ describe('POST /${name}', () => {
57
+ it('should create a new ${camelName}', async () => {
58
+ const response = await app.inject({
59
+ method: 'POST',
60
+ url: '/${name}',
61
+ payload: {
62
+ // TODO: Add required fields
63
+ },
64
+ });
65
+
66
+ expect(response.statusCode).toBe(201);
67
+ expect(response.json()).toHaveProperty('data');
68
+ });
69
+
70
+ it('should return 400 for invalid data', async () => {
71
+ const response = await app.inject({
72
+ method: 'POST',
73
+ url: '/${name}',
74
+ payload: {},
75
+ });
76
+
77
+ expect(response.statusCode).toBe(400);
78
+ });
79
+ });
80
+
81
+ describe('PUT /${name}/:id', () => {
82
+ it('should update a ${camelName}', async () => {
83
+ // TODO: Create test ${camelName} first
84
+ const response = await app.inject({
85
+ method: 'PUT',
86
+ url: '/${name}/1',
87
+ payload: {
88
+ // TODO: Add fields to update
89
+ },
90
+ });
91
+
92
+ expect(response.statusCode).toBe(200);
93
+ expect(response.json()).toHaveProperty('data');
94
+ });
95
+ });
96
+
97
+ describe('DELETE /${name}/:id', () => {
98
+ it('should delete a ${camelName}', async () => {
99
+ // TODO: Create test ${camelName} first
100
+ const response = await app.inject({
101
+ method: 'DELETE',
102
+ url: '/${name}/1',
103
+ });
104
+
105
+ expect(response.statusCode).toBe(204);
106
+ });
107
+ });
108
+ });
109
+ `;
110
+ }
@@ -0,0 +1,139 @@
1
+ export function integrationTestTemplate(
2
+ name: string,
3
+ pascalName: string,
4
+ camelName: string
5
+ ): string {
6
+ return `import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
7
+ import { build } from '../../app.js';
8
+ import { FastifyInstance } from 'fastify';
9
+ import { prisma } from '../../lib/prisma.js';
10
+
11
+ describe('${pascalName} Integration Tests', () => {
12
+ let app: FastifyInstance;
13
+
14
+ beforeAll(async () => {
15
+ app = await build();
16
+ await app.ready();
17
+ });
18
+
19
+ afterAll(async () => {
20
+ await app.close();
21
+ await prisma.$disconnect();
22
+ });
23
+
24
+ beforeEach(async () => {
25
+ // Clean up test data
26
+ // await prisma.${camelName}.deleteMany();
27
+ });
28
+
29
+ describe('Full CRUD workflow', () => {
30
+ it('should create, read, update, and delete a ${camelName}', async () => {
31
+ // Create
32
+ const createResponse = await app.inject({
33
+ method: 'POST',
34
+ url: '/${name}',
35
+ payload: {
36
+ // TODO: Add required fields
37
+ },
38
+ });
39
+
40
+ expect(createResponse.statusCode).toBe(201);
41
+ const created = createResponse.json().data;
42
+ expect(created.id).toBeDefined();
43
+
44
+ // Read
45
+ const readResponse = await app.inject({
46
+ method: 'GET',
47
+ url: \`/${name}/\${created.id}\`,
48
+ });
49
+
50
+ expect(readResponse.statusCode).toBe(200);
51
+ const read = readResponse.json().data;
52
+ expect(read.id).toBe(created.id);
53
+
54
+ // Update
55
+ const updateResponse = await app.inject({
56
+ method: 'PUT',
57
+ url: \`/${name}/\${created.id}\`,
58
+ payload: {
59
+ // TODO: Add fields to update
60
+ },
61
+ });
62
+
63
+ expect(updateResponse.statusCode).toBe(200);
64
+ const updated = updateResponse.json().data;
65
+ expect(updated.id).toBe(created.id);
66
+
67
+ // Delete
68
+ const deleteResponse = await app.inject({
69
+ method: 'DELETE',
70
+ url: \`/${name}/\${created.id}\`,
71
+ });
72
+
73
+ expect(deleteResponse.statusCode).toBe(204);
74
+
75
+ // Verify deletion
76
+ const verifyResponse = await app.inject({
77
+ method: 'GET',
78
+ url: \`/${name}/\${created.id}\`,
79
+ });
80
+
81
+ expect(verifyResponse.statusCode).toBe(404);
82
+ });
83
+ });
84
+
85
+ describe('List and pagination', () => {
86
+ it('should list ${name} with pagination', async () => {
87
+ // Create multiple ${name}
88
+ const count = 5;
89
+ for (let i = 0; i < count; i++) {
90
+ await app.inject({
91
+ method: 'POST',
92
+ url: '/${name}',
93
+ payload: {
94
+ // TODO: Add required fields
95
+ },
96
+ });
97
+ }
98
+
99
+ // Test pagination
100
+ const response = await app.inject({
101
+ method: 'GET',
102
+ url: '/${name}?page=1&limit=3',
103
+ });
104
+
105
+ expect(response.statusCode).toBe(200);
106
+ const result = response.json();
107
+ expect(result.data).toBeDefined();
108
+ expect(result.data.length).toBeLessThanOrEqual(3);
109
+ expect(result.total).toBeGreaterThanOrEqual(count);
110
+ });
111
+ });
112
+
113
+ describe('Validation', () => {
114
+ it('should validate required fields on create', async () => {
115
+ const response = await app.inject({
116
+ method: 'POST',
117
+ url: '/${name}',
118
+ payload: {},
119
+ });
120
+
121
+ expect(response.statusCode).toBe(400);
122
+ expect(response.json()).toHaveProperty('error');
123
+ });
124
+
125
+ it('should validate data types', async () => {
126
+ const response = await app.inject({
127
+ method: 'POST',
128
+ url: '/${name}',
129
+ payload: {
130
+ // TODO: Add invalid field types
131
+ },
132
+ });
133
+
134
+ expect(response.statusCode).toBe(400);
135
+ });
136
+ });
137
+ });
138
+ `;
139
+ }
@@ -0,0 +1,100 @@
1
+ export function serviceTestTemplate(name: string, pascalName: string, camelName: string): string {
2
+ return `import { describe, it, expect, beforeEach } from 'vitest';
3
+ import { ${pascalName}Service } from '../${name}.service.js';
4
+
5
+ describe('${pascalName}Service', () => {
6
+ let service: ${pascalName}Service;
7
+
8
+ beforeEach(() => {
9
+ service = new ${pascalName}Service();
10
+ });
11
+
12
+ describe('getAll', () => {
13
+ it('should return all ${name}', async () => {
14
+ const result = await service.getAll();
15
+
16
+ expect(result).toBeDefined();
17
+ expect(Array.isArray(result)).toBe(true);
18
+ });
19
+
20
+ it('should apply pagination', async () => {
21
+ const result = await service.getAll({ page: 1, limit: 10 });
22
+
23
+ expect(result).toBeDefined();
24
+ expect(result.length).toBeLessThanOrEqual(10);
25
+ });
26
+ });
27
+
28
+ describe('getById', () => {
29
+ it('should return a ${camelName} by id', async () => {
30
+ // TODO: Create test ${camelName} first
31
+ const id = '1';
32
+ const result = await service.getById(id);
33
+
34
+ expect(result).toBeDefined();
35
+ expect(result.id).toBe(id);
36
+ });
37
+
38
+ it('should return null for non-existent id', async () => {
39
+ const result = await service.getById('999999');
40
+
41
+ expect(result).toBeNull();
42
+ });
43
+ });
44
+
45
+ describe('create', () => {
46
+ it('should create a new ${camelName}', async () => {
47
+ const data = {
48
+ // TODO: Add required fields
49
+ };
50
+
51
+ const result = await service.create(data);
52
+
53
+ expect(result).toBeDefined();
54
+ expect(result.id).toBeDefined();
55
+ });
56
+
57
+ it('should throw error for invalid data', async () => {
58
+ await expect(service.create({} as any)).rejects.toThrow();
59
+ });
60
+ });
61
+
62
+ describe('update', () => {
63
+ it('should update a ${camelName}', async () => {
64
+ // TODO: Create test ${camelName} first
65
+ const id = '1';
66
+ const updates = {
67
+ // TODO: Add fields to update
68
+ };
69
+
70
+ const result = await service.update(id, updates);
71
+
72
+ expect(result).toBeDefined();
73
+ expect(result.id).toBe(id);
74
+ });
75
+
76
+ it('should return null for non-existent id', async () => {
77
+ const result = await service.update('999999', {});
78
+
79
+ expect(result).toBeNull();
80
+ });
81
+ });
82
+
83
+ describe('delete', () => {
84
+ it('should delete a ${camelName}', async () => {
85
+ // TODO: Create test ${camelName} first
86
+ const id = '1';
87
+ const result = await service.delete(id);
88
+
89
+ expect(result).toBe(true);
90
+ });
91
+
92
+ it('should return false for non-existent id', async () => {
93
+ const result = await service.delete('999999');
94
+
95
+ expect(result).toBe(false);
96
+ });
97
+ });
98
+ });
99
+ `;
100
+ }
@@ -0,0 +1,155 @@
1
+ /* eslint-disable no-console */
2
+ import chalk from 'chalk';
3
+ import path from 'path';
4
+
5
+ export interface FileOperation {
6
+ type: 'create' | 'modify' | 'delete';
7
+ path: string;
8
+ content?: string;
9
+ size?: number;
10
+ }
11
+
12
+ export class DryRunManager {
13
+ private static instance: DryRunManager;
14
+ private enabled = false;
15
+ private operations: FileOperation[] = [];
16
+
17
+ private constructor() {}
18
+
19
+ static getInstance(): DryRunManager {
20
+ if (!DryRunManager.instance) {
21
+ DryRunManager.instance = new DryRunManager();
22
+ }
23
+ return DryRunManager.instance;
24
+ }
25
+
26
+ enable(): void {
27
+ this.enabled = true;
28
+ this.operations = [];
29
+ }
30
+
31
+ disable(): void {
32
+ this.enabled = false;
33
+ this.operations = [];
34
+ }
35
+
36
+ isEnabled(): boolean {
37
+ return this.enabled;
38
+ }
39
+
40
+ addOperation(operation: FileOperation): void {
41
+ if (this.enabled) {
42
+ this.operations.push(operation);
43
+ }
44
+ }
45
+
46
+ getOperations(): FileOperation[] {
47
+ return [...this.operations];
48
+ }
49
+
50
+ printSummary(): void {
51
+ if (!this.enabled || this.operations.length === 0) {
52
+ return;
53
+ }
54
+
55
+ console.log(chalk.bold.yellow('\n📋 Dry Run - Preview of changes:\n'));
56
+ console.log(chalk.gray('No files will be written. Remove --dry-run to apply changes.\n'));
57
+
58
+ const createOps = this.operations.filter((op) => op.type === 'create');
59
+ const modifyOps = this.operations.filter((op) => op.type === 'modify');
60
+ const deleteOps = this.operations.filter((op) => op.type === 'delete');
61
+
62
+ if (createOps.length > 0) {
63
+ console.log(chalk.green.bold(`\n✓ Files to be created (${createOps.length}):`));
64
+ createOps.forEach((op) => {
65
+ const size = op.content ? `${op.content.length} bytes` : 'unknown size';
66
+ console.log(` ${chalk.green('+')} ${chalk.cyan(op.path)} ${chalk.gray(`(${size})`)}`);
67
+ });
68
+ }
69
+
70
+ if (modifyOps.length > 0) {
71
+ console.log(chalk.yellow.bold(`\n~ Files to be modified (${modifyOps.length}):`));
72
+ modifyOps.forEach((op) => {
73
+ console.log(` ${chalk.yellow('~')} ${chalk.cyan(op.path)}`);
74
+ });
75
+ }
76
+
77
+ if (deleteOps.length > 0) {
78
+ console.log(chalk.red.bold(`\n- Files to be deleted (${deleteOps.length}):`));
79
+ deleteOps.forEach((op) => {
80
+ console.log(` ${chalk.red('-')} ${chalk.cyan(op.path)}`);
81
+ });
82
+ }
83
+
84
+ console.log(chalk.gray('\n' + '─'.repeat(60)));
85
+ console.log(
86
+ chalk.bold(` Total operations: ${this.operations.length}`) +
87
+ chalk.gray(
88
+ ` (${createOps.length} create, ${modifyOps.length} modify, ${deleteOps.length} delete)`
89
+ )
90
+ );
91
+ console.log(chalk.gray('─'.repeat(60)));
92
+ console.log(chalk.yellow('\n⚠ This was a dry run. No files were created or modified.'));
93
+ console.log(chalk.gray(' Remove --dry-run to apply these changes.\n'));
94
+ }
95
+
96
+ // Helper to format file path relative to cwd
97
+ relativePath(filePath: string): string {
98
+ return path.relative(process.cwd(), filePath);
99
+ }
100
+ }
101
+
102
+ // Wrapper functions for file operations
103
+ export async function dryRunWriteFile(
104
+ filePath: string,
105
+ content: string,
106
+ actualWriteFn: (path: string, content: string) => Promise<void>
107
+ ): Promise<void> {
108
+ const dryRun = DryRunManager.getInstance();
109
+
110
+ if (dryRun.isEnabled()) {
111
+ dryRun.addOperation({
112
+ type: 'create',
113
+ path: dryRun.relativePath(filePath),
114
+ content,
115
+ size: content.length,
116
+ });
117
+ return;
118
+ }
119
+
120
+ await actualWriteFn(filePath, content);
121
+ }
122
+
123
+ export async function dryRunModifyFile(
124
+ filePath: string,
125
+ actualModifyFn: (path: string) => Promise<void>
126
+ ): Promise<void> {
127
+ const dryRun = DryRunManager.getInstance();
128
+
129
+ if (dryRun.isEnabled()) {
130
+ dryRun.addOperation({
131
+ type: 'modify',
132
+ path: dryRun.relativePath(filePath),
133
+ });
134
+ return;
135
+ }
136
+
137
+ await actualModifyFn(filePath);
138
+ }
139
+
140
+ export async function dryRunDeleteFile(
141
+ filePath: string,
142
+ actualDeleteFn: (path: string) => Promise<void>
143
+ ): Promise<void> {
144
+ const dryRun = DryRunManager.getInstance();
145
+
146
+ if (dryRun.isEnabled()) {
147
+ dryRun.addOperation({
148
+ type: 'delete',
149
+ path: dryRun.relativePath(filePath),
150
+ });
151
+ return;
152
+ }
153
+
154
+ await actualDeleteFn(filePath);
155
+ }
@@ -0,0 +1,184 @@
1
+ /* eslint-disable no-console */
2
+ import chalk from 'chalk';
3
+
4
+ export interface ErrorSuggestion {
5
+ message: string;
6
+ suggestions: string[];
7
+ docsLink?: string;
8
+ }
9
+
10
+ export class ServCraftError extends Error {
11
+ suggestions: string[];
12
+ docsLink?: string;
13
+
14
+ constructor(message: string, suggestions: string[] = [], docsLink?: string) {
15
+ super(message);
16
+ this.name = 'ServCraftError';
17
+ this.suggestions = suggestions;
18
+ this.docsLink = docsLink;
19
+ }
20
+ }
21
+
22
+ // Common error types with suggestions
23
+ export const ErrorTypes = {
24
+ MODULE_NOT_FOUND: (moduleName: string): ServCraftError =>
25
+ new ServCraftError(
26
+ `Module "${moduleName}" not found`,
27
+ [
28
+ `Run ${chalk.cyan('servcraft list')} to see available modules`,
29
+ `Check the spelling of the module name`,
30
+ `Visit ${chalk.blue('https://github.com/Le-Sourcier/servcraft#modules')} for module list`,
31
+ ],
32
+ 'https://github.com/Le-Sourcier/servcraft#add-pre-built-modules'
33
+ ),
34
+
35
+ MODULE_ALREADY_EXISTS: (moduleName: string): ServCraftError =>
36
+ new ServCraftError(`Module "${moduleName}" already exists`, [
37
+ `Use ${chalk.cyan('servcraft add ' + moduleName + ' --force')} to overwrite`,
38
+ `Use ${chalk.cyan('servcraft add ' + moduleName + ' --update')} to update`,
39
+ `Use ${chalk.cyan('servcraft add ' + moduleName + ' --skip-existing')} to skip`,
40
+ ]),
41
+
42
+ NOT_IN_PROJECT: (): ServCraftError =>
43
+ new ServCraftError(
44
+ 'Not in a ServCraft project directory',
45
+ [
46
+ `Run ${chalk.cyan('servcraft init')} to create a new project`,
47
+ `Navigate to your ServCraft project directory`,
48
+ `Check if ${chalk.yellow('package.json')} exists`,
49
+ ],
50
+ 'https://github.com/Le-Sourcier/servcraft#initialize-project'
51
+ ),
52
+
53
+ FILE_ALREADY_EXISTS: (fileName: string): ServCraftError =>
54
+ new ServCraftError(`File "${fileName}" already exists`, [
55
+ `Use ${chalk.cyan('--force')} flag to overwrite`,
56
+ `Choose a different name`,
57
+ `Delete the existing file first`,
58
+ ]),
59
+
60
+ INVALID_DATABASE: (database: string): ServCraftError =>
61
+ new ServCraftError(`Invalid database type: "${database}"`, [
62
+ `Valid options: ${chalk.cyan('postgresql, mysql, sqlite, mongodb, none')}`,
63
+ `Use ${chalk.cyan('servcraft init --db postgresql')} for PostgreSQL`,
64
+ ]),
65
+
66
+ INVALID_VALIDATOR: (validator: string): ServCraftError =>
67
+ new ServCraftError(`Invalid validator type: "${validator}"`, [
68
+ `Valid options: ${chalk.cyan('zod, joi, yup')}`,
69
+ `Default is ${chalk.cyan('zod')}`,
70
+ ]),
71
+
72
+ MISSING_DEPENDENCY: (dependency: string, command: string): ServCraftError =>
73
+ new ServCraftError(`Missing dependency: "${dependency}"`, [
74
+ `Run ${chalk.cyan(command)} to install`,
75
+ `Check your ${chalk.yellow('package.json')}`,
76
+ ]),
77
+
78
+ INVALID_FIELD_FORMAT: (field: string): ServCraftError =>
79
+ new ServCraftError(`Invalid field format: "${field}"`, [
80
+ `Expected format: ${chalk.cyan('name:type')}`,
81
+ `Example: ${chalk.cyan('name:string age:number isActive:boolean')}`,
82
+ `Supported types: string, number, boolean, date`,
83
+ ]),
84
+
85
+ GIT_NOT_INITIALIZED: (): ServCraftError =>
86
+ new ServCraftError('Git repository not initialized', [
87
+ `Run ${chalk.cyan('git init')} to initialize git`,
88
+ `This is required for some ServCraft features`,
89
+ ]),
90
+ };
91
+
92
+ // Display error with suggestions
93
+ export function displayError(error: Error | ServCraftError): void {
94
+ console.error('\n' + chalk.red.bold('✗ Error: ') + chalk.red(error.message));
95
+
96
+ if (error instanceof ServCraftError) {
97
+ if (error.suggestions.length > 0) {
98
+ console.log('\n' + chalk.yellow.bold('💡 Suggestions:'));
99
+ error.suggestions.forEach((suggestion) => {
100
+ console.log(chalk.yellow(' • ') + suggestion);
101
+ });
102
+ }
103
+
104
+ if (error.docsLink) {
105
+ console.log(
106
+ '\n' + chalk.blue.bold('📚 Documentation: ') + chalk.blue.underline(error.docsLink)
107
+ );
108
+ }
109
+ }
110
+
111
+ console.log(); // Empty line for spacing
112
+ }
113
+
114
+ // Handle common Node.js errors
115
+ export function handleSystemError(err: NodeJS.ErrnoException): ServCraftError {
116
+ switch (err.code) {
117
+ case 'ENOENT':
118
+ return new ServCraftError(`File or directory not found: ${err.path}`, [
119
+ `Check if the path exists`,
120
+ `Create the directory first`,
121
+ ]);
122
+
123
+ case 'EACCES':
124
+ case 'EPERM':
125
+ return new ServCraftError(`Permission denied: ${err.path}`, [
126
+ `Check file permissions`,
127
+ `Try running with elevated privileges (not recommended)`,
128
+ `Change ownership of the directory`,
129
+ ]);
130
+
131
+ case 'EEXIST':
132
+ return new ServCraftError(`File or directory already exists: ${err.path}`, [
133
+ `Use a different name`,
134
+ `Remove the existing file first`,
135
+ `Use ${chalk.cyan('--force')} to overwrite`,
136
+ ]);
137
+
138
+ case 'ENOTDIR':
139
+ return new ServCraftError(`Not a directory: ${err.path}`, [
140
+ `Check the path`,
141
+ `A file exists where a directory is expected`,
142
+ ]);
143
+
144
+ case 'EISDIR':
145
+ return new ServCraftError(`Is a directory: ${err.path}`, [
146
+ `Cannot perform this operation on a directory`,
147
+ `Did you mean to target a file?`,
148
+ ]);
149
+
150
+ default:
151
+ return new ServCraftError(err.message, [
152
+ `Check system error code: ${err.code}`,
153
+ `Review the error details above`,
154
+ ]);
155
+ }
156
+ }
157
+
158
+ // Validate project structure
159
+ export function validateProject(): ServCraftError | null {
160
+ try {
161
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
162
+ const fs = require('fs');
163
+
164
+ if (!fs.existsSync('package.json')) {
165
+ return ErrorTypes.NOT_IN_PROJECT();
166
+ }
167
+
168
+ const packageJson = JSON.parse(fs.readFileSync('package.json', 'utf-8'));
169
+
170
+ if (!packageJson.dependencies?.fastify) {
171
+ return new ServCraftError('This does not appear to be a ServCraft project', [
172
+ `ServCraft projects require Fastify`,
173
+ `Run ${chalk.cyan('servcraft init')} to create a new project`,
174
+ ]);
175
+ }
176
+
177
+ return null;
178
+ } catch {
179
+ return new ServCraftError('Failed to validate project', [
180
+ `Ensure you are in the project root directory`,
181
+ `Check if ${chalk.yellow('package.json')} is valid`,
182
+ ]);
183
+ }
184
+ }