Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
122 changes: 62 additions & 60 deletions scripts/web-env/index.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
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,
confirm,
findRepoRoot,
question,
readSecret,
redeployLatest,
resolveVault,
resolveVercelContexts,
setEnvDefault,
Expand All @@ -16,51 +18,8 @@ import {
stripSurroundingQuotes,
trackedEnvFiles,
type Environment,
type Values,
} from './shared.js';

type Options = {
name: string;
dryRun: boolean;
valueFiles: Partial<Record<Environment, string>>;
};

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<Record<Environment, string>> = {};
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<boolean> {
while (true) {
const answer = (await question(`Is ${name} sensitive? [Y/n] `)).trim().toLowerCase();
Expand All @@ -84,9 +43,12 @@ function normalizeFileValue(value: string): string {
return /[\r\n]/.test(valueWithoutTrailingNewline) ? value : valueWithoutTrailingNewline;
}

async function collectValues(options: Options): Promise<Values> {
const values: Partial<Values> = {};
for (const environment of ENVIRONMENTS) {
async function collectValues(
options: Options,
environments: readonly Environment[]
): Promise<Partial<Record<Environment, string>>> {
const values: Partial<Record<Environment, string>> = {};
for (const environment of environments) {
const file = options.valueFiles[environment];
if (file) {
const value = stripSurroundingQuotes(
Expand All @@ -103,7 +65,7 @@ async function collectValues(options: Options): Promise<Values> {
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<Map<string, string>> {
Expand Down Expand Up @@ -146,7 +108,7 @@ function assignmentValue(content: string, name: string): string | undefined {
function rejectMatchingTrackedValues(
repoRoot: string,
name: string,
values: Values,
values: Partial<Record<Environment, string>>,
defaults: Map<string, string>
): void {
for (const relativeFile of trackedEnvFiles(repoRoot)) {
Expand All @@ -163,28 +125,43 @@ function rejectMatchingTrackedValues(

async function main(): Promise<void> {
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<string, string>()
: 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'> =>
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.');
Expand All @@ -199,19 +176,44 @@ async function main(): Promise<void> {
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 });
}
Expand Down
46 changes: 46 additions & 0 deletions scripts/web-env/options.test.ts
Original file line number Diff line number Diff line change
@@ -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/
);
});
67 changes: 67 additions & 0 deletions scripts/web-env/options.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { ENVIRONMENTS, type Environment } from './shared.js';

export type Options = {
name: string;
dryRun: boolean;
only?: Environment;
valueFiles: Partial<Record<Environment, string>>;
};

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<Record<Environment, string>> = {};
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 };
}
Loading
Loading