|
| 1 | +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; |
| 2 | +import os from 'node:os'; |
| 3 | +import path from 'node:path'; |
| 4 | +import { |
| 5 | + ENVIRONMENTS, |
| 6 | + PROJECTS, |
| 7 | + confirm, |
| 8 | + findRepoRoot, |
| 9 | + question, |
| 10 | + readSecret, |
| 11 | + resolveVault, |
| 12 | + resolveVercelContexts, |
| 13 | + setEnvDefault, |
| 14 | + setVariable, |
| 15 | + setVaultValue, |
| 16 | + trackedEnvFiles, |
| 17 | + type Environment, |
| 18 | + type Values, |
| 19 | +} from './shared.js'; |
| 20 | + |
| 21 | +type Options = { |
| 22 | + name: string; |
| 23 | + dryRun: boolean; |
| 24 | + valueFiles: Partial<Record<Environment, string>>; |
| 25 | +}; |
| 26 | + |
| 27 | +function usage(): never { |
| 28 | + throw new Error( |
| 29 | + [ |
| 30 | + 'Usage: pnpm web:env set VARIABLE [--dry-run]', |
| 31 | + ' [--development-file PATH] [--staging-file PATH] [--production-file PATH]', |
| 32 | + ].join('\n') |
| 33 | + ); |
| 34 | +} |
| 35 | + |
| 36 | +function parseOptions(args: string[]): Options { |
| 37 | + if (args[0] !== 'set' || !args[1]) usage(); |
| 38 | + const name = args[1]; |
| 39 | + const valueFiles: Partial<Record<Environment, string>> = {}; |
| 40 | + let dryRun = false; |
| 41 | + |
| 42 | + for (let index = 2; index < args.length; index += 1) { |
| 43 | + const argument = args[index]; |
| 44 | + if (argument === '--dry-run') dryRun = true; |
| 45 | + else { |
| 46 | + const match = argument?.match(/^--(development|staging|production)-file(?:=(.*))?$/); |
| 47 | + if (!match) usage(); |
| 48 | + const environment = match[1] as Environment; |
| 49 | + const nextArgument = args[index + 1]; |
| 50 | + const file = match[2] || nextArgument; |
| 51 | + if (!file) usage(); |
| 52 | + if (!match[2]) index += 1; |
| 53 | + valueFiles[environment] = file; |
| 54 | + } |
| 55 | + } |
| 56 | + |
| 57 | + if (!/^[A-Z_][A-Z0-9_]*$/.test(name)) { |
| 58 | + throw new Error('Variable names must contain only uppercase letters, digits, and underscores.'); |
| 59 | + } |
| 60 | + return { name, dryRun, valueFiles }; |
| 61 | +} |
| 62 | + |
| 63 | +async function askSensitivity(name: string): Promise<boolean> { |
| 64 | + while (true) { |
| 65 | + const answer = (await question(`Is ${name} sensitive? [Y/n] `)).trim().toLowerCase(); |
| 66 | + if (!['', 'y', 'yes', 'n', 'no'].includes(answer)) { |
| 67 | + console.warn('Please answer yes or no.'); |
| 68 | + continue; |
| 69 | + } |
| 70 | + const sensitive = !['n', 'no'].includes(answer); |
| 71 | + if (sensitive && name.startsWith('NEXT_PUBLIC_')) { |
| 72 | + console.warn('NEXT_PUBLIC_* values are browser-visible; answer no.'); |
| 73 | + continue; |
| 74 | + } |
| 75 | + return sensitive; |
| 76 | + } |
| 77 | +} |
| 78 | + |
| 79 | +function normalizeFileValue(value: string): string { |
| 80 | + const trailingNewlineLength = value.endsWith('\r\n') ? 2 : value.endsWith('\n') ? 1 : 0; |
| 81 | + if (trailingNewlineLength === 0) return value; |
| 82 | + const valueWithoutTrailingNewline = value.slice(0, -trailingNewlineLength); |
| 83 | + return /[\r\n]/.test(valueWithoutTrailingNewline) ? value : valueWithoutTrailingNewline; |
| 84 | +} |
| 85 | + |
| 86 | +async function collectValues(options: Options): Promise<Values> { |
| 87 | + const values: Partial<Values> = {}; |
| 88 | + for (const environment of ENVIRONMENTS) { |
| 89 | + const file = options.valueFiles[environment]; |
| 90 | + if (file) { |
| 91 | + const value = normalizeFileValue(readFileSync(path.resolve(file), 'utf8')); |
| 92 | + if (!value) throw new Error(`${environment} value file cannot be empty.`); |
| 93 | + values[environment] = value; |
| 94 | + continue; |
| 95 | + } |
| 96 | + |
| 97 | + while (!values[environment]) { |
| 98 | + const value = await readSecret(`${environment} value: `); |
| 99 | + if (value) values[environment] = value; |
| 100 | + else console.warn(`${environment} value cannot be empty. Please try again.`); |
| 101 | + } |
| 102 | + } |
| 103 | + return values as Values; |
| 104 | +} |
| 105 | + |
| 106 | +async function collectDefaults(repoRoot: string, name: string): Promise<Map<string, string>> { |
| 107 | + const defaults = new Map<string, string>(); |
| 108 | + for (const relativeFile of trackedEnvFiles(repoRoot)) { |
| 109 | + const value = await question( |
| 110 | + `${relativeFile}: default value for ${name} (press Return to skip): ` |
| 111 | + ); |
| 112 | + if (!value) continue; |
| 113 | + defaults.set(relativeFile, value); |
| 114 | + } |
| 115 | + return defaults; |
| 116 | +} |
| 117 | + |
| 118 | +function warnAboutMissingTrackedDefault(name: string): void { |
| 119 | + const border = '='.repeat(78); |
| 120 | + console.warn(` |
| 121 | +\x1b[1;33m${border} |
| 122 | +NO TRACKED ENV DEFAULT WILL BE ADDED |
| 123 | +
|
| 124 | +Make sure the application can start and run without ${name}. If the code requires |
| 125 | +this variable, external contributors without access to shared secrets will run |
| 126 | +into setup, test, or build failures. |
| 127 | +${border}\x1b[0m |
| 128 | +`); |
| 129 | +} |
| 130 | + |
| 131 | +function assignmentValue(content: string, name: string): string | undefined { |
| 132 | + const assignment = content.split('\n').find(line => line.startsWith(`${name}=`)); |
| 133 | + if (!assignment) return undefined; |
| 134 | + const value = assignment.slice(name.length + 1); |
| 135 | + try { |
| 136 | + const parsed: unknown = JSON.parse(value); |
| 137 | + return typeof parsed === 'string' ? parsed : value; |
| 138 | + } catch { |
| 139 | + return value; |
| 140 | + } |
| 141 | +} |
| 142 | + |
| 143 | +function rejectMatchingTrackedValues( |
| 144 | + repoRoot: string, |
| 145 | + name: string, |
| 146 | + values: Values, |
| 147 | + defaults: Map<string, string> |
| 148 | +): void { |
| 149 | + for (const relativeFile of trackedEnvFiles(repoRoot)) { |
| 150 | + const content = readFileSync(path.join(repoRoot, relativeFile), 'utf8'); |
| 151 | + const trackedValue = defaults.get(relativeFile) ?? assignmentValue(content, name); |
| 152 | + const matchesRemoteValue = Object.values(values).some(value => trackedValue === value); |
| 153 | + if (matchesRemoteValue) { |
| 154 | + throw new Error( |
| 155 | + `${relativeFile} contains or would contain a remote environment value. Use a non-secret local default instead.` |
| 156 | + ); |
| 157 | + } |
| 158 | + } |
| 159 | +} |
| 160 | + |
| 161 | +async function main(): Promise<void> { |
| 162 | + const options = parseOptions(process.argv.slice(2)); |
| 163 | + const sensitive = await askSensitivity(options.name); |
| 164 | + const repoRoot = findRepoRoot(); |
| 165 | + const tempDirectory = mkdtempSync(path.join(os.tmpdir(), 'kilo-web-env-')); |
| 166 | + |
| 167 | + try { |
| 168 | + console.log('Checking Vercel and 1Password access...'); |
| 169 | + const contexts = resolveVercelContexts(tempDirectory); |
| 170 | + const vaultId = sensitive ? resolveVault() : undefined; |
| 171 | + const values = await collectValues(options); |
| 172 | + const defaults = await collectDefaults(repoRoot, options.name); |
| 173 | + if (defaults.size === 0) warnAboutMissingTrackedDefault(options.name); |
| 174 | + rejectMatchingTrackedValues(repoRoot, options.name, values, defaults); |
| 175 | + |
| 176 | + console.log('\nPlan'); |
| 177 | + for (const environment of ENVIRONMENTS) { |
| 178 | + const type = sensitive && environment !== 'development' ? 'sensitive' : 'encrypted'; |
| 179 | + for (const project of PROJECTS) console.log(`- ${project}/${environment}: ${type}`); |
| 180 | + } |
| 181 | + for (const [file, value] of defaults) |
| 182 | + console.log(`- ${file}: ${options.name}=${JSON.stringify(value)}`); |
| 183 | + console.log(`- 1Password: ${sensitive ? 'update Production copy' : 'skip'}`); |
| 184 | + console.log('- Deployments: not triggered'); |
| 185 | + |
| 186 | + if (options.dryRun) { |
| 187 | + console.log('\nDry run complete; nothing changed.'); |
| 188 | + return; |
| 189 | + } |
| 190 | + if (!(await confirm('\nApply these changes?'))) { |
| 191 | + console.log('Cancelled; nothing changed.'); |
| 192 | + return; |
| 193 | + } |
| 194 | + |
| 195 | + for (const [relativeFile, value] of defaults) { |
| 196 | + setEnvDefault(path.join(repoRoot, relativeFile), options.name, value); |
| 197 | + } |
| 198 | + |
| 199 | + for (const environment of ENVIRONMENTS) { |
| 200 | + for (const context of contexts) { |
| 201 | + console.log(`Setting ${context.project}/${environment}...`); |
| 202 | + setVariable(context, environment, options.name, values[environment], sensitive); |
| 203 | + } |
| 204 | + } |
| 205 | + if (vaultId) { |
| 206 | + console.log('Updating 1Password Production copy...'); |
| 207 | + setVaultValue(vaultId, options.name, values.production); |
| 208 | + } |
| 209 | + |
| 210 | + console.log('\nDone. Rerun the same command if a provider failed partway through.'); |
| 211 | + console.log('Deploy Staging or Production separately when the new value should take effect.'); |
| 212 | + } finally { |
| 213 | + rmSync(tempDirectory, { recursive: true, force: true }); |
| 214 | + } |
| 215 | +} |
| 216 | + |
| 217 | +main().catch(error => { |
| 218 | + console.error(error instanceof Error ? error.message : 'Environment update failed.'); |
| 219 | + process.exitCode = 1; |
| 220 | +}); |
0 commit comments