From 7e9fc13b7fc00f44574989fe9a689128d38e8f38 Mon Sep 17 00:00:00 2001 From: hbrooks Date: Sat, 27 Jun 2026 10:17:18 -0400 Subject: [PATCH 1/3] feat: add `agent sandbox variable` commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire up the public /v1 sandbox environment variable endpoints added on the backend (GET/PUT/DELETE /v1/sandboxes/variables): agent sandbox variable list # GET — names + timestamps (values write-only) agent sandbox variable set K V # PUT — single upsert agent sandbox variable set --from-file .env # PUT — batch from a .env file agent sandbox variable rm K # DELETE These are customer-scoped variables an agent pulls into its sandbox when its config names them. Values are write-only — the API never returns them, so list output shows only names + timestamps. Name validation is left to the server (one source of truth). Adds ApiClient methods, typed request/response mirrors, a .env parser with tests, and README usage. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 4 ++ src/cli.tsx | 2 + src/commands/sandbox.ts | 120 ++++++++++++++++++++++++++++++++++++++++ src/lib/api.ts | 34 ++++++++++++ src/lib/types.ts | 24 ++++++++ test/api.test.ts | 46 +++++++++++++++ test/sandbox.test.ts | 37 +++++++++++++ 7 files changed, 267 insertions(+) create mode 100644 src/commands/sandbox.ts create mode 100644 test/sandbox.test.ts diff --git a/README.md b/README.md index eac9afa..9704a57 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,10 @@ agent config list # list saved agent configs agent config get # show one config as YAML (-o json for JSON) agent config init [path] # scaffold a starter config (default: agents/my_agent.yaml) +agent sandbox variable list # list sandbox env variable names (values are write-only) +agent sandbox variable set K V # create/update a variable (or --from-file .env for a batch) +agent sandbox variable rm K # delete a variable + agent budget # current budget summary agent usage # usage dashboard for the period agent ping # check authenticated /v1 connectivity diff --git a/src/cli.tsx b/src/cli.tsx index 4b5be90..7b1d4c6 100644 --- a/src/cli.tsx +++ b/src/cli.tsx @@ -3,6 +3,7 @@ import { registerLogin } from './commands/login' import { registerMe } from './commands/me' import { registerRun } from './commands/run' import { registerConfig } from './commands/config' +import { registerSandbox } from './commands/sandbox' import { registerUsage } from './commands/usage' import { registerPing } from './commands/ping' import { VERSION } from './lib/constants' @@ -18,6 +19,7 @@ registerLogin(program) registerMe(program) registerRun(program) registerConfig(program) +registerSandbox(program) registerUsage(program) registerPing(program) diff --git a/src/commands/sandbox.ts b/src/commands/sandbox.ts new file mode 100644 index 0000000..a7125ef --- /dev/null +++ b/src/commands/sandbox.ts @@ -0,0 +1,120 @@ +import { type Command } from 'commander' +import { readFileSync } from 'node:fs' +import { ApiClient } from '../lib/api' +import { formatTs, printJson, printTable, runAction } from '../lib/output' +import type { SandboxVariableInput, SandboxVariableSummary } from '../lib/types' + +export function registerSandbox(program: Command): void { + const sandbox = program.command('sandbox').description('Manage sandbox resources') + + const variable = sandbox + .command('variable') + .alias('var') + .description('Manage sandbox environment variables (values are write-only)') + + variable + .command('list') + .alias('ls') + .description('List sandbox environment variables (GET /v1/sandboxes/variables)') + .option('--json', 'output raw JSON') + .action(async (opts: { json?: boolean }) => { + await runAction(async () => { + const variables = await new ApiClient().listSandboxVariables() + printVariables(variables, opts.json) + }) + }) + + variable + .command('set [name] [value]') + .description('Create or update variables (PUT /v1/sandboxes/variables)') + .option('-f, --from-file ', 'load KEY=VALUE pairs from a .env-style file') + .option('--json', 'output raw JSON') + .action( + async ( + name: string | undefined, + value: string | undefined, + opts: { fromFile?: string; json?: boolean }, + ) => { + await runAction(async () => { + const inputs = collectInputs(name, value, opts.fromFile) + const variables = await new ApiClient().putSandboxVariables(inputs) + if (!opts.json) { + const names = inputs.map((v) => v.name).join(', ') + console.log(`✓ stored ${inputs.length} variable(s) (values hidden): ${names}`) + } + printVariables(variables, opts.json) + }) + }, + ) + + variable + .command('rm ') + .alias('delete') + .description('Delete a variable (DELETE /v1/sandboxes/variables/{name})') + .option('--json', 'output raw JSON') + .action(async (name: string, opts: { json?: boolean }) => { + await runAction(async () => { + const variables = await new ApiClient().deleteSandboxVariable(name) + if (!opts.json) console.log(`✓ deleted ${name}`) + printVariables(variables, opts.json) + }) + }) +} + +// Build the upsert batch from either a single `name value` pair or a file. Name +// validation is left to the server (one source of truth for the rules); we only +// enforce that the caller gave us something to send. +function collectInputs( + name: string | undefined, + value: string | undefined, + fromFile: string | undefined, +): SandboxVariableInput[] { + if (fromFile) { + if (name !== undefined) { + throw new Error('pass either a name/value pair or --from-file, not both') + } + const inputs = parseEnvFile(readFileSync(fromFile, 'utf8')) + if (inputs.length === 0) throw new Error(`no KEY=VALUE pairs found in ${fromFile}`) + return inputs + } + if (name === undefined || value === undefined) { + throw new Error('provide a name and value, or --from-file ') + } + return [{ name, value }] +} + +// Parse a .env-style file: `KEY=VALUE` per line, blank lines and `#` comments +// skipped, an optional leading `export `, and matching surrounding quotes +// stripped. Splits on the first `=` so values may contain `=`. +export function parseEnvFile(contents: string): SandboxVariableInput[] { + const inputs: SandboxVariableInput[] = [] + for (const raw of contents.split('\n')) { + const line = raw.trim() + if (line === '' || line.startsWith('#')) continue + const withoutExport = line.startsWith('export ') ? line.slice(7).trim() : line + const eq = withoutExport.indexOf('=') + if (eq === -1) continue + const name = withoutExport.slice(0, eq).trim() + let value = withoutExport.slice(eq + 1).trim() + if (value.length >= 2 && (value[0] === '"' || value[0] === "'") && value.at(-1) === value[0]) { + value = value.slice(1, -1) + } + inputs.push({ name, value }) + } + return inputs +} + +function printVariables(variables: SandboxVariableSummary[], json?: boolean): void { + if (json) { + printJson(variables) + return + } + if (variables.length === 0) { + console.log('No sandbox variables.') + return + } + printTable( + ['NAME', 'CREATED', 'UPDATED'], + variables.map((v) => [v.name, formatTs(v.created_at), formatTs(v.updated_at)]), + ) +} diff --git a/src/lib/api.ts b/src/lib/api.ts index 5d2c775..70a1f9e 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -4,9 +4,12 @@ import type { BudgetSummary, CliAuthPoll, CliAuthStart, + GetSandboxVariablesResponse, ListAgentConfigsResponse, ListAgentRunsQuery, ListAgentRunsResponse, + SandboxVariableInput, + SandboxVariableSummary, SavedAgentConfig, StartAgentRunRequest, UsageDashboard, @@ -114,6 +117,37 @@ export class ApiClient { return this.request('GET', `/v1/agents/configs/${encodeURIComponent(configId)}`) } + // -------------------------- sandbox variables --------------------------- + // All three return the full current list (the backend echoes it after every + // mutation), so callers can render the resulting state. + + async listSandboxVariables(): Promise { + const res = await this.request( + 'GET', + '/v1/sandboxes/variables', + ) + return res.variables + } + + async putSandboxVariables( + variables: SandboxVariableInput[], + ): Promise { + const res = await this.request( + 'PUT', + '/v1/sandboxes/variables', + { variables }, + ) + return res.variables + } + + async deleteSandboxVariable(name: string): Promise { + const res = await this.request( + 'DELETE', + `/v1/sandboxes/variables/${encodeURIComponent(name)}`, + ) + return res.variables + } + // --------------------------- device-code auth --------------------------- // Unauthenticated: the CLI has no credential yet — that's what it's obtaining. diff --git a/src/lib/types.ts b/src/lib/types.ts index 9586633..f5dbd79 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -141,6 +141,30 @@ export interface ListAgentRunsQuery { limit?: number } +// -------------------------- sandbox variables --------------------------- +// Customer-scoped environment variables injected into a sandbox when an agent +// config names them. Values are write-only: the API accepts them but never +// returns them, so the summary carries only the name and timestamps. + +export interface SandboxVariableSummary { + name: string + created_at: string + updated_at: string +} + +export interface GetSandboxVariablesResponse { + variables: SandboxVariableSummary[] +} + +export interface SandboxVariableInput { + name: string + value: string +} + +export interface PutSandboxVariablesRequest { + variables: SandboxVariableInput[] +} + // ------------------------------ cli auth -------------------------------- export interface CliAuthStart { diff --git a/test/api.test.ts b/test/api.test.ts index b1cdea6..a36732d 100644 --- a/test/api.test.ts +++ b/test/api.test.ts @@ -92,3 +92,49 @@ describe('ApiClient.request', () => { expect(err.message).toContain('GET /x failed: 500 boom') }) }) + +describe('ApiClient sandbox variables', () => { + afterEach(() => vi.unstubAllGlobals()) + + it('lists variables and unwraps the response envelope', async () => { + const fetchMock = vi.fn( + async () => + new Response(JSON.stringify({ variables: [{ name: 'A', created_at: '', updated_at: '' }] }), { + status: 200, + }), + ) + vi.stubGlobal('fetch', fetchMock) + + const out = await new ApiClient('http://api.test', 't').listSandboxVariables() + expect(out).toEqual([{ name: 'A', created_at: '', updated_at: '' }]) + expect(fetchMock.mock.calls[0][0]).toBe('http://api.test/v1/sandboxes/variables') + expect((fetchMock.mock.calls[0][1] as RequestInit).method).toBe('GET') + }) + + it('PUTs the variables batch and returns the echoed list', async () => { + const fetchMock = vi.fn( + async () => new Response(JSON.stringify({ variables: [] }), { status: 200 }), + ) + vi.stubGlobal('fetch', fetchMock) + + await new ApiClient('http://api.test', 't').putSandboxVariables([{ name: 'TOKEN', value: 'x' }]) + const [url, init] = fetchMock.mock.calls[0] + expect(url).toBe('http://api.test/v1/sandboxes/variables') + expect((init as RequestInit).method).toBe('PUT') + expect(JSON.parse((init as RequestInit).body as string)).toEqual({ + variables: [{ name: 'TOKEN', value: 'x' }], + }) + }) + + it('URL-encodes the name on delete', async () => { + const fetchMock = vi.fn( + async () => new Response(JSON.stringify({ variables: [] }), { status: 200 }), + ) + vi.stubGlobal('fetch', fetchMock) + + await new ApiClient('http://api.test', 't').deleteSandboxVariable('MY/VAR') + const [url, init] = fetchMock.mock.calls[0] + expect(url).toBe('http://api.test/v1/sandboxes/variables/MY%2FVAR') + expect((init as RequestInit).method).toBe('DELETE') + }) +}) diff --git a/test/sandbox.test.ts b/test/sandbox.test.ts new file mode 100644 index 0000000..95e4b19 --- /dev/null +++ b/test/sandbox.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest' +import { parseEnvFile } from '../src/commands/sandbox' + +describe('parseEnvFile', () => { + it('parses KEY=VALUE pairs', () => { + expect(parseEnvFile('A=1\nB=two')).toEqual([ + { name: 'A', value: '1' }, + { name: 'B', value: 'two' }, + ]) + }) + + it('skips blank lines and # comments', () => { + expect(parseEnvFile('\n# a comment\nA=1\n\n')).toEqual([{ name: 'A', value: '1' }]) + }) + + it('strips a leading `export `', () => { + expect(parseEnvFile('export TOKEN=abc')).toEqual([{ name: 'TOKEN', value: 'abc' }]) + }) + + it('splits on the first = so values may contain =', () => { + expect(parseEnvFile('URL=https://x.test/?a=b')).toEqual([ + { name: 'URL', value: 'https://x.test/?a=b' }, + ]) + }) + + it('strips matching surrounding quotes', () => { + expect(parseEnvFile(`A="quoted"\nB='single'\nC="mismatch'`)).toEqual([ + { name: 'A', value: 'quoted' }, + { name: 'B', value: 'single' }, + { name: 'C', value: `"mismatch'` }, + ]) + }) + + it('ignores lines with no =', () => { + expect(parseEnvFile('NOEQUALS\nA=1')).toEqual([{ name: 'A', value: '1' }]) + }) +}) From 6eeac21861e91ca13beb0864d3fc4da830795529 Mon Sep 17 00:00:00 2001 From: hbrooks Date: Sat, 27 Jun 2026 10:25:09 -0400 Subject: [PATCH 2/3] feat: support multiple inline variables in `set` Change `sandbox variable set` to take variadic `KEY=VALUE` arguments (`set A=1 B=2`) instead of a single space-separated name/value pair, and allow combining inline pairs with `--from-file` (file first, inline overrides). Factor the shared `KEY=VALUE` parsing into `parseAssignment`, reused by both the inline args and the .env file parser. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 2 +- src/commands/sandbox.ts | 98 ++++++++++++++++++++++------------------- test/sandbox.test.ts | 40 ++++++++++++++++- 3 files changed, 93 insertions(+), 47 deletions(-) diff --git a/README.md b/README.md index 9704a57..358c739 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ agent config get # show one config as YAML (-o json for JSON) agent config init [path] # scaffold a starter config (default: agents/my_agent.yaml) agent sandbox variable list # list sandbox env variable names (values are write-only) -agent sandbox variable set K V # create/update a variable (or --from-file .env for a batch) +agent sandbox variable set A=1 B=2 # create/update variables (or --from-file .env) agent sandbox variable rm K # delete a variable agent budget # current budget summary diff --git a/src/commands/sandbox.ts b/src/commands/sandbox.ts index a7125ef..9534d29 100644 --- a/src/commands/sandbox.ts +++ b/src/commands/sandbox.ts @@ -25,27 +25,21 @@ export function registerSandbox(program: Command): void { }) variable - .command('set [name] [value]') - .description('Create or update variables (PUT /v1/sandboxes/variables)') + .command('set [assignments...]') + .description('Create or update variables, e.g. `set A=1 B=2` (PUT /v1/sandboxes/variables)') .option('-f, --from-file ', 'load KEY=VALUE pairs from a .env-style file') .option('--json', 'output raw JSON') - .action( - async ( - name: string | undefined, - value: string | undefined, - opts: { fromFile?: string; json?: boolean }, - ) => { - await runAction(async () => { - const inputs = collectInputs(name, value, opts.fromFile) - const variables = await new ApiClient().putSandboxVariables(inputs) - if (!opts.json) { - const names = inputs.map((v) => v.name).join(', ') - console.log(`✓ stored ${inputs.length} variable(s) (values hidden): ${names}`) - } - printVariables(variables, opts.json) - }) - }, - ) + .action(async (assignments: string[], opts: { fromFile?: string; json?: boolean }) => { + await runAction(async () => { + const inputs = collectInputs(assignments, opts.fromFile) + const variables = await new ApiClient().putSandboxVariables(inputs) + if (!opts.json) { + const names = inputs.map((v) => v.name).join(', ') + console.log(`✓ stored ${inputs.length} variable(s) (values hidden): ${names}`) + } + printVariables(variables, opts.json) + }) + }) variable .command('rm ') @@ -61,45 +55,59 @@ export function registerSandbox(program: Command): void { }) } -// Build the upsert batch from either a single `name value` pair or a file. Name -// validation is left to the server (one source of truth for the rules); we only -// enforce that the caller gave us something to send. -function collectInputs( - name: string | undefined, - value: string | undefined, +// Build the upsert batch from inline `KEY=VALUE` arguments and/or a file. File +// pairs come first so an inline arg with the same name overrides the file (the +// server upserts in order, last write wins). Name validation is left to the +// server (one source of truth for the rules); we only parse and require that +// the caller gave us something to send. +export function collectInputs( + assignments: string[], fromFile: string | undefined, ): SandboxVariableInput[] { + const inputs: SandboxVariableInput[] = [] if (fromFile) { - if (name !== undefined) { - throw new Error('pass either a name/value pair or --from-file, not both') + const fileInputs = parseEnvFile(readFileSync(fromFile, 'utf8')) + if (fileInputs.length === 0) throw new Error(`no KEY=VALUE pairs found in ${fromFile}`) + inputs.push(...fileInputs) + } + for (const raw of assignments) { + const parsed = parseAssignment(raw) + if (parsed === null) { + throw new Error(`invalid assignment '${raw}' (expected KEY=VALUE)`) } - const inputs = parseEnvFile(readFileSync(fromFile, 'utf8')) - if (inputs.length === 0) throw new Error(`no KEY=VALUE pairs found in ${fromFile}`) - return inputs + inputs.push(parsed) } - if (name === undefined || value === undefined) { - throw new Error('provide a name and value, or --from-file ') + if (inputs.length === 0) { + throw new Error('provide KEY=VALUE pairs, or --from-file ') } - return [{ name, value }] + return inputs } -// Parse a .env-style file: `KEY=VALUE` per line, blank lines and `#` comments -// skipped, an optional leading `export `, and matching surrounding quotes -// stripped. Splits on the first `=` so values may contain `=`. +// Parse one `KEY=VALUE` assignment. Strips an optional leading `export `, splits +// on the first `=` (so values may contain `=`), and removes matching surrounding +// quotes. Returns null when there is no `=`. +export function parseAssignment(raw: string): SandboxVariableInput | null { + const line = raw.trim() + const withoutExport = line.startsWith('export ') ? line.slice(7).trim() : line + const eq = withoutExport.indexOf('=') + if (eq === -1) return null + const name = withoutExport.slice(0, eq).trim() + let value = withoutExport.slice(eq + 1).trim() + if (value.length >= 2 && (value[0] === '"' || value[0] === "'") && value.at(-1) === value[0]) { + value = value.slice(1, -1) + } + return { name, value } +} + +// Parse a .env-style file: one `KEY=VALUE` per line, with blank lines and `#` +// comments skipped. Each non-comment line is parsed by parseAssignment. export function parseEnvFile(contents: string): SandboxVariableInput[] { const inputs: SandboxVariableInput[] = [] for (const raw of contents.split('\n')) { const line = raw.trim() if (line === '' || line.startsWith('#')) continue - const withoutExport = line.startsWith('export ') ? line.slice(7).trim() : line - const eq = withoutExport.indexOf('=') - if (eq === -1) continue - const name = withoutExport.slice(0, eq).trim() - let value = withoutExport.slice(eq + 1).trim() - if (value.length >= 2 && (value[0] === '"' || value[0] === "'") && value.at(-1) === value[0]) { - value = value.slice(1, -1) - } - inputs.push({ name, value }) + const parsed = parseAssignment(line) + if (parsed !== null) inputs.push(parsed) } return inputs } diff --git a/test/sandbox.test.ts b/test/sandbox.test.ts index 95e4b19..51e1b97 100644 --- a/test/sandbox.test.ts +++ b/test/sandbox.test.ts @@ -1,5 +1,43 @@ import { describe, expect, it } from 'vitest' -import { parseEnvFile } from '../src/commands/sandbox' +import { collectInputs, parseAssignment, parseEnvFile } from '../src/commands/sandbox' + +describe('parseAssignment', () => { + it('parses KEY=VALUE', () => { + expect(parseAssignment('A=1')).toEqual({ name: 'A', value: '1' }) + }) + + it('splits on the first = so values may contain =', () => { + expect(parseAssignment('URL=https://x.test/?a=b')).toEqual({ + name: 'URL', + value: 'https://x.test/?a=b', + }) + }) + + it('strips a leading `export ` and surrounding quotes', () => { + expect(parseAssignment('export TOKEN="abc"')).toEqual({ name: 'TOKEN', value: 'abc' }) + }) + + it('returns null when there is no =', () => { + expect(parseAssignment('NOEQUALS')).toBeNull() + }) +}) + +describe('collectInputs', () => { + it('collects multiple inline assignments', () => { + expect(collectInputs(['A=1', 'B=2'], undefined)).toEqual([ + { name: 'A', value: '1' }, + { name: 'B', value: '2' }, + ]) + }) + + it('throws on an inline arg with no =', () => { + expect(() => collectInputs(['A=1', 'BAD'], undefined)).toThrow(/invalid assignment 'BAD'/) + }) + + it('throws when given nothing', () => { + expect(() => collectInputs([], undefined)).toThrow(/provide KEY=VALUE/) + }) +}) describe('parseEnvFile', () => { it('parses KEY=VALUE pairs', () => { From 7c30471565d6e732906f0dd23059ad05b3856d20 Mon Sep 17 00:00:00 2001 From: hbrooks Date: Sat, 27 Jun 2026 10:30:01 -0400 Subject: [PATCH 3/3] feat: accept JSON files in `set --from-file` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `--from-file` now picks the format by extension: a `.json` file is parsed as a flat object of name → value, anything else stays .env-style KEY=VALUE. JSON values must be strings — a nested object, array, or non-string value is rejected rather than silently coerced. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 2 +- src/commands/sandbox.ts | 38 +++++++++++++++++++++++++++++++++++--- test/sandbox.test.ts | 33 ++++++++++++++++++++++++++++++++- 3 files changed, 68 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 358c739..b75206e 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ agent config get # show one config as YAML (-o json for JSON) agent config init [path] # scaffold a starter config (default: agents/my_agent.yaml) agent sandbox variable list # list sandbox env variable names (values are write-only) -agent sandbox variable set A=1 B=2 # create/update variables (or --from-file .env) +agent sandbox variable set A=1 B=2 # create/update variables (or --from-file .env/.json) agent sandbox variable rm K # delete a variable agent budget # current budget summary diff --git a/src/commands/sandbox.ts b/src/commands/sandbox.ts index 9534d29..61921ad 100644 --- a/src/commands/sandbox.ts +++ b/src/commands/sandbox.ts @@ -27,7 +27,7 @@ export function registerSandbox(program: Command): void { variable .command('set [assignments...]') .description('Create or update variables, e.g. `set A=1 B=2` (PUT /v1/sandboxes/variables)') - .option('-f, --from-file ', 'load KEY=VALUE pairs from a .env-style file') + .option('-f, --from-file ', 'load variables from a .env or .json file') .option('--json', 'output raw JSON') .action(async (assignments: string[], opts: { fromFile?: string; json?: boolean }) => { await runAction(async () => { @@ -66,8 +66,8 @@ export function collectInputs( ): SandboxVariableInput[] { const inputs: SandboxVariableInput[] = [] if (fromFile) { - const fileInputs = parseEnvFile(readFileSync(fromFile, 'utf8')) - if (fileInputs.length === 0) throw new Error(`no KEY=VALUE pairs found in ${fromFile}`) + const fileInputs = readVarsFromFile(fromFile) + if (fileInputs.length === 0) throw new Error(`no variables found in ${fromFile}`) inputs.push(...fileInputs) } for (const raw of assignments) { @@ -83,6 +83,38 @@ export function collectInputs( return inputs } +// Read a variables file, picking the format by extension: `.json` is a flat +// object of name → value, anything else is a .env-style KEY=VALUE file. +function readVarsFromFile(path: string): SandboxVariableInput[] { + const contents = readFileSync(path, 'utf8') + return path.toLowerCase().endsWith('.json') + ? parseJsonVars(contents) + : parseEnvFile(contents) +} + +// Parse a flat JSON object of name → value. Values must be strings (sandbox +// variable values are strings); a nested object, array, or non-string value is +// rejected rather than silently coerced. +export function parseJsonVars(contents: string): SandboxVariableInput[] { + let parsed: unknown + try { + parsed = JSON.parse(contents) + } catch (err) { + throw new Error(`invalid JSON: ${(err as Error).message}`) + } + if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error('expected a JSON object of variable name to value') + } + const inputs: SandboxVariableInput[] = [] + for (const [name, value] of Object.entries(parsed)) { + if (typeof value !== 'string') { + throw new Error(`value for '${name}' must be a string`) + } + inputs.push({ name, value }) + } + return inputs +} + // Parse one `KEY=VALUE` assignment. Strips an optional leading `export `, splits // on the first `=` (so values may contain `=`), and removes matching surrounding // quotes. Returns null when there is no `=`. diff --git a/test/sandbox.test.ts b/test/sandbox.test.ts index 51e1b97..265852a 100644 --- a/test/sandbox.test.ts +++ b/test/sandbox.test.ts @@ -1,5 +1,36 @@ import { describe, expect, it } from 'vitest' -import { collectInputs, parseAssignment, parseEnvFile } from '../src/commands/sandbox' +import { + collectInputs, + parseAssignment, + parseEnvFile, + parseJsonVars, +} from '../src/commands/sandbox' + +describe('parseJsonVars', () => { + it('parses a flat object of name → value', () => { + expect(parseJsonVars('{"A":"1","B":"two"}')).toEqual([ + { name: 'A', value: '1' }, + { name: 'B', value: 'two' }, + ]) + }) + + it('accepts an empty object', () => { + expect(parseJsonVars('{}')).toEqual([]) + }) + + it('throws on invalid JSON', () => { + expect(() => parseJsonVars('{not json}')).toThrow(/invalid JSON/) + }) + + it('rejects a top-level array', () => { + expect(() => parseJsonVars('["A","B"]')).toThrow(/object of variable name/) + }) + + it('rejects non-string values', () => { + expect(() => parseJsonVars('{"A":1}')).toThrow(/value for 'A' must be a string/) + expect(() => parseJsonVars('{"A":{"nested":"x"}}')).toThrow(/value for 'A' must be a string/) + }) +}) describe('parseAssignment', () => { it('parses KEY=VALUE', () => {