@mcoda/generators 0.1.9 → 0.1.12

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.
@@ -1,11 +1,13 @@
1
1
  import { describe, it } from "node:test";
2
2
  import assert from "node:assert/strict";
3
+ import path from "node:path";
3
4
  import { Index } from "../index.js";
4
5
  import { DocsScaffolder } from "../scaffolding/docs/DocsScaffolder.js";
5
6
  import { WorkspaceScaffolder } from "../scaffolding/workspace/WorkspaceScaffolder.js";
6
7
  import { GlobalScaffolder } from "../scaffolding/global/GlobalScaffolder.js";
7
8
  import { GenerateTypes } from "../openapi/generateTypes.js";
8
9
  import { ValidateSchema } from "../openapi/validateSchema.js";
10
+ import { ValidateSqlSchema } from "../sql/validateSchema.js";
9
11
  describe("generators shells", () => {
10
12
  it("exports scaffolders and generators", () => {
11
13
  assert.ok(new Index());
@@ -14,5 +16,128 @@ describe("generators shells", () => {
14
16
  assert.ok(new GlobalScaffolder());
15
17
  assert.ok(new GenerateTypes());
16
18
  assert.ok(new ValidateSchema());
19
+ assert.ok(new ValidateSqlSchema());
20
+ });
21
+ });
22
+ describe("DocsScaffolder deployment blueprint", () => {
23
+ it("generates deterministic artifacts with ports and env mapping", () => {
24
+ const scaffolder = new DocsScaffolder();
25
+ const sds = [
26
+ "# SDS",
27
+ "We use MySQL for persistence.",
28
+ "Redis is used for caching.",
29
+ ].join("\n");
30
+ const openapi = [
31
+ "openapi: 3.1.0",
32
+ "info:",
33
+ " title: Demo",
34
+ " version: 1.0.0",
35
+ "servers:",
36
+ " - url: http://localhost:8080",
37
+ "paths:",
38
+ " /health:",
39
+ " get:",
40
+ " operationId: getHealth",
41
+ " responses:",
42
+ " '200':",
43
+ " description: ok",
44
+ ].join("\n");
45
+ const result = scaffolder.generateDeploymentBlueprint({
46
+ sdsContent: sds,
47
+ openapiContent: openapi,
48
+ outputDir: "/tmp/deploy",
49
+ serviceName: "sample-service",
50
+ });
51
+ assert.equal(result.port, 8080);
52
+ assert.ok(result.services.includes("mysql"));
53
+ assert.ok(result.services.includes("redis"));
54
+ const compose = result.files.find((file) => path.basename(file.path) === "docker-compose.yml");
55
+ assert.ok(compose);
56
+ assert.match(compose?.content ?? "", /\$\{SERVICE_PORT\}:\$\{SERVICE_PORT\}/);
57
+ const envExample = result.files.find((file) => path.basename(file.path) === ".env.example");
58
+ assert.ok(envExample);
59
+ assert.match(envExample?.content ?? "", /SERVICE_PORT=8080/);
60
+ assert.match(envExample?.content ?? "", /DATABASE_URL=/);
61
+ assert.match(envExample?.content ?? "", /REDIS_URL=/);
62
+ const envDoc = result.files.find((file) => path.basename(file.path) === "env-secrets.md");
63
+ assert.ok(envDoc);
64
+ const kustomization = result.files.find((file) => path.basename(file.path) === "kustomization.yaml");
65
+ assert.ok(kustomization);
66
+ });
67
+ });
68
+ describe("ValidateSchema", () => {
69
+ it("flags empty paths and invalid operation ids", () => {
70
+ const validator = new ValidateSchema();
71
+ const spec = [
72
+ "openapi: 2.0.0",
73
+ "info:",
74
+ " title: Demo",
75
+ "paths:",
76
+ " /health:",
77
+ " get:",
78
+ " operationId: bad id",
79
+ " responses:",
80
+ " '200':",
81
+ " description: ok",
82
+ ].join("\n");
83
+ const result = validator.validateContent(spec);
84
+ assert.ok(result.errors.some((error) => error.includes("Invalid openapi version")));
85
+ assert.ok(result.errors.some((error) => error.includes("Invalid operationId")));
86
+ assert.ok(result.errors.some((error) => error.includes("Missing info.version")));
87
+ });
88
+ it("passes for a valid spec", () => {
89
+ const validator = new ValidateSchema();
90
+ const spec = [
91
+ "openapi: 3.1.0",
92
+ "info:",
93
+ " title: Demo",
94
+ " version: 1.0.0",
95
+ "paths:",
96
+ " /health:",
97
+ " get:",
98
+ " operationId: getHealth",
99
+ " responses:",
100
+ " '200':",
101
+ " description: ok",
102
+ " content:",
103
+ " application/json:",
104
+ " schema:",
105
+ " $ref: '#/components/schemas/Health'",
106
+ "components:",
107
+ " schemas:",
108
+ " Health:",
109
+ " type: object",
110
+ ].join("\n");
111
+ const result = validator.validateContent(spec);
112
+ assert.equal(result.errors.length, 0);
113
+ });
114
+ });
115
+ describe("ValidateSqlSchema", () => {
116
+ it("flags prose lines and unterminated statements", () => {
117
+ const validator = new ValidateSqlSchema();
118
+ const schema = [
119
+ "CREATE TABLE users (",
120
+ " id INTEGER PRIMARY KEY,",
121
+ " name TEXT",
122
+ ")",
123
+ "This is not SQL",
124
+ ].join("\n");
125
+ const result = validator.validateContent(schema);
126
+ assert.ok(result.errors.length > 0);
127
+ assert.ok(result.errors.some((error) => error.type === "prose" && error.line === 5));
128
+ assert.ok(result.errors.some((error) => error.type === "unterminated"));
129
+ });
130
+ it("passes for valid SQL", () => {
131
+ const validator = new ValidateSqlSchema();
132
+ const schema = [
133
+ "CREATE TABLE users (",
134
+ " id INTEGER PRIMARY KEY,",
135
+ " name TEXT NOT NULL",
136
+ ");",
137
+ "CREATE INDEX idx_users_name ON users(name);",
138
+ "PRAGMA foreign_keys = ON;",
139
+ ].join("\n");
140
+ const result = validator.validateContent(schema);
141
+ assert.equal(result.errors.length, 0);
17
142
  });
18
143
  });
package/dist/index.d.ts CHANGED
@@ -1,3 +1,5 @@
1
1
  export declare class Index {
2
2
  }
3
+ export { DocsScaffolder } from "./scaffolding/docs/DocsScaffolder.js";
4
+ export { ValidateSqlSchema } from "./sql/validateSchema.js";
3
5
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,qBAAa,KAAK;CAAG"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,qBAAa,KAAK;CAAG;AAErB,OAAO,EAAE,cAAc,EAAE,MAAM,sCAAsC,CAAC;AACtE,OAAO,EAAE,iBAAiB,EAAE,MAAM,yBAAyB,CAAC"}
package/dist/index.js CHANGED
@@ -1,2 +1,4 @@
1
1
  export class Index {
2
2
  }
3
+ export { DocsScaffolder } from "./scaffolding/docs/DocsScaffolder.js";
4
+ export { ValidateSqlSchema } from "./sql/validateSchema.js";
@@ -1,3 +1,8 @@
1
1
  export declare class ValidateSchema {
2
+ validateContent(raw: string): {
3
+ doc?: any;
4
+ errors: string[];
5
+ };
6
+ validateDocument(doc: any): string[];
2
7
  }
3
8
  //# sourceMappingURL=validateSchema.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"validateSchema.d.ts","sourceRoot":"","sources":["../../src/openapi/validateSchema.ts"],"names":[],"mappings":"AAAA,qBAAa,cAAc;CAAG"}
1
+ {"version":3,"file":"validateSchema.d.ts","sourceRoot":"","sources":["../../src/openapi/validateSchema.ts"],"names":[],"mappings":"AA4BA,qBAAa,cAAc;IACzB,eAAe,CAAC,GAAG,EAAE,MAAM,GAAG;QAAE,GAAG,CAAC,EAAE,GAAG,CAAC;QAAC,MAAM,EAAE,MAAM,EAAE,CAAA;KAAE;IAqB7D,gBAAgB,CAAC,GAAG,EAAE,GAAG,GAAG,MAAM,EAAE;CA8ErC"}
@@ -1,2 +1,130 @@
1
+ import YAML from "yaml";
2
+ const HTTP_METHODS = ["get", "post", "put", "patch", "delete", "options", "head", "trace"];
3
+ const OPERATION_ID_PATTERN = /^[A-Za-z0-9_.-]+$/;
4
+ const isPlainObject = (value) => Boolean(value && typeof value === "object" && !Array.isArray(value));
5
+ const operationUsesJsonSchema = (operation) => {
6
+ const contentBlocks = [];
7
+ const requestContent = operation.requestBody?.content;
8
+ if (isPlainObject(requestContent))
9
+ contentBlocks.push(requestContent);
10
+ const responses = operation.responses;
11
+ if (isPlainObject(responses)) {
12
+ for (const response of Object.values(responses)) {
13
+ const responseContent = response?.content;
14
+ if (isPlainObject(responseContent))
15
+ contentBlocks.push(responseContent);
16
+ }
17
+ }
18
+ for (const content of contentBlocks) {
19
+ for (const [contentType, media] of Object.entries(content)) {
20
+ if (!contentType.toLowerCase().includes("json"))
21
+ continue;
22
+ if (media?.schema)
23
+ return true;
24
+ }
25
+ }
26
+ return false;
27
+ };
1
28
  export class ValidateSchema {
29
+ validateContent(raw) {
30
+ if (!raw || !raw.trim()) {
31
+ return { errors: ["OpenAPI spec is empty."] };
32
+ }
33
+ let parsed;
34
+ try {
35
+ parsed = YAML.parse(raw);
36
+ }
37
+ catch (error) {
38
+ try {
39
+ parsed = JSON.parse(raw);
40
+ }
41
+ catch {
42
+ return {
43
+ errors: [
44
+ `OpenAPI parse failed: ${error.message ?? String(error)}`,
45
+ ],
46
+ };
47
+ }
48
+ }
49
+ return { doc: parsed, errors: this.validateDocument(parsed) };
50
+ }
51
+ validateDocument(doc) {
52
+ const errors = [];
53
+ if (!isPlainObject(doc)) {
54
+ errors.push("OpenAPI spec is not an object.");
55
+ return errors;
56
+ }
57
+ const version = doc.openapi;
58
+ if (!version) {
59
+ errors.push("Missing openapi version.");
60
+ }
61
+ else if (typeof version !== "string" || !version.startsWith("3.")) {
62
+ errors.push(`Invalid openapi version: ${String(version)}.`);
63
+ }
64
+ const info = doc.info;
65
+ if (!isPlainObject(info)) {
66
+ errors.push("Missing info section.");
67
+ }
68
+ else {
69
+ if (!info.title)
70
+ errors.push("Missing info.title.");
71
+ if (!info.version)
72
+ errors.push("Missing info.version.");
73
+ }
74
+ const paths = doc.paths;
75
+ if (!isPlainObject(paths)) {
76
+ errors.push("Missing paths section.");
77
+ }
78
+ else if (Object.keys(paths).length === 0) {
79
+ errors.push("paths section is empty.");
80
+ }
81
+ const operationIds = new Map();
82
+ let hasOperations = false;
83
+ let hasJsonSchemaUsage = false;
84
+ if (isPlainObject(paths)) {
85
+ for (const [pathKey, pathItem] of Object.entries(paths)) {
86
+ if (!isPlainObject(pathItem)) {
87
+ errors.push(`Path item for ${pathKey} must be an object.`);
88
+ continue;
89
+ }
90
+ const methods = HTTP_METHODS.filter((method) => method in pathItem);
91
+ if (methods.length === 0) {
92
+ errors.push(`Path ${pathKey} has no operations.`);
93
+ continue;
94
+ }
95
+ for (const method of methods) {
96
+ const operation = pathItem[method];
97
+ if (!isPlainObject(operation)) {
98
+ errors.push(`Operation ${method.toUpperCase()} ${pathKey} must be an object.`);
99
+ continue;
100
+ }
101
+ hasOperations = true;
102
+ if (operationUsesJsonSchema(operation)) {
103
+ hasJsonSchemaUsage = true;
104
+ }
105
+ const operationId = operation.operationId;
106
+ if (!operationId || typeof operationId !== "string") {
107
+ errors.push(`Missing operationId for ${method.toUpperCase()} ${pathKey}.`);
108
+ }
109
+ else if (/\s/.test(operationId) || !OPERATION_ID_PATTERN.test(operationId)) {
110
+ errors.push(`Invalid operationId "${operationId}" for ${method.toUpperCase()} ${pathKey}.`);
111
+ }
112
+ else if (operationIds.has(operationId)) {
113
+ errors.push(`Duplicate operationId "${operationId}" detected.`);
114
+ }
115
+ else {
116
+ operationIds.set(operationId, `${method.toUpperCase()} ${pathKey}`);
117
+ }
118
+ }
119
+ }
120
+ }
121
+ const components = doc.components;
122
+ const schemas = isPlainObject(components) ? components.schemas : undefined;
123
+ const schemaCount = isPlainObject(schemas) ? Object.keys(schemas).length : 0;
124
+ const hasSchemaRefs = typeof doc === "object" && JSON.stringify(doc).includes("#/components/schemas/");
125
+ if ((hasJsonSchemaUsage || hasSchemaRefs || hasOperations) && schemaCount === 0) {
126
+ errors.push("Missing components.schemas for JSON payloads.");
127
+ }
128
+ return errors;
129
+ }
2
130
  }
@@ -1,3 +1,30 @@
1
+ export interface DeploymentEnvVar {
2
+ name: string;
3
+ value: string;
4
+ secret: boolean;
5
+ description: string;
6
+ usedBy: string[];
7
+ }
8
+ export interface DeploymentBlueprintFile {
9
+ path: string;
10
+ content: string;
11
+ }
12
+ export interface DeploymentBlueprintInput {
13
+ sdsContent: string;
14
+ openapiContent?: string;
15
+ outputDir: string;
16
+ serviceName?: string;
17
+ }
18
+ export interface DeploymentBlueprintOutput {
19
+ baseDir: string;
20
+ files: DeploymentBlueprintFile[];
21
+ env: DeploymentEnvVar[];
22
+ services: string[];
23
+ port: number;
24
+ }
1
25
  export declare class DocsScaffolder {
26
+ generateDeploymentBlueprint(input: DeploymentBlueprintInput): DeploymentBlueprintOutput;
27
+ writeDeploymentBlueprint(output: DeploymentBlueprintOutput): Promise<void>;
28
+ generateDeploymentBlueprintFiles(input: DeploymentBlueprintInput): Promise<DeploymentBlueprintOutput>;
2
29
  }
3
30
  //# sourceMappingURL=DocsScaffolder.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"DocsScaffolder.d.ts","sourceRoot":"","sources":["../../../src/scaffolding/docs/DocsScaffolder.ts"],"names":[],"mappings":"AAAA,qBAAa,cAAc;CAAG"}
1
+ {"version":3,"file":"DocsScaffolder.d.ts","sourceRoot":"","sources":["../../../src/scaffolding/docs/DocsScaffolder.ts"],"names":[],"mappings":"AAIA,MAAM,WAAW,gBAAgB;IAC/B,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,OAAO,CAAC;IAChB,WAAW,EAAE,MAAM,CAAC;IACpB,MAAM,EAAE,MAAM,EAAE,CAAC;CAClB;AAED,MAAM,WAAW,uBAAuB;IACtC,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,wBAAwB;IACvC,UAAU,EAAE,MAAM,CAAC;IACnB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,MAAM,WAAW,yBAAyB;IACxC,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,uBAAuB,EAAE,CAAC;IACjC,GAAG,EAAE,gBAAgB,EAAE,CAAC;IACxB,QAAQ,EAAE,MAAM,EAAE,CAAC;IACnB,IAAI,EAAE,MAAM,CAAC;CACd;AA4ZD,qBAAa,cAAc;IACzB,2BAA2B,CAAC,KAAK,EAAE,wBAAwB,GAAG,yBAAyB;IA+FjF,wBAAwB,CAAC,MAAM,EAAE,yBAAyB,GAAG,OAAO,CAAC,IAAI,CAAC;IAU1E,gCAAgC,CACpC,KAAK,EAAE,wBAAwB,GAC9B,OAAO,CAAC,yBAAyB,CAAC;CAKtC"}
@@ -1,2 +1,472 @@
1
+ import path from "node:path";
2
+ import { promises as fs } from "node:fs";
3
+ import YAML from "yaml";
4
+ const DEPENDENCY_DEFINITIONS = [
5
+ {
6
+ key: "mysql",
7
+ name: "mysql",
8
+ keywords: ["mysql", "mariadb"],
9
+ image: "mysql:8",
10
+ port: 3306,
11
+ appEnv: [
12
+ {
13
+ name: "DATABASE_URL",
14
+ value: "mysql://app:${MYSQL_PASSWORD}@mysql:3306/app",
15
+ secret: true,
16
+ description: "MySQL connection string for the application.",
17
+ usedBy: ["app"],
18
+ },
19
+ ],
20
+ serviceEnv: [
21
+ {
22
+ name: "MYSQL_DATABASE",
23
+ value: "app",
24
+ secret: false,
25
+ description: "MySQL database name.",
26
+ usedBy: ["mysql"],
27
+ },
28
+ {
29
+ name: "MYSQL_USER",
30
+ value: "app",
31
+ secret: false,
32
+ description: "MySQL user name.",
33
+ usedBy: ["mysql"],
34
+ },
35
+ {
36
+ name: "MYSQL_PASSWORD",
37
+ value: "change-me",
38
+ secret: true,
39
+ description: "MySQL user password.",
40
+ usedBy: ["mysql"],
41
+ },
42
+ {
43
+ name: "MYSQL_ROOT_PASSWORD",
44
+ value: "change-me",
45
+ secret: true,
46
+ description: "MySQL root password.",
47
+ usedBy: ["mysql"],
48
+ },
49
+ ],
50
+ },
51
+ {
52
+ key: "redis",
53
+ name: "redis",
54
+ keywords: ["redis"],
55
+ image: "redis:7",
56
+ port: 6379,
57
+ appEnv: [
58
+ {
59
+ name: "REDIS_URL",
60
+ value: "redis://redis:6379",
61
+ secret: false,
62
+ description: "Redis connection URL.",
63
+ usedBy: ["app"],
64
+ },
65
+ ],
66
+ serviceEnv: [],
67
+ },
68
+ {
69
+ key: "nats",
70
+ name: "nats",
71
+ keywords: ["nats"],
72
+ image: "nats:2",
73
+ port: 4222,
74
+ appEnv: [
75
+ {
76
+ name: "NATS_URL",
77
+ value: "nats://nats:4222",
78
+ secret: false,
79
+ description: "NATS connection URL.",
80
+ usedBy: ["app"],
81
+ },
82
+ ],
83
+ serviceEnv: [],
84
+ },
85
+ {
86
+ key: "minio",
87
+ name: "minio",
88
+ keywords: ["minio", "object storage", "s3"],
89
+ image: "minio/minio",
90
+ port: 9000,
91
+ command: "server /data",
92
+ appEnv: [
93
+ {
94
+ name: "S3_ENDPOINT",
95
+ value: "http://minio:9000",
96
+ secret: false,
97
+ description: "Object storage endpoint.",
98
+ usedBy: ["app"],
99
+ },
100
+ {
101
+ name: "S3_ACCESS_KEY",
102
+ value: "minio",
103
+ secret: false,
104
+ description: "Object storage access key.",
105
+ usedBy: ["app"],
106
+ },
107
+ {
108
+ name: "S3_SECRET_KEY",
109
+ value: "change-me",
110
+ secret: true,
111
+ description: "Object storage secret key.",
112
+ usedBy: ["app"],
113
+ },
114
+ ],
115
+ serviceEnv: [
116
+ {
117
+ name: "MINIO_ROOT_USER",
118
+ value: "minio",
119
+ secret: false,
120
+ description: "MinIO root user.",
121
+ usedBy: ["minio"],
122
+ },
123
+ {
124
+ name: "MINIO_ROOT_PASSWORD",
125
+ value: "change-me",
126
+ secret: true,
127
+ description: "MinIO root password.",
128
+ usedBy: ["minio"],
129
+ },
130
+ ],
131
+ },
132
+ {
133
+ key: "clickhouse",
134
+ name: "clickhouse",
135
+ keywords: ["clickhouse"],
136
+ image: "clickhouse/clickhouse-server:23",
137
+ port: 8123,
138
+ appEnv: [
139
+ {
140
+ name: "CLICKHOUSE_URL",
141
+ value: "http://clickhouse:8123",
142
+ secret: false,
143
+ description: "ClickHouse HTTP endpoint.",
144
+ usedBy: ["app"],
145
+ },
146
+ ],
147
+ serviceEnv: [],
148
+ },
149
+ ];
150
+ const normalizeServiceName = (value) => {
151
+ const raw = value?.trim().toLowerCase() ?? "";
152
+ const normalized = raw.replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "");
153
+ return normalized || "app";
154
+ };
155
+ const parseOpenApi = (raw) => {
156
+ if (!raw || !raw.trim())
157
+ return undefined;
158
+ try {
159
+ return YAML.parse(raw);
160
+ }
161
+ catch {
162
+ try {
163
+ return JSON.parse(raw);
164
+ }
165
+ catch {
166
+ return undefined;
167
+ }
168
+ }
169
+ };
170
+ const extractPortFromUrl = (url) => {
171
+ const trimmed = url.trim();
172
+ if (!trimmed)
173
+ return undefined;
174
+ const hasScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed);
175
+ try {
176
+ const parsed = new URL(hasScheme ? trimmed : `http://${trimmed}`);
177
+ if (parsed.port)
178
+ return Number(parsed.port);
179
+ if (hasScheme) {
180
+ if (parsed.protocol === "https:")
181
+ return 443;
182
+ if (parsed.protocol === "http:")
183
+ return 80;
184
+ }
185
+ }
186
+ catch {
187
+ const portMatch = trimmed.match(/:(\d{2,5})(?:\/|$)/);
188
+ if (portMatch)
189
+ return Number(portMatch[1]);
190
+ }
191
+ return undefined;
192
+ };
193
+ const resolveServicePort = (openapiContent) => {
194
+ const doc = parseOpenApi(openapiContent);
195
+ const servers = Array.isArray(doc?.servers) ? doc.servers : [];
196
+ for (const server of servers) {
197
+ if (!server || typeof server.url !== "string")
198
+ continue;
199
+ const port = extractPortFromUrl(server.url);
200
+ if (port)
201
+ return port;
202
+ }
203
+ return 3000;
204
+ };
205
+ const detectDependencies = (sdsContent) => {
206
+ const normalized = sdsContent.toLowerCase();
207
+ return DEPENDENCY_DEFINITIONS.filter((dependency) => dependency.keywords.some((keyword) => normalized.includes(keyword)));
208
+ };
209
+ const remapUsedBy = (env, appName) => ({
210
+ ...env,
211
+ usedBy: env.usedBy.map((entry) => (entry === "app" ? appName : entry)),
212
+ });
213
+ const mergeEnvVars = (env) => {
214
+ const map = new Map();
215
+ for (const item of env) {
216
+ const existing = map.get(item.name);
217
+ if (!existing) {
218
+ map.set(item.name, { ...item, usedBy: Array.from(new Set(item.usedBy)) });
219
+ continue;
220
+ }
221
+ existing.secret = existing.secret || item.secret;
222
+ existing.description = existing.description || item.description;
223
+ existing.value = existing.value || item.value;
224
+ existing.usedBy = Array.from(new Set([...existing.usedBy, ...item.usedBy]));
225
+ }
226
+ return Array.from(map.values()).sort((a, b) => a.name.localeCompare(b.name));
227
+ };
228
+ const envRef = (name) => "${" + name + "}";
229
+ const buildEnvExample = (env) => {
230
+ const lines = env
231
+ .slice()
232
+ .sort((a, b) => a.name.localeCompare(b.name))
233
+ .map((item) => `${item.name}=${item.value}`);
234
+ return `${lines.join("\n")}\n`;
235
+ };
236
+ const buildEnvMappingDoc = (env) => {
237
+ const lines = [
238
+ "# Deployment Environment Variables",
239
+ "",
240
+ "| Name | Secret | Used By | Description |",
241
+ "| --- | --- | --- | --- |",
242
+ ...env.map((item) => `| ${item.name} | ${item.secret ? "Yes" : "No"} | ${item.usedBy
243
+ .slice()
244
+ .sort()
245
+ .join(", ")} | ${item.description} |`),
246
+ "",
247
+ ];
248
+ return lines.join("\n");
249
+ };
250
+ const buildDockerCompose = (appName, port, env, dependencies) => {
251
+ const lines = ['version: "3.9"', "services:"];
252
+ const appEnv = env.filter((item) => item.usedBy.includes(appName));
253
+ const depNames = dependencies.map((dep) => dep.name).sort();
254
+ lines.push(` ${appName}:`);
255
+ lines.push(` image: ${appName}`);
256
+ lines.push(" ports:");
257
+ lines.push(` - "${envRef("SERVICE_PORT")}:${envRef("SERVICE_PORT")}"`);
258
+ if (appEnv.length > 0) {
259
+ lines.push(" environment:");
260
+ for (const item of appEnv) {
261
+ lines.push(` ${item.name}: "${envRef(item.name)}"`);
262
+ }
263
+ }
264
+ if (depNames.length > 0) {
265
+ lines.push(" depends_on:");
266
+ for (const dep of depNames) {
267
+ lines.push(` - ${dep}`);
268
+ }
269
+ }
270
+ const sortedDeps = dependencies.slice().sort((a, b) => a.name.localeCompare(b.name));
271
+ for (const dependency of sortedDeps) {
272
+ lines.push(` ${dependency.name}:`);
273
+ lines.push(` image: ${dependency.image}`);
274
+ if (dependency.command) {
275
+ lines.push(` command: ${dependency.command}`);
276
+ }
277
+ lines.push(" ports:");
278
+ lines.push(` - "${dependency.port}:${dependency.port}"`);
279
+ if (dependency.serviceEnv.length > 0) {
280
+ lines.push(" environment:");
281
+ for (const item of dependency.serviceEnv) {
282
+ lines.push(` ${item.name}: "${envRef(item.name)}"`);
283
+ }
284
+ }
285
+ }
286
+ return `${lines.join("\n")}\n`;
287
+ };
288
+ const buildConfigMap = (name, env) => {
289
+ const entries = env.filter((item) => !item.secret);
290
+ const lines = [
291
+ "apiVersion: v1",
292
+ "kind: ConfigMap",
293
+ "metadata:",
294
+ ` name: ${name}`,
295
+ "data:",
296
+ ];
297
+ if (entries.length === 0) {
298
+ lines.push(" {}");
299
+ }
300
+ else {
301
+ for (const item of entries) {
302
+ lines.push(` ${item.name}: "${item.value}"`);
303
+ }
304
+ }
305
+ return `${lines.join("\n")}\n`;
306
+ };
307
+ const buildSecret = (name, env) => {
308
+ const entries = env.filter((item) => item.secret);
309
+ const lines = [
310
+ "apiVersion: v1",
311
+ "kind: Secret",
312
+ "metadata:",
313
+ ` name: ${name}`,
314
+ "type: Opaque",
315
+ "stringData:",
316
+ ];
317
+ if (entries.length === 0) {
318
+ lines.push(" {}");
319
+ }
320
+ else {
321
+ for (const item of entries) {
322
+ lines.push(` ${item.name}: "${item.value}"`);
323
+ }
324
+ }
325
+ return `${lines.join("\n")}\n`;
326
+ };
327
+ const buildDeployment = (name, image, port, configName, secretName) => {
328
+ const lines = [
329
+ "apiVersion: apps/v1",
330
+ "kind: Deployment",
331
+ "metadata:",
332
+ ` name: ${name}`,
333
+ "spec:",
334
+ " replicas: 1",
335
+ " selector:",
336
+ " matchLabels:",
337
+ ` app: ${name}`,
338
+ " template:",
339
+ " metadata:",
340
+ " labels:",
341
+ ` app: ${name}`,
342
+ " spec:",
343
+ " containers:",
344
+ ` - name: ${name}`,
345
+ ` image: ${image}`,
346
+ " ports:",
347
+ ` - containerPort: ${port}`,
348
+ " envFrom:",
349
+ " - configMapRef:",
350
+ ` name: ${configName}`,
351
+ " - secretRef:",
352
+ ` name: ${secretName}`,
353
+ ];
354
+ return `${lines.join("\n")}\n`;
355
+ };
356
+ const buildService = (name, port) => {
357
+ const lines = [
358
+ "apiVersion: v1",
359
+ "kind: Service",
360
+ "metadata:",
361
+ ` name: ${name}`,
362
+ "spec:",
363
+ " selector:",
364
+ ` app: ${name}`,
365
+ " ports:",
366
+ ` - port: ${port}`,
367
+ ` targetPort: ${port}`,
368
+ ];
369
+ return `${lines.join("\n")}\n`;
370
+ };
371
+ const buildKustomization = (resources) => {
372
+ const lines = [
373
+ "apiVersion: kustomize.config.k8s.io/v1beta1",
374
+ "kind: Kustomization",
375
+ "resources:",
376
+ ...resources.map((resource) => ` - ${resource}`),
377
+ ];
378
+ return `${lines.join("\n")}\n`;
379
+ };
1
380
  export class DocsScaffolder {
381
+ generateDeploymentBlueprint(input) {
382
+ const serviceName = normalizeServiceName(input.serviceName);
383
+ const port = resolveServicePort(input.openapiContent);
384
+ const dependencies = detectDependencies(input.sdsContent);
385
+ const baseEnv = {
386
+ name: "SERVICE_PORT",
387
+ value: String(port),
388
+ secret: false,
389
+ description: "Port exposed by the service.",
390
+ usedBy: [serviceName],
391
+ };
392
+ const envVars = [
393
+ baseEnv,
394
+ ...dependencies.flatMap((dependency) => dependency.appEnv.map((env) => remapUsedBy(env, serviceName))),
395
+ ...dependencies.flatMap((dependency) => dependency.serviceEnv),
396
+ ];
397
+ const mergedEnv = mergeEnvVars(envVars);
398
+ const baseDir = input.outputDir;
399
+ const k8sDir = path.join(baseDir, "k8s");
400
+ const configName = `${serviceName}-config`;
401
+ const secretName = `${serviceName}-secrets`;
402
+ const files = [];
403
+ files.push({
404
+ path: path.join(baseDir, "docker-compose.yml"),
405
+ content: buildDockerCompose(serviceName, port, mergedEnv, dependencies),
406
+ });
407
+ files.push({
408
+ path: path.join(baseDir, ".env.example"),
409
+ content: buildEnvExample(mergedEnv),
410
+ });
411
+ files.push({
412
+ path: path.join(baseDir, "env-secrets.md"),
413
+ content: buildEnvMappingDoc(mergedEnv),
414
+ });
415
+ files.push({
416
+ path: path.join(k8sDir, "configmap.yaml"),
417
+ content: buildConfigMap(configName, mergedEnv),
418
+ });
419
+ files.push({
420
+ path: path.join(k8sDir, "secret.yaml"),
421
+ content: buildSecret(secretName, mergedEnv),
422
+ });
423
+ files.push({
424
+ path: path.join(k8sDir, `${serviceName}-deployment.yaml`),
425
+ content: buildDeployment(serviceName, serviceName, port, configName, secretName),
426
+ });
427
+ files.push({
428
+ path: path.join(k8sDir, `${serviceName}-service.yaml`),
429
+ content: buildService(serviceName, port),
430
+ });
431
+ const sortedDeps = dependencies.slice().sort((a, b) => a.name.localeCompare(b.name));
432
+ for (const dependency of sortedDeps) {
433
+ files.push({
434
+ path: path.join(k8sDir, `${dependency.name}-deployment.yaml`),
435
+ content: buildDeployment(dependency.name, dependency.image, dependency.port, configName, secretName),
436
+ });
437
+ files.push({
438
+ path: path.join(k8sDir, `${dependency.name}-service.yaml`),
439
+ content: buildService(dependency.name, dependency.port),
440
+ });
441
+ }
442
+ const k8sResources = files
443
+ .filter((file) => path.dirname(file.path) === k8sDir)
444
+ .map((file) => path.basename(file.path))
445
+ .filter((name) => name !== "kustomization.yaml")
446
+ .sort();
447
+ files.push({
448
+ path: path.join(k8sDir, "kustomization.yaml"),
449
+ content: buildKustomization(k8sResources),
450
+ });
451
+ const sortedFiles = files.slice().sort((a, b) => a.path.localeCompare(b.path));
452
+ return {
453
+ baseDir,
454
+ files: sortedFiles,
455
+ env: mergedEnv,
456
+ services: [serviceName, ...sortedDeps.map((dep) => dep.name)],
457
+ port,
458
+ };
459
+ }
460
+ async writeDeploymentBlueprint(output) {
461
+ const dirs = Array.from(new Set(output.files.map((file) => path.dirname(file.path))));
462
+ for (const dir of dirs) {
463
+ await fs.mkdir(dir, { recursive: true });
464
+ }
465
+ await Promise.all(output.files.map((file) => fs.writeFile(file.path, file.content, "utf8")));
466
+ }
467
+ async generateDeploymentBlueprintFiles(input) {
468
+ const output = this.generateDeploymentBlueprint(input);
469
+ await this.writeDeploymentBlueprint(output);
470
+ return output;
471
+ }
2
472
  }
