@truefoundry/tfy-infra-engine 0.0.0-canary.edab06d
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/README.md +287 -0
- package/dist/index.d.mts +589 -0
- package/dist/index.d.ts +589 -0
- package/dist/index.js +1155 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +1109 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +59 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1155 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __export = (target, all) => {
|
|
9
|
+
for (var name in all)
|
|
10
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
11
|
+
};
|
|
12
|
+
var __copyProps = (to, from, except, desc) => {
|
|
13
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
14
|
+
for (let key of __getOwnPropNames(from))
|
|
15
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
16
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
17
|
+
}
|
|
18
|
+
return to;
|
|
19
|
+
};
|
|
20
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
21
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
22
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
23
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
24
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
25
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
26
|
+
mod
|
|
27
|
+
));
|
|
28
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
29
|
+
|
|
30
|
+
// src/index.ts
|
|
31
|
+
var index_exports = {};
|
|
32
|
+
__export(index_exports, {
|
|
33
|
+
DEFAULT_PREFIX: () => DEFAULT_PREFIX,
|
|
34
|
+
EngineError: () => EngineError,
|
|
35
|
+
EngineErrorCode: () => EngineErrorCode,
|
|
36
|
+
classifyFile: () => classifyFile,
|
|
37
|
+
createEngine: () => createEngine,
|
|
38
|
+
isTofuAvailable: () => isTofuAvailable,
|
|
39
|
+
parseHeader: () => parseHeader,
|
|
40
|
+
templateJsonSchema: () => templateJsonSchema,
|
|
41
|
+
validateJsonSchemaStructure: () => validateJsonSchemaStructure,
|
|
42
|
+
validateTemplateJson: () => validateTemplateJson
|
|
43
|
+
});
|
|
44
|
+
module.exports = __toCommonJS(index_exports);
|
|
45
|
+
|
|
46
|
+
// src/errors.ts
|
|
47
|
+
var EngineErrorCode = /* @__PURE__ */ ((EngineErrorCode2) => {
|
|
48
|
+
EngineErrorCode2["TEMPLATE_NOT_FOUND"] = "TEMPLATE_NOT_FOUND";
|
|
49
|
+
EngineErrorCode2["NETWORK_ERROR"] = "NETWORK_ERROR";
|
|
50
|
+
EngineErrorCode2["GITHUB_NOT_FOUND"] = "GITHUB_NOT_FOUND";
|
|
51
|
+
EngineErrorCode2["GITHUB_RATE_LIMITED"] = "GITHUB_RATE_LIMITED";
|
|
52
|
+
EngineErrorCode2["FILE_NOT_FOUND"] = "FILE_NOT_FOUND";
|
|
53
|
+
EngineErrorCode2["HTTPS_AUTH_FAILED"] = "HTTPS_AUTH_FAILED";
|
|
54
|
+
EngineErrorCode2["SCHEMA_INVALID"] = "SCHEMA_INVALID";
|
|
55
|
+
EngineErrorCode2["INPUT_VALIDATION_FAILED"] = "INPUT_VALIDATION_FAILED";
|
|
56
|
+
EngineErrorCode2["TEMPLATE_JSON_NOT_FOUND"] = "TEMPLATE_JSON_NOT_FOUND";
|
|
57
|
+
EngineErrorCode2["TEMPLATE_JSON_INVALID"] = "TEMPLATE_JSON_INVALID";
|
|
58
|
+
EngineErrorCode2["TEMPLATE_SYNTAX_ERROR"] = "TEMPLATE_SYNTAX_ERROR";
|
|
59
|
+
EngineErrorCode2["TOFU_NOT_FOUND"] = "TOFU_NOT_FOUND";
|
|
60
|
+
EngineErrorCode2["TOFU_FMT_FAILED"] = "TOFU_FMT_FAILED";
|
|
61
|
+
EngineErrorCode2["CACHE_ERROR"] = "CACHE_ERROR";
|
|
62
|
+
EngineErrorCode2["ENVELOPE_VALIDATION_FAILED"] = "ENVELOPE_VALIDATION_FAILED";
|
|
63
|
+
EngineErrorCode2["MANIFEST_PARSE_ERROR"] = "MANIFEST_PARSE_ERROR";
|
|
64
|
+
return EngineErrorCode2;
|
|
65
|
+
})(EngineErrorCode || {});
|
|
66
|
+
var EngineError = class _EngineError extends Error {
|
|
67
|
+
code;
|
|
68
|
+
details;
|
|
69
|
+
constructor(message, code, cause, details) {
|
|
70
|
+
super(message, { cause });
|
|
71
|
+
this.name = "EngineError";
|
|
72
|
+
this.code = code;
|
|
73
|
+
this.details = details;
|
|
74
|
+
if (Error.captureStackTrace) {
|
|
75
|
+
Error.captureStackTrace(this, _EngineError);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Create a string representation including error code.
|
|
80
|
+
*/
|
|
81
|
+
toString() {
|
|
82
|
+
return `[${this.code}] ${this.message}`;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Convert error to JSON-serializable object.
|
|
86
|
+
*/
|
|
87
|
+
toJSON() {
|
|
88
|
+
const causeError = this.cause;
|
|
89
|
+
return {
|
|
90
|
+
name: this.name,
|
|
91
|
+
code: this.code,
|
|
92
|
+
message: this.message,
|
|
93
|
+
details: this.details,
|
|
94
|
+
cause: causeError?.message
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
// src/schema/validator.ts
|
|
100
|
+
var import_ajv = __toESM(require("ajv"));
|
|
101
|
+
|
|
102
|
+
// src/schema/template-json-schema.ts
|
|
103
|
+
var templateJsonSchema = {
|
|
104
|
+
$schema: "http://json-schema.org/draft-07/schema#",
|
|
105
|
+
type: "object",
|
|
106
|
+
properties: {
|
|
107
|
+
template: {
|
|
108
|
+
type: "object",
|
|
109
|
+
properties: {
|
|
110
|
+
name: { type: "string", minLength: 1 },
|
|
111
|
+
description: { type: "string" },
|
|
112
|
+
version: { type: "string", pattern: "^\\d+\\.\\d+\\.\\d+$" }
|
|
113
|
+
},
|
|
114
|
+
required: ["name", "version"],
|
|
115
|
+
additionalProperties: false
|
|
116
|
+
},
|
|
117
|
+
schema: {
|
|
118
|
+
type: "object",
|
|
119
|
+
description: "JSON Schema Draft-07 defining template inputs",
|
|
120
|
+
properties: {
|
|
121
|
+
type: { type: "string", const: "object" },
|
|
122
|
+
properties: { type: "object" },
|
|
123
|
+
required: { type: "array", items: { type: "string" } }
|
|
124
|
+
},
|
|
125
|
+
required: ["type", "properties"]
|
|
126
|
+
}
|
|
127
|
+
},
|
|
128
|
+
required: ["template", "schema"]
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
// src/schema/validator.ts
|
|
132
|
+
var ajv = new import_ajv.default({
|
|
133
|
+
allErrors: true,
|
|
134
|
+
strict: false,
|
|
135
|
+
validateSchema: false
|
|
136
|
+
});
|
|
137
|
+
var validate = ajv.compile(templateJsonSchema);
|
|
138
|
+
function validateTemplateJson(data) {
|
|
139
|
+
if (!data || typeof data !== "object" || Array.isArray(data)) {
|
|
140
|
+
throw new EngineError(
|
|
141
|
+
"template.json must be a JSON object",
|
|
142
|
+
"TEMPLATE_JSON_INVALID" /* TEMPLATE_JSON_INVALID */
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
const valid = validate(data);
|
|
146
|
+
if (!valid) {
|
|
147
|
+
const message = formatAjvErrors(validate.errors ?? []);
|
|
148
|
+
throw new EngineError(message, "TEMPLATE_JSON_INVALID" /* TEMPLATE_JSON_INVALID */);
|
|
149
|
+
}
|
|
150
|
+
const record = data;
|
|
151
|
+
const templateBlock = record["template"];
|
|
152
|
+
const metadata = {
|
|
153
|
+
name: templateBlock["name"],
|
|
154
|
+
version: templateBlock["version"],
|
|
155
|
+
...templateBlock["description"] !== void 0 ? { description: templateBlock["description"] } : {}
|
|
156
|
+
};
|
|
157
|
+
const jsonSchema = record["schema"];
|
|
158
|
+
return { metadata, jsonSchema };
|
|
159
|
+
}
|
|
160
|
+
function formatAjvErrors(errors) {
|
|
161
|
+
const first = errors[0];
|
|
162
|
+
if (!first) {
|
|
163
|
+
return "template.json validation failed";
|
|
164
|
+
}
|
|
165
|
+
const path = first.instancePath || "";
|
|
166
|
+
if (first.keyword === "required") {
|
|
167
|
+
const prop = first.params["missingProperty"];
|
|
168
|
+
return `template.json must have a "${prop}" key`;
|
|
169
|
+
}
|
|
170
|
+
if (first.keyword === "type") {
|
|
171
|
+
const expected = first.params["type"];
|
|
172
|
+
return `template.json${path ? ` "${dotPath(path)}"` : ""} must be ${expected}`;
|
|
173
|
+
}
|
|
174
|
+
if (first.keyword === "pattern" && path === "/template/version") {
|
|
175
|
+
return `template.json "template.version" must be semver format (x.y.z)`;
|
|
176
|
+
}
|
|
177
|
+
if (first.keyword === "minLength" && path === "/template/name") {
|
|
178
|
+
return `template.json "template.name" must be a non-empty string`;
|
|
179
|
+
}
|
|
180
|
+
if (first.keyword === "const") {
|
|
181
|
+
const expected = first.params["allowedValue"];
|
|
182
|
+
return `template.json "${dotPath(path)}" must be ${JSON.stringify(expected)}`;
|
|
183
|
+
}
|
|
184
|
+
return `template.json validation failed at ${path || "/"}: ${first.message ?? "unknown error"}`;
|
|
185
|
+
}
|
|
186
|
+
function dotPath(jsonPointer) {
|
|
187
|
+
return jsonPointer.replace(/^\//, "").replace(/\//g, ".");
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// src/schema/input-validator.ts
|
|
191
|
+
var import_ajv2 = __toESM(require("ajv"));
|
|
192
|
+
var ajv2 = new import_ajv2.default({
|
|
193
|
+
useDefaults: true,
|
|
194
|
+
allErrors: true,
|
|
195
|
+
discriminator: true,
|
|
196
|
+
strict: false,
|
|
197
|
+
validateSchema: false
|
|
198
|
+
});
|
|
199
|
+
function createInputValidator(jsonSchema) {
|
|
200
|
+
try {
|
|
201
|
+
return ajv2.compile(jsonSchema);
|
|
202
|
+
} catch (error) {
|
|
203
|
+
throw new EngineError(
|
|
204
|
+
`Failed to compile JSON Schema: ${error.message}`,
|
|
205
|
+
"SCHEMA_INVALID" /* SCHEMA_INVALID */,
|
|
206
|
+
error
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
function validateAndApplyDefaults(validator, inputs) {
|
|
211
|
+
const valid = validator(inputs);
|
|
212
|
+
if (valid) {
|
|
213
|
+
return { valid: true };
|
|
214
|
+
}
|
|
215
|
+
const errors = (validator.errors ?? []).map(ajvErrorToValidationError);
|
|
216
|
+
return {
|
|
217
|
+
valid: false,
|
|
218
|
+
errors
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
function validateJsonSchemaStructure(schema) {
|
|
222
|
+
if (!schema || typeof schema !== "object") {
|
|
223
|
+
throw new EngineError(
|
|
224
|
+
"Schema must be a non-null object",
|
|
225
|
+
"SCHEMA_INVALID" /* SCHEMA_INVALID */,
|
|
226
|
+
void 0,
|
|
227
|
+
{ received: typeof schema }
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
if (schema.type !== "object") {
|
|
231
|
+
throw new EngineError(
|
|
232
|
+
`Schema root must have "type": "object", got "${schema.type ?? "undefined"}"`,
|
|
233
|
+
"SCHEMA_INVALID" /* SCHEMA_INVALID */,
|
|
234
|
+
void 0,
|
|
235
|
+
{ received: schema.type }
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
if (!schema.properties || typeof schema.properties !== "object") {
|
|
239
|
+
throw new EngineError(
|
|
240
|
+
'Schema root must have a "properties" key',
|
|
241
|
+
"SCHEMA_INVALID" /* SCHEMA_INVALID */,
|
|
242
|
+
void 0,
|
|
243
|
+
{ hasProperties: !!schema.properties }
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
function ajvErrorToValidationError(error) {
|
|
248
|
+
return {
|
|
249
|
+
path: error.instancePath || "/",
|
|
250
|
+
message: error.message ?? "Validation failed",
|
|
251
|
+
expected: formatExpected(error),
|
|
252
|
+
received: void 0
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
function formatExpected(error) {
|
|
256
|
+
const params = error.params;
|
|
257
|
+
if (!params) return void 0;
|
|
258
|
+
if (error.keyword === "type") {
|
|
259
|
+
return String(params["type"]);
|
|
260
|
+
}
|
|
261
|
+
if (error.keyword === "required") {
|
|
262
|
+
return `required property '${String(params["missingProperty"])}'`;
|
|
263
|
+
}
|
|
264
|
+
if (error.keyword === "pattern") {
|
|
265
|
+
return `match pattern ${String(params["pattern"])}`;
|
|
266
|
+
}
|
|
267
|
+
if (error.keyword === "enum") {
|
|
268
|
+
return `one of ${JSON.stringify(params["allowedValues"])}`;
|
|
269
|
+
}
|
|
270
|
+
if (error.keyword === "const") {
|
|
271
|
+
return `value ${JSON.stringify(params["allowedValue"])}`;
|
|
272
|
+
}
|
|
273
|
+
return void 0;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// src/render/renderer.ts
|
|
277
|
+
var import_handlebars2 = __toESM(require("handlebars"));
|
|
278
|
+
|
|
279
|
+
// src/helpers/hcl.ts
|
|
280
|
+
var import_handlebars = __toESM(require("handlebars"));
|
|
281
|
+
function registerHclHelpers(handlebars) {
|
|
282
|
+
handlebars.registerHelper("toTerraformValue", function(value) {
|
|
283
|
+
return new import_handlebars.default.SafeString(formatValue(value));
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
function formatValue(value) {
|
|
287
|
+
if (value === null || value === void 0) {
|
|
288
|
+
return "null";
|
|
289
|
+
}
|
|
290
|
+
if (typeof value === "string") {
|
|
291
|
+
const escaped = value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n");
|
|
292
|
+
return `"${escaped}"`;
|
|
293
|
+
}
|
|
294
|
+
if (typeof value === "boolean" || typeof value === "number") {
|
|
295
|
+
return String(value);
|
|
296
|
+
}
|
|
297
|
+
if (Array.isArray(value)) {
|
|
298
|
+
const elements = value.map((item) => formatValue(item));
|
|
299
|
+
if (elements.length === 0) {
|
|
300
|
+
return "[]";
|
|
301
|
+
}
|
|
302
|
+
if (elements.some((e) => e.includes("\n") || e.length > 40)) {
|
|
303
|
+
return `[
|
|
304
|
+
${elements.join(",\n ")}
|
|
305
|
+
]`;
|
|
306
|
+
}
|
|
307
|
+
return `[${elements.join(", ")}]`;
|
|
308
|
+
}
|
|
309
|
+
if (typeof value === "object") {
|
|
310
|
+
return formatObject(value);
|
|
311
|
+
}
|
|
312
|
+
return JSON.stringify(value);
|
|
313
|
+
}
|
|
314
|
+
function formatObject(obj) {
|
|
315
|
+
const entries = Object.entries(obj);
|
|
316
|
+
if (entries.length === 0) {
|
|
317
|
+
return "{}";
|
|
318
|
+
}
|
|
319
|
+
const formatted = entries.map(([key, val]) => {
|
|
320
|
+
const quotedKey = /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key) ? key : `"${key}"`;
|
|
321
|
+
return `${quotedKey} = ${formatValue(val)}`;
|
|
322
|
+
});
|
|
323
|
+
return `{
|
|
324
|
+
${formatted.join("\n ")}
|
|
325
|
+
}`;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// src/helpers/logic.ts
|
|
329
|
+
function registerLogicHelpers(handlebars) {
|
|
330
|
+
handlebars.registerHelper("eq", (a, b) => a === b);
|
|
331
|
+
handlebars.registerHelper("and", function(...args) {
|
|
332
|
+
const values = args.slice(0, -1);
|
|
333
|
+
return values.every(Boolean);
|
|
334
|
+
});
|
|
335
|
+
handlebars.registerHelper("or", function(...args) {
|
|
336
|
+
const values = args.slice(0, -1);
|
|
337
|
+
return values.some(Boolean);
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// src/helpers/types.ts
|
|
342
|
+
function registerTypeHelpers(handlebars) {
|
|
343
|
+
handlebars.registerHelper("isString", (value) => {
|
|
344
|
+
return typeof value === "string";
|
|
345
|
+
});
|
|
346
|
+
handlebars.registerHelper("isDefined", (value) => {
|
|
347
|
+
return value !== void 0 && value !== null;
|
|
348
|
+
});
|
|
349
|
+
handlebars.registerHelper("isNotEmpty", (value) => {
|
|
350
|
+
if (value === null || value === void 0) return false;
|
|
351
|
+
if (typeof value === "string") return value.length > 0;
|
|
352
|
+
if (Array.isArray(value)) return value.length > 0;
|
|
353
|
+
if (typeof value === "object") return Object.keys(value).length > 0;
|
|
354
|
+
return true;
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// src/helpers/index.ts
|
|
359
|
+
function registerAllHelpers(handlebars) {
|
|
360
|
+
registerHclHelpers(handlebars);
|
|
361
|
+
registerLogicHelpers(handlebars);
|
|
362
|
+
registerTypeHelpers(handlebars);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// src/render/renderer.ts
|
|
366
|
+
var HBS_COMPILE_OPTIONS = {
|
|
367
|
+
noEscape: true,
|
|
368
|
+
strict: false
|
|
369
|
+
};
|
|
370
|
+
function createConfiguredHandlebars() {
|
|
371
|
+
const hbs = import_handlebars2.default.create();
|
|
372
|
+
registerAllHelpers(hbs);
|
|
373
|
+
return hbs;
|
|
374
|
+
}
|
|
375
|
+
function createRenderContext(inputs, template) {
|
|
376
|
+
return {
|
|
377
|
+
...inputs,
|
|
378
|
+
_template: {
|
|
379
|
+
name: template.metadata.name,
|
|
380
|
+
version: template.metadata.version,
|
|
381
|
+
source: template.source
|
|
382
|
+
}
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
function renderTemplate(template, inputs) {
|
|
386
|
+
const hbs = createConfiguredHandlebars();
|
|
387
|
+
const context = createRenderContext(inputs, template);
|
|
388
|
+
const results = /* @__PURE__ */ new Map();
|
|
389
|
+
for (const [outputPath, hbsContent] of template.files) {
|
|
390
|
+
try {
|
|
391
|
+
const compiled = hbs.compile(hbsContent, HBS_COMPILE_OPTIONS);
|
|
392
|
+
const rendered = compiled(context);
|
|
393
|
+
results.set(outputPath, rendered);
|
|
394
|
+
} catch (error) {
|
|
395
|
+
throw new EngineError(
|
|
396
|
+
`Failed to render ${outputPath}: ${error.message}`,
|
|
397
|
+
"TEMPLATE_SYNTAX_ERROR" /* TEMPLATE_SYNTAX_ERROR */,
|
|
398
|
+
error,
|
|
399
|
+
{ file: outputPath, template: template.source }
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
return results;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// src/render/formatter.ts
|
|
407
|
+
var import_node_child_process = require("child_process");
|
|
408
|
+
var import_node_util = require("util");
|
|
409
|
+
var execFileAsync = (0, import_node_util.promisify)(import_node_child_process.execFile);
|
|
410
|
+
var DEFAULT_FORMAT_OPTIONS = {
|
|
411
|
+
skip: false,
|
|
412
|
+
timeout: 1e4
|
|
413
|
+
};
|
|
414
|
+
function normalizeBlankLines(content) {
|
|
415
|
+
let result = content.replace(/\n{3,}/g, "\n\n").replace(/^\n+/, "");
|
|
416
|
+
result = result.replace(/\n*$/, "\n");
|
|
417
|
+
return result;
|
|
418
|
+
}
|
|
419
|
+
var HCL_FORMATTABLE_SUFFIXES = [".tf", ".tfvars", ".tftest.hcl"];
|
|
420
|
+
function isHclFormattable(filePath) {
|
|
421
|
+
return HCL_FORMATTABLE_SUFFIXES.some((suffix) => filePath.endsWith(suffix));
|
|
422
|
+
}
|
|
423
|
+
async function formatHCL(content, options) {
|
|
424
|
+
const opts = { ...DEFAULT_FORMAT_OPTIONS, ...options };
|
|
425
|
+
if (opts.skip) {
|
|
426
|
+
return normalizeBlankLines(content);
|
|
427
|
+
}
|
|
428
|
+
return new Promise((resolve, reject) => {
|
|
429
|
+
const child = (0, import_node_child_process.spawn)("tofu", ["fmt", "-"], {
|
|
430
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
431
|
+
});
|
|
432
|
+
let stdout = "";
|
|
433
|
+
let stderr = "";
|
|
434
|
+
let timedOut = false;
|
|
435
|
+
const timer = setTimeout(() => {
|
|
436
|
+
timedOut = true;
|
|
437
|
+
child.kill();
|
|
438
|
+
}, opts.timeout);
|
|
439
|
+
child.stdout.on("data", (data) => {
|
|
440
|
+
stdout += data.toString();
|
|
441
|
+
});
|
|
442
|
+
child.stderr.on("data", (data) => {
|
|
443
|
+
stderr += data.toString();
|
|
444
|
+
});
|
|
445
|
+
child.on("error", (error) => {
|
|
446
|
+
clearTimeout(timer);
|
|
447
|
+
if (error.code === "ENOENT") {
|
|
448
|
+
reject(
|
|
449
|
+
new EngineError(
|
|
450
|
+
"tofu binary not found. Please install OpenTofu or use --skip-format option.",
|
|
451
|
+
"TOFU_NOT_FOUND" /* TOFU_NOT_FOUND */,
|
|
452
|
+
error,
|
|
453
|
+
{ suggestion: "Install OpenTofu from https://opentofu.org/" }
|
|
454
|
+
)
|
|
455
|
+
);
|
|
456
|
+
} else {
|
|
457
|
+
reject(
|
|
458
|
+
new EngineError(
|
|
459
|
+
`tofu fmt failed: ${error.message}`,
|
|
460
|
+
"TOFU_FMT_FAILED" /* TOFU_FMT_FAILED */,
|
|
461
|
+
error,
|
|
462
|
+
{ stderr }
|
|
463
|
+
)
|
|
464
|
+
);
|
|
465
|
+
}
|
|
466
|
+
});
|
|
467
|
+
child.on("close", (code) => {
|
|
468
|
+
clearTimeout(timer);
|
|
469
|
+
if (timedOut) {
|
|
470
|
+
reject(
|
|
471
|
+
new EngineError(
|
|
472
|
+
`tofu fmt timed out after ${opts.timeout}ms`,
|
|
473
|
+
"TOFU_FMT_FAILED" /* TOFU_FMT_FAILED */,
|
|
474
|
+
void 0,
|
|
475
|
+
{ timeout: opts.timeout }
|
|
476
|
+
)
|
|
477
|
+
);
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
if (code !== 0) {
|
|
481
|
+
reject(
|
|
482
|
+
new EngineError(
|
|
483
|
+
`tofu fmt failed: ${stderr || "Unknown error"}`,
|
|
484
|
+
"TOFU_FMT_FAILED" /* TOFU_FMT_FAILED */,
|
|
485
|
+
void 0,
|
|
486
|
+
{ stderr, exitCode: code }
|
|
487
|
+
)
|
|
488
|
+
);
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
resolve(normalizeBlankLines(stdout));
|
|
492
|
+
});
|
|
493
|
+
child.stdin.write(content);
|
|
494
|
+
child.stdin.end();
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
async function formatFiles(files, options) {
|
|
498
|
+
const opts = { ...DEFAULT_FORMAT_OPTIONS, ...options };
|
|
499
|
+
if (opts.skip) {
|
|
500
|
+
return files;
|
|
501
|
+
}
|
|
502
|
+
const results = /* @__PURE__ */ new Map();
|
|
503
|
+
for (const [path, content] of files) {
|
|
504
|
+
if (isHclFormattable(path)) {
|
|
505
|
+
try {
|
|
506
|
+
const formatted = await formatHCL(content, options);
|
|
507
|
+
results.set(path, formatted);
|
|
508
|
+
} catch (error) {
|
|
509
|
+
if (error instanceof EngineError) {
|
|
510
|
+
throw new EngineError(error.message, error.code, error.cause, {
|
|
511
|
+
...error.details,
|
|
512
|
+
file: path
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
throw error;
|
|
516
|
+
}
|
|
517
|
+
} else {
|
|
518
|
+
results.set(path, normalizeBlankLines(content));
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
return results;
|
|
522
|
+
}
|
|
523
|
+
async function isTofuAvailable() {
|
|
524
|
+
try {
|
|
525
|
+
await execFileAsync("tofu", ["version"], {
|
|
526
|
+
timeout: 5e3,
|
|
527
|
+
encoding: "utf-8"
|
|
528
|
+
});
|
|
529
|
+
return true;
|
|
530
|
+
} catch {
|
|
531
|
+
return false;
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// src/integrity/hasher.ts
|
|
536
|
+
var import_node_crypto = require("crypto");
|
|
537
|
+
|
|
538
|
+
// src/integrity/header.ts
|
|
539
|
+
var HEADER_BEGIN = "# @tfy-status:begin";
|
|
540
|
+
var HEADER_END = "# @tfy-status:end";
|
|
541
|
+
function parseHeader(content) {
|
|
542
|
+
const lines = content.split("\n");
|
|
543
|
+
if (lines[0] !== HEADER_BEGIN) {
|
|
544
|
+
return void 0;
|
|
545
|
+
}
|
|
546
|
+
if (lines[2] !== HEADER_END) {
|
|
547
|
+
return void 0;
|
|
548
|
+
}
|
|
549
|
+
const jsonLine = lines[1];
|
|
550
|
+
if (!jsonLine || !jsonLine.startsWith("# ")) {
|
|
551
|
+
return void 0;
|
|
552
|
+
}
|
|
553
|
+
const jsonStr = jsonLine.substring(2);
|
|
554
|
+
try {
|
|
555
|
+
const parsed = JSON.parse(jsonStr);
|
|
556
|
+
return {
|
|
557
|
+
managed: parsed["managed"],
|
|
558
|
+
source: parsed["source"],
|
|
559
|
+
version: parsed["version"],
|
|
560
|
+
intentId: parsed["intent_id"],
|
|
561
|
+
contentHash: parsed["content_hash"]
|
|
562
|
+
};
|
|
563
|
+
} catch {
|
|
564
|
+
return void 0;
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
function stripHeader(content) {
|
|
568
|
+
if (!content.startsWith(HEADER_BEGIN + "\n")) {
|
|
569
|
+
return content;
|
|
570
|
+
}
|
|
571
|
+
const endMarker = HEADER_END + "\n";
|
|
572
|
+
const endIndex = content.indexOf(endMarker);
|
|
573
|
+
if (endIndex === -1) {
|
|
574
|
+
return content;
|
|
575
|
+
}
|
|
576
|
+
let stripped = content.substring(endIndex + endMarker.length);
|
|
577
|
+
if (stripped.startsWith("\n")) {
|
|
578
|
+
stripped = stripped.substring(1);
|
|
579
|
+
}
|
|
580
|
+
return stripped;
|
|
581
|
+
}
|
|
582
|
+
function injectHeader(content, header) {
|
|
583
|
+
const jsonPayload = JSON.stringify({
|
|
584
|
+
managed: header.managed,
|
|
585
|
+
source: header.source,
|
|
586
|
+
version: header.version,
|
|
587
|
+
intent_id: header.intentId,
|
|
588
|
+
content_hash: header.contentHash
|
|
589
|
+
});
|
|
590
|
+
return `${HEADER_BEGIN}
|
|
591
|
+
# ${jsonPayload}
|
|
592
|
+
${HEADER_END}
|
|
593
|
+
|
|
594
|
+
${content}`;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// src/integrity/normalizer.ts
|
|
598
|
+
function normalizeForHashing(content) {
|
|
599
|
+
let normalized = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
600
|
+
normalized = normalized.split("\n").map((line) => line.replace(/[\t ]+$/, "")).join("\n");
|
|
601
|
+
normalized = normalized.replace(/\n*$/, "\n");
|
|
602
|
+
return normalized;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// src/integrity/zones.ts
|
|
606
|
+
var import_node_path = require("path");
|
|
607
|
+
var DEFAULT_PREFIX = "tfy_";
|
|
608
|
+
var MANIFEST_FILENAME = "manifest.json";
|
|
609
|
+
function classifyFile(filePath, prefix = DEFAULT_PREFIX) {
|
|
610
|
+
const name = (0, import_node_path.basename)(filePath.replace(/\\/g, "/"));
|
|
611
|
+
if (name === MANIFEST_FILENAME) {
|
|
612
|
+
return "platform";
|
|
613
|
+
}
|
|
614
|
+
if (name.startsWith(prefix)) {
|
|
615
|
+
return "platform";
|
|
616
|
+
}
|
|
617
|
+
return "user";
|
|
618
|
+
}
|
|
619
|
+
function countPlatformFiles(files) {
|
|
620
|
+
let count = 0;
|
|
621
|
+
for (const [, entry] of files) {
|
|
622
|
+
if (entry.zone === "platform") count++;
|
|
623
|
+
}
|
|
624
|
+
return count;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// src/integrity/hasher.ts
|
|
628
|
+
function sha256(content) {
|
|
629
|
+
const hex = (0, import_node_crypto.createHash)("sha256").update(content, "utf-8").digest("hex");
|
|
630
|
+
return `sha256:${hex}`;
|
|
631
|
+
}
|
|
632
|
+
function hashFileContent(content) {
|
|
633
|
+
const stripped = stripHeader(content);
|
|
634
|
+
const normalized = normalizeForHashing(stripped);
|
|
635
|
+
return sha256(normalized);
|
|
636
|
+
}
|
|
637
|
+
function computeAggregateHash(files) {
|
|
638
|
+
const entries = [];
|
|
639
|
+
for (const [path, entry] of files) {
|
|
640
|
+
if (entry.zone === "platform" && path !== MANIFEST_FILENAME) {
|
|
641
|
+
entries.push({
|
|
642
|
+
path,
|
|
643
|
+
hash: hashFileContent(entry.content)
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
entries.sort((a, b) => a.path.localeCompare(b.path));
|
|
648
|
+
const concatenated = entries.map((e) => `${e.path}:${e.hash}`).join("\n");
|
|
649
|
+
return sha256(concatenated);
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// src/render/manifest.ts
|
|
653
|
+
var ENGINE_VERSION = "0.0.0-canary.edab06d";
|
|
654
|
+
function generateManifest(opts) {
|
|
655
|
+
const { files, template, inputs, formatted, intentId, platformPrefix = "tfy_" } = opts;
|
|
656
|
+
const manifestFiles = [];
|
|
657
|
+
for (const [path, entry] of files) {
|
|
658
|
+
if (path === MANIFEST_FILENAME) continue;
|
|
659
|
+
manifestFiles.push({
|
|
660
|
+
path,
|
|
661
|
+
hash: hashFileContent(entry.content),
|
|
662
|
+
size: Buffer.byteLength(entry.content, "utf-8"),
|
|
663
|
+
source: template.source,
|
|
664
|
+
version: template.metadata.version,
|
|
665
|
+
zone: entry.zone
|
|
666
|
+
});
|
|
667
|
+
}
|
|
668
|
+
manifestFiles.sort((a, b) => a.path.localeCompare(b.path));
|
|
669
|
+
const aggregateHash = computeAggregateHash(files);
|
|
670
|
+
return {
|
|
671
|
+
manifestVersion: "1.0",
|
|
672
|
+
intentId,
|
|
673
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
674
|
+
templateSource: template.source,
|
|
675
|
+
templateVersion: template.metadata.version,
|
|
676
|
+
aggregateHash,
|
|
677
|
+
engine: {
|
|
678
|
+
version: ENGINE_VERSION,
|
|
679
|
+
formatted
|
|
680
|
+
},
|
|
681
|
+
inputs,
|
|
682
|
+
platformPrefix,
|
|
683
|
+
files: manifestFiles
|
|
684
|
+
};
|
|
685
|
+
}
|
|
686
|
+
function serializeManifest(manifest) {
|
|
687
|
+
const ordered = {
|
|
688
|
+
manifestVersion: manifest.manifestVersion,
|
|
689
|
+
intentId: manifest.intentId,
|
|
690
|
+
generatedAt: manifest.generatedAt,
|
|
691
|
+
templateSource: manifest.templateSource,
|
|
692
|
+
templateVersion: manifest.templateVersion,
|
|
693
|
+
aggregateHash: manifest.aggregateHash,
|
|
694
|
+
engine: manifest.engine,
|
|
695
|
+
inputs: manifest.inputs,
|
|
696
|
+
platformPrefix: manifest.platformPrefix,
|
|
697
|
+
files: manifest.files
|
|
698
|
+
};
|
|
699
|
+
return JSON.stringify(ordered, null, 2) + "\n";
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
// src/integrity/drift.ts
|
|
703
|
+
function buildDriftReport(currentFiles, manifest, prefix = DEFAULT_PREFIX, incomingSource) {
|
|
704
|
+
const entries = [];
|
|
705
|
+
const manifestPathSet = new Set(manifest.files.map((f) => f.path));
|
|
706
|
+
const checkedPaths = /* @__PURE__ */ new Set();
|
|
707
|
+
for (const manifestEntry of manifest.files) {
|
|
708
|
+
if (manifestEntry.zone !== "platform") continue;
|
|
709
|
+
checkedPaths.add(manifestEntry.path);
|
|
710
|
+
const currentEntry = currentFiles.get(manifestEntry.path);
|
|
711
|
+
if (!currentEntry) {
|
|
712
|
+
entries.push({
|
|
713
|
+
path: manifestEntry.path,
|
|
714
|
+
type: "missing_file",
|
|
715
|
+
details: "File listed in Manifest but not found in current file map"
|
|
716
|
+
});
|
|
717
|
+
continue;
|
|
718
|
+
}
|
|
719
|
+
const actualHash = hashFileContent(currentEntry.content);
|
|
720
|
+
if (actualHash !== manifestEntry.hash) {
|
|
721
|
+
entries.push({
|
|
722
|
+
path: manifestEntry.path,
|
|
723
|
+
type: "content_drift",
|
|
724
|
+
details: `Content hash mismatch: expected ${manifestEntry.hash}, actual ${actualHash}`
|
|
725
|
+
});
|
|
726
|
+
}
|
|
727
|
+
const header = currentEntry.header ?? parseHeader(currentEntry.content);
|
|
728
|
+
if (header) {
|
|
729
|
+
if (header.source !== manifestEntry.source) {
|
|
730
|
+
entries.push({
|
|
731
|
+
path: manifestEntry.path,
|
|
732
|
+
type: "metadata_inconsistency",
|
|
733
|
+
details: `Header source '${header.source}' does not match Manifest source '${manifestEntry.source}'`
|
|
734
|
+
});
|
|
735
|
+
}
|
|
736
|
+
if (header.version !== manifestEntry.version) {
|
|
737
|
+
entries.push({
|
|
738
|
+
path: manifestEntry.path,
|
|
739
|
+
type: "metadata_inconsistency",
|
|
740
|
+
details: `Header version '${header.version}' does not match Manifest version '${manifestEntry.version}'`
|
|
741
|
+
});
|
|
742
|
+
}
|
|
743
|
+
if (header.contentHash !== manifestEntry.hash) {
|
|
744
|
+
entries.push({
|
|
745
|
+
path: manifestEntry.path,
|
|
746
|
+
type: "metadata_inconsistency",
|
|
747
|
+
details: `Header content_hash '${header.contentHash}' does not match Manifest hash '${manifestEntry.hash}'`
|
|
748
|
+
});
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
for (const [path, entry] of currentFiles) {
|
|
753
|
+
if (entry.zone === "platform" || classifyFile(path, prefix) === "platform") {
|
|
754
|
+
if (!manifestPathSet.has(path) && path !== MANIFEST_FILENAME) {
|
|
755
|
+
entries.push({
|
|
756
|
+
path,
|
|
757
|
+
type: "unexpected_file",
|
|
758
|
+
details: "Platform-prefixed file found in current file map but not listed in Manifest"
|
|
759
|
+
});
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
if (incomingSource) {
|
|
764
|
+
for (const [path, entry] of currentFiles) {
|
|
765
|
+
if (entry.zone !== "platform") continue;
|
|
766
|
+
const header = entry.header ?? parseHeader(entry.content);
|
|
767
|
+
if (header && header.source !== incomingSource) {
|
|
768
|
+
entries.push({
|
|
769
|
+
path,
|
|
770
|
+
type: "source_mismatch",
|
|
771
|
+
details: `File source '${header.source}' does not match incoming source '${incomingSource}'`
|
|
772
|
+
});
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
const actualAggregateHash = computeAggregateHash(currentFiles);
|
|
777
|
+
const expectedAggregateHash = manifest.aggregateHash;
|
|
778
|
+
const summary = buildSummary(entries, manifest);
|
|
779
|
+
return {
|
|
780
|
+
valid: entries.length === 0,
|
|
781
|
+
aggregateHash: {
|
|
782
|
+
expected: expectedAggregateHash,
|
|
783
|
+
actual: actualAggregateHash
|
|
784
|
+
},
|
|
785
|
+
entries,
|
|
786
|
+
summary
|
|
787
|
+
};
|
|
788
|
+
}
|
|
789
|
+
function buildSummary(entries, manifest) {
|
|
790
|
+
const driftedPaths = /* @__PURE__ */ new Set();
|
|
791
|
+
const inconsistentPaths = /* @__PURE__ */ new Set();
|
|
792
|
+
let missingFiles = 0;
|
|
793
|
+
let unexpectedFiles = 0;
|
|
794
|
+
let sourceMismatches = 0;
|
|
795
|
+
for (const entry of entries) {
|
|
796
|
+
switch (entry.type) {
|
|
797
|
+
case "content_drift":
|
|
798
|
+
driftedPaths.add(entry.path);
|
|
799
|
+
break;
|
|
800
|
+
case "metadata_inconsistency":
|
|
801
|
+
inconsistentPaths.add(entry.path);
|
|
802
|
+
break;
|
|
803
|
+
case "missing_file":
|
|
804
|
+
missingFiles++;
|
|
805
|
+
break;
|
|
806
|
+
case "unexpected_file":
|
|
807
|
+
unexpectedFiles++;
|
|
808
|
+
break;
|
|
809
|
+
case "source_mismatch":
|
|
810
|
+
sourceMismatches++;
|
|
811
|
+
break;
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
return {
|
|
815
|
+
totalFiles: manifest.files.filter((f) => f.zone === "platform").length,
|
|
816
|
+
driftedFiles: driftedPaths.size,
|
|
817
|
+
inconsistentFiles: inconsistentPaths.size,
|
|
818
|
+
missingFiles,
|
|
819
|
+
unexpectedFiles,
|
|
820
|
+
sourceMismatches
|
|
821
|
+
};
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
// src/schema/envelope.ts
|
|
825
|
+
function fail(message) {
|
|
826
|
+
throw new EngineError(message, "ENVELOPE_VALIDATION_FAILED" /* ENVELOPE_VALIDATION_FAILED */);
|
|
827
|
+
}
|
|
828
|
+
function validateEnvelope(data) {
|
|
829
|
+
if (!data || typeof data !== "object" || Array.isArray(data)) {
|
|
830
|
+
fail("Envelope must be a non-null object");
|
|
831
|
+
}
|
|
832
|
+
const record = data;
|
|
833
|
+
const knownFields = /* @__PURE__ */ new Set(["template", "inputs", "options", "intentId", "platformPrefix"]);
|
|
834
|
+
for (const key of Object.keys(record)) {
|
|
835
|
+
if (!knownFields.has(key)) {
|
|
836
|
+
fail(`Unknown field "${key}" in envelope. Allowed: ${[...knownFields].join(", ")}`);
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
validateTemplateObject(record["template"]);
|
|
840
|
+
if (typeof record["intentId"] !== "string" || record["intentId"].length === 0) {
|
|
841
|
+
fail("intentId is required and must be non-empty");
|
|
842
|
+
}
|
|
843
|
+
const inputs = record["inputs"] !== void 0 ? record["inputs"] : {};
|
|
844
|
+
if (typeof inputs !== "object" || inputs === null || Array.isArray(inputs)) {
|
|
845
|
+
fail("inputs must be an object");
|
|
846
|
+
}
|
|
847
|
+
const platformPrefix = record["platformPrefix"] !== void 0 ? record["platformPrefix"] : "tfy_";
|
|
848
|
+
if (typeof platformPrefix !== "string") {
|
|
849
|
+
fail("platformPrefix must be a string");
|
|
850
|
+
}
|
|
851
|
+
let options;
|
|
852
|
+
if (record["options"] !== void 0) {
|
|
853
|
+
options = validateRenderOptions(record["options"]);
|
|
854
|
+
}
|
|
855
|
+
return {
|
|
856
|
+
template: record["template"],
|
|
857
|
+
inputs,
|
|
858
|
+
options,
|
|
859
|
+
intentId: record["intentId"],
|
|
860
|
+
platformPrefix
|
|
861
|
+
};
|
|
862
|
+
}
|
|
863
|
+
function parseRenderOptions(options) {
|
|
864
|
+
return {
|
|
865
|
+
skipFormat: options?.skipFormat ?? false
|
|
866
|
+
};
|
|
867
|
+
}
|
|
868
|
+
function validateTemplateObject(value) {
|
|
869
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
870
|
+
fail("template must be a non-null object");
|
|
871
|
+
}
|
|
872
|
+
const template = value;
|
|
873
|
+
if (!template["metadata"] || typeof template["metadata"] !== "object") {
|
|
874
|
+
fail("template.metadata must be an object");
|
|
875
|
+
}
|
|
876
|
+
const metadata = template["metadata"];
|
|
877
|
+
if (typeof metadata["name"] !== "string" || metadata["name"].length === 0) {
|
|
878
|
+
fail("template.metadata.name must be a non-empty string");
|
|
879
|
+
}
|
|
880
|
+
if (typeof metadata["version"] !== "string" || !/^\d+\.\d+\.\d+$/.test(metadata["version"])) {
|
|
881
|
+
fail("template.metadata.version must be semver format (x.y.z)");
|
|
882
|
+
}
|
|
883
|
+
if (!template["jsonSchema"] || typeof template["jsonSchema"] !== "object") {
|
|
884
|
+
fail("template.jsonSchema must be an object");
|
|
885
|
+
}
|
|
886
|
+
const hasFiles = template["files"] instanceof Map && template["files"].size > 0 || template["staticFiles"] instanceof Map && template["staticFiles"].size > 0;
|
|
887
|
+
if (!hasFiles) {
|
|
888
|
+
fail("Template must have at least one file (in files or staticFiles)");
|
|
889
|
+
}
|
|
890
|
+
if (template["files"] !== void 0 && !(template["files"] instanceof Map)) {
|
|
891
|
+
fail("template.files must be a Map");
|
|
892
|
+
}
|
|
893
|
+
if (template["staticFiles"] !== void 0 && !(template["staticFiles"] instanceof Map)) {
|
|
894
|
+
fail("template.staticFiles must be a Map");
|
|
895
|
+
}
|
|
896
|
+
if (typeof template["source"] !== "string" || template["source"].length === 0) {
|
|
897
|
+
fail("Template source URI is required");
|
|
898
|
+
}
|
|
899
|
+
if (!template["version"] || typeof template["version"] !== "object") {
|
|
900
|
+
fail("Template version info is required");
|
|
901
|
+
}
|
|
902
|
+
const version = template["version"];
|
|
903
|
+
if (typeof version["semver"] !== "string" || !/^\d+\.\d+\.\d+$/.test(version["semver"])) {
|
|
904
|
+
fail("template.version.semver must be semver format (x.y.z)");
|
|
905
|
+
}
|
|
906
|
+
if (version["apiVersion"] !== void 0 && typeof version["apiVersion"] !== "string") {
|
|
907
|
+
fail("template.version.apiVersion must be a string if provided");
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
function validateRenderOptions(value) {
|
|
911
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
912
|
+
fail("options must be an object");
|
|
913
|
+
}
|
|
914
|
+
const opts = value;
|
|
915
|
+
const knownOptionFields = /* @__PURE__ */ new Set(["skipFormat"]);
|
|
916
|
+
for (const key of Object.keys(opts)) {
|
|
917
|
+
if (!knownOptionFields.has(key)) {
|
|
918
|
+
fail(`Unknown option field "${key}". Allowed: ${[...knownOptionFields].join(", ")}`);
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
if (opts["skipFormat"] !== void 0 && typeof opts["skipFormat"] !== "boolean") {
|
|
922
|
+
fail("options.skipFormat must be a boolean");
|
|
923
|
+
}
|
|
924
|
+
return {
|
|
925
|
+
skipFormat: opts["skipFormat"]
|
|
926
|
+
};
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
// src/engine.ts
|
|
930
|
+
var HclEngineImpl = class {
|
|
931
|
+
/**
|
|
932
|
+
* Install: Generate all files for a new cluster.
|
|
933
|
+
*
|
|
934
|
+
* Pipeline: validate envelope → validate inputs (AJV) → render templates →
|
|
935
|
+
* format .tf files → classify by zone → hash + inject headers → generate manifest.
|
|
936
|
+
*/
|
|
937
|
+
async install(envelope) {
|
|
938
|
+
const validatedEnvelope = this.validateEnvelopeOrThrow(envelope);
|
|
939
|
+
const options = parseRenderOptions(validatedEnvelope.options);
|
|
940
|
+
const prefix = validatedEnvelope.platformPrefix ?? DEFAULT_PREFIX;
|
|
941
|
+
const { renderedFiles, template, formatted, inputsWithDefaults } = await this.renderPipeline(
|
|
942
|
+
validatedEnvelope,
|
|
943
|
+
options
|
|
944
|
+
);
|
|
945
|
+
const { fileMap, manifest } = this.buildFilesAndManifest(
|
|
946
|
+
renderedFiles,
|
|
947
|
+
validatedEnvelope,
|
|
948
|
+
template,
|
|
949
|
+
inputsWithDefaults,
|
|
950
|
+
formatted,
|
|
951
|
+
prefix
|
|
952
|
+
);
|
|
953
|
+
return { files: fileMap, manifest };
|
|
954
|
+
}
|
|
955
|
+
/**
|
|
956
|
+
* Upgrade: Update an existing cluster to new templates.
|
|
957
|
+
*
|
|
958
|
+
* Pipeline: validate envelope → drift detection (FR-028) → if source mismatch,
|
|
959
|
+
* short-circuit → validate inputs → render → format → hash + manifest.
|
|
960
|
+
*/
|
|
961
|
+
async upgrade(envelope, currentFiles, previousManifest) {
|
|
962
|
+
const validatedEnvelope = this.validateEnvelopeOrThrow(envelope);
|
|
963
|
+
const options = parseRenderOptions(validatedEnvelope.options);
|
|
964
|
+
const prefix = validatedEnvelope.platformPrefix ?? DEFAULT_PREFIX;
|
|
965
|
+
const incomingSource = validatedEnvelope.template.source;
|
|
966
|
+
const driftReport = buildDriftReport(currentFiles, previousManifest, prefix, incomingSource);
|
|
967
|
+
const sourceBlocked = driftReport.summary.sourceMismatches > 0;
|
|
968
|
+
if (sourceBlocked) {
|
|
969
|
+
return {
|
|
970
|
+
files: currentFiles,
|
|
971
|
+
manifest: previousManifest,
|
|
972
|
+
driftReport,
|
|
973
|
+
sourceBlocked: true
|
|
974
|
+
};
|
|
975
|
+
}
|
|
976
|
+
const { renderedFiles, template, formatted, inputsWithDefaults } = await this.renderPipeline(
|
|
977
|
+
validatedEnvelope,
|
|
978
|
+
options
|
|
979
|
+
);
|
|
980
|
+
const { fileMap, manifest } = this.buildFilesAndManifest(
|
|
981
|
+
renderedFiles,
|
|
982
|
+
validatedEnvelope,
|
|
983
|
+
template,
|
|
984
|
+
inputsWithDefaults,
|
|
985
|
+
formatted,
|
|
986
|
+
prefix
|
|
987
|
+
);
|
|
988
|
+
return { files: fileMap, manifest, driftReport, sourceBlocked: false };
|
|
989
|
+
}
|
|
990
|
+
/**
|
|
991
|
+
* Verify: Check integrity of existing files against a Manifest.
|
|
992
|
+
*/
|
|
993
|
+
verify(currentFiles, previousManifest) {
|
|
994
|
+
if (!previousManifest || !previousManifest.manifestVersion || !previousManifest.files || !previousManifest.aggregateHash) {
|
|
995
|
+
throw new EngineError(
|
|
996
|
+
"Invalid manifest: missing required fields (manifestVersion, files, aggregateHash)",
|
|
997
|
+
"MANIFEST_PARSE_ERROR" /* MANIFEST_PARSE_ERROR */
|
|
998
|
+
);
|
|
999
|
+
}
|
|
1000
|
+
const driftReport = buildDriftReport(currentFiles, previousManifest);
|
|
1001
|
+
return Promise.resolve({ driftReport });
|
|
1002
|
+
}
|
|
1003
|
+
/**
|
|
1004
|
+
* Hash-Only: Calculate expected aggregate hash without full file generation.
|
|
1005
|
+
*/
|
|
1006
|
+
async hashOnly(envelope) {
|
|
1007
|
+
const validatedEnvelope = this.validateEnvelopeOrThrow(envelope);
|
|
1008
|
+
const options = parseRenderOptions(validatedEnvelope.options);
|
|
1009
|
+
const prefix = validatedEnvelope.platformPrefix ?? DEFAULT_PREFIX;
|
|
1010
|
+
const { renderedFiles, template, formatted, inputsWithDefaults } = await this.renderPipeline(
|
|
1011
|
+
validatedEnvelope,
|
|
1012
|
+
options
|
|
1013
|
+
);
|
|
1014
|
+
const { fileMap, manifest } = this.buildFilesAndManifest(
|
|
1015
|
+
renderedFiles,
|
|
1016
|
+
validatedEnvelope,
|
|
1017
|
+
template,
|
|
1018
|
+
inputsWithDefaults,
|
|
1019
|
+
formatted,
|
|
1020
|
+
prefix
|
|
1021
|
+
);
|
|
1022
|
+
return {
|
|
1023
|
+
aggregateHash: manifest.aggregateHash,
|
|
1024
|
+
templateSource: template.source,
|
|
1025
|
+
templateVersion: template.metadata.version,
|
|
1026
|
+
fileCount: countPlatformFiles(fileMap)
|
|
1027
|
+
};
|
|
1028
|
+
}
|
|
1029
|
+
// =========================================================================
|
|
1030
|
+
// Private helpers
|
|
1031
|
+
// =========================================================================
|
|
1032
|
+
/**
|
|
1033
|
+
* Validate envelope or throw ENVELOPE_VALIDATION_FAILED.
|
|
1034
|
+
*/
|
|
1035
|
+
validateEnvelopeOrThrow(envelope) {
|
|
1036
|
+
try {
|
|
1037
|
+
return validateEnvelope(envelope);
|
|
1038
|
+
} catch (error) {
|
|
1039
|
+
throw new EngineError(
|
|
1040
|
+
`Envelope validation failed: ${error.message}`,
|
|
1041
|
+
"ENVELOPE_VALIDATION_FAILED" /* ENVELOPE_VALIDATION_FAILED */,
|
|
1042
|
+
error
|
|
1043
|
+
);
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
/**
|
|
1047
|
+
* Shared render pipeline: validate schema → validate inputs → render → format.
|
|
1048
|
+
*/
|
|
1049
|
+
async renderPipeline(validatedEnvelope, options) {
|
|
1050
|
+
const template = validatedEnvelope.template;
|
|
1051
|
+
validateJsonSchemaStructure(template.jsonSchema);
|
|
1052
|
+
const validator = createInputValidator(template.jsonSchema);
|
|
1053
|
+
const inputsWithDefaults = structuredClone(validatedEnvelope.inputs);
|
|
1054
|
+
const validation = validateAndApplyDefaults(validator, inputsWithDefaults);
|
|
1055
|
+
if (!validation.valid) {
|
|
1056
|
+
throw new EngineError(
|
|
1057
|
+
`Input validation failed: ${validation.errors?.map((e) => `${e.path}: ${e.message}`).join(", ")}`,
|
|
1058
|
+
"INPUT_VALIDATION_FAILED" /* INPUT_VALIDATION_FAILED */,
|
|
1059
|
+
void 0,
|
|
1060
|
+
{ errors: validation.errors }
|
|
1061
|
+
);
|
|
1062
|
+
}
|
|
1063
|
+
let renderedFiles = renderTemplate(template, inputsWithDefaults);
|
|
1064
|
+
if (template.staticFiles) {
|
|
1065
|
+
for (const [path, content] of template.staticFiles) {
|
|
1066
|
+
renderedFiles.set(path, content);
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
const formatted = !options.skipFormat;
|
|
1070
|
+
if (formatted) {
|
|
1071
|
+
renderedFiles = await formatFiles(renderedFiles, {
|
|
1072
|
+
skip: false,
|
|
1073
|
+
timeout: 1e4
|
|
1074
|
+
});
|
|
1075
|
+
}
|
|
1076
|
+
for (const [path, content] of renderedFiles) {
|
|
1077
|
+
if (content.trim() === "") {
|
|
1078
|
+
renderedFiles.delete(path);
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
return { renderedFiles, template, formatted, inputsWithDefaults };
|
|
1082
|
+
}
|
|
1083
|
+
/**
|
|
1084
|
+
* Build a zone-tagged FileEntry map from raw rendered files.
|
|
1085
|
+
*/
|
|
1086
|
+
buildFileMap(renderedFiles, prefix, meta) {
|
|
1087
|
+
const fileMap = /* @__PURE__ */ new Map();
|
|
1088
|
+
for (const [path, content] of renderedFiles) {
|
|
1089
|
+
const zone = classifyFile(path, prefix);
|
|
1090
|
+
if (zone === "platform") {
|
|
1091
|
+
const contentHash = hashFileContent(content);
|
|
1092
|
+
const header = {
|
|
1093
|
+
managed: true,
|
|
1094
|
+
source: meta.source,
|
|
1095
|
+
version: meta.version,
|
|
1096
|
+
intentId: meta.intentId,
|
|
1097
|
+
contentHash
|
|
1098
|
+
};
|
|
1099
|
+
const contentWithHeader = injectHeader(content, header);
|
|
1100
|
+
fileMap.set(path, {
|
|
1101
|
+
content: contentWithHeader,
|
|
1102
|
+
zone: "platform",
|
|
1103
|
+
header
|
|
1104
|
+
});
|
|
1105
|
+
} else {
|
|
1106
|
+
fileMap.set(path, {
|
|
1107
|
+
content,
|
|
1108
|
+
zone: "user"
|
|
1109
|
+
});
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
return fileMap;
|
|
1113
|
+
}
|
|
1114
|
+
/**
|
|
1115
|
+
* Build zone-tagged file map, generate manifest, and insert it into the map.
|
|
1116
|
+
* Shared by install, upgrade, and hashOnly.
|
|
1117
|
+
*/
|
|
1118
|
+
buildFilesAndManifest(renderedFiles, validatedEnvelope, template, inputsWithDefaults, formatted, prefix) {
|
|
1119
|
+
const fileMap = this.buildFileMap(renderedFiles, prefix, {
|
|
1120
|
+
source: template.source,
|
|
1121
|
+
version: template.metadata.version,
|
|
1122
|
+
intentId: validatedEnvelope.intentId
|
|
1123
|
+
});
|
|
1124
|
+
const manifest = generateManifest({
|
|
1125
|
+
files: fileMap,
|
|
1126
|
+
template,
|
|
1127
|
+
inputs: inputsWithDefaults,
|
|
1128
|
+
formatted,
|
|
1129
|
+
intentId: validatedEnvelope.intentId,
|
|
1130
|
+
platformPrefix: prefix
|
|
1131
|
+
});
|
|
1132
|
+
fileMap.set(MANIFEST_FILENAME, {
|
|
1133
|
+
content: serializeManifest(manifest),
|
|
1134
|
+
zone: "platform"
|
|
1135
|
+
});
|
|
1136
|
+
return { fileMap, manifest };
|
|
1137
|
+
}
|
|
1138
|
+
};
|
|
1139
|
+
function createEngine() {
|
|
1140
|
+
return new HclEngineImpl();
|
|
1141
|
+
}
|
|
1142
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
1143
|
+
0 && (module.exports = {
|
|
1144
|
+
DEFAULT_PREFIX,
|
|
1145
|
+
EngineError,
|
|
1146
|
+
EngineErrorCode,
|
|
1147
|
+
classifyFile,
|
|
1148
|
+
createEngine,
|
|
1149
|
+
isTofuAvailable,
|
|
1150
|
+
parseHeader,
|
|
1151
|
+
templateJsonSchema,
|
|
1152
|
+
validateJsonSchemaStructure,
|
|
1153
|
+
validateTemplateJson
|
|
1154
|
+
});
|
|
1155
|
+
//# sourceMappingURL=index.js.map
|