From dac0614fa62e6c736a56a474e6e59510e5d435d9 Mon Sep 17 00:00:00 2001 From: Martin Hansen Date: Fri, 4 Sep 2026 11:13:56 +0200 Subject: [PATCH 1/2] Support conventional command forwarding Enable the standard -- argument boundary so scratch can pass child options to tools such as psql without parsing them as Xata flags. --- src/app.ts | 3 +++ src/commands/scratch.ts | 16 +++++++++------- src/commands/scratch.unit.test.ts | 6 ++++-- src/lib/global-flags.unit.test.ts | 4 ++++ src/lib/profile.unit.test.ts | 4 ++++ test-binary/e2e/smoke.e2e.test.ts | 21 +++++++++++++++++++++ 6 files changed, 45 insertions(+), 9 deletions(-) diff --git a/src/app.ts b/src/app.ts index 2d4cb18..67bd6ef 100644 --- a/src/app.ts +++ b/src/app.ts @@ -60,6 +60,9 @@ const routes = buildRouteMap({ export const app = buildApplication(addGlobalFlags(routes), { name: CLI_NAME, + scanner: { + allowArgumentEscapeSequence: true + }, versionInfo: { currentVersion: getCLIVersion(), getLatestVersion, diff --git a/src/commands/scratch.ts b/src/commands/scratch.ts index 20cfcd5..c478010 100644 --- a/src/commands/scratch.ts +++ b/src/commands/scratch.ts @@ -206,14 +206,14 @@ export async function implementation(this: LocalContext, flags: Flags, ...comman const hasBinary = command.length > 0; if (hasQuery && hasBinary) { - this.process.stderr.write(chalk.red('Use either --execute/-x or a binary command, not both.\n')); + this.process.stderr.write( + chalk.red('Use either --execute/-x or -- [arguments...], not both.\n') + ); this.process.exit(1); } if (!hasQuery && !hasBinary) { - this.process.stderr.write( - chalk.red("Expected --execute/-x or a binary command, for example 'xata scratch psql'.\n") - ); + this.process.stderr.write(chalk.red('Expected --execute/-x or -- [arguments...].\n')); this.process.exit(1); } @@ -379,8 +379,10 @@ export const ScratchCommand = buildCommand({ fullDescription: 'Creates a branch from the parent given, runs what it is asked to, and deletes the branch afterwards, so a query or a migration can be tried against real data without touching an existing branch.', customUsage: [ - { input: '--execute "select count(*) from users"', brief: 'Run a query against a throwaway copy' }, - { input: 'psql', brief: 'Open a Postgres client on the scratch branch' } + { input: '--execute "select count(*) from users"', brief: 'Run SQL with the built-in client' }, + { input: '-x "select count(*) from users"', brief: 'Run SQL using the short execute flag' }, + { input: '-- psql -c "select count(*) from users"', brief: 'Run psql with arguments' }, + { input: '-- npm run migrate', brief: 'Run a database tool against the scratch branch' } ] }, parameters: { @@ -428,7 +430,7 @@ export const ScratchCommand = buildCommand({ kind: 'array', minimum: 0, parameter: { - brief: 'Binary command to run with scratch database environment variables', + brief: 'Command and arguments to run with scratch database environment variables; place child options after --', parse: String, placeholder: 'command' } diff --git a/src/commands/scratch.unit.test.ts b/src/commands/scratch.unit.test.ts index 3de1146..682375f 100644 --- a/src/commands/scratch.unit.test.ts +++ b/src/commands/scratch.unit.test.ts @@ -246,11 +246,13 @@ describe('scratch command', () => { const binary = path.join(tempDir, 'check-env'); fs.writeFileSync( binary, - `#!/usr/bin/env bun\nif (!process.env.DATABASE_URL || !process.env.XATA_DATABASE_URL || !process.env.PGHOST || process.env.PGDATABASE !== 'app') process.exit(6);\nprocess.exit(7);\n` + `#!/usr/bin/env bun\nif (!process.env.DATABASE_URL || !process.env.XATA_DATABASE_URL || !process.env.PGHOST || process.env.PGDATABASE !== 'app') process.exit(6);\nif (JSON.stringify(Bun.argv.slice(2)) !== JSON.stringify(['-c', 'SELECT version();', '--profile', 'child', '--debug'])) process.exit(8);\nprocess.exit(7);\n` ); fs.chmodSync(binary, 0o755); - await expect(implementation.call(context, { json: false }, binary)).rejects.toThrow('exit:7'); + await expect( + implementation.call(context, { json: false }, binary, '-c', 'SELECT version();', '--profile', 'child', '--debug') + ).rejects.toThrow('exit:7'); expect(deleteBranch).toHaveBeenCalledTimes(1); }); diff --git a/src/lib/global-flags.unit.test.ts b/src/lib/global-flags.unit.test.ts index d12f5cf..b92ae89 100644 --- a/src/lib/global-flags.unit.test.ts +++ b/src/lib/global-flags.unit.test.ts @@ -59,6 +59,10 @@ describe('getDebugFlag', () => { expect(getDebugFlag(['branch', 'list'])).toBe(false); }); + test('does not read a child command flag after --', () => { + expect(getDebugFlag(['scratch', '--', 'tool', '--debug'])).toBe(false); + }); + test('is false for arguments it cannot parse', () => { expect(getDebugFlag(['--'])).toBe(false); }); diff --git a/src/lib/profile.unit.test.ts b/src/lib/profile.unit.test.ts index daa8ff3..aaaa973 100644 --- a/src/lib/profile.unit.test.ts +++ b/src/lib/profile.unit.test.ts @@ -20,6 +20,10 @@ describe('getProfileFlag', () => { expect(getProfileFlag(['branch', 'list', '--json'])).toBeUndefined(); }); + test('does not read a child command flag after --', () => { + expect(getProfileFlag(['scratch', '--', 'psql', '--profile', 'child'])).toBeUndefined(); + }); + test('returns undefined when the flag has no value', () => { expect(getProfileFlag(['branch', 'list', '--profile'])).toBeUndefined(); }); diff --git a/test-binary/e2e/smoke.e2e.test.ts b/test-binary/e2e/smoke.e2e.test.ts index f191a3f..0194a4b 100644 --- a/test-binary/e2e/smoke.e2e.test.ts +++ b/test-binary/e2e/smoke.e2e.test.ts @@ -17,6 +17,27 @@ describe('CLI binary smoke tests', () => { expect(result.stdout).toMatch(/branch|project|organization/i); }); + test('scratch help shows conventional command forwarding', async () => { + const result = await runCli(['scratch', '--help']); + + expect(result.code).toBe(0); + expect(result.stdout).toContain('-- psql -c "select count(*) from users"'); + }); + + test('scratch accepts child flags after the argument delimiter', async () => { + const result = await runCli([ + 'scratch', + '--', + 'definitely-missing-xata-scratch-binary', + '-c', + 'select 1' + ]); + + expect(result.code).toBe(1); + expect(result.stderr).toContain('Executable not found: definitely-missing-xata-scratch-binary'); + expect(result.stderr).not.toContain('No alias registered for -c'); + }); + test('status command works when not initialized', async () => { const result = await runCli(['status']); From 51b2396c3df3ccc5ed226c7ab6939999b49b0f54 Mon Sep 17 00:00:00 2001 From: Martin Hansen Date: Fri, 4 Sep 2026 11:39:58 +0200 Subject: [PATCH 2/2] Update src/commands/scratch.ts Co-authored-by: div --- src/commands/scratch.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/scratch.ts b/src/commands/scratch.ts index c478010..2ed6fe2 100644 --- a/src/commands/scratch.ts +++ b/src/commands/scratch.ts @@ -430,7 +430,7 @@ export const ScratchCommand = buildCommand({ kind: 'array', minimum: 0, parameter: { - brief: 'Command and arguments to run with scratch database environment variables; place child options after --', + brief: 'Binary command to run with scratch database environment variables; pass arguments to the binary after --', parse: String, placeholder: 'command' }