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 docs/reference/cli/secrets.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ On a direct Cloudflare target, the value is stored as an encrypted Worker secret
## `void secret sync` {#void-secret-sync}

```
void secret sync <file> [--project <name>]
void secret sync <file> [--project <name>] [--yes]
```

Upload production server values from a dotenv file stored outside your project. Include only keys declared in `env.ts`; leave out local development credentials and public `VITE_*` values.
Expand All @@ -39,6 +39,8 @@ Upload production server values from a dotenv file stored outside your project.
void secret sync /secure/path/production.env
```

Pass `--yes` to skip confirmation. This is required in CI and non-interactive shells; without it, the command exits non-zero without uploading secrets.

Existing remote secrets absent from the file are kept. Every entry must be a server key declared in `env.ts`, and its value must pass validation.

## `void secret delete` {#void-secret-delete}
Expand Down
2 changes: 1 addition & 1 deletion packages/void/src/cli/cloudflare-operation-process.ts
Original file line number Diff line number Diff line change
Expand Up @@ -967,7 +967,7 @@ async function secrets(): Promise<Result> {
const patch = Object.fromEntries(
Object.entries(values).map(([name, text]) => [name, { type: 'secret_text', text }]),
);
return withSecretFile(JSON.stringify(patch), (path) =>
return withSecretFile(JSON.stringify({ secrets: patch }), (path) =>
apply(() => runCf(['workers', 'secrets', 'bulk', '--worker', worker, '--file', path])),
);
}
Expand Down
15 changes: 11 additions & 4 deletions packages/void/src/cli/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -825,10 +825,17 @@ const HELP_TREE: HelpTree = {
list: withProjectOption('List project secrets.', 'void secret list [options]'),
put: withProjectOption('Set a project secret.', 'void secret put <name> [options]'),
delete: withProjectOption('Delete a project secret.', 'void secret delete <name> [options]'),
sync: withProjectOption(
'Sync secrets from a dotenv file.',
'void secret sync <file> [options]',
),
sync: {
summary: 'Sync secrets from a dotenv file.',
usage: ['void secret sync <file> [options]'],
options: [
{ command: '--project <name>', description: 'Project name override' },
{
command: '--yes',
description: 'Skip confirmation (required in CI and non-interactive shells)',
},
],
},
},
},
domain: {
Expand Down
16 changes: 12 additions & 4 deletions packages/void/src/cli/secret-args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,14 @@ export type SecretArgs =
| { action: 'list'; projectName?: string }
| { action: 'put'; name: string; value?: string; projectName?: string }
| { action: 'delete'; name: string; projectName?: string }
| { action: 'sync'; file: string; projectName?: string };
| { action: 'sync'; file: string; projectName?: string; yes?: boolean };

export function parseSecretArgs(args: Array<string>): SecretArgs {
const parsed = parseArgs({
args,
options: {
project: { type: 'string' },
yes: { type: 'boolean' },
},
allowPositionals: true,
strict: true,
Expand All @@ -30,6 +31,10 @@ export function parseSecretArgs(args: Array<string>): SecretArgs {
if (projectTokens.length > 1) {
throw new Error('secret: --project can only be specified once.');
}
const yesTokens = tokens.filter((t) => t.kind === 'option' && t.name === 'yes');
if (yesTokens.length > 1) {
throw new Error('secret: --yes can only be specified once.');
}

const projectValue = parsed.values.project;
const projectName = typeof projectValue === 'string' ? projectValue : undefined;
Expand All @@ -38,6 +43,9 @@ export function parseSecretArgs(args: Array<string>): SecretArgs {
}

const [action, name, value, extra] = parsed.positionals;
if (parsed.values.yes && action !== 'sync') {
throw new Error(`secret: \`void secret ${action}\` does not support --yes.`);
}
if (extra) {
throw new Error(`secret: Unexpected argument '${extra}'.`);
}
Expand Down Expand Up @@ -81,12 +89,12 @@ export function parseSecretArgs(args: Array<string>): SecretArgs {

if (action === 'sync') {
if (!name) {
throw new Error('secret: Usage: void secret sync <file> [--project <name>].');
throw new Error('secret: Usage: void secret sync <file> [--project <name>] [--yes].');
}
if (value) {
throw new Error('secret: Usage: void secret sync <file> [--project <name>].');
throw new Error('secret: Usage: void secret sync <file> [--project <name>] [--yes].');
}
return { action, file: name, projectName };
return { action, file: name, projectName, yes: parsed.values.yes };
}

throw new Error('secret: Usage: void secret <list|put|delete|sync> ...');
Expand Down
19 changes: 12 additions & 7 deletions packages/void/src/cli/secret.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,12 +59,17 @@ export async function runSecretCommand(root: string, args: SecretArgs): Promise<
log.message(` ${s.name}`);
}

