@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,266 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://vertile.ai/schemas/iac.schema.json",
4
+ "title": "Vertile AI IaC Manifest",
5
+ "description": "Portable app infrastructure intent for Vertile AI IaC.",
6
+ "type": "object",
7
+ "required": ["version", "project", "providers", "apps"],
8
+ "additionalProperties": true,
9
+ "properties": {
10
+ "$schema": {
11
+ "type": "string"
12
+ },
13
+ "version": {
14
+ "const": 1
15
+ },
16
+ "project": {
17
+ "oneOf": [
18
+ { "type": "string", "minLength": 1 },
19
+ {
20
+ "type": "object",
21
+ "required": ["name"],
22
+ "additionalProperties": true,
23
+ "properties": {
24
+ "key": { "type": "string", "minLength": 1 },
25
+ "name": { "type": "string", "minLength": 1 }
26
+ }
27
+ }
28
+ ]
29
+ },
30
+ "environments": {
31
+ "type": "array",
32
+ "items": { "type": "string", "minLength": 1 },
33
+ "uniqueItems": true,
34
+ "default": ["development", "preview", "production"]
35
+ },
36
+ "providers": {
37
+ "type": "object",
38
+ "additionalProperties": { "$ref": "#/$defs/providerConfig" },
39
+ "properties": {
40
+ "vercel": {
41
+ "allOf": [
42
+ { "$ref": "#/$defs/providerConfig" },
43
+ {
44
+ "type": "object",
45
+ "properties": {
46
+ "team": { "type": "string" },
47
+ "teamId": { "type": "string" },
48
+ "teamSlug": { "type": "string" },
49
+ "projectDefaults": { "$ref": "#/$defs/vercelProjectSettings" },
50
+ "projectSettingsDefaults": { "$ref": "#/$defs/vercelProjectSettings" }
51
+ }
52
+ }
53
+ ]
54
+ },
55
+ "aws": {
56
+ "allOf": [
57
+ { "$ref": "#/$defs/providerConfig" },
58
+ {
59
+ "type": "object",
60
+ "properties": {
61
+ "region": { "type": "string" },
62
+ "defaultTags": {
63
+ "type": "object",
64
+ "additionalProperties": { "type": ["string", "number", "boolean"] }
65
+ }
66
+ }
67
+ }
68
+ ]
69
+ },
70
+ "digitalocean": {
71
+ "allOf": [
72
+ { "$ref": "#/$defs/providerConfig" },
73
+ {
74
+ "type": "object",
75
+ "properties": {
76
+ "region": { "type": "string" },
77
+ "version": { "type": "string" }
78
+ }
79
+ }
80
+ ]
81
+ }
82
+ }
83
+ },
84
+ "env": {
85
+ "type": "object",
86
+ "additionalProperties": true,
87
+ "properties": {
88
+ "sourceDir": { "type": "string" },
89
+ "dir": { "type": "string" },
90
+ "infraDir": { "type": "string" },
91
+ "sharedKey": { "type": "string" },
92
+ "sync": {
93
+ "type": "object",
94
+ "additionalProperties": true,
95
+ "properties": {
96
+ "apps": {
97
+ "type": "array",
98
+ "items": { "type": "string", "minLength": 1 },
99
+ "uniqueItems": true
100
+ },
101
+ "sharedKey": { "type": "string" },
102
+ "skipInVercel": { "type": "boolean" }
103
+ }
104
+ }
105
+ }
106
+ },
107
+ "apps": {
108
+ "type": "array",
109
+ "items": { "$ref": "#/$defs/app" }
110
+ },
111
+ "domains": {
112
+ "type": "array",
113
+ "items": { "$ref": "#/$defs/domain" }
114
+ },
115
+ "objectStorage": {
116
+ "type": "array",
117
+ "items": { "$ref": "#/$defs/keyedResource" }
118
+ },
119
+ "databases": {
120
+ "type": "array",
121
+ "items": {
122
+ "allOf": [
123
+ { "$ref": "#/$defs/keyedResource" },
124
+ {
125
+ "type": "object",
126
+ "properties": {
127
+ "engine": { "type": "string" }
128
+ }
129
+ }
130
+ ]
131
+ }
132
+ },
133
+ "queues": {
134
+ "type": "array",
135
+ "items": {
136
+ "allOf": [
137
+ { "$ref": "#/$defs/keyedResource" },
138
+ {
139
+ "type": "object",
140
+ "properties": {
141
+ "kind": { "type": "string" }
142
+ }
143
+ }
144
+ ]
145
+ }
146
+ },
147
+ "sandboxes": {
148
+ "type": "array",
149
+ "items": { "$ref": "#/$defs/keyedResource" }
150
+ },
151
+ "clusters": {
152
+ "type": "array",
153
+ "items": {
154
+ "allOf": [
155
+ { "$ref": "#/$defs/keyedResource" },
156
+ {
157
+ "type": "object",
158
+ "properties": {
159
+ "size": { "type": "integer", "minimum": 1 },
160
+ "nodes": { "type": "integer", "minimum": 1 }
161
+ }
162
+ }
163
+ ]
164
+ }
165
+ }
166
+ },
167
+ "$defs": {
168
+ "providerConfig": {
169
+ "type": "object",
170
+ "additionalProperties": true,
171
+ "properties": {
172
+ "resources": {
173
+ "type": "array",
174
+ "items": { "$ref": "#/$defs/providerResource" }
175
+ }
176
+ }
177
+ },
178
+ "providerResource": {
179
+ "type": "object",
180
+ "required": ["type", "name"],
181
+ "additionalProperties": true,
182
+ "properties": {
183
+ "type": { "type": "string", "minLength": 1 },
184
+ "name": { "type": "string", "minLength": 1 },
185
+ "values": {
186
+ "type": "object",
187
+ "additionalProperties": true
188
+ }
189
+ }
190
+ },
191
+ "vercelProjectSettings": {
192
+ "type": "object",
193
+ "additionalProperties": true,
194
+ "properties": {
195
+ "rootDirectory": { "type": ["string", "null"] },
196
+ "nodeVersion": { "type": ["string", "null"] },
197
+ "enableAffectedProjectsDeployments": { "type": ["boolean", "null"] }
198
+ }
199
+ },
200
+ "app": {
201
+ "type": "object",
202
+ "required": ["key"],
203
+ "additionalProperties": true,
204
+ "properties": {
205
+ "key": { "type": "string", "minLength": 1 },
206
+ "id": { "type": "string" },
207
+ "projectId": { "type": "string" },
208
+ "name": { "type": "string" },
209
+ "framework": { "type": "string" },
210
+ "rootDirectory": { "type": "string" },
211
+ "outputDir": { "type": "string" },
212
+ "sourceKey": { "type": "string" },
213
+ "nodeVersion": { "type": "string" },
214
+ "enableAffectedProjectsDeployments": { "type": "boolean" },
215
+ "domains": {
216
+ "type": "array",
217
+ "items": { "$ref": "#/$defs/domain" }
218
+ },
219
+ "env": {
220
+ "type": "object",
221
+ "additionalProperties": true,
222
+ "properties": {
223
+ "sourceKey": { "type": "string" },
224
+ "outputDir": { "type": "string" },
225
+ "sharedPrefix": { "type": "string" }
226
+ }
227
+ },
228
+ "providers": {
229
+ "type": "object",
230
+ "additionalProperties": true
231
+ }
232
+ }
233
+ },
234
+ "domain": {
235
+ "oneOf": [
236
+ { "type": "string", "minLength": 1 },
237
+ {
238
+ "type": "object",
239
+ "required": ["name"],
240
+ "additionalProperties": true,
241
+ "properties": {
242
+ "name": { "type": "string", "minLength": 1 },
243
+ "app": { "type": "string" },
244
+ "project": { "type": "string" },
245
+ "key": { "type": "string" },
246
+ "gitBranch": { "type": ["string", "null"] },
247
+ "verified": { "type": "boolean" }
248
+ }
249
+ }
250
+ ]
251
+ },
252
+ "keyedResource": {
253
+ "type": "object",
254
+ "required": ["key"],
255
+ "additionalProperties": true,
256
+ "properties": {
257
+ "key": { "type": "string", "minLength": 1 },
258
+ "name": { "type": "string" },
259
+ "providers": {
260
+ "type": "object",
261
+ "additionalProperties": true
262
+ }
263
+ }
264
+ }
265
+ }
266
+ }
package/src/apply.mjs ADDED
@@ -0,0 +1,38 @@
1
+ #!/usr/bin/env node
2
+
3
+ import process from 'node:process'
4
+ import { hasFlag, parseTargetOption, readOption } from './core/args.mjs'
5
+ import { resolvePlatformContext } from './core/context.mjs'
6
+ import { assertEnvironment, readManifest } from './core/manifest.mjs'
7
+ import { writeTargets } from './core/render.mjs'
8
+ import { terraformApply } from './core/terraform.mjs'
9
+
10
+ async function main() {
11
+ const argv = process.argv.slice(2)
12
+ const context = resolvePlatformContext(argv)
13
+ const manifest = readManifest(context.manifestPath)
14
+ const environment = readOption(argv, '--env') || 'production'
15
+ const targets = parseTargetOption(argv)
16
+ const autoApprove = hasFlag(argv, '--yes') || hasFlag(argv, '--auto-approve')
17
+
18
+ assertEnvironment(manifest, environment)
19
+
20
+ if (!autoApprove && !process.stdin.isTTY) {
21
+ throw new Error('Refusing non-interactive apply without --yes.')
22
+ }
23
+
24
+ const rendered = await writeTargets({ context, manifest, environment, targets })
25
+ for (const item of rendered) {
26
+ console.log(`Applying ${item.workspace}`)
27
+ terraformApply({
28
+ terraformBin: context.terraformBin,
29
+ workspace: item.workspace,
30
+ autoApprove,
31
+ })
32
+ }
33
+ }
34
+
35
+ main().catch((error) => {
36
+ console.error(error.message)
37
+ process.exit(1)
38
+ })
package/src/cli.mjs ADDED
@@ -0,0 +1,74 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { spawnSync } from 'node:child_process'
4
+ import path from 'node:path'
5
+ import process from 'node:process'
6
+ import { fileURLToPath } from 'node:url'
7
+ import { sharedOptionsHelp } from './shared.mjs'
8
+
9
+ const command = process.argv[2]
10
+ const args = process.argv.slice(3)
11
+ const root = path.dirname(fileURLToPath(import.meta.url))
12
+
13
+ const commands = new Map([
14
+ ['render', path.join(root, 'render.mjs')],
15
+ ['plan', path.join(root, 'plan.mjs')],
16
+ ['apply', path.join(root, 'apply.mjs')],
17
+ ['sync-env', path.join(root, 'sync-env.mjs')],
18
+ ['env', path.join(root, 'provision-env.mjs')],
19
+ ['projects', path.join(root, 'reconcile-project-settings.mjs')],
20
+ ['domains', path.join(root, 'reconcile-project-domains.mjs')],
21
+ ])
22
+
23
+ function printHelp() {
24
+ console.log(`vertile-iac
25
+
26
+ Usage:
27
+ vertile-iac render --target=vercel|aws|digitalocean|all --env=<name> [options]
28
+ vertile-iac plan --target=vercel|aws|digitalocean|all --env=<name> [options]
29
+ vertile-iac apply --target=vercel|aws|digitalocean|all --env=<name> [options]
30
+ vertile-iac sync-env [options]
31
+ vertile-iac env [options]
32
+ vertile-iac projects [options]
33
+ vertile-iac domains [options]
34
+
35
+ Commands:
36
+ render Render Terraform workspaces from infrastructure/iac/iac.json.
37
+ plan Render Terraform workspaces and run terraform plan.
38
+ apply Render Terraform workspaces and run terraform apply.
39
+ sync-env Generate package .env files from the configured env source tree.
40
+ env Compatibility: reconcile Vercel team and project environment variables.
41
+ projects Compatibility: reconcile Vercel project settings.
42
+ domains Compatibility: reconcile Vercel project domains.
43
+
44
+ ${sharedOptionsHelp()}
45
+ --out <path> Generated Terraform root. Defaults to .vertile/terraform.
46
+ --target <name|all> Target provider: vercel, aws, digitalocean, or all.
47
+ --env <name> Environment to render, plan, or apply. Defaults to production.
48
+ --terraform-bin <path> Terraform executable. Defaults to terraform.
49
+ --yes Allow non-interactive apply with Terraform auto-approve.
50
+ `)
51
+ }
52
+
53
+ if (!command || command === '--help' || command === '-h') {
54
+ printHelp()
55
+ process.exit(0)
56
+ }
57
+
58
+ const script = commands.get(command)
59
+ if (!script) {
60
+ console.error(`Unknown command: ${command}`)
61
+ printHelp()
62
+ process.exit(1)
63
+ }
64
+
65
+ const result = spawnSync(process.execPath, [script, ...args], {
66
+ stdio: 'inherit',
67
+ })
68
+
69
+ if (result.error) {
70
+ console.error(result.error.message)
71
+ process.exit(1)
72
+ }
73
+
74
+ process.exit(result.status ?? 0)
@@ -0,0 +1,36 @@
1
+ export const supportedTargets = ['vercel', 'aws', 'digitalocean']
2
+
3
+ export function readOption(argv, name) {
4
+ const prefix = `${name}=`
5
+ const inline = argv.find((arg) => arg.startsWith(prefix))
6
+ if (inline) return inline.slice(prefix.length)
7
+
8
+ const index = argv.indexOf(name)
9
+ if (index !== -1) return argv[index + 1] || ''
10
+
11
+ return ''
12
+ }
13
+
14
+ export function hasFlag(argv, name) {
15
+ return argv.includes(name)
16
+ }
17
+
18
+ export function parseTargetOption(argv) {
19
+ const target = readOption(argv, '--target') || 'all'
20
+ if (target === 'all') return supportedTargets
21
+
22
+ const targets = target
23
+ .split(',')
24
+ .map((value) => value.trim())
25
+ .filter(Boolean)
26
+
27
+ for (const value of targets) {
28
+ if (!supportedTargets.includes(value)) {
29
+ throw new Error(
30
+ `Invalid --target value "${value}". Use one of: ${supportedTargets.join(', ')}, all`,
31
+ )
32
+ }
33
+ }
34
+
35
+ return [...new Set(targets)]
36
+ }
@@ -0,0 +1,26 @@
1
+ import { sanitizeName } from './hcl.mjs'
2
+
3
+ export function providerValues(item, provider) {
4
+ return {
5
+ ...(item.values || {}),
6
+ ...((item.providers && item.providers[provider]) || {}),
7
+ }
8
+ }
9
+
10
+ export function resourceName(...parts) {
11
+ return sanitizeName(parts.filter(Boolean).join('_'))
12
+ }
13
+
14
+ export function providerResourceName(manifest, environment, item) {
15
+ return resourceName(manifest.project.name, environment, item.key)
16
+ }
17
+
18
+ export function compactBody(body) {
19
+ return Object.fromEntries(
20
+ Object.entries(body).filter(([, value]) => value !== undefined && value !== ''),
21
+ )
22
+ }
23
+
24
+ export function terraformVariableName(...parts) {
25
+ return resourceName(...parts)
26
+ }
@@ -0,0 +1,39 @@
1
+ import path from 'node:path'
2
+ import { findProjectRoot } from '../shared.mjs'
3
+ import { readOption } from './args.mjs'
4
+
5
+ function resolveFrom(rootDir, value) {
6
+ if (!value) return ''
7
+ return path.isAbsolute(value) ? value : path.join(rootDir, value)
8
+ }
9
+
10
+ export function resolvePlatformContext(argv) {
11
+ const repoRootArg = readOption(argv, '--repo-root')
12
+ const repoRoot = repoRootArg
13
+ ? path.resolve(repoRootArg)
14
+ : findProjectRoot(process.cwd())
15
+ const iacDir = resolveFrom(
16
+ repoRoot,
17
+ readOption(argv, '--iac-dir') || 'infrastructure/iac',
18
+ )
19
+ const manifestPath = resolveFrom(
20
+ repoRoot,
21
+ readOption(argv, '--iac-manifest') || path.relative(repoRoot, path.join(iacDir, 'iac.json')),
22
+ )
23
+ const generatedRoot = resolveFrom(
24
+ repoRoot,
25
+ readOption(argv, '--out') || '.vertile/terraform',
26
+ )
27
+
28
+ return {
29
+ repoRoot,
30
+ iacDir,
31
+ manifestPath,
32
+ generatedRoot,
33
+ terraformBin: readOption(argv, '--terraform-bin') || 'terraform',
34
+ }
35
+ }
36
+
37
+ export function targetWorkspace(context, target) {
38
+ return path.join(context.generatedRoot, target)
39
+ }
@@ -0,0 +1,110 @@
1
+ function isPlainObject(value) {
2
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
3
+ }
4
+
5
+ function quote(value) {
6
+ return JSON.stringify(String(value))
7
+ }
8
+
9
+ function formatKey(key) {
10
+ return /^[A-Za-z_][A-Za-z0-9_]*$/.test(key) ? key : quote(key)
11
+ }
12
+
13
+ function formatValue(value, indent = 0) {
14
+ const pad = ' '.repeat(indent)
15
+ const nestedPad = ' '.repeat(indent + 2)
16
+
17
+ if (typeof value === 'string') return quote(value)
18
+ if (typeof value === 'number' || typeof value === 'boolean') return String(value)
19
+ if (value === null) return 'null'
20
+
21
+ if (Array.isArray(value)) {
22
+ if (value.length === 0) return '[]'
23
+ return `[\n${value.map((item) => `${nestedPad}${formatValue(item, indent + 2)},`).join('\n')}\n${pad}]`
24
+ }
25
+
26
+ if (isPlainObject(value)) {
27
+ if (value.__raw) return value.value
28
+ const entries = Object.entries(value)
29
+ if (entries.length === 0) return '{}'
30
+ return `{\n${entries.map(([key, item]) => `${nestedPad}${formatKey(key)} = ${formatValue(item, indent + 2)}`).join('\n')}\n${pad}}`
31
+ }
32
+
33
+ return quote(value)
34
+ }
35
+
36
+ export function block(type, labels = [], body = {}) {
37
+ const labelText = labels.map((label) => ` ${quote(label)}`).join('')
38
+ const lines = [`${type}${labelText} {`]
39
+
40
+ for (const [key, value] of Object.entries(body)) {
41
+ if (value === undefined) continue
42
+ if (value && typeof value === 'object' && value.__raw) {
43
+ lines.push(` ${key} = ${value.value}`)
44
+ continue
45
+ }
46
+ lines.push(` ${key} = ${formatValue(value, 2)}`)
47
+ }
48
+
49
+ lines.push('}')
50
+ return lines.join('\n')
51
+ }
52
+
53
+ export function nestedBlock(type, body = {}, indent = 0) {
54
+ const pad = ' '.repeat(indent)
55
+ const lines = [`${pad}${type} {`]
56
+
57
+ for (const [key, value] of Object.entries(body)) {
58
+ if (value === undefined) continue
59
+ lines.push(`${pad} ${key} = ${formatValue(value, indent + 2)}`)
60
+ }
61
+
62
+ lines.push(`${pad}}`)
63
+ return lines.join('\n')
64
+ }
65
+
66
+ export function raw(value) {
67
+ return { __raw: true, value }
68
+ }
69
+
70
+ export function sanitizeName(value) {
71
+ const sanitized = String(value)
72
+ .toLowerCase()
73
+ .replace(/[^a-z0-9_]+/g, '_')
74
+ .replace(/^_+|_+$/g, '')
75
+ return sanitized || 'resource'
76
+ }
77
+
78
+ export function renderGenericResources(resources = []) {
79
+ return resources
80
+ .map((resource) => block('resource', [resource.type, resource.name], resource.values || {}))
81
+ .join('\n\n')
82
+ }
83
+
84
+ export function renderLocals(manifest, environment) {
85
+ return block('locals', [], {
86
+ project_name: manifest.project.name,
87
+ environment,
88
+ })
89
+ }
90
+
91
+ export function renderRequiredProvider(name, source, version) {
92
+ return [
93
+ 'terraform {',
94
+ ' required_providers {',
95
+ ` ${name} = {`,
96
+ ` source = ${quote(source)}`,
97
+ ` version = ${quote(version)}`,
98
+ ' }',
99
+ ' }',
100
+ '}',
101
+ ].join('\n')
102
+ }
103
+
104
+ export function renderVariable(name, options = {}) {
105
+ return block('variable', [name], options)
106
+ }
107
+
108
+ export function renderOutput(name, options = {}) {
109
+ return block('output', [name], options)
110
+ }