[cli] Allow ls to specify a limit up to 100 (#8735)

### Related Issues

Allow the `ls` commands to have a `--limit` option that allows a user to fetch up to 100 records per page. Currently the default is 20, but the API allows up to 100. This keeps the default, if unspecified at 20. 

Fixes: https://linear.app/vercel/issue/VCCLI-244/add-limit-option-all-the-ls-subcommands

This adds in `ls --limit` into:
- [x] `alias ls`
- [x] `certs ls`
- [x] `dns ls`
- [x] `domains ls`

I note that `env` has an `ls` command, but it doesn't have a pagination command and [looking at the API](https://vercel.com/docs/rest-api#endpoints/projects/retrieve-the-environment-variables-of-a-project-by-id-or-name) it doesn't support pagination or limit.

Wasn't sure if I should add in `-L` as a short cut to `--limit`, seems like a good idea.

~Couldn't find any tests that cover this API, but I could be looking in the wrong place, this is the first pull request, so my apologies if I've missed it. But I'll take another look tomorrow and leave it in the draft state for now.~

Added in unit tests for each of the commands and mocks for those unit tests, which is has caused this PR to get quite a bit larger, sorry about that.

Of note for reviewers, there were a few places where I changed `console.log` to `output.log` to get the output passed to the tests - as far as I can tell everything works on the command line and in tests.

### 📋 Checklist


#### Tests

- [x] The code changed/added as part of this PR has been covered with tests
- [x] All tests pass locally with `yarn test-unit`

#### Code Review

- [x] This PR has a concise title and thorough description useful to a reviewer
- [x] Issue from task tracker has a link to this PR
This commit is contained in:
Andy McKay
2022-12-06 18:46:31 -06:00
committed by GitHub
parent cd487bc604
commit 9b92a810bb
22 changed files with 366 additions and 54 deletions

View File

@@ -38,6 +38,9 @@ const help = () => {
-S, --scope Set a custom scope
-N, --next Show next page of results
-y, --yes Skip the confirmation prompt when removing an alias
--limit=${chalk.bold.underline(
'VALUE'
)} Number of results to return per page (default: 20, max: 100)
${chalk.dim('Examples:')}
@@ -81,6 +84,7 @@ export default async function main(client: Client) {
'--json': Boolean,
'--yes': Boolean,
'--next': Number,
'--limit': Number,
'-y': '--yes',
'-N': '--next',
});

View File

@@ -4,28 +4,30 @@ import table from 'text-table';
import Client from '../../util/client';
import getAliases from '../../util/alias/get-aliases';
import getScope from '../../util/get-scope';
import {
PaginationOptions,
getPaginationOpts,
} from '../../util/get-pagination-opts';
import stamp from '../../util/output/stamp';
import strlen from '../../util/strlen';
import getCommandFlags from '../../util/get-command-flags';
import { getCommandName } from '../../util/pkg-name';
import { Alias } from '../../types';
interface Options {
'--next'?: number;
}
export default async function ls(
client: Client,
opts: Options,
opts: PaginationOptions,
args: string[]
) {
const { output } = client;
const { '--next': nextTimestamp } = opts;
const { contextName } = await getScope(client);
if (typeof nextTimestamp !== undefined && Number.isNaN(nextTimestamp)) {
output.error('Please provide a number for flag --next');
let paginationOptions;
try {
paginationOptions = getPaginationOpts(opts);
} catch (err: unknown) {
output.prettyError(err);
return 1;
}
@@ -46,10 +48,10 @@ export default async function ls(
const { aliases, pagination } = await getAliases(
client,
undefined,
nextTimestamp
...paginationOptions
);
output.log(`aliases found under ${chalk.bold(contextName)} ${lsStamp()}`);
console.log(printAliasTable(aliases));
output.log(printAliasTable(aliases));
if (pagination && pagination.count === 20) {
const flags = getCommandFlags(opts, ['_', '--next']);

View File

@@ -50,6 +50,9 @@ const help = () => {
'FILE'
)} CA certificate chain file
-N, --next Show next page of results
--limit=${chalk.bold.underline(
'VALUE'
)} Number of results to return per page (default: 20, max: 100)
${chalk.dim('Examples:')}
@@ -92,6 +95,7 @@ export default async function main(client: Client) {
'--ca': String,
'--next': Number,
'-N': '--next',
'--limit': Number,
});
} catch (err) {
handleError(err);

View File

@@ -3,6 +3,10 @@ import ms from 'ms';
import table from 'text-table';
import Client from '../../util/client';
import getScope from '../../util/get-scope';
import {
PaginationOptions,
getPaginationOpts,
} from '../../util/get-pagination-opts';
import stamp from '../../util/output/stamp';
import getCerts from '../../util/certs/get-certs';
import strlen from '../../util/strlen';
@@ -10,23 +14,23 @@ import { Cert } from '../../types';
import getCommandFlags from '../../util/get-command-flags';
import { getCommandName } from '../../util/pkg-name';
interface Options {
'--next'?: number;
}
async function ls(
client: Client,
opts: Options,
opts: PaginationOptions,
args: string[]
): Promise<number> {
const { output } = client;
const { '--next': nextTimestamp } = opts;
const { contextName } = await getScope(client);
if (typeof nextTimestamp !== 'undefined' && Number.isNaN(nextTimestamp)) {
output.error('Please provide a number for flag --next');
let paginationOptions;
try {
paginationOptions = getPaginationOpts(opts);
} catch (err: unknown) {
output.prettyError(err);
return 1;
}
const lsStamp = stamp();
if (args.length !== 0) {
@@ -39,9 +43,10 @@ async function ls(
}
// Get the list of certificates
const { certs, pagination } = await getCerts(client, nextTimestamp).catch(
err => err
);
const { certs, pagination } = await getCerts(
client,
...paginationOptions
).catch(err => err);
output.log(
`${
@@ -50,7 +55,7 @@ async function ls(
);
if (certs.length > 0) {
console.log(formatCertsTable(certs));
output.log(formatCertsTable(certs));
}
if (pagination && pagination.count === 20) {

View File

@@ -38,6 +38,9 @@ const help = () => {
)} Login token
-S, --scope Set a custom scope
-N, --next Show next page of results
--limit=${chalk.bold.underline(
'VALUE'
)} Number of results to return per page (default: 20, max: 100)
${chalk.dim('Examples:')}
@@ -100,7 +103,11 @@ export default async function main(client: Client) {
let argv;
try {
argv = getArgs(client.argv.slice(2), { '--next': Number, '-N': '--next' });
argv = getArgs(client.argv.slice(2), {
'--next': Number,
'-N': '--next',
'--limit': Number,
});
} catch (error) {
handleError(error);
return 1;

View File

@@ -9,21 +9,20 @@ import getDNSRecords, {
} from '../../util/dns/get-dns-records';
import getDomainDNSRecords from '../../util/dns/get-domain-dns-records';
import getScope from '../../util/get-scope';
import {
PaginationOptions,
getPaginationOpts,
} from '../../util/get-pagination-opts';
import stamp from '../../util/output/stamp';
import getCommandFlags from '../../util/get-command-flags';
import { getCommandName } from '../../util/pkg-name';
type Options = {
'--next'?: number;
};
export default async function ls(
client: Client,
opts: Options,
opts: PaginationOptions,
args: string[]
) {
const { output } = client;
const { '--next': nextTimestamp } = opts;
const { contextName } = await getScope(client);
const [domainName] = args;
@@ -38,8 +37,12 @@ export default async function ls(
return 1;
}
if (typeof nextTimestamp !== 'undefined' && Number.isNaN(nextTimestamp)) {
output.error('Please provide a number for flag --next');
let paginationOptions;
try {
paginationOptions = getPaginationOpts(opts);
} catch (err: unknown) {
output.prettyError(err);
return 1;
}
@@ -48,8 +51,8 @@ export default async function ls(
output,
client,
domainName,
nextTimestamp,
4
4,
...paginationOptions
);
if (data instanceof DomainNotFound) {
output.error(
@@ -67,7 +70,7 @@ export default async function ls(
records.length > 0 ? 'Records' : 'No records'
} found under ${chalk.bold(contextName)} ${chalk.gray(lsStamp())}`
);
console.log(getDNSRecordsTable([{ domainName, records }]));
output.log(getDNSRecordsTable([{ domainName, records }]));
if (pagination && pagination.count === 20) {
const flags = getCommandFlags(opts, ['_', '--next']);
@@ -85,7 +88,7 @@ export default async function ls(
output,
client,
contextName,
nextTimestamp
...paginationOptions
);
const nRecords = dnsRecords.reduce((p, r) => r.records.length + p, 0);
output.log(
@@ -93,7 +96,7 @@ export default async function ls(
contextName
)} ${chalk.gray(lsStamp())}`
);
console.log(getDNSRecordsTable(dnsRecords));
output.log(getDNSRecordsTable(dnsRecords));
if (pagination && pagination.count === 20) {
const flags = getCommandFlags(opts, ['_', '--next']);
output.log(

View File

@@ -45,6 +45,9 @@ const help = () => {
)} Login token
-S, --scope Set a custom scope
-N, --next Show next page of results
--limit=${chalk.bold.underline(
'VALUE'
)} Number of results to return per page (default: 20, max: 100)
-y, --yes Skip the confirmation prompt when removing a domain
${chalk.dim('Examples:')}
@@ -94,6 +97,7 @@ export default async function main(client: Client) {
'--next': Number,
'-N': '--next',
'-y': '--yes',
'--limit': Number,
});
} catch (error) {
handleError(error);

View File

@@ -10,24 +10,27 @@ import formatTable from '../../util/format-table';
import { formatDateWithoutTime } from '../../util/format-date';
import { Domain } from '../../types';
import getCommandFlags from '../../util/get-command-flags';
import {
PaginationOptions,
getPaginationOpts,
} from '../../util/get-pagination-opts';
import { getCommandName } from '../../util/pkg-name';
import isDomainExternal from '../../util/domains/is-domain-external';
import { getDomainRegistrar } from '../../util/domains/get-domain-registrar';
type Options = {
'--next': number;
};
export default async function ls(
client: Client,
opts: Partial<Options>,
opts: Partial<PaginationOptions>,
args: string[]
) {
const { output } = client;
const { '--next': nextTimestamp } = opts;
if (typeof nextTimestamp !== undefined && Number.isNaN(nextTimestamp)) {
output.error('Please provide a number for flag --next');
let paginationOptions;
try {
paginationOptions = getPaginationOpts(opts);
} catch (err: unknown) {
output.prettyError(err);
return 1;
}
@@ -46,7 +49,10 @@ export default async function ls(
output.spinner(`Fetching Domains under ${chalk.bold(contextName)}`);
const { domains, pagination } = await getDomains(client, nextTimestamp);
const { domains, pagination } = await getDomains(
client,
...paginationOptions
);
output.log(
`${plural('Domain', domains.length, true)} found under ${chalk.bold(

View File

@@ -9,12 +9,14 @@ type Response = {
export default async function getAliases(
client: Client,
deploymentId?: string,
next?: number
next?: number,
limit = 20
) {
let aliasUrl = `/v3/now/aliases?limit=20`;
let aliasUrl = `/v3/now/aliases?limit=${limit}`;
if (next) {
aliasUrl += `&until=${next}`;
}
const to = deploymentId
? `/now/deployments/${deploymentId}/aliases`
: aliasUrl;

View File

@@ -6,8 +6,12 @@ type Response = {
pagination: PaginationOptions;
};
export default async function getCerts(client: Client, next?: number) {
let certsUrl = `/v4/now/certs?limit=20`;
export default async function getCerts(
client: Client,
next?: number,
limit = 20
) {
let certsUrl = `/v4/now/certs?limit=${limit}`;
if (next) {
certsUrl += `&until=${next}`;

View File

@@ -12,14 +12,15 @@ export default async function getDomainDNSRecords(
output: Output,
client: Client,
domain: string,
apiVersion = 3,
nextTimestamp?: number,
apiVersion = 3
limit = 20
) {
output.debug(`Fetching for DNS records of domain ${domain}`);
try {
let url = `/v${apiVersion}/domains/${encodeURIComponent(
domain
)}/records?limit=20`;
)}/records?limit=${limit}`;
if (nextTimestamp) {
url += `&until=${nextTimestamp}`;

View File

@@ -6,8 +6,12 @@ type Response = {
pagination: PaginationOptions;
};
export default async function getDomains(client: Client, next?: number) {
let domainUrl = `/v5/domains?limit=20`;
export default async function getDomains(
client: Client,
next?: number,
limit = 20
) {
let domainUrl = `/v5/domains?limit=${limit}`;
if (next) {
domainUrl += `&until=${next}`;
}

View File

@@ -0,0 +1,21 @@
export interface PaginationOptions {
'--next'?: number;
'--limit'?: number;
}
export function getPaginationOpts(opts: PaginationOptions) {
const { '--next': nextTimestamp, '--limit': limit } = opts;
if (typeof nextTimestamp !== undefined && Number.isNaN(nextTimestamp)) {
throw new Error('Please provide a number for option --next');
}
if (
typeof limit === 'number' &&
(!Number.isInteger(limit) || limit > 100 || limit < 1)
) {
throw new Error('Please provide an integer from 1 to 100 for option --limit');
}
return [nextTimestamp, limit];
}

View File

@@ -0,0 +1,32 @@
import chance from 'chance';
import { client } from './client';
export function useAlias() {
function create(alias: string) {
return {
alias: `dummy-${alias}.app`,
created: chance().timestamp(),
createdAt: chance().timestamp(),
deletedAt: null,
deployment: {
id: chance().guid(),
url: chance().domain(),
},
deploymentId: chance().guid(),
projectId: chance().guid(),
redirect: null,
redirectStatusCode: null,
uid: chance().guid(),
updatedAt: chance().timestamp(),
};
}
client.scenario.get('/v3/now/aliases', (_req, res) => {
const limit = parseInt(_req.query.limit);
const aliases = Array.from({ length: limit }, (v, i) => create(`${i}`));
res.json({
aliases: aliases,
pagination: { count: limit, total: limit, page: 1, pages: 1 },
});
});
}

View File

@@ -0,0 +1,24 @@
import chance from 'chance';
import { client } from './client';
export function useCert() {
function create(cert: string) {
return {
uid: `dummy-${cert}.cert`,
created: chance().timestamp(),
expiration: chance().timestamp(),
renew: chance().bool(),
cns: [chance().domain()],
age: chance().integer({ min: 0, max: 1000 }),
};
}
client.scenario.get('/v4/now/certs', (_req, res) => {
const limit = parseInt(_req.query.limit);
const certs = Array.from({ length: limit }, (v, i) => create(`${i}`));
res.json({
certs: certs,
pagination: { count: limit, total: limit, page: 1, pages: 1 },
});
});
}

View File

@@ -0,0 +1,31 @@
import chance from 'chance';
import { client } from './client';
import { createDomain } from './domains';
export function useDns() {
client.scenario.get('/v3/domains/:domain?/records', (_req, res) => {
res.json({
records: [
{
id: chance().guid(),
name: chance().domain(),
type: chance().string(),
value: chance().integer(),
createdAt: chance().timestamp(),
},
],
pagination: { count: 1, total: 1, page: 1, pages: 1 },
});
});
client.scenario.get('/v5/domains', (req, res) => {
const limit = parseInt(req.query.limit);
const domains = Array.from({ length: limit }, (_, k) =>
createDomain(k.toString())
);
res.json({
domains: domains,
pagination: { count: limit, total: limit, page: 1, pages: 1 },
});
});
}

View File

@@ -0,0 +1,42 @@
import chance from 'chance';
import { client } from './client';
export function createDomain(k: string) {
return {
suffix: chance().bool(),
verified: chance().bool(),
nameservers: chance().string(),
intendedNameservers: chance().string(),
customNameservers: chance().string(),
creator: {
username: chance().string(),
email: chance().email(),
customerId: chance().guid(),
isDomainReseller: chance().bool(),
id: chance().guid(),
},
createdAt: chance().timestamp(),
id: chance().guid(),
name: k ? `example-${k}.com` : 'example.com',
expiresAt: chance().timestamp(),
boughtAt: chance().timestamp(),
orderedAt: chance().timestamp(),
renew: chance().bool(),
serviceType: chance().string(),
transferredAt: chance().timestamp(),
transferStartedAt: chance().timestamp(),
};
}
export function useDomains() {
client.scenario.get('/v5/domains', (req, res) => {
const limit = parseInt(req.query.limit);
const domains = Array.from({ length: limit }, (v, i) =>
createDomain(`${i}`)
);
res.json({
domains: domains,
pagination: { count: limit, total: limit, page: 1, pages: 1 },
});
});
}

View File

@@ -0,0 +1,24 @@
import { client } from '../../mocks/client';
import alias from '../../../src/commands/alias';
import { useUser } from '../../mocks/user';
import { useAlias } from '../../mocks/alias';
describe('alias', () => {
it('should list up to 20 aliases by default', async () => {
useUser();
useAlias();
client.setArgv('alias', 'ls');
const exitCodePromise = alias(client);
await expect(exitCodePromise).resolves.toEqual(0);
await expect(client.stderr).toOutput('dummy-19.app');
});
it('should list up to 2 aliases', async () => {
useUser();
useAlias();
client.setArgv('alias', 'ls', '--limit', '2');
const exitCodePromise = alias(client);
await expect(exitCodePromise).resolves.toEqual(0);
await expect(client.stderr).toOutput('dummy-1.app');
});
});

View File

@@ -0,0 +1,24 @@
import { client } from '../../mocks/client';
import certs from '../../../src/commands/certs';
import { useUser } from '../../mocks/user';
import { useCert } from '../../mocks/certs';
describe('certs', () => {
it('should list up to 20 certs by default', async () => {
useUser();
useCert();
client.setArgv('certs', 'ls');
const exitCodePromise = certs(client);
await expect(client.stderr).toOutput('dummy-19.cert');
await expect(exitCodePromise).resolves.toEqual(0);
});
it('should list up to 2 certs if limit set to 2', async () => {
useUser();
useCert();
client.setArgv('certs', 'ls', '--limit', '2');
const exitCodePromise = certs(client);
await expect(client.stderr).toOutput('dummy-1.cert');
await expect(exitCodePromise).resolves.toEqual(0);
});
});

View File

@@ -0,0 +1,24 @@
import { client } from '../../mocks/client';
import dns from '../../../src/commands/dns';
import { useUser } from '../../mocks/user';
import { useDns } from '../../mocks/dns';
describe('dns', () => {
it('should list up to 20 dns by default', async () => {
useUser();
useDns();
client.setArgv('dns', 'ls');
let exitCodePromise = dns(client);
await expect(client.stderr).toOutput('example-19.com');
await expect(exitCodePromise).resolves.toEqual(0);
});
it('should list up to 2 dns if limit set to 2', async () => {
useUser();
useDns();
client.setArgv('dns', 'ls', '--limit', 2);
let exitCodePromise = dns(client);
await expect(client.stderr).toOutput('example-2.com');
await expect(exitCodePromise).resolves.toEqual(0);
});
});

View File

@@ -0,0 +1,24 @@
import { client } from '../../mocks/client';
import domains from '../../../src/commands/domains';
import { useUser } from '../../mocks/user';
import { useDomains } from '../../mocks/domains';
describe('domains', () => {
it('should list up to 20 domains by default', async () => {
useUser();
useDomains();
client.setArgv('domains', 'ls');
let exitCodePromise = domains(client);
await expect(client.stderr).toOutput('example-19.com');
await expect(exitCodePromise).resolves.toEqual(0);
});
it('should list up to 2 domains if limit set to 2', async () => {
useUser();
useDomains();
client.setArgv('domains', 'ls', '--limit', '2');
const exitCodePromise = domains(client);
await expect(client.stderr).toOutput('example-1.com');
await expect(exitCodePromise).resolves.toEqual(0);
});
});

View File

@@ -0,0 +1,20 @@
import { getPaginationOpts } from '../../../src/util/get-pagination-opts';
import getArgs from '../../../src/util/get-args';
describe('getOpts', () => {
it('should throw an error if next not a number', async () => {
const args = getArgs([`--next=oops`], { '--next': Number });
expect(() => {
getPaginationOpts(args);
}).toThrowError();
});
it('should throw an error if limit not valid', async () => {
for (let limit of ['abc', '101', '1.1', '-1']) {
const args = getArgs([`--limit=${limit}`], { '--limit': Number });
expect(() => {
getPaginationOpts(args);
}).toThrowError();
}
});
});