const proceed = await confirm({
message: `This will overwrite ${formatCount(secrets.length, 'secret')} in the ${backend.target}. Continue?`,
});
if (isCancel(proceed) || !proceed) {
cancel('Secret sync cancelled.');
process.exit(0);
if (!args.yes) {
if (process.env.CI || !process.stdin.isTTY || !process.stdout.isTTY) {
throw new Error('secret: Pass --yes to sync secrets in CI or a non-interactive shell.');
}
const proceed = await confirm({
message: `This will overwrite ${formatCount(secrets.length, 'secret')} in the ${backend.target}. Continue?`,
});
if (isCancel(proceed) || !proceed) {
cancel('Secret sync cancelled.');
process.exit(1);
}
}

await backend.sync(secrets);
Expand Down Expand Up @@ -203,7 +208,7 @@ async function readSecretValue(): Promise<string> {

if (isCancel(result) || typeof result === 'symbol') {
cancel('Secret command cancelled.');
process.exit(0);
process.exit(1);
}

const value = result ?? '';
Expand Down
61 changes: 61 additions & 0 deletions packages/void/test/integration/secret-cli.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { spawnSync } from 'node:child_process';
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { pathToFileURL } from 'node:url';
import { afterEach, expect, it } from 'vite-plus/test';

const packageRoot = join(import.meta.dirname, '../..');
const cliEntry = join(packageRoot, 'dist/cli/cli.mjs');
const envPublicUrl = pathToFileURL(join(packageRoot, 'dist/runtime/env-public.mjs')).href;
const roots: Array<string> = [];

afterEach(() => {
for (const root of roots.splice(0)) {
rmSync(root, { recursive: true, force: true });
}
});

it.each(['', 'true'])('rejects secret sync with closed stdin and CI=%s', (ci) => {
const root = mkdtempSync(join(tmpdir(), 'void-secret-cli-'));
roots.push(root);
mkdirSync(join(root, '.void'));
writeFileSync(join(root, 'package.json'), '{"type":"module"}\n');
writeFileSync(join(root, '.void/project.json'), '{"platform":"cloudflare"}\n');
writeFileSync(
join(root, 'void.config.ts'),
'export default { cloudflare: { name: "test-worker", account_id: "test-account" } };\n',
);
writeFileSync(
join(root, 'env.ts'),
`import { defineEnv, string } from ${JSON.stringify(envPublicUrl)};\n` +
'export default defineEnv({ API_TOKEN: string() });\n',
);
const file = join(root, 'production.env');
writeFileSync(file, 'API_TOKEN=test-secret-value\n');

const result = spawnSync(process.execPath, [cliEntry, 'secret', 'sync', file], {
cwd: root,
input: '',
encoding: 'utf8',
timeout: 10_000,
env: {
PATH: process.env.PATH,
SystemRoot: process.env.SystemRoot,
HOME: root,
USERPROFILE: root,
NODE_OPTIONS: process.env.NODE_OPTIONS,
CI: ci,
CLOUDFLARE_API_BASE_URL: 'http://127.0.0.1:1',
},
});

expect(result.error).toBeUndefined();
expect(result.signal).toBeNull();
expect(result.status, result.stdout + result.stderr).toBe(1);
const output = result.stdout + result.stderr;
expect(output).toContain('--yes');
expect(output).not.toContain('Continue?');
expect(output).not.toContain('Synced');
expect(output).not.toContain('test-secret-value');
});
154 changes: 154 additions & 0 deletions packages/void/test/unit/cloudflare-operation-process-secrets.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import { spawnSync } from 'node:child_process';
import { mkdtempSync, readFileSync, rmSync, writeFileSync, existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { pathToFileURL } from 'node:url';
import type { CloudflareOperation } from '@void/deploy-cloudflare';
import { expect, it } from 'vite-plus/test';
import {
decodeCloudflareOperationResult,
encodeCloudflareOperation,
} from '../../src/cli/cloudflare-process.ts';

function runSecretBulk(
input: string,
options?: { preloadBody?: string; recordCallsPath?: string },
) {
const directory = mkdtempSync(join(tmpdir(), 'void-cf-secret-bulk-test-'));
try {
const config = join(directory, 'wrangler.json');
const capture = join(directory, 'cf-args.json');
const bulkBody = join(directory, 'bulk-body.json');
const callLog = join(directory, 'cf-call-log.json');
const preload = join(directory, 'capture-cf.mjs');
writeFileSync(config, JSON.stringify({ name: 'my-worker', account_id: 'test-account' }));
writeFileSync(
preload,
options?.preloadBody ??
`import child from 'node:child_process';
import { readFileSync, writeFileSync } from 'node:fs';
import { syncBuiltinESMExports } from 'node:module';
const originalSpawnSync = child.spawnSync.bind(child);
child.spawnSync = (binary, args, options) => {
const isCfCli = Array.isArray(args) && typeof args[0] === 'string' && /(?:\\/|^)cf$/.test(args[0]);
if (!isCfCli) {
return originalSpawnSync(binary, args, options);
}
writeFileSync(process.env.VOID_TEST_CF_CAPTURE, JSON.stringify(args));
const fileIndex = args.indexOf('--file');
if (fileIndex >= 0) {
writeFileSync(process.env.VOID_TEST_BULK_BODY, readFileSync(args[fileIndex + 1], 'utf8'));
}
return { status: 0, stdout: '{}', stderr: '' };
};
syncBuiltinESMExports();
`,
);
const entry = join(
import.meta.dirname,
'..',
'..',
'src',
'cli',
'cloudflare-operation-process.ts',
);
const operation: CloudflareOperation = {
type: 'secret.bulk',
configPath: config,
workerName: 'my-worker',
};
const child = spawnSync(
process.execPath,
['--import', pathToFileURL(preload).href, entry, ...encodeCloudflareOperation(operation)],
{
env: {
...process.env,
VOID_TEST_CF_CAPTURE: capture,
VOID_TEST_BULK_BODY: bulkBody,
VOID_TEST_CALL_LOG: callLog,
CLOUDFLARE_ACCOUNT_ID: 'test-account',
},
encoding: 'utf8',
input,
},
);
const result = decodeCloudflareOperationResult({
status: child.status,
stdout: child.stdout,
stderr: child.stderr,
});
return {
cfArgs: existsSync(capture)
? (JSON.parse(readFileSync(capture, 'utf8')) as Array<string>).slice(1)
: undefined,
bulkPayload: existsSync(bulkBody)
? (JSON.parse(readFileSync(bulkBody, 'utf8')) as unknown)
: undefined,
calls: existsSync(callLog)
? (JSON.parse(readFileSync(callLog, 'utf8')) as Array<string>)
: [],
result,
};
} finally {
rmSync(directory, { recursive: true, force: true });
}
}

it('wraps Workers secrets bulk payload in a secrets object', () => {
const { cfArgs, bulkPayload, result } = runSecretBulk(
JSON.stringify({ ONE: 'first', TWO: 'second' }),
);
expect(result.status, result.stderr).toBe(0);
expect(cfArgs!.slice(0, -1)).toEqual([
'workers',
'secrets',
'bulk',
'--worker',
'my-worker',
'--file',
]);
expect(cfArgs!.at(-1)).toMatch(/body\.json$/);
expect(bulkPayload).toEqual({
secrets: {
ONE: { type: 'secret_text', text: 'first' },
TWO: { type: 'secret_text', text: 'second' },
},
});
});

it('returns a non-zero status when secret bulk draft deploy fails after Worker 10007', () => {
const { calls, result } = runSecretBulk(JSON.stringify({ ONE: 'first' }), {
preloadBody: `import child from 'node:child_process';
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { syncBuiltinESMExports } from 'node:module';
const originalSpawnSync = child.spawnSync.bind(child);
child.spawnSync = (binary, args, options) => {
const isCfCli = Array.isArray(args) && typeof args[0] === 'string' && /(?:\\/|^)cf$/.test(args[0]);
if (!isCfCli) {
return originalSpawnSync(binary, args, options);
}
const label = args.slice(1).join(' ');
const logPath = process.env.VOID_TEST_CALL_LOG;
const calls = logPath && existsSync(logPath)
? JSON.parse(readFileSync(logPath, 'utf8'))
: [];
calls.push(label);
if (logPath) {
writeFileSync(logPath, JSON.stringify(calls));
}
if (label.includes('workers secrets bulk')) {
return { status: 1, stdout: '', stderr: '[10007] Worker script not found.' };
}
if (label.includes('deploy') && label.includes('--prebuilt')) {
return { status: 1, stdout: '', stderr: 'Draft deploy failed.' };
}
return { status: 0, stdout: '{}', stderr: '' };
};
syncBuiltinESMExports();`,
});
expect(result.status).toBe(1);
expect(result.stderr).toContain('Draft deploy failed.');
expect(calls.some((call) => call.includes('workers secrets bulk'))).toBe(true);
expect(calls.some((call) => call.includes('void:secret-draft'))).toBe(true);
expect(calls.filter((call) => call.includes('workers secrets bulk'))).toHaveLength(1);
});
19 changes: 17 additions & 2 deletions packages/void/test/unit/secret-args.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,13 +102,28 @@ describe('parseSecretArgs', () => {

it('errors on missing sync file path', () => {
expect(() => parseSecretArgs(['sync'])).toThrow(
'secret: Usage: void secret sync <file> [--project <name>].',
'secret: Usage: void secret sync <file> [--project <name>] [--yes].',
);
});

it('errors on extra sync positional', () => {
expect(() => parseSecretArgs(['sync', '.env', 'extra'])).toThrow(
'secret: Usage: void secret sync <file> [--project <name>].',
'secret: Usage: void secret sync <file> [--project <name>] [--yes].',
);
});

it('parses sync with --yes', () => {
expect(parseSecretArgs(['sync', '.env', '--yes'])).toEqual({
action: 'sync',
file: '.env',
projectName: undefined,
yes: true,
});
});

it('rejects --yes on other subcommands', () => {
expect(() => parseSecretArgs(['list', '--yes'])).toThrow(
'secret: `void secret list` does not support --yes.',
);
});
});
Loading