Skip to content

Commit c448b7c

Browse files
authored
feat(web): add environment management workflow (#4050)
* feat(web): add environment management workflow * refactor(web): simplify environment management tool * docs(web): document simplified env workflow * ci(web): narrow setup smoke triggers * chore(web): typecheck env scripts * fix(web): invoke pinned Vercel CLI correctly * feat(web): prompt for env sensitivity * fix(web): warn on matching tracked env values * fix(web): show 1Password command failures * fix(web): create 1Password items from stdin template * fix(web): let 1Password prompt for sign-in * fix(web): default env file prompts to skip * fix(web): warn when env defaults are skipped * fix(web): streamline env default prompts * feat(web): record env updater in 1Password notes * perf(web): trust fixed Vercel env targets * fix(web): persist 1Password item updates * perf(web): batch backfill environment reads * feat(web): default env backfill to dry run * fix(web): limit env backfill to Production * chore(web): remove completed env backfill * fix(web): harden environment value synchronization
1 parent a96d05a commit c448b7c

10 files changed

Lines changed: 621 additions & 7 deletions

File tree

.github/workflows/ci.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,10 @@ jobs:
4747
filters: |
4848
kilocode_backend:
4949
- 'apps/web/src/**'
50+
- 'apps/web/.env'
51+
- 'apps/web/.env.test'
52+
- 'apps/web/.env.development.local.example'
53+
- '.env.local.example'
5054
- 'apps/web/package.json'
5155
- 'apps/web/tsconfig.json'
5256
- 'apps/web/tsconfig.*.json'

.github/workflows/setup-smoke.yml

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,16 @@ on:
44
schedule:
55
- cron: '17 * * * *'
66
workflow_dispatch:
7+
pull_request:
8+
branches: [main]
9+
paths:
10+
- '.env.local.example'
11+
- 'apps/web/.env'
12+
- 'apps/web/.env.development.local.example'
13+
- 'dev/local/setup-env.ts'
14+
- 'dev/local/env-sync/**'
15+
- 'scripts/dev.sh'
16+
- '.github/workflows/setup-smoke.yml'
717

818
permissions:
919
contents: read
@@ -147,7 +157,7 @@ jobs:
147157
retention-days: 7
148158

149159
- name: Notify setup smoke failure
150-
if: failure()
160+
if: failure() && github.event_name != 'pull_request'
151161
env:
152162
GH_TOKEN: ${{ github.token }}
153163
ISSUE_NUMBER: '3791'

DEVELOPMENT.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,28 @@ The setup covers: `NEXTAUTH_SECRET`, `NEXTAUTH_URL`, `POSTGRES_URL`, `CALLBACK_T
150150

151151
These changes will allow you to do local testing with a fake account.
152152

153+
#### c. Add or rotate shared web environment variables
154+
155+
Use the repository workflow instead of editing Vercel projects independently. It updates `kilocode-app` and `kilocode-global-app` together for Development, Staging, and Production:
156+
157+
```bash
158+
pnpm web:env set EXAMPLE_API_TOKEN
159+
```
160+
161+
Prerequisites:
162+
163+
- Sign in with `vercel login` and have access to both projects in the `kilocode` scope.
164+
- Install the 1Password CLI and have write access to the `Kilo Web ENV Production` vault. If needed, the CLI prompts you to sign in with Touch ID.
165+
- Have `pnpm` available; the command runs the pinned Vercel CLI with `pnpm dlx`.
166+
167+
The command asks whether the variable is sensitive, defaulting to yes. Sensitive Production and Staging values use Vercel's sensitive type, while Development remains encrypted but exportable through `vercel env pull`. The Production value is also stored as a concealed, exact-name item in `Kilo Web ENV Production`; its notes identify the local user and computer that last updated it.
168+
169+
Answer no for public or otherwise non-secret configuration. `NEXT_PUBLIC_*` variables must be non-sensitive because Next.js exposes them to browsers. Non-sensitive values are not copied to 1Password.
170+
171+
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 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.
172+
173+
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.
174+
153175
### 4. Start the database
154176

155177
The project uses PostgreSQL 18 with pgvector, running via Docker. The compose file is at `dev/docker-compose.yml`:

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,10 +33,12 @@
3333
"dev:setup-env": "tsx dev/local/setup-env.ts",
3434
"dev:seed": "tsx dev/seed/index.ts",
3535
"dev:discord-gateway-cron": "tsx dev/discord-gateway-cron.ts",
36-
"dev:kiloclaw-fly-instances": "tsx services/kiloclaw/scripts/dev-fly-instances.ts"
36+
"dev:kiloclaw-fly-instances": "tsx services/kiloclaw/scripts/dev-fly-instances.ts",
37+
"web:env": "tsx scripts/web-env/index.ts"
3738
},
3839
"packageManager": "pnpm@11.1.2",
3940
"devDependencies": {
41+
"@types/node": "catalog:",
4042
"@typescript/native-preview": "catalog:",
4143
"husky": "9.1.7",
4244
"ink": "6.8.0",

pnpm-lock.yaml

Lines changed: 6 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

scripts/lint-all.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ set -euo pipefail
66

77
PATH="node_modules/.bin:$PATH"
88

9-
lint_dirs=(apps/web/src)
9+
lint_dirs=(apps/web/src scripts/web-env)
1010
mobile_lint_dirs=()
1111

1212
# Resolve workspace directories using pnpm (handles glob expansion)

scripts/typecheck-all.sh

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,9 @@ else
4242
pnpm --filter @kilocode/trpc run build
4343
fi
4444

45-
# 2. Root typecheck (always — it's fast with incremental tsgo)
45+
# 2. Root typechecks (always — they are fast with incremental tsgo)
4646
tsgo --noEmit -p apps/web/tsconfig.json
47+
tsgo --noEmit -p scripts/web-env/tsconfig.json
4748

4849
# 3. Workspace typecheck
4950
if ! $changes_only; then

scripts/web-env/index.ts

Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
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

Comments
 (0)