@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,549 @@
1
+ #!/usr/bin/env node
2
+
3
+ import fs from 'node:fs'
4
+ import process from 'node:process'
5
+ import {
6
+ readVercelEnvManifest,
7
+ readVercelProjectDomainsManifest,
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
+ reconcileDelete: false,
35
+ skipNewDomainVerify: false,
36
+ }
37
+
38
+ for (const arg of argv) {
39
+ if (arg === '--apply') {
40
+ args.apply = true
41
+ continue
42
+ }
43
+
44
+ if (arg === '--reconcile-delete') {
45
+ args.reconcileDelete = true
46
+ continue
47
+ }
48
+
49
+ if (arg === '--skip-new-domain-verify') {
50
+ args.skipNewDomainVerify = true
51
+ continue
52
+ }
53
+
54
+ if (arg.startsWith('--projects=')) {
55
+ args.projects = arg
56
+ .slice('--projects='.length)
57
+ .split(',')
58
+ .map((value) => value.trim())
59
+ .filter(Boolean)
60
+ continue
61
+ }
62
+ }
63
+
64
+ return args
65
+ }
66
+
67
+ function readTokenFromFile(filePath) {
68
+ if (!fs.existsSync(filePath)) return ''
69
+
70
+ const lines = fs.readFileSync(filePath, 'utf8').split(/\r?\n/)
71
+ for (const line of lines) {
72
+ const trimmed = line.trim()
73
+ if (!trimmed || trimmed.startsWith('#')) continue
74
+
75
+ if (trimmed.startsWith('VERCEL_TOKEN=')) {
76
+ return trimmed.slice('VERCEL_TOKEN='.length).trim()
77
+ }
78
+ if (trimmed.startsWith('VERCEL_API_KEY=')) {
79
+ return trimmed.slice('VERCEL_API_KEY='.length).trim()
80
+ }
81
+
82
+ return trimmed
83
+ }
84
+
85
+ return ''
86
+ }
87
+
88
+ function toQuery(params) {
89
+ const query = new URLSearchParams()
90
+ for (const [key, value] of Object.entries(params)) {
91
+ if (value === undefined || value === null || value === '') continue
92
+ query.set(key, String(value))
93
+ }
94
+ const encoded = query.toString()
95
+ return encoded ? `?${encoded}` : ''
96
+ }
97
+
98
+ function sleep(ms) {
99
+ return new Promise((resolve) => setTimeout(resolve, ms))
100
+ }
101
+
102
+ function readRetryAfterMs(response, attempt) {
103
+ const retryAfter = response.headers.get('retry-after')
104
+ if (retryAfter) {
105
+ const retryAfterSeconds = Number(retryAfter)
106
+ if (Number.isFinite(retryAfterSeconds)) {
107
+ return Math.max(0, retryAfterSeconds * 1000)
108
+ }
109
+
110
+ const retryAfterDate = Date.parse(retryAfter)
111
+ if (Number.isFinite(retryAfterDate)) {
112
+ return Math.max(0, retryAfterDate - Date.now())
113
+ }
114
+ }
115
+
116
+ const reset = Number(response.headers.get('x-ratelimit-reset'))
117
+ if (Number.isFinite(reset) && reset > 0) {
118
+ return Math.max(0, reset * 1000 - Date.now())
119
+ }
120
+
121
+ return Math.min(30000, 1000 * 2 ** attempt)
122
+ }
123
+
124
+ async function request({
125
+ token,
126
+ method,
127
+ pathname,
128
+ query,
129
+ body,
130
+ acceptedStatus = [200],
131
+ }) {
132
+ const url = `${apiBase}${pathname}${toQuery(query || {})}`
133
+
134
+ for (let attempt = 0; attempt < maxRequestAttempts; attempt += 1) {
135
+ const response = await fetch(url, {
136
+ method,
137
+ headers: {
138
+ Authorization: `Bearer ${token}`,
139
+ 'Content-Type': 'application/json',
140
+ Accept: 'application/json',
141
+ },
142
+ body: body ? JSON.stringify(body) : undefined,
143
+ })
144
+
145
+ const text = await response.text()
146
+ const payload = text ? (() => {
147
+ try {
148
+ return JSON.parse(text)
149
+ } catch {
150
+ return text
151
+ }
152
+ })() : {}
153
+
154
+ if (acceptedStatus.includes(response.status)) {
155
+ if (defaultThrottleMs > 0) await sleep(defaultThrottleMs)
156
+ return { status: response.status, payload }
157
+ }
158
+
159
+ if (response.status === 429 && attempt < maxRequestAttempts - 1) {
160
+ const delayMs = readRetryAfterMs(response, attempt)
161
+ console.warn(
162
+ `[rate-limit] Vercel API ${method} ${pathname} returned 429; retrying in ${Math.ceil(delayMs / 1000)}s`,
163
+ )
164
+ await sleep(delayMs)
165
+ continue
166
+ }
167
+
168
+ throw new Error(
169
+ `Vercel API ${method} ${pathname} failed (${response.status}): ${typeof payload === 'string' ? payload : JSON.stringify(payload)}`,
170
+ )
171
+ }
172
+
173
+ throw new Error(`Vercel API ${method} ${pathname} failed after retries`)
174
+ }
175
+
176
+ async function resolveTeamId({ token, teamSlug }) {
177
+ const result = await request({
178
+ token,
179
+ method: 'GET',
180
+ pathname: '/v1/teams',
181
+ })
182
+ const rows = Array.isArray(result.payload?.teams) ? result.payload.teams : []
183
+ const match = rows.find((team) => team?.slug === teamSlug)
184
+ if (!match?.id) {
185
+ throw new Error(`Unable to resolve team ID for slug "${teamSlug}"`)
186
+ }
187
+ return match.id
188
+ }
189
+
190
+ async function listTeamProjects({ token, teamId }) {
191
+ const result = await request({
192
+ token,
193
+ method: 'GET',
194
+ pathname: '/v9/projects',
195
+ query: { teamId, limit: 100 },
196
+ })
197
+ return Array.isArray(result.payload?.projects) ? result.payload.projects : []
198
+ }
199
+
200
+ async function createTeamProject({ token, teamId, name }) {
201
+ const result = await request({
202
+ token,
203
+ method: 'POST',
204
+ pathname: '/v10/projects',
205
+ query: { teamId },
206
+ body: { name },
207
+ })
208
+
209
+ const project = result.payload
210
+ const id =
211
+ typeof project?.id === 'string'
212
+ ? project.id.trim()
213
+ : typeof project?.project?.id === 'string'
214
+ ? project.project.id.trim()
215
+ : ''
216
+
217
+ if (!id) {
218
+ throw new Error(`Created Vercel project "${name}" but no project id was returned`)
219
+ }
220
+
221
+ return id
222
+ }
223
+
224
+ function normalizeDomainConfigs(domains) {
225
+ const list = Array.isArray(domains) ? domains : []
226
+ const byName = new Map()
227
+
228
+ for (const domain of list) {
229
+ const config =
230
+ typeof domain === 'string'
231
+ ? { name: domain }
232
+ : domain && typeof domain === 'object'
233
+ ? domain
234
+ : null
235
+ const name =
236
+ typeof config?.name === 'string' ? config.name.trim().toLowerCase() : ''
237
+ if (!name) continue
238
+
239
+ byName.set(name, {
240
+ name,
241
+ gitBranch:
242
+ typeof config.gitBranch === 'string' && config.gitBranch.trim()
243
+ ? config.gitBranch.trim()
244
+ : null,
245
+ verified: typeof config.verified === 'boolean' ? config.verified : true,
246
+ })
247
+ }
248
+
249
+ return [...byName.values()]
250
+ }
251
+
252
+ function computeDiff({ desired, current, reconcileDelete }) {
253
+ const desiredByName = new Map(desired.map((domain) => [domain.name, domain]))
254
+ const currentByName = new Map(current.map((domain) => [domain.name, domain]))
255
+
256
+ const toAdd = desired.filter((domain) => !currentByName.has(domain.name))
257
+ const toUpdate = desired.filter((domain) => {
258
+ const currentDomain = currentByName.get(domain.name)
259
+ if (!currentDomain) return false
260
+
261
+ return domain.gitBranch !== currentDomain.gitBranch
262
+ })
263
+ const toVerify = desired.filter((domain) => {
264
+ const currentDomain = currentByName.get(domain.name)
265
+ return currentDomain?.verified === false
266
+ })
267
+ const toRemove = reconcileDelete
268
+ ? current.filter((domain) => !desiredByName.has(domain.name))
269
+ : []
270
+
271
+ return { toAdd, toUpdate, toVerify, toRemove }
272
+ }
273
+
274
+ function domainLabel(domain) {
275
+ const details = [domain.gitBranch ? `branch=${domain.gitBranch}` : ''].filter(Boolean)
276
+
277
+ return details.length ? `${domain.name} (${details.join(', ')})` : domain.name
278
+ }
279
+
280
+ function domainCreateBody(domain) {
281
+ return {
282
+ name: domain.name,
283
+ gitBranch: domain.gitBranch,
284
+ }
285
+ }
286
+
287
+ function domainUpdateBody(domain) {
288
+ return {
289
+ gitBranch: domain.gitBranch,
290
+ }
291
+ }
292
+
293
+ function printVerificationChallenges(domain, payload) {
294
+ const verification = Array.isArray(payload?.verification) ? payload.verification : []
295
+ if (verification.length === 0) return
296
+
297
+ console.log(` [verify] ${domain} requires DNS verification`)
298
+ for (const challenge of verification) {
299
+ const type = challenge?.type || 'UNKNOWN'
300
+ const host = challenge?.domain || '<domain>'
301
+ const value = challenge?.value || '<value>'
302
+ console.log(` - ${type} ${host} -> ${value}`)
303
+ }
304
+ }
305
+
306
+ async function verifyProjectDomain({ token, configured, teamSlug, domain }) {
307
+ const result = await request({
308
+ token,
309
+ method: 'POST',
310
+ pathname: `/v9/projects/${encodeURIComponent(configured.id)}/domains/${encodeURIComponent(domain.name)}/verify`,
311
+ query: { slug: teamSlug },
312
+ acceptedStatus: [200, 400, 403, 409],
313
+ })
314
+
315
+ if (result.status === 200) {
316
+ console.log(` [applied] ${configured.key}: verified ${domain.name}`)
317
+ return
318
+ }
319
+
320
+ const message =
321
+ typeof result.payload?.error?.message === 'string'
322
+ ? result.payload.error.message
323
+ : typeof result.payload?.message === 'string'
324
+ ? result.payload.message
325
+ : JSON.stringify(result.payload)
326
+
327
+ console.log(` [verify] ${domain.name} is still pending DNS verification: ${message}`)
328
+ printVerificationChallenges(domain.name, result.payload)
329
+ }
330
+
331
+ async function main() {
332
+ const args = parseArgs(process.argv.slice(2))
333
+ const dryRun = !args.apply
334
+
335
+ const token =
336
+ process.env.VERCEL_TOKEN ||
337
+ process.env.VERCEL_API_KEY ||
338
+ readTokenFromFile(tokenFilePath)
339
+
340
+ if (!dryRun && !token) {
341
+ throw new Error('Missing VERCEL_TOKEN (or VERCEL_API_KEY) for apply mode')
342
+ }
343
+
344
+ const envManifest = readVercelEnvManifest(iacContext)
345
+ const projectDomainsManifest = readVercelProjectDomainsManifest(iacContext)
346
+
347
+ const configuredProjects = Array.isArray(envManifest.projects)
348
+ ? envManifest.projects
349
+ : []
350
+ const projectDomains = Array.isArray(projectDomainsManifest.projects)
351
+ ? projectDomainsManifest.projects
352
+ : []
353
+
354
+ const teamSlug = envManifest.teamSlug
355
+ if (!teamSlug || typeof teamSlug !== 'string') {
356
+ throw new Error('Missing or invalid teamSlug in the env manifest')
357
+ }
358
+ const projectByKey = new Map(
359
+ configuredProjects
360
+ .filter(
361
+ (project) =>
362
+ typeof project.key === 'string' &&
363
+ typeof project.id === 'string' &&
364
+ project.id.trim().length > 0,
365
+ )
366
+ .map((project) => [project.key, project]),
367
+ )
368
+
369
+ if (token) {
370
+ const teamId = await resolveTeamId({ token, teamSlug })
371
+ const remoteProjects = await listTeamProjects({ token, teamId })
372
+ const remoteIdByName = new Map(
373
+ remoteProjects
374
+ .filter(
375
+ (project) =>
376
+ typeof project.name === 'string' &&
377
+ project.name.trim().length > 0 &&
378
+ typeof project.id === 'string' &&
379
+ project.id.trim().length > 0
380
+ )
381
+ .map((project) => [project.name.trim(), project.id.trim()])
382
+ )
383
+
384
+ for (const project of configuredProjects) {
385
+ if (
386
+ typeof project?.key !== 'string' ||
387
+ typeof project?.name !== 'string' ||
388
+ (typeof project?.id === 'string' && project.id.trim().length > 0)
389
+ ) {
390
+ continue
391
+ }
392
+
393
+ const projectName = project.name.trim()
394
+ let resolvedId = remoteIdByName.get(projectName)
395
+
396
+ if (!resolvedId && shouldAutoCreateProject(project.key)) {
397
+ if (dryRun) {
398
+ console.log(
399
+ `[plan] ${project.key}: would create Vercel project "${projectName}" because env-manifest id is missing`,
400
+ )
401
+ continue
402
+ }
403
+
404
+ resolvedId = await createTeamProject({
405
+ token,
406
+ teamId,
407
+ name: projectName,
408
+ })
409
+ remoteIdByName.set(projectName, resolvedId)
410
+ console.log(
411
+ `[created] ${project.key}: created Vercel project "${projectName}" -> ${resolvedId}`
412
+ )
413
+ }
414
+
415
+ if (!resolvedId) continue
416
+
417
+ projectByKey.set(project.key, { ...project, id: resolvedId })
418
+ console.log(
419
+ `[resolved] ${project.key}: using Vercel project "${project.name}" -> ${resolvedId}`
420
+ )
421
+ }
422
+ }
423
+
424
+ const requested = args.projects.length ? new Set(args.projects) : null
425
+
426
+ for (const entry of projectDomains) {
427
+ if (!entry || typeof entry.key !== 'string') continue
428
+ if (requested && !requested.has(entry.key)) continue
429
+
430
+ const configured = projectByKey.get(entry.key)
431
+ if (!configured) {
432
+ console.log(`[skip] ${entry.key}: missing Vercel project ID in env-manifest`)
433
+ continue
434
+ }
435
+
436
+ const desiredDomains = normalizeDomainConfigs(entry.domains)
437
+ if (desiredDomains.length === 0) {
438
+ console.log(`[skip] ${entry.key}: no desired domains configured`)
439
+ continue
440
+ }
441
+
442
+ if (!token) {
443
+ console.log(
444
+ `[dry-run/offline] ${entry.key}: cannot fetch remote domains without token (desired: ${desiredDomains.map(domainLabel).join(', ')})`,
445
+ )
446
+ continue
447
+ }
448
+
449
+ const { payload } = await request({
450
+ token,
451
+ method: 'GET',
452
+ pathname: `/v9/projects/${encodeURIComponent(configured.id)}/domains`,
453
+ query: { slug: teamSlug, redirects: 'false' },
454
+ })
455
+ const currentDomains = normalizeDomainConfigs(
456
+ (Array.isArray(payload?.domains) ? payload.domains : []).map((domain) => ({
457
+ name: domain?.name,
458
+ gitBranch: domain?.gitBranch,
459
+ verified: domain?.verified,
460
+ })),
461
+ )
462
+
463
+ const { toAdd, toUpdate, toVerify, toRemove } = computeDiff({
464
+ desired: desiredDomains,
465
+ current: currentDomains,
466
+ reconcileDelete: args.reconcileDelete,
467
+ })
468
+
469
+ if (
470
+ toAdd.length === 0 &&
471
+ toUpdate.length === 0 &&
472
+ toVerify.length === 0 &&
473
+ toRemove.length === 0
474
+ ) {
475
+ console.log(`[ok] ${entry.key}: project domains already in sync`)
476
+ continue
477
+ }
478
+
479
+ console.log(`[diff] ${entry.key}`)
480
+ if (toAdd.length > 0) {
481
+ console.log(` - add: ${toAdd.map(domainLabel).join(', ')}`)
482
+ }
483
+ if (toUpdate.length > 0) {
484
+ console.log(` - update: ${toUpdate.map(domainLabel).join(', ')}`)
485
+ }
486
+ if (toVerify.length > 0) {
487
+ console.log(` - verify: ${toVerify.map((domain) => domain.name).join(', ')}`)
488
+ }
489
+ if (toRemove.length > 0) {
490
+ console.log(` - remove: ${toRemove.map((domain) => domain.name).join(', ')}`)
491
+ }
492
+
493
+ if (dryRun) continue
494
+
495
+ for (const domain of toAdd) {
496
+ const result = await request({
497
+ token,
498
+ method: 'POST',
499
+ pathname: `/v10/projects/${encodeURIComponent(configured.id)}/domains`,
500
+ query: { slug: teamSlug },
501
+ body: domainCreateBody(domain),
502
+ acceptedStatus: [200, 201],
503
+ })
504
+
505
+ console.log(` [applied] ${entry.key}: added ${domainLabel(domain)}`)
506
+ printVerificationChallenges(domain.name, result.payload)
507
+ if (result.payload?.verified === false) {
508
+ if (args.skipNewDomainVerify) {
509
+ console.log(
510
+ ` [verify] ${domain.name} added but not verified yet; rerun apply after DNS propagates`,
511
+ )
512
+ } else {
513
+ await verifyProjectDomain({ token, configured, teamSlug, domain })
514
+ }
515
+ }
516
+ }
517
+
518
+ for (const domain of toUpdate) {
519
+ await request({
520
+ token,
521
+ method: 'PATCH',
522
+ pathname: `/v9/projects/${encodeURIComponent(configured.id)}/domains/${encodeURIComponent(domain.name)}`,
523
+ query: { slug: teamSlug },
524
+ body: domainUpdateBody(domain),
525
+ })
526
+ console.log(` [applied] ${entry.key}: updated ${domainLabel(domain)}`)
527
+ }
528
+
529
+ for (const domain of toVerify) {
530
+ await verifyProjectDomain({ token, configured, teamSlug, domain })
531
+ }
532
+
533
+ for (const domain of toRemove) {
534
+ await request({
535
+ token,
536
+ method: 'DELETE',
537
+ pathname: `/v9/projects/${encodeURIComponent(configured.id)}/domains/${encodeURIComponent(domain.name)}`,
538
+ query: { slug: teamSlug },
539
+ acceptedStatus: [200, 204, 404],
540
+ })
541
+ console.log(` [applied] ${entry.key}: removed ${domain.name}`)
542
+ }
543
+ }
544
+ }
545
+
546
+ main().catch((error) => {
547
+ console.error('Error:', error instanceof Error ? error.message : String(error))
548
+ process.exit(1)
549
+ })