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.
- package/.github/workflows/ci.yml +9 -4
- package/README.md +63 -2
- package/ROADMAP.md +86 -41
- package/dist/cli/index.cjs +1510 -172
- package/dist/cli/index.cjs.map +1 -1
- package/dist/cli/index.js +1516 -172
- package/dist/cli/index.js.map +1 -1
- package/package.json +1 -1
- package/src/cli/commands/add-module.ts +36 -7
- package/src/cli/commands/completion.ts +146 -0
- package/src/cli/commands/doctor.ts +123 -0
- package/src/cli/commands/generate.ts +73 -1
- package/src/cli/commands/init.ts +29 -10
- package/src/cli/commands/list.ts +274 -0
- package/src/cli/commands/remove.ts +102 -0
- package/src/cli/commands/update.ts +221 -0
- package/src/cli/index.ts +20 -0
- package/src/cli/templates/controller-test.ts +110 -0
- package/src/cli/templates/integration-test.ts +139 -0
- package/src/cli/templates/service-test.ts +100 -0
- package/src/cli/utils/dry-run.ts +155 -0
- package/src/cli/utils/error-handler.ts +184 -0
- package/src/cli/utils/helpers.ts +13 -0
- package/tests/cli/add.test.ts +32 -0
- package/tests/cli/completion.test.ts +35 -0
- package/tests/cli/doctor.test.ts +23 -0
- package/tests/cli/dry-run.test.ts +39 -0
- package/tests/cli/errors.test.ts +29 -0
- package/tests/cli/generate.test.ts +39 -0
- package/tests/cli/init.test.ts +63 -0
- package/tests/cli/list.test.ts +25 -0
- package/tests/cli/remove.test.ts +28 -0
- package/tests/cli/update.test.ts +34 -0
|
@@ -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
|
+
}
|