@vertile-ai/iac 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,297 @@
1
+ #!/usr/bin/env node
2
+
3
+ import fs from 'node:fs'
4
+ import path from 'node:path'
5
+ import process from 'node:process'
6
+ import { resolvePlatformContext } from './core/context.mjs'
7
+ import { readManifest } from './core/manifest.mjs'
8
+ import { readOption } from './shared.mjs'
9
+
10
+ const defaultVariants = {
11
+ local: { output: '.env.local', sources: ['.env.local'] },
12
+ production: { output: '.env.production', sources: ['.env.production'], strict: true },
13
+ staging: { output: '.env.staging', sources: ['.env.staging'], strict: true },
14
+ preview: { output: '.env.staging', sources: ['.env.staging'], strict: true },
15
+ test: { output: '.env.test', sources: ['.env.test'] },
16
+ }
17
+
18
+ function hasFlag(argv, flag) {
19
+ return argv.includes(flag)
20
+ }
21
+
22
+ function splitList(value) {
23
+ return value
24
+ .split(',')
25
+ .map((item) => item.trim())
26
+ .filter(Boolean)
27
+ }
28
+
29
+ function selectedVariantNames(argv) {
30
+ const value = readOption(argv, '--variants')
31
+ if (!value) return ['local', 'production', 'staging', 'test']
32
+
33
+ const names = splitList(value)
34
+ const invalid = names.filter((name) => !defaultVariants[name])
35
+ if (invalid.length > 0) {
36
+ throw new Error(
37
+ `Invalid --variants values: ${invalid.join(', ')}. Supported: ${Object.keys(defaultVariants).join(',')}`,
38
+ )
39
+ }
40
+ return names
41
+ }
42
+
43
+ function envSourceDir(manifest) {
44
+ return (
45
+ manifest.env.sourceDir ||
46
+ manifest.env.dir ||
47
+ manifest.env.infraDir ||
48
+ manifest.infraDir ||
49
+ 'infrastructure'
50
+ )
51
+ }
52
+
53
+ function parseEnvLine(line) {
54
+ if (!line) return null
55
+ const trimmed = line.trim()
56
+ if (!trimmed || trimmed.startsWith('#')) return null
57
+
58
+ const eqIndex = line.indexOf('=')
59
+ if (eqIndex <= 0) return null
60
+
61
+ const key = line.slice(0, eqIndex).trim()
62
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) return null
63
+
64
+ return { key, value: line.slice(eqIndex + 1) }
65
+ }
66
+
67
+ function readEnvFile(filePath) {
68
+ if (!fs.existsSync(filePath)) return []
69
+ return fs.readFileSync(filePath, 'utf8').split(/\r?\n/).map(parseEnvLine).filter(Boolean)
70
+ }
71
+
72
+ function mergeLayers(layers) {
73
+ const order = []
74
+ const values = new Map()
75
+
76
+ for (const layer of layers) {
77
+ for (const { key, value } of layer) {
78
+ if (!values.has(key)) order.push(key)
79
+ values.set(key, value)
80
+ }
81
+ }
82
+
83
+ return order.map((key) => ({ key, value: values.get(key) }))
84
+ }
85
+
86
+ function entriesToLines(entries) {
87
+ return entries.map(({ key, value }) => `${key}=${value}`)
88
+ }
89
+
90
+ function resolveLayer({ rootDir, baseDir, variant }) {
91
+ const examplePath = path.join(baseDir, '.env.example')
92
+ const sourcePath = path.join(baseDir, variant.sources[0])
93
+
94
+ if (variant.strict) {
95
+ if (!fs.existsSync(sourcePath)) {
96
+ throw new Error(
97
+ `Missing required infrastructure env source file: ${path.relative(rootDir, sourcePath)}`,
98
+ )
99
+ }
100
+ return {
101
+ entries: readEnvFile(sourcePath),
102
+ sourceLabel: path.relative(rootDir, sourcePath),
103
+ }
104
+ }
105
+
106
+ if (!fs.existsSync(examplePath) && !fs.existsSync(sourcePath)) {
107
+ throw new Error(
108
+ `Missing required infrastructure env source files: ${path.relative(rootDir, sourcePath)} or ${path.relative(rootDir, examplePath)}`,
109
+ )
110
+ }
111
+
112
+ const layers = []
113
+ const labels = []
114
+ if (fs.existsSync(examplePath)) {
115
+ layers.push(readEnvFile(examplePath))
116
+ labels.push(path.relative(rootDir, examplePath))
117
+ }
118
+ if (fs.existsSync(sourcePath)) {
119
+ layers.push(readEnvFile(sourcePath))
120
+ labels.push(path.relative(rootDir, sourcePath))
121
+ }
122
+
123
+ return {
124
+ entries: mergeLayers(layers),
125
+ sourceLabel: labels.join(' + '),
126
+ }
127
+ }
128
+
129
+ function appSharedPrefix(app) {
130
+ return app.env?.sharedPrefix || app.providers?.vercel?.env?.sharedPrefix || ''
131
+ }
132
+
133
+ function projectSharedLayer(sharedLayer, app, sharedPrefixes = []) {
134
+ const prefix = appSharedPrefix(app)
135
+ if (!prefix) {
136
+ return sharedLayer.filter(
137
+ ({ key }) => !sharedPrefixes.some((item) => key.startsWith(item)),
138
+ )
139
+ }
140
+
141
+ return sharedLayer
142
+ .filter(({ key }) => key.startsWith(prefix))
143
+ .map(({ key, value }) => ({ key: key.slice(prefix.length), value }))
144
+ .filter(({ key }) => key)
145
+ }
146
+
147
+ function assertNoSharedOverrides(sharedLayer, packageLayer, appKey) {
148
+ const sharedKeys = new Set(sharedLayer.map(({ key }) => key))
149
+ const overlaps = packageLayer
150
+ .map(({ key }) => key)
151
+ .filter((key, index, keys) => sharedKeys.has(key) && keys.indexOf(key) === index)
152
+
153
+ if (overlaps.length === 0) return
154
+
155
+ throw new Error(
156
+ `Detected shared env key overrides for app "${appKey}": ${overlaps.join(', ')}`,
157
+ )
158
+ }
159
+
160
+ function linesToEnvMap(lines) {
161
+ const values = new Map()
162
+ for (const line of lines) {
163
+ const parsed = parseEnvLine(line)
164
+ if (parsed) values.set(parsed.key, parsed.value)
165
+ }
166
+ return values
167
+ }
168
+
169
+ function diffEnvMaps(before, after) {
170
+ const changes = []
171
+ const orderedKeys = [...after.keys(), ...before.keys()]
172
+ const seen = new Set()
173
+
174
+ for (const key of orderedKeys) {
175
+ if (seen.has(key)) continue
176
+ seen.add(key)
177
+
178
+ if (!before.has(key) && after.has(key)) changes.push({ type: 'added', key })
179
+ else if (before.has(key) && !after.has(key)) changes.push({ type: 'removed', key })
180
+ else if (before.get(key) !== after.get(key)) changes.push({ type: 'updated', key })
181
+ }
182
+
183
+ return changes
184
+ }
185
+
186
+ function syncApps(manifest) {
187
+ const configured = manifest.env.sync?.apps
188
+ if (Array.isArray(configured) && configured.length > 0) {
189
+ const keys = new Set(configured)
190
+ return manifest.apps.filter((app) => keys.has(app.key))
191
+ }
192
+ return manifest.apps
193
+ }
194
+
195
+ function appOutputDir(rootDir, app) {
196
+ const configured = app.env?.outputDir || app.outputDir || app.rootDirectory
197
+ if (!configured) {
198
+ throw new Error(`Missing rootDirectory or env.outputDir for app "${app.key}"`)
199
+ }
200
+ return path.isAbsolute(configured) ? configured : path.join(rootDir, configured)
201
+ }
202
+
203
+ function appSourceKey(app) {
204
+ return app.env?.sourceKey || app.sourceKey || app.key
205
+ }
206
+
207
+ function shouldSkipSync(manifest) {
208
+ if (manifest.env.sync?.skipInVercel === false) return false
209
+ return (
210
+ process.env.SKIP_INFRA_ENV_SYNC === '1' ||
211
+ process.env.VERCEL === '1' ||
212
+ Boolean(process.env.VERCEL_ENV) ||
213
+ Boolean(process.env.VERCEL_TARGET_ENV)
214
+ )
215
+ }
216
+
217
+ async function main() {
218
+ const argv = process.argv.slice(2)
219
+ const context = resolvePlatformContext(argv)
220
+ const manifest = readManifest(context.manifestPath)
221
+ const dryRun = hasFlag(argv, '--dry-run')
222
+ const variants = selectedVariantNames(argv).map((name) => ({
223
+ name,
224
+ ...defaultVariants[name],
225
+ }))
226
+
227
+ if (shouldSkipSync(manifest)) {
228
+ console.log('Skipping infrastructure env sync; using provisioned environment variables.')
229
+ return
230
+ }
231
+
232
+ const sourceRoot = path.join(context.repoRoot, envSourceDir(manifest))
233
+ const sharedKey = manifest.env.sync?.sharedKey || manifest.env.sharedKey || 'shared'
234
+ const apps = syncApps(manifest)
235
+ const sharedPrefixes = apps.map(appSharedPrefix).filter(Boolean)
236
+
237
+ for (const app of apps) {
238
+ for (const variant of variants) {
239
+ const shared = resolveLayer({
240
+ rootDir: context.repoRoot,
241
+ baseDir: path.join(sourceRoot, sharedKey),
242
+ variant,
243
+ })
244
+ const scoped = resolveLayer({
245
+ rootDir: context.repoRoot,
246
+ baseDir: path.join(sourceRoot, appSourceKey(app)),
247
+ variant,
248
+ })
249
+
250
+ const sharedEntries = projectSharedLayer(shared.entries, app, sharedPrefixes)
251
+ assertNoSharedOverrides(sharedEntries, scoped.entries, app.key)
252
+
253
+ const merged = mergeLayers([sharedEntries, scoped.entries])
254
+ const mergedLines = entriesToLines(merged)
255
+ const outputPath = path.join(appOutputDir(context.repoRoot, app), variant.output)
256
+ const before = fs.existsSync(outputPath) ? fs.readFileSync(outputPath, 'utf8') : ''
257
+ const content = [
258
+ '# AUTO-GENERATED FILE. DO NOT EDIT DIRECTLY.',
259
+ `# Source: ${shared.sourceLabel} + ${scoped.sourceLabel}`,
260
+ '',
261
+ ...mergedLines,
262
+ '',
263
+ ].join('\n')
264
+
265
+ if (before === content) continue
266
+
267
+ const beforeEnv = linesToEnvMap(before.split(/\r?\n/))
268
+ const afterEnv = linesToEnvMap(mergedLines)
269
+ const changes = diffEnvMaps(beforeEnv, afterEnv)
270
+
271
+ if (!dryRun) {
272
+ fs.mkdirSync(path.dirname(outputPath), { recursive: true })
273
+ fs.writeFileSync(outputPath, content, 'utf8')
274
+ }
275
+
276
+ for (const change of changes) {
277
+ const verb = dryRun
278
+ ? change.type === 'added'
279
+ ? 'Would add'
280
+ : change.type === 'removed'
281
+ ? 'Would remove'
282
+ : 'Would update'
283
+ : change.type === 'added'
284
+ ? 'Added'
285
+ : change.type === 'removed'
286
+ ? 'Removed'
287
+ : 'Updated'
288
+ console.log(`${verb} ${path.relative(context.repoRoot, outputPath)} ${change.key}`)
289
+ }
290
+ }
291
+ }
292
+ }
293
+
294
+ main().catch((error) => {
295
+ console.error('Error:', error instanceof Error ? error.message : String(error))
296
+ process.exit(1)
297
+ })