Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
7454a59
feat(web): add environment management workflow
RSO Jun 16, 2026
ef0fcba
refactor(web): simplify environment management tool
RSO Jun 16, 2026
8fd783c
docs(web): document simplified env workflow
RSO Jun 16, 2026
0f84777
ci(web): narrow setup smoke triggers
RSO Jun 16, 2026
14a95a4
chore(web): typecheck env scripts
RSO Jun 16, 2026
7296620
fix(web): invoke pinned Vercel CLI correctly
RSO Jun 16, 2026
2ea10f9
feat(web): prompt for env sensitivity
RSO Jun 17, 2026
69cef46
fix(web): warn on matching tracked env values
RSO Jun 17, 2026
96646f4
fix(web): show 1Password command failures
RSO Jun 17, 2026
fe11443
fix(web): create 1Password items from stdin template
RSO Jun 17, 2026
e1c69d3
fix(web): let 1Password prompt for sign-in
RSO Jun 17, 2026
2c2f2ca
fix(web): default env file prompts to skip
RSO Jun 17, 2026
5eb9253
fix(web): warn when env defaults are skipped
RSO Jun 17, 2026
f400834
fix(web): streamline env default prompts
RSO Jun 17, 2026
53d76d8
feat(web): record env updater in 1Password notes
RSO Jun 17, 2026
3044f7d
perf(web): trust fixed Vercel env targets
RSO Jun 17, 2026
b022c03
fix(web): persist 1Password item updates
RSO Jun 17, 2026
34ad7e1
perf(web): batch backfill environment reads
RSO Jun 17, 2026
15272e4
feat(web): default env backfill to dry run
RSO Jun 17, 2026
556e255
fix(web): limit env backfill to Production
RSO Jun 17, 2026
735fb05
chore(web): remove completed env backfill
RSO Jun 17, 2026
1cf082e
fix(web): harden environment value synchronization
RSO Jun 17, 2026
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: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@ jobs:
filters: |
kilocode_backend:
- 'apps/web/src/**'
- 'apps/web/.env'
- 'apps/web/.env.test'
- 'apps/web/.env.development.local.example'
- '.env.local.example'
- 'apps/web/package.json'
- 'apps/web/tsconfig.json'
- 'apps/web/tsconfig.*.json'
Expand Down
12 changes: 11 additions & 1 deletion .github/workflows/setup-smoke.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,16 @@ on:
schedule:
- cron: '17 * * * *'
workflow_dispatch:
pull_request:
branches: [main]
paths:
- '.env.local.example'
- 'apps/web/.env'
- 'apps/web/.env.development.local.example'
- 'dev/local/setup-env.ts'
- 'dev/local/env-sync/**'
- 'scripts/dev.sh'
- '.github/workflows/setup-smoke.yml'

permissions:
contents: read
Expand Down Expand Up @@ -147,7 +157,7 @@ jobs:
retention-days: 7

