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: 2 additions & 2 deletions DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,13 +164,13 @@ Prerequisites:
- 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.
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 and Staging values are also stored in one concealed, exact-name item in `Kilo Web ENV Production`: Production uses the built-in `password` field and Staging uses `password (staging)`. Either field can be created before the other. The item's 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 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.

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.
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 updates only the selected environment's 1Password field when the value is sensitive.

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.

Expand Down
25 changes: 17 additions & 8 deletions scripts/web-env/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
stripSurroundingQuotes,
trackedEnvFiles,
type Environment,
type VaultEnvironment,
} from './shared.js';

async function askSensitivity(name: string): Promise<boolean> {
Expand Down Expand Up @@ -131,10 +132,14 @@ async function main(): Promise<void> {
const tempDirectory = mkdtempSync(path.join(os.tmpdir(), 'kilo-web-env-'));

try {
const updatesProductionVault = sensitive && environments.includes('production');
console.log(`Checking Vercel${updatesProductionVault ? ' and 1Password' : ''} access...`);
const vaultEnvironments = sensitive
? environments.filter(
(environment): environment is VaultEnvironment => environment !== 'development'
)
: [];
console.log(`Checking Vercel${vaultEnvironments.length > 0 ? ' and 1Password' : ''} access...`);
const contexts = resolveVercelContexts(tempDirectory);
const vault = updatesProductionVault ? resolveVault() : undefined;
const vault = vaultEnvironments.length > 0 ? resolveVault() : undefined;
const values = await collectValues(options, environments);
const defaults = options.only
? new Map<string, string>()
Expand All @@ -154,7 +159,9 @@ async function main(): Promise<void> {
}
for (const [file, value] of defaults)
console.log(`- ${file}: ${options.name}=${JSON.stringify(value)}`);
console.log(`- 1Password: ${updatesProductionVault ? 'update Production copy' : 'skip'}`);
console.log(
`- 1Password: ${vaultEnvironments.length > 0 ? `update ${vaultEnvironments.join(' and ')} fields` : 'skip'}`
);
console.log(
`- Deployments: ${
deployableEnvironments.length > 0
Expand Down Expand Up @@ -185,10 +192,12 @@ async function main(): Promise<void> {
}
}
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, productionValue);
for (const environment of vaultEnvironments) {
const value = values[environment];
if (!value) throw new Error(`Missing ${environment} value.`);
console.log(`Updating 1Password ${environment} copy...`);
await setVaultValue(vault, options.name, value, environment);
}
}

if (
Expand Down
103 changes: 91 additions & 12 deletions scripts/web-env/shared.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
resolveVercelContexts,
setVaultValue,
stripSurroundingQuotes,
type VaultEnvironment,
} from './shared.js';

// These tests mutate shared process.env (PATH and FAKE_OP_*) and restore it in a
Expand Down Expand Up @@ -37,17 +38,20 @@ if (args[0] === 'account' && args[1] === 'list') {
} else if (args[0] === 'vault' && args[1] === 'get') {
process.stdout.write(JSON.stringify({ id: 'vault-id' }));
} else if (args[0] === 'item' && args[1] === 'list') {
const items = process.env.FAKE_OP_EXISTING
const items = process.env.FAKE_OP_EXISTING !== 'none'
? [{ id: 'existing-id', title: 'TEST_SECRET' }]
: [];
process.stdout.write(JSON.stringify(items));
} else if (args[0] === 'item' && args[1] === 'get') {
const passwordFields = process.env.FAKE_OP_EXISTING === 'production'
? [{ id: 'password', label: 'password', type: 'CONCEALED', purpose: 'PASSWORD', value: 'old-production-value' }]
: [{ id: 'generated-staging-id', label: 'password (staging)', type: 'CONCEALED', value: 'old-staging-value' }];
process.stdout.write(JSON.stringify({
id: 'existing-id',
title: 'TEST_SECRET',
category: 'PASSWORD',
fields: [
{ id: 'password', label: 'password', type: 'CONCEALED', purpose: 'PASSWORD', value: 'old-value' },
...passwordFields,
{ id: 'notesPlain', label: 'notesPlain', type: 'STRING', purpose: 'NOTES', value: '' }
],
sections: []
Expand Down Expand Up @@ -87,7 +91,10 @@ if (args[2] === 'list') {
}
`;

async function captureOpInvocations(existing: boolean): Promise<Invocation[]> {
async function captureOpInvocations(
existing: 'none' | VaultEnvironment,
environment: VaultEnvironment
): Promise<Invocation[]> {
const directory = mkdtempSync(path.join(os.tmpdir(), 'web-env-op-test-'));
const logFile = path.join(directory, 'op.jsonl');
writeFileSync(path.join(directory, 'op'), FAKE_OP, { mode: 0o700 });
Expand All @@ -97,14 +104,14 @@ async function captureOpInvocations(existing: boolean): Promise<Invocation[]> {
const originalExisting = process.env.FAKE_OP_EXISTING;
process.env.PATH = `${directory}:${originalPath ?? ''}`;
process.env.FAKE_OP_LOG = logFile;
if (existing) process.env.FAKE_OP_EXISTING = '1';
else delete process.env.FAKE_OP_EXISTING;
process.env.FAKE_OP_EXISTING = existing;

try {
await setVaultValue(
{ accountId: 'account-id', vaultId: 'vault-id' },
'TEST_SECRET',
'secret-value'
`new-${environment}-value`,
environment
);
return readFileSync(logFile, 'utf8')
.trim()
Expand Down Expand Up @@ -182,7 +189,7 @@ void test('redeployLatest redeploys the latest ready deployment for the target e
});

void test('setVaultValue creates an item from a template without sending the secret through stdin', async () => {
const invocations = await captureOpInvocations(false);
const invocations = await captureOpInvocations('none', 'production');
const create = invocations.find(invocation => invocation.args[1] === 'create');
assert.ok(create);
assert.deepEqual(create.args, [
Expand All @@ -196,13 +203,20 @@ void test('setVaultValue creates an item from a template without sending the sec
'--format=json',
]);
assert.equal(create.stdin, '');
const item = JSON.parse(create.templateInput) as { title?: string; fields?: unknown[] };
const item = JSON.parse(create.templateInput) as {
title?: string;
fields?: Array<{ id?: string; label?: string; value?: string }>;
};
assert.equal(item.title, 'TEST_SECRET');
assert.ok(item.fields?.some(field => JSON.stringify(field).includes('secret-value')));
assert.equal(item.fields?.find(field => field.id === 'password')?.value, 'new-production-value');
assert.equal(
item.fields?.find(field => field.label === 'password (staging)'),
undefined
);
});

void test('setVaultValue updates an item from a template without sending the secret through stdin', async () => {
const invocations = await captureOpInvocations(true);
const invocations = await captureOpInvocations('production', 'production');
const edit = invocations.find(invocation => invocation.args[1] === 'edit');
assert.ok(edit);
assert.deepEqual(edit.args, [
Expand All @@ -217,9 +231,74 @@ void test('setVaultValue updates an item from a template without sending the sec
'--format=json',
]);
assert.equal(edit.stdin, '');
const item = JSON.parse(edit.templateInput) as { title?: string; fields?: unknown[] };
const item = JSON.parse(edit.templateInput) as {
title?: string;
fields?: Array<{ id?: string; label?: string; value?: string }>;
};
assert.equal(item.title, 'TEST_SECRET');
assert.ok(item.fields?.some(field => JSON.stringify(field).includes('secret-value')));
assert.equal(item.fields?.find(field => field.id === 'password')?.value, 'new-production-value');
});

void test('setVaultValue creates a staging-only item without a production value', async () => {
const invocations = await captureOpInvocations('none', 'staging');
const create = invocations.find(invocation => invocation.args[1] === 'create');
assert.ok(create);
const item = JSON.parse(create.templateInput) as {
fields?: Array<{ id?: string; label?: string; type?: string; value?: string }>;
};
assert.deepEqual(
item.fields?.find(field => field.label === 'password (staging)'),
{
id: 'password-staging',
label: 'password (staging)',
type: 'CONCEALED',
value: 'new-staging-value',
}
);
assert.equal(
item.fields?.find(field => field.id === 'password'),
undefined
);
});

void test('setVaultValue adds staging to an item that only has production', async () => {
const invocations = await captureOpInvocations('production', 'staging');
const edit = invocations.find(invocation => invocation.args[1] === 'edit');
assert.ok(edit);
const item = JSON.parse(edit.templateInput) as {
fields?: Array<{ id?: string; label?: string; value?: string }>;
};
assert.equal(item.fields?.find(field => field.id === 'password')?.value, 'old-production-value');
assert.equal(
item.fields?.find(field => field.label === 'password (staging)')?.value,
'new-staging-value'
);
});

void test('setVaultValue updates an existing staging field by label', async () => {
const invocations = await captureOpInvocations('staging', 'staging');
const edit = invocations.find(invocation => invocation.args[1] === 'edit');
assert.ok(edit);
const item = JSON.parse(edit.templateInput) as {
fields?: Array<{ id?: string; label?: string; value?: string }>;
};
const staging = item.fields?.find(field => field.label === 'password (staging)');
assert.equal(staging?.id, 'generated-staging-id');
assert.equal(staging?.value, 'new-staging-value');
});

void test('setVaultValue adds production to an item that only has staging', async () => {
const invocations = await captureOpInvocations('staging', 'production');
const edit = invocations.find(invocation => invocation.args[1] === 'edit');
assert.ok(edit);
const item = JSON.parse(edit.templateInput) as {
fields?: Array<{ id?: string; label?: string; value?: string }>;
};
assert.equal(item.fields?.find(field => field.id === 'password')?.value, 'new-production-value');
assert.equal(
item.fields?.find(field => field.label === 'password (staging)')?.value,
'old-staging-value'
);
});

void test('resolveVault selects the kilocode account before resolving the vault', () => {
Expand Down
71 changes: 55 additions & 16 deletions scripts/web-env/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export type OnePasswordContext = {
accountId: string;
vaultId: string;
};
export type VaultEnvironment = Extract<Environment, 'staging' | 'production'>;

type JsonRecord = Record<string, unknown>;

Expand Down Expand Up @@ -478,10 +479,42 @@ function setAuditNote(item: JsonRecord, note: string): void {
notes.value = preserved ? `${preserved}\n${note}` : note;
}

function vaultPasswordField(environment: VaultEnvironment, value: string): JsonRecord {
if (environment === 'production') {
return {
id: 'password',
label: 'password',
type: 'CONCEALED',
purpose: 'PASSWORD',
value,
};
}
return {
id: 'password-staging',
label: 'password (staging)',
type: 'CONCEALED',
value,
};
}

function findVaultPasswordField(
fields: JsonRecord[],
environment: VaultEnvironment
): JsonRecord | undefined {
const matches = fields.filter(field =>
environment === 'production' ? field.id === 'password' : field.label === 'password (staging)'
);
if (matches.length > 1) {
throw new Error(`1Password item has more than one ${environment} password field.`);
}
return matches[0];
}

export async function setVaultValue(
context: OnePasswordContext,
name: string,
value: string
value: string,
environment: VaultEnvironment
): Promise<void> {
const note = auditNote();
const existing = findVaultItem(context, name);
Expand All @@ -490,13 +523,7 @@ export async function setVaultValue(
title: name,
category: 'PASSWORD',
fields: [
{
id: 'password',
label: 'password',
type: 'CONCEALED',
purpose: 'PASSWORD',
value,
},
vaultPasswordField(environment, value),
{
id: 'notesPlain',
label: 'notesPlain',
Expand All @@ -523,10 +550,12 @@ export async function setVaultValue(
),
`Create ${name}`
);
const createdPassword = records(created.fields).find(field => field.id === 'password');
const createdPassword = findVaultPasswordField(records(created.fields), environment);
const createdNotes = records(created.fields).find(field => field.id === 'notesPlain');
if (createdPassword?.value !== value || createdNotes?.value !== note) {
throw new Error(`1Password did not persist the new ${name} value and audit note.`);
throw new Error(
`1Password did not persist the new ${name} ${environment} value and audit note.`
);
}
return;
}
Expand All @@ -546,11 +575,19 @@ export async function setVaultValue(
]),
`Read ${name}`
);
const password = records(item.fields).find(field => field.id === 'password');
if (!password || password.type !== 'CONCEALED') {
throw new Error(`1Password item ${name} does not have a concealed password field.`);
const fields = records(item.fields);
const password = findVaultPasswordField(fields, environment);
if (password && password.type !== 'CONCEALED') {
throw new Error(`1Password item ${name} does not have a concealed ${environment} field.`);
}
if (password) password.value = value;
else {
const itemFields = item.fields;
if (!Array.isArray(itemFields)) {
throw new Error('1Password item does not have editable fields.');
}
itemFields.push(vaultPasswordField(environment, value));
}
password.value = value;
setAuditNote(item, note);
const expectedNotes = stringValue(
records(item.fields).find(field => field.id === 'notesPlain') ?? {},
Expand All @@ -573,10 +610,12 @@ export async function setVaultValue(
),
`Update ${name}`
);
const updatedPassword = records(updated.fields).find(field => field.id === 'password');
const updatedPassword = findVaultPasswordField(records(updated.fields), environment);
const updatedNotes = records(updated.fields).find(field => field.id === 'notesPlain');
if (updatedPassword?.value !== value || updatedNotes?.value !== expectedNotes) {
throw new Error(`1Password did not persist the updated ${name} value and audit note.`);
throw new Error(
`1Password did not persist the updated ${name} ${environment} value and audit note.`
);
}
}

Expand Down
Loading