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..2ed6fe2 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: 'Binary command to run with scratch database environment variables; pass arguments to the binary 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']);