- name: Notify setup smoke failure
if: failure()
if: failure() && github.event_name != 'pull_request'
env:
GH_TOKEN: ${{ github.token }}
ISSUE_NUMBER: '3791'
Expand Down
22 changes: 22 additions & 0 deletions DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,28 @@ The setup covers: `NEXTAUTH_SECRET`, `NEXTAUTH_URL`, `POSTGRES_URL`, `CALLBACK_T

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

#### c. Add or rotate shared web environment variables

Use the repository workflow instead of editing Vercel projects independently. It updates `kilocode-app` and `kilocode-global-app` together for Development, Staging, and Production:

```bash
pnpm web:env set EXAMPLE_API_TOKEN
```

Prerequisites:

- Sign in with `vercel login` and have access to both projects in the `kilocode` scope.
- 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.
- Have `pnpm` available; the command runs the pinned Vercel CLI with `pnpm dlx`.

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.

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.

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.

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.

### 4. Start the database

The project uses PostgreSQL 18 with pgvector, running via Docker. The compose file is at `dev/docker-compose.yml`:
Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,12 @@
"dev:setup-env": "tsx dev/local/setup-env.ts",
"dev:seed": "tsx dev/seed/index.ts",
"dev:discord-gateway-cron": "tsx dev/discord-gateway-cron.ts",
"dev:kiloclaw-fly-instances": "tsx services/kiloclaw/scripts/dev-fly-instances.ts"
"dev:kiloclaw-fly-instances": "tsx services/kiloclaw/scripts/dev-fly-instances.ts",
"web:env": "tsx scripts/web-env/index.ts"
},
"packageManager": "pnpm@11.1.2",
"devDependencies": {
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"husky": "9.1.7",
"ink": "6.8.0",
Expand Down
9 changes: 6 additions & 3 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion scripts/lint-all.sh
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ set -euo pipefail

PATH="node_modules/.bin:$PATH"

lint_dirs=(apps/web/src)
lint_dirs=(apps/web/src scripts/web-env)
mobile_lint_dirs=()

# Resolve workspace directories using pnpm (handles glob expansion)
Expand Down
3 changes: 2 additions & 1 deletion scripts/typecheck-all.sh
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,9 @@ else
pnpm --filter @kilocode/trpc run build
fi

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

# 3. Workspace typecheck
if ! $changes_only; then
Expand Down
220 changes: 220 additions & 0 deletions scripts/web-env/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import {
ENVIRONMENTS,
PROJECTS,
confirm,
findRepoRoot,
question,
readSecret,
resolveVault,
resolveVercelContexts,
setEnvDefault,
setVariable,
setVaultValue,
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();
if (!['', 'y', 'yes', 'n', 'no'].includes(answer)) {
console.warn('Please answer yes or no.');
continue;
}
const sensitive = !['n', 'no'].includes(answer);
if (sensitive && name.startsWith('NEXT_PUBLIC_')) {
console.warn('NEXT_PUBLIC_* values are browser-visible; answer no.');
continue;
}
return sensitive;
}
}

function normalizeFileValue(value: string): string {
const trailingNewlineLength = value.endsWith('\r\n') ? 2 : value.endsWith('\n') ? 1 : 0;
if (trailingNewlineLength === 0) return value;
const valueWithoutTrailingNewline = value.slice(0, -trailingNewlineLength);
return /[\r\n]/.test(valueWithoutTrailingNewline) ? value : valueWithoutTrailingNewline;
}

async function collectValues(options: Options): Promise<Values> {
const values: Partial<Values> = {};
for (const environment of ENVIRONMENTS) {
const file = options.valueFiles[environment];
if (file) {
const value = normalizeFileValue(readFileSync(path.resolve(file), 'utf8'));
if (!value) throw new Error(`${environment} value file cannot be empty.`);
values[environment] = value;
continue;
}

while (!values[environment]) {
const value = await readSecret(`${environment} value: `);
if (value) values[environment] = value;
else console.warn(`${environment} value cannot be empty. Please try again.`);
}
}
return values as Values;
}

async function collectDefaults(repoRoot: string, name: string): Promise<Map<string, string>> {
const defaults = new Map<string, string>();
for (const relativeFile of trackedEnvFiles(repoRoot)) {
const value = await question(
`${relativeFile}: default value for ${name} (press Return to skip): `
);
if (!value) continue;
defaults.set(relativeFile, value);
}
return defaults;
}

function warnAboutMissingTrackedDefault(name: string): void {
const border = '='.repeat(78);
console.warn(`
\x1b[1;33m${border}
NO TRACKED ENV DEFAULT WILL BE ADDED

Make sure the application can start and run without ${name}. If the code requires
this variable, external contributors without access to shared secrets will run
into setup, test, or build failures.
${border}\x1b[0m
`);
}

function assignmentValue(content: string, name: string): string | undefined {
const assignment = content.split('\n').find(line => line.startsWith(`${name}=`));
if (!assignment) return undefined;
const value = assignment.slice(name.length + 1);
try {
const parsed: unknown = JSON.parse(value);
return typeof parsed === 'string' ? parsed : value;
} catch {
return value;
}
}

function rejectMatchingTrackedValues(
repoRoot: string,
name: string,
values: Values,
defaults: Map<string, string>
): void {
for (const relativeFile of trackedEnvFiles(repoRoot)) {
const content = readFileSync(path.join(repoRoot, relativeFile), 'utf8');
const trackedValue = defaults.get(relativeFile) ?? assignmentValue(content, name);
const matchesRemoteValue = Object.values(values).some(value => trackedValue === value);
if (matchesRemoteValue) {
throw new Error(
`${relativeFile} contains or would contain a remote environment value. Use a non-secret local default instead.`
);
}
}
}

async function main(): Promise<void> {
const options = parseOptions(process.argv.slice(2));
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 contexts = resolveVercelContexts(tempDirectory);
const vaultId = sensitive ? resolveVault() : undefined;
const values = await collectValues(options);
const defaults = await collectDefaults(repoRoot, options.name);
if (defaults.size === 0) warnAboutMissingTrackedDefault(options.name);
rejectMatchingTrackedValues(repoRoot, options.name, values, defaults);

console.log('\nPlan');
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');

if (options.dryRun) {
console.log('\nDry run complete; nothing changed.');
return;
}
if (!(await confirm('\nApply these changes?'))) {
console.log('Cancelled; nothing changed.');
return;
}

for (const [relativeFile, value] of defaults) {
setEnvDefault(path.join(repoRoot, relativeFile), options.name, value);
}

for (const environment of ENVIRONMENTS) {
for (const context of contexts) {
console.log(`Setting ${context.project}/${environment}...`);
setVariable(context, environment, options.name, values[environment], sensitive);
}
}
if (vaultId) {
console.log('Updating 1Password Production copy...');
setVaultValue(vaultId, options.name, values.production);
}

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 });
}
}

main().catch(error => {
console.error(error instanceof Error ? error.message : 'Environment update failed.');
process.exitCode = 1;
});
Loading
Loading