diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 83d003bdbb..c31eac41dc 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -170,7 +170,9 @@ Answer no for public or otherwise non-secret configuration. `NEXT_PUBLIC_*` vari The command prompts for single-line values without echoing them, then asks for a default value for each tracked root and `apps/web` dotenv file. Enter the raw secret without surrounding quotes (a matching outer `"` or `'` pair is stripped if present, because quoted values break `vercel env pull`). Enter a value directly, or press Return to skip that file. If every file is skipped, the command warns that the application must work without the variable so external contributors can still run it. A tracked default cannot match a remote value; use a non-secret local default instead. Invalid yes/no answers and empty remote values are prompted again instead of terminating the command. For multiline values, use `--development-file`, `--staging-file`, and `--production-file`. Use `--dry-run` to preview the redacted plan. -Remote updates are sequential rather than transactional. If a provider fails partway through, fix the problem and rerun the same command; it safely upserts every target. The workflow does not deploy, so trigger the appropriate deployment separately. +Use `pnpm web:env set EXAMPLE_API_TOKEN --only staging` to rotate just one Vercel environment in both projects. `development`, `staging`, and `production` are supported. A single-environment rotation skips tracked dotenv defaults because they are shared, and skips the Production 1Password copy unless Production is selected. + +Remote updates are sequential rather than transactional. If a provider fails partway through, fix the problem and rerun the same command; it safely upserts every target. After updating Staging or Production, the command explains that existing deployments retain the old value and asks whether to redeploy the latest ready deployment in each affected Vercel project. ### 4. Start the database diff --git a/package.json b/package.json index defa160d32..af68fbc727 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "typecheck": "scripts/typecheck-all.sh", "build": "pnpm --filter web build", "test": "pnpm --filter web test && pnpm run test:web-env && pnpm run test:dev-local", - "test:web-env": "tsx --tsconfig scripts/web-env/tsconfig.json --test scripts/web-env/shared.test.ts", + "test:web-env": "tsx --tsconfig scripts/web-env/tsconfig.json --test scripts/web-env/*.test.ts", "test:setup-smoke": "pnpm --filter web run test:setup-smoke", "lint": "scripts/lint-all.sh", "format": "oxfmt", diff --git a/scripts/web-env/index.ts b/scripts/web-env/index.ts index 2c70f753d6..b18e190d3a 100644 --- a/scripts/web-env/index.ts +++ b/scripts/web-env/index.ts @@ -1,6 +1,7 @@ import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; +import { parseOptions, type Options } from './options.js'; import { ENVIRONMENTS, PROJECTS, @@ -8,6 +9,7 @@ import { findRepoRoot, question, readSecret, + redeployLatest, resolveVault, resolveVercelContexts, setEnvDefault, @@ -16,51 +18,8 @@ import { stripSurroundingQuotes, trackedEnvFiles, type Environment, - type Values, } from './shared.js'; -type Options = { - name: string; - dryRun: boolean; - valueFiles: Partial>; -}; - -function usage(): never { - throw new Error( - [ - 'Usage: pnpm web:env set VARIABLE [--dry-run]', - ' [--development-file PATH] [--staging-file PATH] [--production-file PATH]', - ].join('\n') - ); -} - -function parseOptions(args: string[]): Options { - if (args[0] !== 'set' || !args[1]) usage(); - const name = args[1]; - const valueFiles: Partial> = {}; - let dryRun = false; - - for (let index = 2; index < args.length; index += 1) { - const argument = args[index]; - if (argument === '--dry-run') dryRun = true; - else { - const match = argument?.match(/^--(development|staging|production)-file(?:=(.*))?$/); - if (!match) usage(); - const environment = match[1] as Environment; - const nextArgument = args[index + 1]; - const file = match[2] || nextArgument; - if (!file) usage(); - if (!match[2]) index += 1; - valueFiles[environment] = file; - } - } - - if (!/^[A-Z_][A-Z0-9_]*$/.test(name)) { - throw new Error('Variable names must contain only uppercase letters, digits, and underscores.'); - } - return { name, dryRun, valueFiles }; -} - async function askSensitivity(name: string): Promise { while (true) { const answer = (await question(`Is ${name} sensitive? [Y/n] `)).trim().toLowerCase(); @@ -84,9 +43,12 @@ function normalizeFileValue(value: string): string { return /[\r\n]/.test(valueWithoutTrailingNewline) ? value : valueWithoutTrailingNewline; } -async function collectValues(options: Options): Promise { - const values: Partial = {}; - for (const environment of ENVIRONMENTS) { +async function collectValues( + options: Options, + environments: readonly Environment[] +): Promise>> { + const values: Partial> = {}; + for (const environment of environments) { const file = options.valueFiles[environment]; if (file) { const value = stripSurroundingQuotes( @@ -103,7 +65,7 @@ async function collectValues(options: Options): Promise { else console.warn(`${environment} value cannot be empty. Please try again.`); } } - return values as Values; + return values; } async function collectDefaults(repoRoot: string, name: string): Promise> { @@ -146,7 +108,7 @@ function assignmentValue(content: string, name: string): string | undefined { function rejectMatchingTrackedValues( repoRoot: string, name: string, - values: Values, + values: Partial>, defaults: Map ): void { for (const relativeFile of trackedEnvFiles(repoRoot)) { @@ -163,28 +125,43 @@ function rejectMatchingTrackedValues( async function main(): Promise { const options = parseOptions(process.argv.slice(2)); + const environments: readonly Environment[] = options.only ? [options.only] : ENVIRONMENTS; const sensitive = await askSensitivity(options.name); const repoRoot = findRepoRoot(); const tempDirectory = mkdtempSync(path.join(os.tmpdir(), 'kilo-web-env-')); try { - console.log('Checking Vercel and 1Password access...'); + const updatesProductionVault = sensitive && environments.includes('production'); + console.log(`Checking Vercel${updatesProductionVault ? ' and 1Password' : ''} access...`); const contexts = resolveVercelContexts(tempDirectory); - const vault = sensitive ? resolveVault() : undefined; - const values = await collectValues(options); - const defaults = await collectDefaults(repoRoot, options.name); - if (defaults.size === 0) warnAboutMissingTrackedDefault(options.name); + const vault = updatesProductionVault ? resolveVault() : undefined; + const values = await collectValues(options, environments); + const defaults = options.only + ? new Map() + : await collectDefaults(repoRoot, options.name); + if (!options.only && defaults.size === 0) warnAboutMissingTrackedDefault(options.name); rejectMatchingTrackedValues(repoRoot, options.name, values, defaults); + const deployableEnvironments = environments.filter( + (environment): environment is Exclude => + environment !== 'development' + ); + console.log('\nPlan'); - for (const environment of ENVIRONMENTS) { + for (const environment of environments) { const type = sensitive && environment !== 'development' ? 'sensitive' : 'encrypted'; for (const project of PROJECTS) console.log(`- ${project}/${environment}: ${type}`); } for (const [file, value] of defaults) console.log(`- ${file}: ${options.name}=${JSON.stringify(value)}`); - console.log(`- 1Password: ${sensitive ? 'update Production copy' : 'skip'}`); - console.log('- Deployments: not triggered'); + console.log(`- 1Password: ${updatesProductionVault ? 'update Production copy' : 'skip'}`); + console.log( + `- Deployments: ${ + deployableEnvironments.length > 0 + ? 'ask after environment updates' + : 'not applicable for development' + }` + ); if (options.dryRun) { console.log('\nDry run complete; nothing changed.'); @@ -199,19 +176,44 @@ async function main(): Promise { setEnvDefault(path.join(repoRoot, relativeFile), options.name, value); } - for (const environment of ENVIRONMENTS) { + for (const environment of environments) { + const value = values[environment]; + if (!value) throw new Error(`Missing ${environment} value.`); for (const context of contexts) { console.log(`Setting ${context.project}/${environment}...`); - setVariable(context, environment, options.name, values[environment], sensitive); + setVariable(context, environment, options.name, value, sensitive); } } if (vault) { + const productionValue = values.production; + if (!productionValue) throw new Error('Missing production value.'); console.log('Updating 1Password Production copy...'); - await setVaultValue(vault, options.name, values.production); + await setVaultValue(vault, options.name, productionValue); + } + + if ( + deployableEnvironments.length > 0 && + (await confirm( + `\nEnvironment changes only take effect in new deployments. Redeploy ${deployableEnvironments.join(' and ')} now?` + )) + ) { + for (const environment of deployableEnvironments) { + console.log(`Redeploying ${environment} in both Vercel projects...`); + const deployments = await Promise.all( + contexts.map(async context => ({ + project: context.project, + url: await redeployLatest(context, environment), + })) + ); + for (const deployment of deployments) { + console.log(`- ${deployment.project}: ${deployment.url}`); + } + } + } else if (deployableEnvironments.length > 0) { + console.log('Deployments skipped; the previous deployments still use the old value.'); } console.log('\nDone. Rerun the same command if a provider failed partway through.'); - console.log('Deploy Staging or Production separately when the new value should take effect.'); } finally { rmSync(tempDirectory, { recursive: true, force: true }); } diff --git a/scripts/web-env/options.test.ts b/scripts/web-env/options.test.ts new file mode 100644 index 0000000000..9b167b128f --- /dev/null +++ b/scripts/web-env/options.test.ts @@ -0,0 +1,46 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { parseOptions } from './options.js'; + +void test('parseOptions scopes an update to one environment', () => { + assert.deepEqual( + parseOptions(['set', 'TEST_SECRET', '--only', 'staging', '--staging-file=value']), + { + name: 'TEST_SECRET', + dryRun: false, + only: 'staging', + valueFiles: { staging: 'value' }, + } + ); +}); + +void test('parseOptions accepts the inline only syntax', () => { + assert.deepEqual(parseOptions(['set', 'TEST_SECRET', '--dry-run', '--only=production']), { + name: 'TEST_SECRET', + dryRun: true, + only: 'production', + valueFiles: {}, + }); +}); + +void test('parseOptions rejects unsupported environments', () => { + assert.throws( + () => parseOptions(['set', 'TEST_SECRET', '--only', 'preview']), + /ENVIRONMENT: development \| staging \| production/ + ); +}); + +void test('parseOptions rejects value files outside the selected environment', () => { + assert.throws( + () => + parseOptions([ + 'set', + 'TEST_SECRET', + '--only', + 'staging', + '--production-file', + 'production-value', + ]), + /--only staging cannot be combined with value files for other environments/ + ); +}); diff --git a/scripts/web-env/options.ts b/scripts/web-env/options.ts new file mode 100644 index 0000000000..aa0e8a7b5f --- /dev/null +++ b/scripts/web-env/options.ts @@ -0,0 +1,67 @@ +import { ENVIRONMENTS, type Environment } from './shared.js'; + +export type Options = { + name: string; + dryRun: boolean; + only?: Environment; + valueFiles: Partial>; +}; + +function usage(): never { + throw new Error( + [ + 'Usage: pnpm web:env set VARIABLE [--dry-run] [--only ENVIRONMENT]', + ' [--development-file PATH] [--staging-file PATH] [--production-file PATH]', + ` ENVIRONMENT: ${ENVIRONMENTS.join(' | ')}`, + ].join('\n') + ); +} + +function environment(value: string | undefined): Environment { + const match = ENVIRONMENTS.find(candidate => candidate === value); + if (!match) usage(); + return match; +} + +export function parseOptions(args: string[]): Options { + if (args[0] !== 'set' || !args[1]) usage(); + const name = args[1]; + const valueFiles: Partial> = {}; + let dryRun = false; + let only: Environment | undefined; + + for (let index = 2; index < args.length; index += 1) { + const argument = args[index]; + if (argument === '--dry-run') { + dryRun = true; + continue; + } + + if (argument === '--only' || argument?.startsWith('--only=')) { + if (only) usage(); + const inlineValue = argument.startsWith('--only=') + ? argument.slice('--only='.length) + : undefined; + only = environment(inlineValue ?? args[index + 1]); + if (inlineValue === undefined) index += 1; + continue; + } + + const match = argument?.match(/^--(development|staging|production)-file(?:=(.*))?$/); + if (!match) usage(); + const target = environment(match[1]); + const nextArgument = args[index + 1]; + const file = match[2] || nextArgument; + if (!file) usage(); + if (!match[2]) index += 1; + valueFiles[target] = file; + } + + if (!/^[A-Z_][A-Z0-9_]*$/.test(name)) { + throw new Error('Variable names must contain only uppercase letters, digits, and underscores.'); + } + if (only && ENVIRONMENTS.some(target => target !== only && valueFiles[target])) { + throw new Error(`--only ${only} cannot be combined with value files for other environments.`); + } + return { name, dryRun, only, valueFiles }; +} diff --git a/scripts/web-env/shared.test.ts b/scripts/web-env/shared.test.ts index b74064a528..39936de026 100644 --- a/scripts/web-env/shared.test.ts +++ b/scripts/web-env/shared.test.ts @@ -4,6 +4,7 @@ import os from 'node:os'; import path from 'node:path'; import test from 'node:test'; import { + redeployLatest, resolveVault, resolveVercelContexts, setVaultValue, @@ -68,6 +69,24 @@ process.stderr.write('You are not currently signed in to a 1Password account.\\n process.exitCode = 1; `; +const FAKE_PNPM_REDEPLOY = `#!/usr/bin/env node +const fs = require('node:fs'); +const args = process.argv.slice(2); +fs.appendFileSync(process.env.FAKE_VERCEL_LOG, JSON.stringify(args) + '\\n'); +if (args[2] === 'list') { + process.stdout.write(JSON.stringify({ + deployments: [ + { url: 'latest-staging.example.vercel.app', state: 'READY' }, + { url: 'older-staging.example.vercel.app', state: 'READY' } + ] + })); +} else if (args[2] === 'redeploy') { + process.stdout.write('https://new-staging.example.vercel.app'); +} else { + process.exitCode = 1; +} +`; + async function captureOpInvocations(existing: boolean): Promise { const directory = mkdtempSync(path.join(os.tmpdir(), 'web-env-op-test-')); const logFile = path.join(directory, 'op.jsonl'); @@ -114,6 +133,54 @@ void test('stripSurroundingQuotes removes one matching outer quote pair', () => assert.equal(stripSurroundingQuotes(`"mismatched'`), `"mismatched'`); }); +void test('redeployLatest redeploys the latest ready deployment for the target environment', async () => { + const directory = mkdtempSync(path.join(os.tmpdir(), 'web-env-vercel-test-')); + const logFile = path.join(directory, 'vercel.jsonl'); + writeFileSync(path.join(directory, 'pnpm'), FAKE_PNPM_REDEPLOY, { mode: 0o700 }); + const originalPath = process.env.PATH; + const originalLog = process.env.FAKE_VERCEL_LOG; + process.env.PATH = `${directory}:${originalPath ?? ''}`; + process.env.FAKE_VERCEL_LOG = logFile; + + try { + assert.equal( + await redeployLatest({ project: 'kilocode-app', orgId: 'org-id', cwd: directory }, 'staging'), + 'https://new-staging.example.vercel.app' + ); + const invocations = readFileSync(logFile, 'utf8') + .trim() + .split('\n') + .map(line => JSON.parse(line) as string[]); + assert.deepEqual(invocations[0]?.slice(0, 10), [ + 'dlx', + 'vercel@53.3.1', + 'list', + 'kilocode-app', + '--environment', + 'staging', + '--status', + 'READY', + '--format=json', + '--scope', + ]); + assert.deepEqual(invocations[1]?.slice(0, 7), [ + 'dlx', + 'vercel@53.3.1', + 'redeploy', + 'latest-staging.example.vercel.app', + '--target', + 'staging', + '--scope', + ]); + } finally { + if (originalPath === undefined) delete process.env.PATH; + else process.env.PATH = originalPath; + if (originalLog === undefined) delete process.env.FAKE_VERCEL_LOG; + else process.env.FAKE_VERCEL_LOG = originalLog; + rmSync(directory, { recursive: true, force: true }); + } +}); + void test('setVaultValue creates an item from a template without sending the secret through stdin', async () => { const invocations = await captureOpInvocations(false); const create = invocations.find(invocation => invocation.args[1] === 'create'); diff --git a/scripts/web-env/shared.ts b/scripts/web-env/shared.ts index 65312997aa..3ebddf81f4 100644 --- a/scripts/web-env/shared.ts +++ b/scripts/web-env/shared.ts @@ -15,7 +15,6 @@ const ONE_PASSWORD_CLI_DOCS = 'https://www.1password.dev/cli/get-started'; export type Project = (typeof PROJECTS)[number]; export type Environment = (typeof ENVIRONMENTS)[number]; -export type Values = Record; export type VercelContext = { project: Project; orgId: string; @@ -156,6 +155,77 @@ function vercel( ); } +function vercelAsync(context: VercelContext, args: string[]): Promise { + return new Promise((resolve, reject) => { + const commandArgs = [ + 'dlx', + VERCEL_PACKAGE, + ...args, + '--scope', + 'kilocode', + '--non-interactive', + '--no-color', + '--cwd', + context.cwd, + ]; + const child = spawn('pnpm', commandArgs, { + cwd: context.cwd, + env: { + ...process.env, + VERCEL_ORG_ID: context.orgId, + VERCEL_PROJECT_ID: context.project, + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + const stdout: Buffer[] = []; + let outputBytes = 0; + let settled = false; + + const fail = (error: Error) => { + if (settled) return; + settled = true; + reject(error); + }; + const capture = (chunk: Buffer, keep: boolean) => { + outputBytes += chunk.length; + if (outputBytes > 10 * 1024 * 1024) { + child.kill(); + fail( + new Error( + `pnpm ${commandArgs.slice(0, 3).join(' ')} failed; provider output exceeded 10 MiB.` + ) + ); + return; + } + if (keep) stdout.push(chunk); + }; + + child.stdout.on('data', chunk => capture(Buffer.from(chunk), true)); + child.stderr.on('data', chunk => capture(Buffer.from(chunk), false)); + child.once('error', error => { + if (errorCode(error) === 'ENOENT') { + const message = missingCommandMessage('pnpm', commandArgs); + if (message) { + fail(new MissingCommandError(message)); + return; + } + } + fail(error); + }); + child.once('close', status => { + if (settled) return; + if (status === 0) { + settled = true; + resolve(Buffer.concat(stdout).toString('utf8')); + return; + } + fail( + new Error(`pnpm ${commandArgs.slice(0, 3).join(' ')} failed; provider output was redacted.`) + ); + }); + }); +} + function vercelAccessError(error: unknown): Error { const details = error instanceof Error ? error.message.trim() : ''; return new Error( @@ -230,6 +300,31 @@ export function setVariable( ); } +export async function redeployLatest( + context: VercelContext, + environment: Exclude +): Promise { + const response = parseJson( + await vercelAsync(context, [ + 'list', + context.project, + '--environment', + environment, + '--status', + 'READY', + '--format=json', + ]), + `List ${context.project}/${environment} deployments` + ); + const deployment = records(response.deployments)[0]; + const url = deployment ? stringValue(deployment, 'url') : undefined; + if (!url) { + throw new Error(`No ready deployment found for ${context.project}/${environment}.`); + } + + return (await vercelAsync(context, ['redeploy', url, '--target', environment])).trim(); +} + // setVaultValue streams the secret template to `op` over /dev/fd/3 (see // runOpWithTemplate), which only exists on Unix. resolveVault calls this so the // update flow fails before touching any provider; runOpWithTemplate re-checks as