@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/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