@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
|
@@ -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
|
+
})
|