@@ -0,0 +1,13 @@
1
+ export type SqlValidationIssueType = "empty" | "prose" | "syntax" | "unterminated";
2
+ export interface SqlValidationIssue {
3
+ line: number;
4
+ message: string;
5
+ type: SqlValidationIssueType;
6
+ excerpt?: string;
7
+ }
8
+ export declare class ValidateSqlSchema {
9
+ validateContent(raw: string): {
10
+ errors: SqlValidationIssue[];
11
+ };
12
+ }
13
+ //# sourceMappingURL=validateSchema.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"validateSchema.d.ts","sourceRoot":"","sources":["../../src/sql/validateSchema.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,sBAAsB,GAAG,OAAO,GAAG,OAAO,GAAG,QAAQ,GAAG,cAAc,CAAC;AAEnF,MAAM,WAAW,kBAAkB;IACjC,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,sBAAsB,CAAC;IAC7B,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAkED,qBAAa,iBAAiB;IAC5B,eAAe,CAAC,GAAG,EAAE,MAAM,GAAG;QAAE,MAAM,EAAE,kBAAkB,EAAE,CAAA;KAAE;CAmF/D"}
@@ -0,0 +1,142 @@
1
+ const STATEMENT_START = /^(create|alter|drop|insert|update|delete|select|with|pragma|begin|commit|rollback|end|vacuum|analyze|attach|detach)\b/i;
2
+ const CLAUSE_START = /^(from|where|join|left|right|inner|outer|on|group|order|limit|offset|union|values|set|returning)\b/i;
3
+ const CONTINUATION_START = /^(,|\)|constraint\b|primary\b|foreign\b|unique\b|check\b|references\b|index\b|key\b|generated\b|as\b|collate\b|not\b|null\b)/i;
4
+ const COLUMN_DEF = /^(?:`[^`]+`|"[^"]+"|\[[^\]]+]|[A-Za-z_][\w$]*)(?:\s+[A-Za-z_][\w$]*)(?:\s*\([^)]*\))?/i;
5
+ const TYPE_KEYWORDS = /\b(bigint|int|integer|smallint|tinyint|serial|bigserial|uuid|text|varchar|char|character|boolean|bool|date|timestamp|timestamptz|datetime|time|json|jsonb|blob|real|double|float|numeric|decimal|money|bytea)\b/i;
6
+ const CONSTRAINT_KEYWORDS = /\b(primary|foreign|references|unique|check|default|collate|generated|identity)\b/i;
7
+ const NULL_KEYWORDS = /\bnot\s+null\b|\bnull\b/i;
8
+ const stripComments = (line, state) => {
9
+ let working = line;
10
+ if (state.inBlockComment) {
11
+ const end = working.indexOf("*/");
12
+ if (end === -1)
13
+ return "";
14
+ working = working.slice(end + 2);
15
+ state.inBlockComment = false;
16
+ }
17
+ let blockStart = working.indexOf("/*");
18
+ while (blockStart !== -1) {
19
+ const blockEnd = working.indexOf("*/", blockStart + 2);
20
+ if (blockEnd === -1) {
21
+ working = working.slice(0, blockStart);
22
+ state.inBlockComment = true;
23
+ break;
24
+ }
25
+ working = `${working.slice(0, blockStart)} ${working.slice(blockEnd + 2)}`;
26
+ blockStart = working.indexOf("/*", blockStart);
27
+ }
28
+ const lineComment = working.indexOf("--");
29
+ if (lineComment !== -1) {
30
+ working = working.slice(0, lineComment);
31
+ }
32
+ return working.trim();
33
+ };
34
+ const countParens = (line) => {
35
+ const stripped = line
36
+ .replace(/'[^']*'/g, "")
37
+ .replace(/"[^"]*"/g, "")
38
+ .replace(/\[[^\]]*]/g, "");
39
+ return {
40
+ open: (stripped.match(/\(/g) ?? []).length,
41
+ close: (stripped.match(/\)/g) ?? []).length,
42
+ };
43
+ };
44
+ const isSqlLine = (line, inStatement) => {
45
+ const trimmed = line.trim();
46
+ if (!trimmed)
47
+ return true;
48
+ if (STATEMENT_START.test(trimmed))
49
+ return true;
50
+ if (!inStatement)
51
+ return false;
52
+ if (CLAUSE_START.test(trimmed))
53
+ return true;
54
+ if (CONTINUATION_START.test(trimmed))
55
+ return true;
56
+ if (trimmed === ");" || trimmed === ")" || trimmed === "," || trimmed === ";")
57
+ return true;
58
+ const normalized = trimmed.replace(/[;,)]\s*$/, "").trim();
59
+ if (COLUMN_DEF.test(normalized)) {
60
+ return TYPE_KEYWORDS.test(normalized) || CONSTRAINT_KEYWORDS.test(normalized) || NULL_KEYWORDS.test(normalized);
61
+ }
62
+ return false;
63
+ };
64
+ export class ValidateSqlSchema {
65
+ validateContent(raw) {
66
+ const errors = [];
67
+ if (!raw || !raw.trim()) {
68
+ return { errors: [{ line: 1, type: "empty", message: "SQL schema is empty." }] };
69
+ }
70
+ const lines = raw.split(/\r?\n/);
71
+ const state = { inBlockComment: false, inStatement: false, statementStart: 0, parenDepth: 0 };
72
+ for (let index = 0; index < lines.length; index += 1) {
73
+ const lineNumber = index + 1;
74
+ const rawLine = lines[index] ?? "";
75
+ const code = stripComments(rawLine, state);
76
+ if (!code)
77
+ continue;
78
+ const trimmed = code.trim();
79
+ const isStart = STATEMENT_START.test(trimmed);
80
+ if (!state.inStatement && !isStart) {
81
+ errors.push({
82
+ line: lineNumber,
83
+ type: "prose",
84
+ message: "Non-SQL content detected.",
85
+ excerpt: trimmed.slice(0, 140),
86
+ });
87
+ continue;
88
+ }
89
+ if (state.inStatement && !isSqlLine(trimmed, true)) {
90
+ errors.push({
91
+ line: lineNumber,
92
+ type: "prose",
93
+ message: "Non-SQL content detected inside a statement.",
94
+ excerpt: trimmed.slice(0, 140),
95
+ });
96
+ }
97
+ if (!state.inStatement && isStart) {
98
+ state.inStatement = true;
99
+ state.statementStart = lineNumber;
100
+ }
101
+ const parenCount = countParens(code);
102
+ state.parenDepth += parenCount.open - parenCount.close;
103
+ if (state.parenDepth < 0) {
104
+ errors.push({
105
+ line: lineNumber,
106
+ type: "syntax",
107
+ message: "Unexpected closing parenthesis in SQL statement.",
108
+ excerpt: trimmed.slice(0, 140),
109
+ });
110
+ state.parenDepth = 0;
111
+ }
112
+ if (code.includes(";") && state.parenDepth > 0) {
113
+ errors.push({
114
+ line: lineNumber,
115
+ type: "syntax",
116
+ message: "Statement terminates before closing all parentheses.",
117
+ excerpt: trimmed.slice(0, 140),
118
+ });
119
+ }
120
+ if (code.includes(";") && state.parenDepth <= 0) {
121
+ state.inStatement = false;
122
+ state.statementStart = 0;
123
+ state.parenDepth = 0;
124
+ }
125
+ }
126
+ if (state.inStatement) {
127
+ errors.push({
128
+ line: state.statementStart || lines.length,
129
+ type: "unterminated",
130
+ message: `SQL statement starting at line ${state.statementStart} is missing a terminating semicolon.`,
131
+ });
132
+ }
133
+ if (state.parenDepth > 0) {
134
+ errors.push({
135
+ line: lines.length,
136
+ type: "syntax",
137
+ message: "SQL statement has unbalanced parentheses.",
138
+ });
139
+ }
140
+ return { errors };
141
+ }
142
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mcoda/generators",
3
- "version": "0.1.9",
3
+ "version": "0.1.12",
4
4
  "private": false,
5
5
  "description": "Internal generators and scaffolding for mcoda.",
6
6
  "type": "module",
@@ -26,7 +26,8 @@
26
26
  "access": "public"
27
27
  },
28
28
  "dependencies": {
29
- "@mcoda/shared": "0.1.9"
29
+ "yaml": "^2.4.2",
30
+ "@mcoda/shared": "0.1.12"
30
31
  },
31
32
  "scripts": {
32
33
  "build": "tsc -p tsconfig.json",