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