@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.
- package/README.md +255 -0
- package/docs/README.md +40 -0
- package/docs/index.html +276 -0
- package/docs/manifest.md +130 -0
- package/docs/positioning.md +65 -0
- package/docs/roadmap.md +77 -0
- package/examples/dynomic/README.md +22 -0
- package/examples/dynomic/apps/admin/package.json +7 -0
- package/examples/dynomic/apps/admin/vercel.json +4 -0
- package/examples/dynomic/apps/web/package.json +7 -0
- package/examples/dynomic/apps/web/vercel.json +4 -0
- package/examples/dynomic/infrastructure/iac/iac.json +191 -0
- package/examples/dynomic/package.json +17 -0
- package/package.json +51 -0
- package/schema/iac.schema.json +266 -0
- package/src/apply.mjs +38 -0
- package/src/cli.mjs +74 -0
- package/src/core/args.mjs +36 -0
- package/src/core/concepts.mjs +26 -0
- package/src/core/context.mjs +39 -0
- package/src/core/hcl.mjs +110 -0
- package/src/core/manifest.mjs +110 -0
- package/src/core/render.mjs +38 -0
- package/src/core/terraform.mjs +42 -0
- package/src/core/vercel-manifests.mjs +179 -0
- package/src/plan.mjs +32 -0
- package/src/providers/aws/index.mjs +153 -0
- package/src/providers/digitalocean/index.mjs +85 -0
- package/src/providers/vercel/index.mjs +60 -0
- package/src/provision-env.mjs +798 -0
- package/src/reconcile-project-domains.mjs +549 -0
- package/src/reconcile-project-settings.mjs +385 -0
- package/src/render.mjs +27 -0
- package/src/shared.mjs +116 -0
- package/src/sync-env.mjs +297 -0
package/src/sync-env.mjs
ADDED
|
@@ -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
|
+
})
|