@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,798 @@
|
|
|
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 { readVercelEnvManifest } from './core/vercel-manifests.mjs'
|
|
7
|
+
import { resolveIacContext } from './shared.mjs'
|
|
8
|
+
|
|
9
|
+
const iacContext = resolveIacContext(process.argv.slice(2), {
|
|
10
|
+
autoCreateKeys: 'landing,web-client,web-server,auth,preview,payment',
|
|
11
|
+
autoCreatePrefixes: 'template-',
|
|
12
|
+
})
|
|
13
|
+
const rootDir = iacContext.repoRoot
|
|
14
|
+
const tokenFilePath = iacContext.tokenFilePath
|
|
15
|
+
const apiBase = 'https://api.vercel.com'
|
|
16
|
+
const managedComment = 'managed by @vertile-ai/iac provision-env'
|
|
17
|
+
const legacyManagedComment = 'managed by infrastructure/IAC/provision-env.mjs'
|
|
18
|
+
const olderLegacyManagedComment = 'managed by scripts/vercel/provision-env.mjs'
|
|
19
|
+
const shouldAutoCreateProject = iacContext.shouldAutoCreateProject
|
|
20
|
+
|
|
21
|
+
function readPositiveIntegerEnv(key, fallback) {
|
|
22
|
+
const value = Number(process.env[key])
|
|
23
|
+
return Number.isFinite(value) && value >= 0 ? value : fallback
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const defaultThrottleMs = readPositiveIntegerEnv('VERCEL_API_THROTTLE_MS', 250)
|
|
27
|
+
const maxRequestAttempts = Math.max(
|
|
28
|
+
1,
|
|
29
|
+
readPositiveIntegerEnv('VERCEL_API_MAX_ATTEMPTS', 4),
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
const validTargets = ['development', 'preview', 'production']
|
|
33
|
+
const validScopes = ['team', 'projects', 'all']
|
|
34
|
+
|
|
35
|
+
const c = {
|
|
36
|
+
reset: '\x1b[0m',
|
|
37
|
+
bold: (s) => `\x1b[1m${s}\x1b[0m`,
|
|
38
|
+
dim: (s) => `\x1b[2m${s}\x1b[0m`,
|
|
39
|
+
cyan: (s) => `\x1b[36m${s}\x1b[0m`,
|
|
40
|
+
green: (s) => `\x1b[32m${s}\x1b[0m`,
|
|
41
|
+
yellow: (s) => `\x1b[33m${s}\x1b[0m`,
|
|
42
|
+
blue: (s) => `\x1b[34m${s}\x1b[0m`,
|
|
43
|
+
red: (s) => `\x1b[31m${s}\x1b[0m`,
|
|
44
|
+
gray: (s) => `\x1b[90m${s}\x1b[0m`,
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function parseArgs(argv) {
|
|
48
|
+
const args = {
|
|
49
|
+
apply: false,
|
|
50
|
+
scope: 'all',
|
|
51
|
+
targets: ['preview', 'production'],
|
|
52
|
+
projects: [],
|
|
53
|
+
reconcileDelete: false,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
for (const arg of argv) {
|
|
57
|
+
if (arg === '--apply') {
|
|
58
|
+
args.apply = true
|
|
59
|
+
continue
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (arg.startsWith('--scope=')) {
|
|
63
|
+
args.scope = arg.slice('--scope='.length)
|
|
64
|
+
continue
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (arg.startsWith('--targets=')) {
|
|
68
|
+
args.targets = arg
|
|
69
|
+
.slice('--targets='.length)
|
|
70
|
+
.split(',')
|
|
71
|
+
.map((value) => value.trim())
|
|
72
|
+
.filter(Boolean)
|
|
73
|
+
continue
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (arg.startsWith('--projects=')) {
|
|
77
|
+
args.projects = arg
|
|
78
|
+
.slice('--projects='.length)
|
|
79
|
+
.split(',')
|
|
80
|
+
.map((value) => value.trim())
|
|
81
|
+
.filter(Boolean)
|
|
82
|
+
continue
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (arg === '--reconcile-delete') {
|
|
86
|
+
args.reconcileDelete = true
|
|
87
|
+
continue
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (!validScopes.includes(args.scope)) {
|
|
92
|
+
throw new Error(
|
|
93
|
+
`Invalid --scope value "${args.scope}". Use one of: ${validScopes.join(', ')}`,
|
|
94
|
+
)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
for (const target of args.targets) {
|
|
98
|
+
if (!validTargets.includes(target)) {
|
|
99
|
+
throw new Error(
|
|
100
|
+
`Invalid target "${target}". Use one of: ${validTargets.join(', ')}`,
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return args
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function parseEnvFile(filePath) {
|
|
109
|
+
if (!fs.existsSync(filePath)) return []
|
|
110
|
+
|
|
111
|
+
const lines = fs.readFileSync(filePath, 'utf8').split(/\r?\n/)
|
|
112
|
+
const entries = []
|
|
113
|
+
const seen = new Set()
|
|
114
|
+
|
|
115
|
+
for (const line of lines) {
|
|
116
|
+
if (!line || line.trim().startsWith('#')) continue
|
|
117
|
+
const index = line.indexOf('=')
|
|
118
|
+
if (index <= 0) continue
|
|
119
|
+
|
|
120
|
+
const key = line.slice(0, index).trim()
|
|
121
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue
|
|
122
|
+
|
|
123
|
+
let value = line.slice(index + 1).trim()
|
|
124
|
+
if (
|
|
125
|
+
(value.startsWith('"') && value.endsWith('"')) ||
|
|
126
|
+
(value.startsWith("'") && value.endsWith("'"))
|
|
127
|
+
) {
|
|
128
|
+
value = value.slice(1, -1)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (!seen.has(key)) {
|
|
132
|
+
entries.push({ key, value })
|
|
133
|
+
seen.add(key)
|
|
134
|
+
} else {
|
|
135
|
+
const i = entries.findIndex((entry) => entry.key === key)
|
|
136
|
+
entries[i] = { key, value }
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return entries
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function readTokenFromFile(filePath) {
|
|
144
|
+
if (!fs.existsSync(filePath)) return ''
|
|
145
|
+
|
|
146
|
+
const lines = fs.readFileSync(filePath, 'utf8').split(/\r?\n/)
|
|
147
|
+
for (const line of lines) {
|
|
148
|
+
const trimmed = line.trim()
|
|
149
|
+
if (!trimmed || trimmed.startsWith('#')) continue
|
|
150
|
+
|
|
151
|
+
if (trimmed.startsWith('VERCEL_TOKEN=')) {
|
|
152
|
+
return trimmed.slice('VERCEL_TOKEN='.length).trim()
|
|
153
|
+
}
|
|
154
|
+
if (trimmed.startsWith('VERCEL_API_KEY=')) {
|
|
155
|
+
return trimmed.slice('VERCEL_API_KEY='.length).trim()
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Also allow plain token-only files.
|
|
159
|
+
return trimmed
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return ''
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Maps a Vercel target name to the .env filename used in infrastructure folders.
|
|
166
|
+
const targetToEnvFile = {
|
|
167
|
+
development: '.env.development',
|
|
168
|
+
staging: '.env.staging',
|
|
169
|
+
production: '.env.production',
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function readInfraScopedEntries(infraDir, target, projects) {
|
|
173
|
+
const envFile = targetToEnvFile[target]
|
|
174
|
+
|
|
175
|
+
const teamEntries = parseEnvFile(path.join(rootDir, infraDir, 'shared', envFile))
|
|
176
|
+
|
|
177
|
+
const projectEntries = Object.fromEntries(
|
|
178
|
+
projects.map((project) => [
|
|
179
|
+
project.key,
|
|
180
|
+
parseEnvFile(path.join(rootDir, infraDir, project.key, envFile)),
|
|
181
|
+
]),
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
return { teamEntries, projectEntries }
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function targetIncludes(envVar, target) {
|
|
188
|
+
const envTarget = envVar?.target
|
|
189
|
+
if (!envTarget) return false
|
|
190
|
+
if (Array.isArray(envTarget)) return envTarget.includes(target)
|
|
191
|
+
return envTarget === target
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function toQuery(params) {
|
|
195
|
+
const query = new URLSearchParams()
|
|
196
|
+
for (const [key, value] of Object.entries(params)) {
|
|
197
|
+
if (value === undefined || value === null || value === '') continue
|
|
198
|
+
query.set(key, String(value))
|
|
199
|
+
}
|
|
200
|
+
const encoded = query.toString()
|
|
201
|
+
return encoded ? `?${encoded}` : ''
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function sleep(ms) {
|
|
205
|
+
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function chunkEntries(entries, size) {
|
|
209
|
+
const chunks = []
|
|
210
|
+
for (let index = 0; index < entries.length; index += size) {
|
|
211
|
+
chunks.push(entries.slice(index, index + size))
|
|
212
|
+
}
|
|
213
|
+
return chunks
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function readRetryAfterMs(response, attempt) {
|
|
217
|
+
const retryAfter = response.headers.get('retry-after')
|
|
218
|
+
if (retryAfter) {
|
|
219
|
+
const retryAfterSeconds = Number(retryAfter)
|
|
220
|
+
if (Number.isFinite(retryAfterSeconds)) {
|
|
221
|
+
return Math.max(0, retryAfterSeconds * 1000)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const retryAfterDate = Date.parse(retryAfter)
|
|
225
|
+
if (Number.isFinite(retryAfterDate)) {
|
|
226
|
+
return Math.max(0, retryAfterDate - Date.now())
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const reset = Number(response.headers.get('x-ratelimit-reset'))
|
|
231
|
+
if (Number.isFinite(reset) && reset > 0) {
|
|
232
|
+
return Math.max(0, reset * 1000 - Date.now())
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return Math.min(30000, 1000 * 2 ** attempt)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async function requestJSON({ token, method, pathname, query, body }) {
|
|
239
|
+
const url = `${apiBase}${pathname}${toQuery(query || {})}`
|
|
240
|
+
|
|
241
|
+
for (let attempt = 0; attempt < maxRequestAttempts; attempt += 1) {
|
|
242
|
+
const response = await fetch(url, {
|
|
243
|
+
method,
|
|
244
|
+
headers: {
|
|
245
|
+
Authorization: `Bearer ${token}`,
|
|
246
|
+
'Content-Type': 'application/json',
|
|
247
|
+
Accept: 'application/json',
|
|
248
|
+
},
|
|
249
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
if (response.ok) {
|
|
253
|
+
if (defaultThrottleMs > 0) await sleep(defaultThrottleMs)
|
|
254
|
+
if (response.status === 204) return {}
|
|
255
|
+
return response.json()
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const errorText = await response.text()
|
|
259
|
+
if (response.status === 429 && attempt < maxRequestAttempts - 1) {
|
|
260
|
+
const delayMs = readRetryAfterMs(response, attempt)
|
|
261
|
+
console.warn(
|
|
262
|
+
`[rate-limit] Vercel API ${method} ${pathname} returned 429; retrying in ${Math.ceil(delayMs / 1000)}s`,
|
|
263
|
+
)
|
|
264
|
+
await sleep(delayMs)
|
|
265
|
+
continue
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
throw new Error(
|
|
269
|
+
`Vercel API ${method} ${pathname} failed (${response.status}): ${errorText}`,
|
|
270
|
+
)
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
throw new Error(`Vercel API ${method} ${pathname} failed after retries`)
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
async function resolveTeamId({ token, teamSlug }) {
|
|
277
|
+
const teams = await requestJSON({
|
|
278
|
+
token,
|
|
279
|
+
method: 'GET',
|
|
280
|
+
pathname: '/v1/teams',
|
|
281
|
+
})
|
|
282
|
+
const rows = Array.isArray(teams.teams) ? teams.teams : []
|
|
283
|
+
const match = rows.find((team) => team?.slug === teamSlug)
|
|
284
|
+
if (!match?.id) {
|
|
285
|
+
throw new Error(`Unable to resolve team ID for slug "${teamSlug}"`)
|
|
286
|
+
}
|
|
287
|
+
return match.id
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async function listTeamProjects({ token, teamId }) {
|
|
291
|
+
const projects = await requestJSON({
|
|
292
|
+
token,
|
|
293
|
+
method: 'GET',
|
|
294
|
+
pathname: '/v9/projects',
|
|
295
|
+
query: { teamId, limit: 100 },
|
|
296
|
+
})
|
|
297
|
+
return Array.isArray(projects.projects) ? projects.projects : []
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
async function createTeamProject({ token, teamId, name }) {
|
|
301
|
+
const project = await requestJSON({
|
|
302
|
+
token,
|
|
303
|
+
method: 'POST',
|
|
304
|
+
pathname: '/v10/projects',
|
|
305
|
+
query: { teamId },
|
|
306
|
+
body: { name },
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
const id =
|
|
310
|
+
typeof project?.id === 'string'
|
|
311
|
+
? project.id.trim()
|
|
312
|
+
: typeof project?.project?.id === 'string'
|
|
313
|
+
? project.project.id.trim()
|
|
314
|
+
: ''
|
|
315
|
+
|
|
316
|
+
if (!id) {
|
|
317
|
+
throw new Error(`Created Vercel project "${name}" but no project id was returned`)
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
return id
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function isOnlyExistingKeyAndTargetError(error) {
|
|
324
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
325
|
+
const jsonStart = message.indexOf('{')
|
|
326
|
+
if (jsonStart < 0) return false
|
|
327
|
+
|
|
328
|
+
try {
|
|
329
|
+
const payload = JSON.parse(message.slice(jsonStart))
|
|
330
|
+
const failed = Array.isArray(payload?.failed) ? payload.failed : []
|
|
331
|
+
if (failed.length === 0) return false
|
|
332
|
+
return failed.every(
|
|
333
|
+
(item) => item?.error?.code === 'existing_key_and_target',
|
|
334
|
+
)
|
|
335
|
+
} catch {
|
|
336
|
+
return false
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function isManagedEnvVar(envVar) {
|
|
341
|
+
return (
|
|
342
|
+
envVar?.comment === managedComment ||
|
|
343
|
+
envVar?.comment === legacyManagedComment ||
|
|
344
|
+
envVar?.comment === olderLegacyManagedComment
|
|
345
|
+
)
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
async function listTeamEnvVars({ token, teamSlug }) {
|
|
349
|
+
const rows = []
|
|
350
|
+
let until
|
|
351
|
+
|
|
352
|
+
while (true) {
|
|
353
|
+
const response = await requestJSON({
|
|
354
|
+
token,
|
|
355
|
+
method: 'GET',
|
|
356
|
+
pathname: '/v1/env',
|
|
357
|
+
query: { slug: teamSlug, limit: 100, until },
|
|
358
|
+
})
|
|
359
|
+
|
|
360
|
+
const pageRows = Array.isArray(response.data) ? response.data : []
|
|
361
|
+
rows.push(...pageRows)
|
|
362
|
+
|
|
363
|
+
const next = response?.pagination?.next
|
|
364
|
+
if (!next) break
|
|
365
|
+
until = next
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
return rows
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
async function listProjectEnvVars({ token, teamSlug, projectId }) {
|
|
372
|
+
const response = await requestJSON({
|
|
373
|
+
token,
|
|
374
|
+
method: 'GET',
|
|
375
|
+
pathname: `/v10/projects/${encodeURIComponent(projectId)}/env`,
|
|
376
|
+
query: { slug: teamSlug, decrypt: 'false' },
|
|
377
|
+
})
|
|
378
|
+
|
|
379
|
+
return Array.isArray(response.envs) ? response.envs : []
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
async function deleteTeamEnvVar({ token, teamSlug, envVarId }) {
|
|
383
|
+
await requestJSON({
|
|
384
|
+
token,
|
|
385
|
+
method: 'DELETE',
|
|
386
|
+
pathname: `/v1/env/${encodeURIComponent(envVarId)}`,
|
|
387
|
+
query: { slug: teamSlug },
|
|
388
|
+
})
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
async function deleteProjectEnvVar({ token, teamSlug, projectId, envVarId }) {
|
|
392
|
+
await requestJSON({
|
|
393
|
+
token,
|
|
394
|
+
method: 'DELETE',
|
|
395
|
+
pathname: `/v9/projects/${encodeURIComponent(projectId)}/env/${encodeURIComponent(envVarId)}`,
|
|
396
|
+
query: { slug: teamSlug },
|
|
397
|
+
})
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
async function upsertTeamShared({
|
|
401
|
+
token,
|
|
402
|
+
dryRun,
|
|
403
|
+
reconcileDelete,
|
|
404
|
+
teamSlug,
|
|
405
|
+
target,
|
|
406
|
+
entries,
|
|
407
|
+
projectIds,
|
|
408
|
+
}) {
|
|
409
|
+
if (entries.length === 0 && !reconcileDelete) {
|
|
410
|
+
console.log(`${c.gray(`[team:${target}]`)} no keys, skipping`)
|
|
411
|
+
return
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
if (!token) {
|
|
415
|
+
if (reconcileDelete) {
|
|
416
|
+
console.log(
|
|
417
|
+
`${c.cyan(`[team:${target}]`)} ${c.yellow('dry-run (offline):')} cannot compute reconcile deletes without token`,
|
|
418
|
+
)
|
|
419
|
+
}
|
|
420
|
+
console.log(
|
|
421
|
+
`${c.cyan(`[team:${target}]`)} ${c.yellow('dry-run (offline):')} would upsert ${c.bold(String(entries.length))} shared keys`,
|
|
422
|
+
)
|
|
423
|
+
return
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const allTeamEnvVars = await listTeamEnvVars({ token, teamSlug })
|
|
427
|
+
const scopedTeamVars = allTeamEnvVars.filter((envVar) =>
|
|
428
|
+
targetIncludes(envVar, target),
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
const existingByKey = new Map()
|
|
432
|
+
for (const envVar of scopedTeamVars) {
|
|
433
|
+
if (typeof envVar?.key === 'string' && typeof envVar?.id === 'string') {
|
|
434
|
+
existingByKey.set(envVar.key, envVar)
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const updates = {}
|
|
439
|
+
const updateKeys = []
|
|
440
|
+
const creates = []
|
|
441
|
+
const desiredKeys = new Set(entries.map((entry) => entry.key))
|
|
442
|
+
|
|
443
|
+
for (const entry of entries) {
|
|
444
|
+
const match = existingByKey.get(entry.key)
|
|
445
|
+
|
|
446
|
+
if (match) {
|
|
447
|
+
updates[match.id] = {
|
|
448
|
+
value: entry.value,
|
|
449
|
+
type: 'encrypted',
|
|
450
|
+
target: [target],
|
|
451
|
+
projectIdUpdates: { link: projectIds },
|
|
452
|
+
comment: managedComment,
|
|
453
|
+
}
|
|
454
|
+
updateKeys.push(entry.key)
|
|
455
|
+
} else {
|
|
456
|
+
creates.push({
|
|
457
|
+
key: entry.key,
|
|
458
|
+
value: entry.value,
|
|
459
|
+
comment: managedComment,
|
|
460
|
+
})
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
const staleManagedVars = reconcileDelete
|
|
465
|
+
? scopedTeamVars.filter(
|
|
466
|
+
(envVar) => !desiredKeys.has(envVar.key) && isManagedEnvVar(envVar),
|
|
467
|
+
)
|
|
468
|
+
: []
|
|
469
|
+
|
|
470
|
+
const updateCount = updateKeys.length
|
|
471
|
+
console.log(
|
|
472
|
+
`${c.cyan(`[team:${target}]`)} ${dryRun ? 'would update' : c.green('updating')} ${c.bold(String(updateCount))}, ${dryRun ? 'would create' : c.green('creating')} ${c.bold(String(creates.length))}`,
|
|
473
|
+
)
|
|
474
|
+
if (reconcileDelete) {
|
|
475
|
+
console.log(
|
|
476
|
+
`${c.cyan(`[team:${target}]`)} ${dryRun ? 'would delete' : c.green('deleting')} ${c.bold(String(staleManagedVars.length))} stale managed keys`,
|
|
477
|
+
)
|
|
478
|
+
}
|
|
479
|
+
if (dryRun) {
|
|
480
|
+
if (updateKeys.length > 0) {
|
|
481
|
+
console.log(` ${c.yellow('update:')} ${c.dim(updateKeys.join(', '))}`)
|
|
482
|
+
}
|
|
483
|
+
if (creates.length > 0) {
|
|
484
|
+
console.log(` ${c.blue('create:')} ${c.dim(creates.map((e) => e.key).join(', '))}`)
|
|
485
|
+
}
|
|
486
|
+
if (staleManagedVars.length > 0) {
|
|
487
|
+
console.log(
|
|
488
|
+
` ${c.red('delete:')} ${c.dim(staleManagedVars.map((envVar) => envVar.key).join(', '))}`,
|
|
489
|
+
)
|
|
490
|
+
}
|
|
491
|
+
return
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
if (updateCount > 0) {
|
|
495
|
+
for (const updateChunk of chunkEntries(Object.entries(updates), 50)) {
|
|
496
|
+
await requestJSON({
|
|
497
|
+
token,
|
|
498
|
+
method: 'PATCH',
|
|
499
|
+
pathname: '/v1/env',
|
|
500
|
+
query: { slug: teamSlug },
|
|
501
|
+
body: { updates: Object.fromEntries(updateChunk) },
|
|
502
|
+
})
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
if (creates.length > 0) {
|
|
507
|
+
try {
|
|
508
|
+
await requestJSON({
|
|
509
|
+
token,
|
|
510
|
+
method: 'POST',
|
|
511
|
+
pathname: '/v1/env',
|
|
512
|
+
query: { slug: teamSlug },
|
|
513
|
+
body: {
|
|
514
|
+
evs: creates,
|
|
515
|
+
type: 'encrypted',
|
|
516
|
+
target: [target],
|
|
517
|
+
projectId: projectIds,
|
|
518
|
+
},
|
|
519
|
+
})
|
|
520
|
+
} catch (error) {
|
|
521
|
+
if (isOnlyExistingKeyAndTargetError(error)) {
|
|
522
|
+
console.log(
|
|
523
|
+
c.yellow(
|
|
524
|
+
`[team:${target}] create request contained only existing keys; continuing`,
|
|
525
|
+
),
|
|
526
|
+
)
|
|
527
|
+
} else {
|
|
528
|
+
throw error
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
for (const envVar of staleManagedVars) {
|
|
534
|
+
await deleteTeamEnvVar({
|
|
535
|
+
token,
|
|
536
|
+
teamSlug,
|
|
537
|
+
envVarId: envVar.id,
|
|
538
|
+
})
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
async function upsertProjectEnv({
|
|
543
|
+
token,
|
|
544
|
+
dryRun,
|
|
545
|
+
reconcileDelete,
|
|
546
|
+
teamSlug,
|
|
547
|
+
projectId,
|
|
548
|
+
projectName,
|
|
549
|
+
target,
|
|
550
|
+
entries,
|
|
551
|
+
}) {
|
|
552
|
+
if (entries.length === 0 && !reconcileDelete) {
|
|
553
|
+
console.log(`${c.gray(`[project:${projectName}:${target}]`)} no keys, skipping`)
|
|
554
|
+
return
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
console.log(
|
|
558
|
+
`${c.cyan(`[project:${projectName}:${target}]`)} ${dryRun ? 'would upsert' : c.green('upserting')} ${c.bold(String(entries.length))} keys`,
|
|
559
|
+
)
|
|
560
|
+
|
|
561
|
+
if (dryRun) {
|
|
562
|
+
console.log(` ${c.blue('upsert:')} ${c.dim(entries.map((e) => e.key).join(', '))}`)
|
|
563
|
+
} else if (!token) {
|
|
564
|
+
throw new Error('Missing Vercel token for apply mode')
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
if (entries.length > 0 && !dryRun) {
|
|
568
|
+
const payload = entries.map((entry) => ({
|
|
569
|
+
key: entry.key,
|
|
570
|
+
value: entry.value,
|
|
571
|
+
type: 'encrypted',
|
|
572
|
+
target: [target],
|
|
573
|
+
comment: managedComment,
|
|
574
|
+
}))
|
|
575
|
+
|
|
576
|
+
await requestJSON({
|
|
577
|
+
token,
|
|
578
|
+
method: 'POST',
|
|
579
|
+
pathname: `/v10/projects/${encodeURIComponent(projectId)}/env`,
|
|
580
|
+
query: { slug: teamSlug, upsert: 'true' },
|
|
581
|
+
body: payload,
|
|
582
|
+
})
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
if (!reconcileDelete) return
|
|
586
|
+
|
|
587
|
+
if (!token) {
|
|
588
|
+
console.log(
|
|
589
|
+
`${c.cyan(`[project:${projectName}:${target}]`)} ${c.yellow('dry-run (offline):')} cannot compute reconcile deletes without token`,
|
|
590
|
+
)
|
|
591
|
+
return
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
const desiredKeys = new Set(entries.map((entry) => entry.key))
|
|
595
|
+
const existingVars = await listProjectEnvVars({ token, teamSlug, projectId })
|
|
596
|
+
const staleManagedVars = existingVars.filter(
|
|
597
|
+
(envVar) => targetIncludes(envVar, target) && !desiredKeys.has(envVar.key) && isManagedEnvVar(envVar),
|
|
598
|
+
)
|
|
599
|
+
|
|
600
|
+
console.log(
|
|
601
|
+
`${c.cyan(`[project:${projectName}:${target}]`)} ${dryRun ? 'would delete' : c.green('deleting')} ${c.bold(String(staleManagedVars.length))} stale managed keys`,
|
|
602
|
+
)
|
|
603
|
+
|
|
604
|
+
if (dryRun) {
|
|
605
|
+
if (staleManagedVars.length > 0) {
|
|
606
|
+
console.log(
|
|
607
|
+
` ${c.red('delete:')} ${c.dim(staleManagedVars.map((envVar) => envVar.key).join(', '))}`,
|
|
608
|
+
)
|
|
609
|
+
}
|
|
610
|
+
return
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
for (const envVar of staleManagedVars) {
|
|
614
|
+
await deleteProjectEnvVar({
|
|
615
|
+
token,
|
|
616
|
+
teamSlug,
|
|
617
|
+
projectId,
|
|
618
|
+
envVarId: envVar.id,
|
|
619
|
+
})
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
async function main() {
|
|
624
|
+
const args = parseArgs(process.argv.slice(2))
|
|
625
|
+
const dryRun = !args.apply
|
|
626
|
+
const token =
|
|
627
|
+
process.env.VERCEL_TOKEN ||
|
|
628
|
+
process.env.VERCEL_API_KEY ||
|
|
629
|
+
readTokenFromFile(tokenFilePath)
|
|
630
|
+
const manifest = readVercelEnvManifest(iacContext)
|
|
631
|
+
const teamSlug = manifest.teamSlug
|
|
632
|
+
const projects = Array.isArray(manifest.projects) ? manifest.projects : []
|
|
633
|
+
const configuredProjects = [...projects]
|
|
634
|
+
const infraDir = iacContext.infraDir || manifest.infraDir
|
|
635
|
+
|
|
636
|
+
if (!teamSlug) {
|
|
637
|
+
throw new Error('Missing "teamSlug" in env manifest')
|
|
638
|
+
}
|
|
639
|
+
if (!infraDir) {
|
|
640
|
+
throw new Error('Missing "infraDir" in env manifest')
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
let teamId = ''
|
|
644
|
+
if (token) {
|
|
645
|
+
teamId = await resolveTeamId({ token, teamSlug })
|
|
646
|
+
const remoteProjects = await listTeamProjects({ token, teamId })
|
|
647
|
+
const remoteIdByName = new Map(
|
|
648
|
+
remoteProjects
|
|
649
|
+
.filter(
|
|
650
|
+
(project) =>
|
|
651
|
+
typeof project.name === 'string' &&
|
|
652
|
+
project.name.trim().length > 0 &&
|
|
653
|
+
typeof project.id === 'string' &&
|
|
654
|
+
project.id.trim().length > 0
|
|
655
|
+
)
|
|
656
|
+
.map((project) => [project.name.trim(), project.id.trim()])
|
|
657
|
+
)
|
|
658
|
+
|
|
659
|
+
for (const project of configuredProjects) {
|
|
660
|
+
if (
|
|
661
|
+
typeof project?.key !== 'string' ||
|
|
662
|
+
typeof project?.name !== 'string' ||
|
|
663
|
+
(typeof project?.id === 'string' && project.id.trim().length > 0)
|
|
664
|
+
) {
|
|
665
|
+
continue
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
const projectName = project.name.trim()
|
|
669
|
+
let resolvedId = remoteIdByName.get(projectName)
|
|
670
|
+
|
|
671
|
+
if (!resolvedId && shouldAutoCreateProject(project.key)) {
|
|
672
|
+
if (dryRun) {
|
|
673
|
+
console.log(
|
|
674
|
+
c.yellow(
|
|
675
|
+
`[plan] ${project.key}: would create Vercel project "${projectName}" because env-manifest id is missing`,
|
|
676
|
+
),
|
|
677
|
+
)
|
|
678
|
+
continue
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
resolvedId = await createTeamProject({
|
|
682
|
+
token,
|
|
683
|
+
teamId,
|
|
684
|
+
name: projectName,
|
|
685
|
+
})
|
|
686
|
+
remoteIdByName.set(projectName, resolvedId)
|
|
687
|
+
console.log(
|
|
688
|
+
c.green(
|
|
689
|
+
`[created] ${project.key}: created Vercel project "${projectName}" -> ${resolvedId}`,
|
|
690
|
+
),
|
|
691
|
+
)
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
if (!resolvedId) continue
|
|
695
|
+
project.id = resolvedId
|
|
696
|
+
console.log(
|
|
697
|
+
c.gray(
|
|
698
|
+
`[resolved] ${project.key}: using Vercel project "${project.name}" -> ${resolvedId}`
|
|
699
|
+
)
|
|
700
|
+
)
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
const configuredProjectsWithId = configuredProjects.filter(
|
|
705
|
+
(project) => typeof project.id === 'string' && project.id.trim().length > 0
|
|
706
|
+
)
|
|
707
|
+
const projectIds = configuredProjectsWithId.map((project) => project.id)
|
|
708
|
+
|
|
709
|
+
const selectedProjects = args.projects.length
|
|
710
|
+
? projects.filter((project) => args.projects.includes(project.key))
|
|
711
|
+
: projects
|
|
712
|
+
|
|
713
|
+
if (args.projects.length && selectedProjects.length !== args.projects.length) {
|
|
714
|
+
const found = new Set(selectedProjects.map((project) => project.key))
|
|
715
|
+
const missing = args.projects.filter((project) => !found.has(project))
|
|
716
|
+
throw new Error(`Unknown project key(s): ${missing.join(', ')}`)
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
const selectedConfiguredProjects = selectedProjects.filter((project) =>
|
|
720
|
+
configuredProjectsWithId.some((configured) => configured.key === project.key),
|
|
721
|
+
)
|
|
722
|
+
const selectedConfiguredProjectIds = selectedConfiguredProjects.map((project) => {
|
|
723
|
+
const configured = configuredProjectsWithId.find(
|
|
724
|
+
(candidate) => candidate.key === project.key,
|
|
725
|
+
)
|
|
726
|
+
return configured.id
|
|
727
|
+
})
|
|
728
|
+
const skippedUnconfiguredProjects = selectedProjects.filter(
|
|
729
|
+
(project) =>
|
|
730
|
+
!configuredProjectsWithId.some((configured) => configured.key === project.key),
|
|
731
|
+
)
|
|
732
|
+
|
|
733
|
+
if (skippedUnconfiguredProjects.length > 0) {
|
|
734
|
+
console.log(
|
|
735
|
+
c.yellow(
|
|
736
|
+
`Skipping project(s) without Vercel project ID: ${skippedUnconfiguredProjects.map((project) => project.key).join(', ')}`,
|
|
737
|
+
),
|
|
738
|
+
)
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
if (!dryRun && !token) {
|
|
742
|
+
throw new Error('Missing VERCEL_TOKEN (or VERCEL_API_KEY) for apply mode')
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
console.log(
|
|
746
|
+
`${c.bold('Mode:')} ${dryRun ? c.yellow('dry-run') : c.green('apply')} ${c.gray('|')} scope=${c.cyan(args.scope)} ${c.gray('|')} targets=${c.cyan(args.targets.join(','))} ${c.gray('|')} reconcile-delete=${c.cyan(args.reconcileDelete ? 'on' : 'off')}`,
|
|
747
|
+
)
|
|
748
|
+
|
|
749
|
+
// preview deployments read from the staging env files
|
|
750
|
+
const resolvedTarget = (target) => (target === 'preview' ? 'staging' : target)
|
|
751
|
+
|
|
752
|
+
if (args.scope === 'team' || args.scope === 'all') {
|
|
753
|
+
for (const target of args.targets) {
|
|
754
|
+
const parsed = readInfraScopedEntries(infraDir, resolvedTarget(target), projects)
|
|
755
|
+
await upsertTeamShared({
|
|
756
|
+
token,
|
|
757
|
+
dryRun,
|
|
758
|
+
reconcileDelete: args.reconcileDelete,
|
|
759
|
+
teamSlug,
|
|
760
|
+
target,
|
|
761
|
+
entries: parsed.teamEntries,
|
|
762
|
+
projectIds: args.projects.length ? selectedConfiguredProjectIds : projectIds,
|
|
763
|
+
})
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
if (args.scope === 'projects' || args.scope === 'all') {
|
|
768
|
+
const parsedByTarget = {}
|
|
769
|
+
for (const target of args.targets) {
|
|
770
|
+
parsedByTarget[target] = readInfraScopedEntries(
|
|
771
|
+
infraDir,
|
|
772
|
+
resolvedTarget(target),
|
|
773
|
+
selectedProjects,
|
|
774
|
+
)
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
for (const project of selectedConfiguredProjects) {
|
|
778
|
+
for (const target of args.targets) {
|
|
779
|
+
const entries = parsedByTarget[target].projectEntries[project.key] || []
|
|
780
|
+
await upsertProjectEnv({
|
|
781
|
+
token,
|
|
782
|
+
dryRun,
|
|
783
|
+
reconcileDelete: args.reconcileDelete,
|
|
784
|
+
teamSlug,
|
|
785
|
+
projectId: project.id,
|
|
786
|
+
projectName: project.name,
|
|
787
|
+
target,
|
|
788
|
+
entries,
|
|
789
|
+
})
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
main().catch((error) => {
|
|
796
|
+
console.error(c.red('Error:'), error instanceof Error ? error.message : String(error))
|
|
797
|
+
process.exit(1)
|
|
798
|
+
})
|