@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,385 @@
1
+ #!/usr/bin/env node
2
+
3
+ import fs from 'node:fs'
4
+ import process from 'node:process'
5
+ import {
6
+ readVercelEnvManifest,
7
+ readVercelProjectSettingsManifest,
8
+ } from './core/vercel-manifests.mjs'
9
+ import { resolveIacContext } from './shared.mjs'
10
+
11
+ const iacContext = resolveIacContext(process.argv.slice(2), {
12
+ autoCreateKeys: 'landing,web-client,web-server,auth,preview,payment',
13
+ autoCreatePrefixes: 'template-',
14
+ })
15
+ const tokenFilePath = iacContext.tokenFilePath
16
+ const apiBase = 'https://api.vercel.com'
17
+ const shouldAutoCreateProject = iacContext.shouldAutoCreateProject
18
+
19
+ function readPositiveIntegerEnv(key, fallback) {
20
+ const value = Number(process.env[key])
21
+ return Number.isFinite(value) && value >= 0 ? value : fallback
22
+ }
23
+
24
+ const defaultThrottleMs = readPositiveIntegerEnv('VERCEL_API_THROTTLE_MS', 250)
25
+ const maxRequestAttempts = Math.max(
26
+ 1,
27
+ readPositiveIntegerEnv('VERCEL_API_MAX_ATTEMPTS', 4),
28
+ )
29
+
30
+ function parseArgs(argv) {
31
+ const args = {
32
+ apply: false,
33
+ projects: [],
34
+ }
35
+
36
+ for (const arg of argv) {
37
+ if (arg === '--apply') {
38
+ args.apply = true
39
+ continue
40
+ }
41
+
42
+ if (arg.startsWith('--projects=')) {
43
+ args.projects = arg
44
+ .slice('--projects='.length)
45
+ .split(',')
46
+ .map((value) => value.trim())
47
+ .filter(Boolean)
48
+ continue
49
+ }
50
+ }
51
+
52
+ return args
53
+ }
54
+
55
+ function readTokenFromFile(filePath) {
56
+ if (!fs.existsSync(filePath)) return ''
57
+
58
+ const lines = fs.readFileSync(filePath, 'utf8').split(/\r?\n/)
59
+ for (const line of lines) {
60
+ const trimmed = line.trim()
61
+ if (!trimmed || trimmed.startsWith('#')) continue
62
+
63
+ if (trimmed.startsWith('VERCEL_TOKEN=')) {
64
+ return trimmed.slice('VERCEL_TOKEN='.length).trim()
65
+ }
66
+ if (trimmed.startsWith('VERCEL_API_KEY=')) {
67
+ return trimmed.slice('VERCEL_API_KEY='.length).trim()
68
+ }
69
+
70
+ return trimmed
71
+ }
72
+
73
+ return ''
74
+ }
75
+
76
+ function toQuery(params) {
77
+ const query = new URLSearchParams()
78
+ for (const [key, value] of Object.entries(params)) {
79
+ if (value === undefined || value === null || value === '') continue
80
+ query.set(key, String(value))
81
+ }
82
+ const encoded = query.toString()
83
+ return encoded ? `?${encoded}` : ''
84
+ }
85
+
86
+ function sleep(ms) {
87
+ return new Promise((resolve) => setTimeout(resolve, ms))
88
+ }
89
+
90
+ function readRetryAfterMs(response, attempt) {
91
+ const retryAfter = response.headers.get('retry-after')
92
+ if (retryAfter) {
93
+ const retryAfterSeconds = Number(retryAfter)
94
+ if (Number.isFinite(retryAfterSeconds)) {
95
+ return Math.max(0, retryAfterSeconds * 1000)
96
+ }
97
+
98
+ const retryAfterDate = Date.parse(retryAfter)
99
+ if (Number.isFinite(retryAfterDate)) {
100
+ return Math.max(0, retryAfterDate - Date.now())
101
+ }
102
+ }
103
+
104
+ const reset = Number(response.headers.get('x-ratelimit-reset'))
105
+ if (Number.isFinite(reset) && reset > 0) {
106
+ return Math.max(0, reset * 1000 - Date.now())
107
+ }
108
+
109
+ return Math.min(30000, 1000 * 2 ** attempt)
110
+ }
111
+
112
+ async function requestJSON({ token, method, pathname, query, body }) {
113
+ const url = `${apiBase}${pathname}${toQuery(query || {})}`
114
+
115
+ for (let attempt = 0; attempt < maxRequestAttempts; attempt += 1) {
116
+ const response = await fetch(url, {
117
+ method,
118
+ headers: {
119
+ Authorization: `Bearer ${token}`,
120
+ 'Content-Type': 'application/json',
121
+ Accept: 'application/json',
122
+ },
123
+ body: body ? JSON.stringify(body) : undefined,
124
+ })
125
+
126
+ if (response.ok) {
127
+ if (defaultThrottleMs > 0) await sleep(defaultThrottleMs)
128
+ if (response.status === 204) return {}
129
+ return response.json()
130
+ }
131
+
132
+ const errorText = await response.text()
133
+ if (response.status === 429 && attempt < maxRequestAttempts - 1) {
134
+ const delayMs = readRetryAfterMs(response, attempt)
135
+ console.warn(
136
+ `[rate-limit] Vercel API ${method} ${pathname} returned 429; retrying in ${Math.ceil(delayMs / 1000)}s`,
137
+ )
138
+ await sleep(delayMs)
139
+ continue
140
+ }
141
+
142
+ throw new Error(
143
+ `Vercel API ${method} ${pathname} failed (${response.status}): ${errorText}`,
144
+ )
145
+ }
146
+
147
+ throw new Error(`Vercel API ${method} ${pathname} failed after retries`)
148
+ }
149
+
150
+ async function resolveTeamId({ token, teamSlug }) {
151
+ const teams = await requestJSON({
152
+ token,
153
+ method: 'GET',
154
+ pathname: '/v1/teams',
155
+ })
156
+ const rows = Array.isArray(teams.teams) ? teams.teams : []
157
+ const match = rows.find((team) => team?.slug === teamSlug)
158
+ if (!match?.id) {
159
+ throw new Error(`Unable to resolve team ID for slug "${teamSlug}"`)
160
+ }
161
+ return match.id
162
+ }
163
+
164
+ async function listTeamProjects({ token, teamId }) {
165
+ const projects = await requestJSON({
166
+ token,
167
+ method: 'GET',
168
+ pathname: '/v9/projects',
169
+ query: { teamId, limit: 100 },
170
+ })
171
+ return Array.isArray(projects.projects) ? projects.projects : []
172
+ }
173
+
174
+ async function createTeamProject({ token, teamId, name }) {
175
+ const project = await requestJSON({
176
+ token,
177
+ method: 'POST',
178
+ pathname: '/v10/projects',
179
+ query: { teamId },
180
+ body: { name },
181
+ })
182
+
183
+ const id =
184
+ typeof project?.id === 'string'
185
+ ? project.id.trim()
186
+ : typeof project?.project?.id === 'string'
187
+ ? project.project.id.trim()
188
+ : ''
189
+
190
+ if (!id) {
191
+ throw new Error(`Created Vercel project "${name}" but no project id was returned`)
192
+ }
193
+
194
+ return id
195
+ }
196
+
197
+ function diffSettings(current, desired) {
198
+ const keys = [
199
+ 'rootDirectory',
200
+ 'nodeVersion',
201
+ 'enableAffectedProjectsDeployments',
202
+ ]
203
+ const patch = {}
204
+ const diffs = []
205
+
206
+ for (const key of keys) {
207
+ const currentValue = current[key] ?? null
208
+ const desiredValue = desired[key] ?? null
209
+ if (currentValue !== desiredValue) {
210
+ patch[key] = desiredValue
211
+ diffs.push({ key, current: currentValue, desired: desiredValue })
212
+ }
213
+ }
214
+
215
+ return { patch, diffs }
216
+ }
217
+
218
+ async function main() {
219
+ const args = parseArgs(process.argv.slice(2))
220
+ const dryRun = !args.apply
221
+
222
+ const token =
223
+ process.env.VERCEL_TOKEN ||
224
+ process.env.VERCEL_API_KEY ||
225
+ readTokenFromFile(tokenFilePath)
226
+
227
+ if (!dryRun && !token) {
228
+ throw new Error('Missing VERCEL_TOKEN (or VERCEL_API_KEY) for apply mode')
229
+ }
230
+
231
+ const envManifest = readVercelEnvManifest(iacContext)
232
+ const projectSettingsManifest = readVercelProjectSettingsManifest(iacContext)
233
+
234
+ const configuredProjects = Array.isArray(envManifest.projects)
235
+ ? envManifest.projects
236
+ : []
237
+ const projectSettings = Array.isArray(projectSettingsManifest.projects)
238
+ ? projectSettingsManifest.projects
239
+ : []
240
+ const teamSlug = envManifest.teamSlug
241
+ if (!teamSlug || typeof teamSlug !== 'string') {
242
+ throw new Error('Missing or invalid teamSlug in the env manifest')
243
+ }
244
+ const teamId = token ? await resolveTeamId({ token, teamSlug }) : ''
245
+
246
+ const defaultSettings = projectSettingsManifest.defaults || {}
247
+
248
+ const projectByKey = new Map(
249
+ configuredProjects
250
+ .filter(
251
+ (project) =>
252
+ typeof project.key === 'string' &&
253
+ typeof project.id === 'string' &&
254
+ project.id.trim().length > 0,
255
+ )
256
+ .map((project) => [project.key, project]),
257
+ )
258
+
259
+ if (token && teamId) {
260
+ const remoteProjects = await listTeamProjects({ token, teamId })
261
+ const remoteIdByName = new Map(
262
+ remoteProjects
263
+ .filter(
264
+ (project) =>
265
+ typeof project.name === 'string' &&
266
+ project.name.trim().length > 0 &&
267
+ typeof project.id === 'string' &&
268
+ project.id.trim().length > 0
269
+ )
270
+ .map((project) => [project.name.trim(), project.id.trim()])
271
+ )
272
+
273
+ for (const project of configuredProjects) {
274
+ if (
275
+ typeof project?.key !== 'string' ||
276
+ typeof project?.name !== 'string' ||
277
+ (typeof project?.id === 'string' && project.id.trim().length > 0)
278
+ ) {
279
+ continue
280
+ }
281
+
282
+ const projectName = project.name.trim()
283
+ let resolvedId = remoteIdByName.get(projectName)
284
+
285
+ if (!resolvedId && shouldAutoCreateProject(project.key)) {
286
+ if (dryRun) {
287
+ console.log(
288
+ `[plan] ${project.key}: would create Vercel project "${projectName}" because env-manifest id is missing`,
289
+ )
290
+ continue
291
+ }
292
+
293
+ resolvedId = await createTeamProject({
294
+ token,
295
+ teamId,
296
+ name: projectName,
297
+ })
298
+ remoteIdByName.set(projectName, resolvedId)
299
+ console.log(
300
+ `[created] ${project.key}: created Vercel project "${projectName}" -> ${resolvedId}`,
301
+ )
302
+ }
303
+
304
+ if (!resolvedId) continue
305
+
306
+ projectByKey.set(project.key, { ...project, id: resolvedId })
307
+ console.log(
308
+ `[resolved] ${project.key}: using Vercel project "${project.name}" -> ${resolvedId}`
309
+ )
310
+ }
311
+ }
312
+
313
+ const requested = args.projects.length ? new Set(args.projects) : null
314
+
315
+ for (const entry of projectSettings) {
316
+ if (requested && !requested.has(entry.key)) continue
317
+
318
+ const configured = projectByKey.get(entry.key)
319
+ if (!configured) {
320
+ console.log(`[skip] ${entry.key}: missing Vercel project ID in env-manifest`)
321
+ continue
322
+ }
323
+
324
+ const desired = {
325
+ rootDirectory: entry.rootDirectory ?? defaultSettings.rootDirectory ?? null,
326
+ nodeVersion: entry.nodeVersion ?? defaultSettings.nodeVersion ?? null,
327
+ enableAffectedProjectsDeployments:
328
+ entry.enableAffectedProjectsDeployments ??
329
+ defaultSettings.enableAffectedProjectsDeployments ??
330
+ null,
331
+ }
332
+
333
+ if (!token) {
334
+ console.log(
335
+ `[dry-run/offline] ${entry.key}: cannot fetch remote settings without token`,
336
+ )
337
+ continue
338
+ }
339
+
340
+ const project = await requestJSON({
341
+ token,
342
+ method: 'GET',
343
+ pathname: `/v9/projects/${encodeURIComponent(configured.id)}`,
344
+ query: { teamId },
345
+ })
346
+
347
+ const current = {
348
+ rootDirectory: project.rootDirectory ?? null,
349
+ nodeVersion: project.nodeVersion ?? null,
350
+ enableAffectedProjectsDeployments:
351
+ project.enableAffectedProjectsDeployments ?? null,
352
+ }
353
+
354
+ const { patch, diffs } = diffSettings(current, desired)
355
+
356
+ if (diffs.length === 0) {
357
+ console.log(`[ok] ${entry.key}: project settings already in sync`)
358
+ continue
359
+ }
360
+
361
+ console.log(`[diff] ${entry.key}`)
362
+ for (const change of diffs) {
363
+ console.log(
364
+ ` - ${change.key}: ${JSON.stringify(change.current)} -> ${JSON.stringify(change.desired)}`,
365
+ )
366
+ }
367
+
368
+ if (dryRun) continue
369
+
370
+ await requestJSON({
371
+ token,
372
+ method: 'PATCH',
373
+ pathname: `/v9/projects/${encodeURIComponent(configured.id)}`,
374
+ query: { teamId },
375
+ body: patch,
376
+ })
377
+
378
+ console.log(`[applied] ${entry.key}: updated remote project settings`)
379
+ }
380
+ }
381
+
382
+ main().catch((error) => {
383
+ console.error('Error:', error instanceof Error ? error.message : String(error))
384
+ process.exit(1)
385
+ })
package/src/render.mjs ADDED
@@ -0,0 +1,27 @@
1
+ #!/usr/bin/env node
2
+
3
+ import process from 'node:process'
4
+ import { 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
+
9
+ async function main() {
10
+ const argv = process.argv.slice(2)
11
+ const context = resolvePlatformContext(argv)
12
+ const manifest = readManifest(context.manifestPath)
13
+ const environment = readOption(argv, '--env') || 'production'
14
+ const targets = parseTargetOption(argv)
15
+
16
+ assertEnvironment(manifest, environment)
17
+
18
+ const rendered = await writeTargets({ context, manifest, environment, targets })
19
+ for (const item of rendered) {
20
+ console.log(`Rendered ${item.workspace}`)
21
+ }
22
+ }
23
+
24
+ main().catch((error) => {
25
+ console.error(error.message)
26
+ process.exit(1)
27
+ })
package/src/shared.mjs ADDED
@@ -0,0 +1,116 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+
4
+ export function findProjectRoot(startDir) {
5
+ let current = startDir
6
+ const { root } = path.parse(current)
7
+
8
+ while (true) {
9
+ const hasPackageJson = fs.existsSync(path.join(current, 'package.json'))
10
+ const hasInfrastructure = fs.existsSync(path.join(current, 'infrastructure'))
11
+ if (hasPackageJson && hasInfrastructure) return current
12
+ if (current === root) break
13
+ current = path.dirname(current)
14
+ }
15
+
16
+ throw new Error(
17
+ `Could not find project root from ${startDir}. Pass --repo-root or run inside a project with package.json and infrastructure/.`,
18
+ )
19
+ }
20
+
21
+ export function readOption(argv, name) {
22
+ const prefix = `${name}=`
23
+ const inline = argv.find((arg) => arg.startsWith(prefix))
24
+ if (inline) return inline.slice(prefix.length)
25
+
26
+ const index = argv.indexOf(name)
27
+ if (index !== -1) return argv[index + 1] || ''
28
+
29
+ return ''
30
+ }
31
+
32
+ function splitList(value) {
33
+ return value
34
+ .split(',')
35
+ .map((item) => item.trim())
36
+ .filter(Boolean)
37
+ }
38
+
39
+ function resolveFrom(rootDir, value) {
40
+ if (!value) return ''
41
+ return path.isAbsolute(value) ? value : path.join(rootDir, value)
42
+ }
43
+
44
+ export function resolveIacContext(argv, defaults = {}) {
45
+ const repoRootArg = readOption(argv, '--repo-root')
46
+ const repoRoot = repoRootArg
47
+ ? path.resolve(repoRootArg)
48
+ : findProjectRoot(process.cwd())
49
+
50
+ const iacDir = resolveFrom(
51
+ repoRoot,
52
+ readOption(argv, '--iac-dir') || defaults.iacDir || 'infrastructure/iac',
53
+ )
54
+ const manifestArg = readOption(argv, '--manifest')
55
+ const projectSettingsArg = readOption(argv, '--project-settings')
56
+ const projectDomainsArg = readOption(argv, '--project-domains')
57
+ const iacManifestArg = readOption(argv, '--iac-manifest')
58
+ const infraDir =
59
+ readOption(argv, '--infra-dir') || defaults.infraDir || ''
60
+
61
+ const autoCreateKeys = new Set([
62
+ ...splitList(defaults.autoCreateKeys || ''),
63
+ ...splitList(readOption(argv, '--auto-create-keys')),
64
+ ])
65
+ const autoCreatePrefixes = [
66
+ ...splitList(defaults.autoCreatePrefixes || ''),
67
+ ...splitList(readOption(argv, '--auto-create-prefixes')),
68
+ ]
69
+
70
+ return {
71
+ repoRoot,
72
+ iacDir,
73
+ infraDir,
74
+ manifestPath: resolveFrom(
75
+ repoRoot,
76
+ manifestArg || path.relative(repoRoot, path.join(iacDir, 'env-manifest.json')),
77
+ ),
78
+ projectSettingsPath: resolveFrom(
79
+ repoRoot,
80
+ projectSettingsArg || path.relative(repoRoot, path.join(iacDir, 'project-settings.json')),
81
+ ),
82
+ projectDomainsPath: resolveFrom(
83
+ repoRoot,
84
+ projectDomainsArg || path.relative(repoRoot, path.join(iacDir, 'project-domains.json')),
85
+ ),
86
+ iacManifestPath: resolveFrom(
87
+ repoRoot,
88
+ iacManifestArg || path.relative(repoRoot, path.join(iacDir, 'iac.json')),
89
+ ),
90
+ tokenFilePath: resolveFrom(
91
+ repoRoot,
92
+ readOption(argv, '--token-file') || defaults.tokenFile || '.vercel.token',
93
+ ),
94
+ explicitManifestPath: Boolean(manifestArg),
95
+ explicitProjectSettingsPath: Boolean(projectSettingsArg),
96
+ explicitProjectDomainsPath: Boolean(projectDomainsArg),
97
+ explicitIacManifestPath: Boolean(iacManifestArg),
98
+ shouldAutoCreateProject(key) {
99
+ return autoCreateKeys.has(key) || autoCreatePrefixes.some((prefix) => key.startsWith(prefix))
100
+ },
101
+ }
102
+ }
103
+
104
+ export function sharedOptionsHelp() {
105
+ return `Shared options:
106
+ --repo-root <path> Project root containing infrastructure/.
107
+ --iac-dir <path> Directory containing project IaC manifests. Defaults to infrastructure/iac.
108
+ --manifest <path> Env manifest path. Defaults to <iac-dir>/env-manifest.json.
109
+ --project-settings <path> Project settings manifest path.
110
+ --project-domains <path> Project domains manifest path.
111
+ --iac-manifest <path> Unified IaC manifest path. Defaults to <iac-dir>/iac.json.
112
+ --infra-dir <path> Override manifest infraDir.
113
+ --token-file <path> Token file. Defaults to <repo-root>/.vercel.token.
114
+ --auto-create-keys <a,b> Project keys allowed to be created in apply mode.
115
+ --auto-create-prefixes <a,b> Project key prefixes allowed to be created in apply mode.`
116
+ }