|
| 1 | +#!/usr/bin/env bun |
| 2 | + |
| 3 | +/** |
| 4 | + * Shows what syncing a Trigger.dev environment from the app's env-vars secret |
| 5 | + * would change, by variable name only, before the sync is enabled for it. |
| 6 | + * |
| 7 | + * Reads the app secret, builds the variables the deploy would sync (the same |
| 8 | + * `buildTriggerEnvVars` the `syncEnvVars` extension uses), and compares them |
| 9 | + * with the values the environment holds now, read through the environment's |
| 10 | + * API key exactly as the deploy CLI reads them. Never prints a value. |
| 11 | + * |
| 12 | + * The deploy only sends variables whose value changed, so an unchanged |
| 13 | + * variable stored as plain text stays plain. `--apply-secret-flags` marks |
| 14 | + * those as secrets with their current value, which is otherwise untouched. |
| 15 | + * |
| 16 | + * Usage: |
| 17 | + * AWS_PROFILE=<profile> bun --no-env-file apps/sim/scripts/plan-trigger-env-sync.ts staging |
| 18 | + * AWS_PROFILE=<profile> bun --no-env-file apps/sim/scripts/plan-trigger-env-sync.ts preview |
| 19 | + * AWS_PROFILE=<profile> bun --no-env-file apps/sim/scripts/plan-trigger-env-sync.ts prod --apply-secret-flags |
| 20 | + */ |
| 21 | + |
| 22 | +import { createLogger } from '@sim/logger' |
| 23 | +import { fetchRuntimeSecrets } from '@sim/runtime-secrets' |
| 24 | +import { |
| 25 | + APP_ENV_SECRET_IDS, |
| 26 | + buildTriggerEnvVars, |
| 27 | + diffTriggerEnv, |
| 28 | +} from '@sim/runtime-secrets/trigger-env' |
| 29 | +import { getErrorMessage } from '@sim/utils/errors' |
| 30 | +import { chunkArray } from '@sim/utils/helpers' |
| 31 | + |
| 32 | +const logger = createLogger('PlanTriggerEnvSync') |
| 33 | + |
| 34 | +const TRIGGER_API_URL = 'https://api.trigger.dev' |
| 35 | + |
| 36 | +/** Variables per import call when flipping secret flags. */ |
| 37 | +const IMPORT_BATCH_SIZE = 50 |
| 38 | + |
| 39 | +interface ListedVariable { |
| 40 | + name: string |
| 41 | + isSecret?: boolean |
| 42 | +} |
| 43 | + |
| 44 | +async function main() { |
| 45 | + const environment = process.argv[2] ?? '' |
| 46 | + const applySecretFlags = process.argv.includes('--apply-secret-flags') |
| 47 | + const secretId = APP_ENV_SECRET_IDS[environment] |
| 48 | + if (!secretId) { |
| 49 | + throw new Error( |
| 50 | + `Usage: plan-trigger-env-sync.ts <${Object.keys(APP_ENV_SECRET_IDS).join('|')}> [--apply-secret-flags]` |
| 51 | + ) |
| 52 | + } |
| 53 | + |
| 54 | + const appEnv = await fetchRuntimeSecrets(secretId) |
| 55 | + const { TRIGGER_PROJECT_ID: project, TRIGGER_SECRET_KEY: apiKey } = appEnv |
| 56 | + if (!project || !apiKey) { |
| 57 | + throw new Error(`${secretId} has no TRIGGER_PROJECT_ID/TRIGGER_SECRET_KEY`) |
| 58 | + } |
| 59 | + const branch = environment === 'preview' ? appEnv.TRIGGER_PREVIEW_BRANCH : undefined |
| 60 | + if (environment === 'preview' && !branch) { |
| 61 | + throw new Error(`${secretId} has no TRIGGER_PREVIEW_BRANCH`) |
| 62 | + } |
| 63 | + |
| 64 | + const headers = { |
| 65 | + Authorization: `Bearer ${apiKey}`, |
| 66 | + 'Content-Type': 'application/json', |
| 67 | + ...(branch ? { 'x-trigger-branch': branch } : {}), |
| 68 | + } |
| 69 | + const envvarsUrl = `${TRIGGER_API_URL}/api/v1/projects/${project}/envvars` |
| 70 | + async function request<T>(url: string, body?: unknown): Promise<T> { |
| 71 | + const response = await fetch(url, { |
| 72 | + method: body === undefined ? 'GET' : 'POST', |
| 73 | + headers, |
| 74 | + body: body === undefined ? undefined : JSON.stringify(body), |
| 75 | + }) |
| 76 | + if (!response.ok) throw new Error(`${url} failed with ${response.status}`) |
| 77 | + return (await response.json()) as T |
| 78 | + } |
| 79 | + async function listPlainNames(): Promise<Set<string>> { |
| 80 | + const listed = await request<ListedVariable[] | { data: ListedVariable[] }>( |
| 81 | + `${envvarsUrl}/${environment}` |
| 82 | + ) |
| 83 | + const variables = Array.isArray(listed) ? listed : listed.data |
| 84 | + return new Set(variables.filter((v) => !v.isSecret).map((v) => v.name)) |
| 85 | + } |
| 86 | + |
| 87 | + const [{ variables: current }, plainNames] = await Promise.all([ |
| 88 | + request<{ variables: Record<string, string> }>(envvarsUrl), |
| 89 | + listPlainNames(), |
| 90 | + ]) |
| 91 | + |
| 92 | + const synced = buildTriggerEnvVars(appEnv) |
| 93 | + const syncedNames = new Set(synced.map((v) => v.name)) |
| 94 | + const { added, changed } = diffTriggerEnv(synced, current) |
| 95 | + const toFlag = synced.filter( |
| 96 | + (v) => v.isSecret && plainNames.has(v.name) && current[v.name] === v.value |
| 97 | + ) |
| 98 | + const names = (vars: { name: string }[]) => vars.map((v) => v.name).sort() |
| 99 | + |
| 100 | + logger.info('Trigger.dev env sync plan', { |
| 101 | + environment, |
| 102 | + branch, |
| 103 | + secretId, |
| 104 | + synced: synced.length, |
| 105 | + unchanged: synced.length - added.length - changed.length, |
| 106 | + added: names(added), |
| 107 | + changed: names(changed), |
| 108 | + plainToSecretOnSync: names(changed.filter((v) => v.isSecret && plainNames.has(v.name))), |
| 109 | + plainUnchangedNeedingSecretFlag: names(toFlag), |
| 110 | + appKeysNotSynced: Object.keys(appEnv) |
| 111 | + .filter((name) => !syncedNames.has(name)) |
| 112 | + .sort(), |
| 113 | + }) |
| 114 | + |
| 115 | + if (!applySecretFlags || toFlag.length === 0) return |
| 116 | + // The import resubmits each value, so re-read right before each batch and skip any variable |
| 117 | + // rotated since the plan: flagging must never write back an older value. |
| 118 | + const skipped: string[] = [] |
| 119 | + for (const batch of chunkArray(toFlag, IMPORT_BATCH_SIZE)) { |
| 120 | + const { variables: latest } = await request<{ variables: Record<string, string> }>(envvarsUrl) |
| 121 | + const unrotated = batch.filter((v) => latest[v.name] === v.value) |
| 122 | + skipped.push(...batch.filter((v) => latest[v.name] !== v.value).map((v) => v.name)) |
| 123 | + if (unrotated.length === 0) continue |
| 124 | + await request(`${envvarsUrl}/${environment}/import`, { |
| 125 | + variables: Object.fromEntries(unrotated.map((v) => [v.name, v.value])), |
| 126 | + override: true, |
| 127 | + isSecret: true, |
| 128 | + }) |
| 129 | + } |
| 130 | + const stillPlain = await listPlainNames() |
| 131 | + const unflagged = names(toFlag.filter((v) => stillPlain.has(v.name) && !skipped.includes(v.name))) |
| 132 | + if (unflagged.length > 0) throw new Error(`Still plain after flagging: ${unflagged.join(', ')}`) |
| 133 | + logger.info('Marked unchanged variables as secrets', { |
| 134 | + environment, |
| 135 | + flagged: toFlag.length - skipped.length, |
| 136 | + skippedAsRotated: skipped.sort(), |
| 137 | + }) |
| 138 | +} |
| 139 | + |
| 140 | +main().catch((error) => { |
| 141 | + logger.error('Trigger.dev env sync plan failed', { error: getErrorMessage(error) }) |
| 142 | + process.exit(1) |
| 143 | +}) |
0 commit comments