@tamyla/clodo-framework 4.0.15 → 4.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +19 -0
- package/dist/config/service-schema-config.js +22 -0
- package/dist/programmatic/createService.js +9 -0
- package/dist/service-management/ServiceOrchestrator.js +85 -0
- package/dist/service-management/handlers/ValidationHandler.js +28 -13
- package/dist/validation/payloadValidation.js +159 -0
- package/package.json +3 -2
- package/scripts/debug_validate_payload.mjs +12 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,22 @@
|
|
|
1
|
+
# [4.2.0](https://github.com/tamylaa/clodo-framework/compare/v4.1.0...v4.2.0) (2026-01-31)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Features
|
|
5
|
+
|
|
6
|
+
* **validation:** integrate ConfigurationValidator into ValidationHandler; add post-generation validation in ServiceOrchestrator; tests ([#5](https://github.com/tamylaa/clodo-framework/issues/5)) ([2889864](https://github.com/tamylaa/clodo-framework/commit/28898641bac4c2d6d206e12dbba8216e1de386a3))
|
|
7
|
+
|
|
8
|
+
# [4.1.0](https://github.com/tamylaa/clodo-framework/compare/v4.0.15...v4.1.0) (2026-01-31)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* **lint:** remove unnecessary escape in serviceName regex to satisfy no-useless-escape ([ac4dcda](https://github.com/tamylaa/clodo-framework/commit/ac4dcdabb39dda3dba3f4e06022f28936a53d645))
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
### Features
|
|
17
|
+
|
|
18
|
+
* **programmatic-service-api:** programmatic createService + configurable validation ([#4](https://github.com/tamylaa/clodo-framework/issues/4)) ([040f68b](https://github.com/tamylaa/clodo-framework/commit/040f68b9115b604b3f27d7baa14c63122494c5f3))
|
|
19
|
+
|
|
1
20
|
## [4.0.15](https://github.com/tamylaa/clodo-framework/compare/v4.0.14...v4.0.15) (2026-01-31)
|
|
2
21
|
|
|
3
22
|
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Centralized configuration for service payload schema values
|
|
2
|
+
// Allows runtime overrides for enums and features to keep validation flexible and configurable
|
|
3
|
+
|
|
4
|
+
let config = {
|
|
5
|
+
serviceTypes: ['api-service', 'data-service', 'worker', 'pages', 'gateway', 'generic'],
|
|
6
|
+
features: ['d1', 'upstash', 'r2', 'pages', 'ws', 'durableObject', 'cron', 'metrics']
|
|
7
|
+
};
|
|
8
|
+
export function getConfig() {
|
|
9
|
+
return {
|
|
10
|
+
...config
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
export function setConfig(updates = {}) {
|
|
14
|
+
if (updates.serviceTypes) config.serviceTypes = [...updates.serviceTypes];
|
|
15
|
+
if (updates.features) config.features = [...updates.features];
|
|
16
|
+
}
|
|
17
|
+
export function resetConfig() {
|
|
18
|
+
config = {
|
|
19
|
+
serviceTypes: ['api-service', 'data-service', 'worker', 'pages', 'gateway', 'generic'],
|
|
20
|
+
features: ['d1', 'upstash', 'r2', 'pages', 'ws', 'durableObject', 'cron', 'metrics']
|
|
21
|
+
};
|
|
22
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { ServiceOrchestrator } from '../service-management/ServiceOrchestrator.js';
|
|
2
|
+
export async function createServiceProgrammatic(payload, options = {}) {
|
|
3
|
+
const orchestrator = new ServiceOrchestrator({
|
|
4
|
+
interactive: false,
|
|
5
|
+
outputPath: options.outputDir || '.'
|
|
6
|
+
});
|
|
7
|
+
return await orchestrator.createService(payload, options);
|
|
8
|
+
}
|
|
9
|
+
export const createService = createServiceProgrammatic; // Backwards-compatible alias
|
|
@@ -99,6 +99,91 @@ export class ServiceOrchestrator {
|
|
|
99
99
|
}
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
+
/**
|
|
103
|
+
* Programmatic service creation API
|
|
104
|
+
* Accepts a ServicePayload and performs validation, generation and returns structured result
|
|
105
|
+
*/
|
|
106
|
+
async createService(payload, options = {}) {
|
|
107
|
+
// Validate payload using new validator
|
|
108
|
+
const {
|
|
109
|
+
validateServicePayload
|
|
110
|
+
} = await import('../validation/payloadValidation.js');
|
|
111
|
+
const validation = validateServicePayload(payload);
|
|
112
|
+
if (!validation.valid && !options.force) {
|
|
113
|
+
return {
|
|
114
|
+
success: false,
|
|
115
|
+
errors: validation.errors,
|
|
116
|
+
warnings: validation.warnings || []
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Map payload to coreInputs expected by generation handlers
|
|
121
|
+
const coreInputs = {
|
|
122
|
+
serviceName: payload.serviceName,
|
|
123
|
+
serviceType: payload.serviceType,
|
|
124
|
+
domainName: payload.domain,
|
|
125
|
+
environment: options.environment || 'development'
|
|
126
|
+
};
|
|
127
|
+
try {
|
|
128
|
+
// Generate derived confirmations and run generation
|
|
129
|
+
const confirmedValues = await this.confirmationHandler.generateAndConfirm(coreInputs);
|
|
130
|
+
const generationResult = await this.generationHandler.generateService(coreInputs, confirmedValues, {
|
|
131
|
+
outputPath: options.outputDir || this.outputPath,
|
|
132
|
+
middlewareStrategy: this.middlewareStrategy
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// Post-generation validation using ValidationHandler
|
|
136
|
+
const validationHandler = options.customConfig ? new ValidationHandler({
|
|
137
|
+
customConfig: options.customConfig
|
|
138
|
+
}) : this.validationHandler;
|
|
139
|
+
try {
|
|
140
|
+
const postValidation = await validationHandler.validateService(generationResult.servicePath);
|
|
141
|
+
if (!postValidation.valid && !options.force) {
|
|
142
|
+
return {
|
|
143
|
+
success: false,
|
|
144
|
+
errors: postValidation.issues || [],
|
|
145
|
+
warnings: validation.warnings || [],
|
|
146
|
+
validationReport: postValidation
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
return {
|
|
150
|
+
success: true,
|
|
151
|
+
serviceId: generationResult.serviceId || null,
|
|
152
|
+
servicePath: generationResult.servicePath || null,
|
|
153
|
+
serviceName: generationResult.serviceName || coreInputs.serviceName,
|
|
154
|
+
fileCount: generationResult.fileCount || (generationResult.generatedFiles || []).length,
|
|
155
|
+
generatedFiles: generationResult.generatedFiles || [],
|
|
156
|
+
warnings: validation.warnings || [],
|
|
157
|
+
validationReport: postValidation
|
|
158
|
+
};
|
|
159
|
+
} catch (valErr) {
|
|
160
|
+
// If validation step throws, surface error unless forced
|
|
161
|
+
if (!options.force) {
|
|
162
|
+
return {
|
|
163
|
+
success: false,
|
|
164
|
+
errors: [valErr.message]
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
return {
|
|
168
|
+
success: true,
|
|
169
|
+
serviceId: generationResult.serviceId || null,
|
|
170
|
+
servicePath: generationResult.servicePath || null,
|
|
171
|
+
serviceName: generationResult.serviceName || coreInputs.serviceName,
|
|
172
|
+
fileCount: generationResult.fileCount || (generationResult.generatedFiles || []).length,
|
|
173
|
+
generatedFiles: generationResult.generatedFiles || [],
|
|
174
|
+
warnings: validation.warnings || [],
|
|
175
|
+
validationReport: {
|
|
176
|
+
error: valErr.message
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
} catch (error) {
|
|
181
|
+
return {
|
|
182
|
+
success: false,
|
|
183
|
+
errors: [error.message]
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
}
|
|
102
187
|
/**
|
|
103
188
|
* Validate an existing service configuration
|
|
104
189
|
*/
|
|
@@ -6,8 +6,7 @@
|
|
|
6
6
|
import fs from 'fs/promises';
|
|
7
7
|
import path from 'path';
|
|
8
8
|
import { FrameworkConfig } from '../../utils/framework-config.js';
|
|
9
|
-
|
|
10
|
-
|
|
9
|
+
import { ConfigurationValidator } from '../../security/ConfigurationValidator.js';
|
|
11
10
|
export class ValidationHandler {
|
|
12
11
|
constructor(options = {}) {
|
|
13
12
|
this.strict = options.strict || false;
|
|
@@ -94,17 +93,33 @@ export class ValidationHandler {
|
|
|
94
93
|
issues.push(...wranglerValidation.issues);
|
|
95
94
|
|
|
96
95
|
// Run comprehensive configuration validation using ConfigurationValidator
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
96
|
+
try {
|
|
97
|
+
// Determine manifest path candidates and select first that exists
|
|
98
|
+
const manifestCandidates = ['clodo-service-manifest.json', 'service-manifest.json', 'manifest.json'];
|
|
99
|
+
let manifestPath = null;
|
|
100
|
+
for (const candidate of manifestCandidates) {
|
|
101
|
+
const candidatePath = path.join(servicePath, candidate);
|
|
102
|
+
try {
|
|
103
|
+
await fs.access(candidatePath);
|
|
104
|
+
manifestPath = candidatePath;
|
|
105
|
+
break;
|
|
106
|
+
} catch {
|
|
107
|
+
// Not found, continue
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
const wranglerPath = path.join(servicePath, 'wrangler.toml');
|
|
111
|
+
if (manifestPath) {
|
|
112
|
+
const configValidation = ConfigurationValidator.validateServiceConfig(manifestPath, wranglerPath);
|
|
113
|
+
if (!configValidation.valid) {
|
|
114
|
+
issues.push(...(configValidation.issues || []).map(i => `Configuration mismatch: ${i.message || JSON.stringify(i)}`));
|
|
115
|
+
}
|
|
116
|
+
} else {
|
|
117
|
+
// No manifest found — warn but do not block validation
|
|
118
|
+
issues.push('Warning: No service manifest found (clodo-service-manifest.json) — skipping manifest↔wrangler validation');
|
|
119
|
+
}
|
|
120
|
+
} catch (error) {
|
|
121
|
+
issues.push(`Configuration validation step failed: ${error.message}`);
|
|
122
|
+
}
|
|
108
123
|
return {
|
|
109
124
|
valid: issues.length === 0,
|
|
110
125
|
issues,
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
// Allowed enums
|
|
4
|
+
import { getConfig } from '../config/service-schema-config.js';
|
|
5
|
+
export const VALID_SERVICE_TYPES = () => getConfig().serviceTypes;
|
|
6
|
+
export const VALID_FEATURES = () => getConfig().features;
|
|
7
|
+
|
|
8
|
+
// Zod schema with refined validations
|
|
9
|
+
export const ServicePayloadSchema = z.object({
|
|
10
|
+
serviceName: z.string().min(3).max(50).regex(/^[a-z0-9-]+$/, 'serviceName must be lowercase letters, numbers and hyphens only'),
|
|
11
|
+
serviceType: z.string().refine(v => VALID_SERVICE_TYPES().includes(v), {
|
|
12
|
+
message: `Invalid serviceType. Expected one of: ${VALID_SERVICE_TYPES().join(', ')}`
|
|
13
|
+
}),
|
|
14
|
+
domain: z.string().min(3).regex(/^([a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,}$/, 'domain must be a valid domain name'),
|
|
15
|
+
description: z.string().optional(),
|
|
16
|
+
template: z.string().optional(),
|
|
17
|
+
features: z.array(z.string().refine(v => VALID_FEATURES().includes(v), {
|
|
18
|
+
message: `Invalid feature. Expected one of: ${VALID_FEATURES().join(', ')}`
|
|
19
|
+
})).optional(),
|
|
20
|
+
bindings: z.array(z.string()).optional(),
|
|
21
|
+
resources: z.record(z.any()).optional(),
|
|
22
|
+
specs: z.record(z.any()).optional(),
|
|
23
|
+
clodo: z.record(z.any()).optional()
|
|
24
|
+
});
|
|
25
|
+
export function validateServicePayload(payload) {
|
|
26
|
+
const result = {
|
|
27
|
+
valid: true,
|
|
28
|
+
errors: [],
|
|
29
|
+
warnings: []
|
|
30
|
+
};
|
|
31
|
+
try {
|
|
32
|
+
ServicePayloadSchema.parse(payload);
|
|
33
|
+
|
|
34
|
+
// Additional semantic checks
|
|
35
|
+
if (payload.features && Array.isArray(payload.features)) {
|
|
36
|
+
const duplicates = payload.features.filter((v, i, a) => a.indexOf(v) !== i);
|
|
37
|
+
if (duplicates.length) {
|
|
38
|
+
result.warnings.push({
|
|
39
|
+
field: 'features',
|
|
40
|
+
code: 'DUPLICATE_FEATURES',
|
|
41
|
+
message: `Duplicate features: ${[...new Set(duplicates)].join(', ')}`
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Check for unknown features if config changed at runtime
|
|
46
|
+
const configured = VALID_FEATURES();
|
|
47
|
+
const unknown = payload.features.filter(f => !configured.includes(f));
|
|
48
|
+
if (unknown.length) {
|
|
49
|
+
result.warnings.push({
|
|
50
|
+
field: 'features',
|
|
51
|
+
code: 'UNKNOWN_FEATURES',
|
|
52
|
+
message: `Unknown features: ${[...new Set(unknown)].join(', ')}`
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
} catch (err) {
|
|
57
|
+
result.valid = false;
|
|
58
|
+
|
|
59
|
+
// Handle Zod-like errors with an errors array
|
|
60
|
+
if (err && err.errors && Array.isArray(err.errors)) {
|
|
61
|
+
for (const e of err.errors) {
|
|
62
|
+
result.errors.push({
|
|
63
|
+
field: (e.path || []).join('.'),
|
|
64
|
+
code: 'SCHEMA_VALIDATION',
|
|
65
|
+
message: e.message
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
// Some environments stringify Zod errors into err.message (JSON array) - try to parse
|
|
70
|
+
else if (typeof err?.message === 'string' && err.message.trim().startsWith('[')) {
|
|
71
|
+
try {
|
|
72
|
+
const parsed = JSON.parse(err.message);
|
|
73
|
+
if (Array.isArray(parsed)) {
|
|
74
|
+
for (const e of parsed) {
|
|
75
|
+
const field = (e.path || []).join('.');
|
|
76
|
+
const msg = e.message || JSON.stringify(e);
|
|
77
|
+
result.errors.push({
|
|
78
|
+
field,
|
|
79
|
+
code: 'SCHEMA_VALIDATION',
|
|
80
|
+
message: msg
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
} else {
|
|
84
|
+
result.errors.push({
|
|
85
|
+
code: 'UNKNOWN',
|
|
86
|
+
message: err.message
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
} catch (parseErr) {
|
|
90
|
+
result.errors.push({
|
|
91
|
+
code: 'UNKNOWN',
|
|
92
|
+
message: err.message
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
} else {
|
|
96
|
+
result.errors.push({
|
|
97
|
+
code: 'UNKNOWN',
|
|
98
|
+
message: err.message
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return result;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Parameter metadata for discovery (derived from schema)
|
|
106
|
+
export function getParameterDefinitions() {
|
|
107
|
+
return {
|
|
108
|
+
serviceName: {
|
|
109
|
+
name: 'serviceName',
|
|
110
|
+
type: 'string',
|
|
111
|
+
required: true,
|
|
112
|
+
description: 'Unique identifier for the service',
|
|
113
|
+
validationRules: [{
|
|
114
|
+
rule: 'pattern',
|
|
115
|
+
value: '^[a-z0-9-]+$',
|
|
116
|
+
message: 'Lowercase letters, numbers and hyphens only'
|
|
117
|
+
}, {
|
|
118
|
+
rule: 'minLength',
|
|
119
|
+
value: 3,
|
|
120
|
+
message: 'At least 3 characters'
|
|
121
|
+
}, {
|
|
122
|
+
rule: 'maxLength',
|
|
123
|
+
value: 50,
|
|
124
|
+
message: 'At most 50 characters'
|
|
125
|
+
}]
|
|
126
|
+
},
|
|
127
|
+
serviceType: {
|
|
128
|
+
name: 'serviceType',
|
|
129
|
+
type: 'string',
|
|
130
|
+
required: true,
|
|
131
|
+
description: 'Type of service to create',
|
|
132
|
+
enum: VALID_SERVICE_TYPES()
|
|
133
|
+
},
|
|
134
|
+
domain: {
|
|
135
|
+
name: 'domain',
|
|
136
|
+
type: 'string',
|
|
137
|
+
required: true,
|
|
138
|
+
description: 'Domain for the service',
|
|
139
|
+
validationRules: [{
|
|
140
|
+
rule: 'pattern',
|
|
141
|
+
value: '^([a-z0-9]+(-[a-z0-9]+)*\\.)+[a-z]{2,}$',
|
|
142
|
+
message: 'Must be a valid domain'
|
|
143
|
+
}]
|
|
144
|
+
},
|
|
145
|
+
features: {
|
|
146
|
+
name: 'features',
|
|
147
|
+
type: 'array',
|
|
148
|
+
required: false,
|
|
149
|
+
description: 'Features to enable',
|
|
150
|
+
enum: VALID_FEATURES
|
|
151
|
+
},
|
|
152
|
+
clodo: {
|
|
153
|
+
name: 'clodo',
|
|
154
|
+
type: 'object',
|
|
155
|
+
required: false,
|
|
156
|
+
description: 'Passthrough data for clodo-application specific configuration'
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tamyla/clodo-framework",
|
|
3
|
-
"version": "4.0
|
|
3
|
+
"version": "4.2.0",
|
|
4
4
|
"description": "Reusable framework for Clodo-style software architecture on Cloudflare Workers + D1",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": [
|
|
@@ -153,7 +153,8 @@
|
|
|
153
153
|
"commander": "^11.0.0",
|
|
154
154
|
"inquirer": "^12.10.0",
|
|
155
155
|
"uuid": "^13.0.0",
|
|
156
|
-
"wrangler": ">=3.0.0"
|
|
156
|
+
"wrangler": ">=3.0.0",
|
|
157
|
+
"zod": "^4.3.6"
|
|
157
158
|
},
|
|
158
159
|
"devDependencies": {
|
|
159
160
|
"@babel/cli": "^7.23.0",
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { validateServicePayload } from '../src/validation/payloadValidation.js';
|
|
2
|
+
|
|
3
|
+
const cases = [
|
|
4
|
+
{ payload: { serviceName: 'Invalid Name', serviceType: 'generic', domain: 'example.com' }, name: 'invalid name' },
|
|
5
|
+
{ payload: { serviceName: 'svc2', serviceType: 'unknown-type', domain: 'example.com' }, name: 'invalid type' },
|
|
6
|
+
{ payload: { serviceName: 'svc3', serviceType: 'generic', domain: 'example.com', features: ['d1','nonsense'] }, name: 'invalid feature' }
|
|
7
|
+
];
|
|
8
|
+
|
|
9
|
+
for (const c of cases) {
|
|
10
|
+
const res = validateServicePayload(c.payload);
|
|
11
|
+
console.log('CASE:', c.name, JSON.stringify(res, null, 2));
|
|
12
|
+
}
|