Skip to content
Open
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
6 changes: 5 additions & 1 deletion docs/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -690,15 +690,19 @@ DESCRIPTION

USAGE
$ apify actors ls [--desc] [--json] [--limit <value>] [--my]
[--offset <value>]
[--offset <value>] [--private | --public]

FLAGS
--desc Sort Actors in descending order.
--json Format the command output as JSON.
--limit=<value> Number of Actors that will be listed.
Defaults to 20.
--my Whether to list Actors made by the logged
in user.
--offset=<value> Number of Actors that will be skipped.
Defaults to 0.
--private Show only private Actors.
--public Show only public Actors.
```

##### `apify actors search`
Expand Down
178 changes: 131 additions & 47 deletions src/commands/actors/ls.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
import { Time } from '@sapphire/duration';
import type { Actor, ActorRunListItem, ActorTaggedBuild, PaginatedList } from 'apify-client';
import type {
Actor,
ActorCollectionListItem,
ActorRunListItem,
ActorTaggedBuild,
ApifyClient,
PaginatedList,
} from 'apify-client';
import chalk from 'chalk';

import type { ACTOR_JOB_STATUSES } from '@apify/consts';
Expand Down Expand Up @@ -97,6 +104,8 @@ interface HydratedListData {
export class ActorsLsCommand extends ApifyCommand<typeof ActorsLsCommand> {
static override name = 'ls' as const;

private static readonly INTERNAL_PAGE_SIZE = 100;

static override description = 'Prints a list of recently executed Actors or Actors you own.';

static override examples = [
Expand All @@ -112,6 +121,14 @@ export class ActorsLsCommand extends ApifyCommand<typeof ActorsLsCommand> {
description: 'List the next page of 50 Actors.',
command: 'apify actors ls --limit 50 --offset 50',
},
{
description: 'List only public Actors.',
command: 'apify actors ls --public',
},
{
description: 'List only private Actors.',
command: 'apify actors ls --private',
},
];

static override docsUrl = 'https://docs.apify.com/cli/docs/reference#apify-actors-ls';
Expand All @@ -121,13 +138,21 @@ export class ActorsLsCommand extends ApifyCommand<typeof ActorsLsCommand> {
description: 'Whether to list Actors made by the logged in user.',
default: false,
}),
public: Flags.boolean({
description: 'Show only public Actors.',
default: false,
exclusive: ['private'],
}),
private: Flags.boolean({
description: 'Show only private Actors.',
default: false,
exclusive: ['public'],
}),
offset: Flags.integer({
description: 'Number of Actors that will be skipped.',
default: 0,
description: 'Number of Actors that will be skipped. Defaults to 0.',
}),
limit: Flags.integer({
description: 'Number of Actors that will be listed.',
default: 20,
description: 'Number of Actors that will be listed. Defaults to 20.',
}),
desc: Flags.boolean({
description: 'Sort Actors in descending order.',
Expand All @@ -138,69 +163,98 @@ export class ActorsLsCommand extends ApifyCommand<typeof ActorsLsCommand> {
static override enableJsonFlag = true;

async run() {
const { desc, limit, offset, my, json } = this.flags;
const { desc, limit, offset, my, json, public: publicOnly, private: privateOnly } = this.flags;

const client = await getLoggedClientOrThrow();

const rawActorList = await client.actors().list({ limit, offset, desc, my });
let actorItems: HydratedListData[];
let jsonTotal: number;
let jsonOffset: number;
let jsonLimit: number;

if (rawActorList.count === 0) {
if (publicOnly || privateOnly) {
const matching: HydratedListData[] = [];
let pageOffset = 0;
let total = Infinity;

while (pageOffset < total) {
const page = await client
.actors()
.list({ desc, my, limit: ActorsLsCommand.INTERNAL_PAGE_SIZE, offset: pageOffset });

total = page.total;

if (page.items.length === 0) break;

const hydrated = await this.hydrateActors(page.items, client);

for (const item of hydrated) {
if (publicOnly && item.actor?.isPublic === true) matching.push(item);
if (privateOnly && item.actor?.isPublic === false) matching.push(item);
}

pageOffset += page.items.length;
}

const sortedMatching = my ? this.sortByModifiedAt(matching) : this.sortByLastRun(matching);
jsonTotal = sortedMatching.length;
jsonOffset = offset ?? 0;
jsonLimit = limit ?? sortedMatching.length;
actorItems = sortedMatching.slice(jsonOffset, jsonOffset + jsonLimit);
} else {
const rawActorList = await client.actors().list({ limit: limit ?? 20, offset: offset ?? 0, desc, my });

if (rawActorList.count === 0) {
if (json) {
printJsonToStdout(rawActorList);
return;
}

info({
message: my ? "You don't have any Actors yet!" : 'There are no recent Actors used by you.',
stdout: true,
});

return;
}

actorItems = await this.hydrateActors(rawActorList.items, client);
actorItems = my ? this.sortByModifiedAt(actorItems) : this.sortByLastRun(actorItems);
jsonTotal = rawActorList.total;
jsonOffset = rawActorList.offset;
jsonLimit = limit ?? 20;
}

if (actorItems.length === 0) {
if (json) {
printJsonToStdout(rawActorList);
printJsonToStdout({ items: [], total: jsonTotal, count: 0, offset: jsonOffset, limit: jsonLimit, desc });
return;
}

info({
message: my ? "You don't have any Actors yet!" : 'There are no recent Actors used by you.',
message: publicOnly ? 'No public Actors found.' : 'No private Actors found.',
stdout: true,
});

return;
}

// Fetch the last run for actors
const actorList: PaginatedList<HydratedListData> = {
...rawActorList,
items: await Promise.all(
rawActorList.items.map(async (actorData) => {
const actor = await client.actor(actorData.id).get();
const runs = await client
.actor(actorData.id)
.runs()
.list({ desc: true, limit: 1 })
// Throws an error if the returned actor changed publicity status
.catch(
() =>
({
count: 0,
desc: true,
items: [],
limit: 1,
offset: 0,
total: 0,
}) satisfies PaginatedList<ActorRunListItem>,
);

return {
...actorData,
actor: actor ?? null,
lastRun: (runs.items[0] ?? null) as ActorRunListItem | null,
} as HydratedListData;
}),
),
};

actorList.items = my ? this.sortByModifiedAt(actorList.items) : this.sortByLastRun(actorList.items);

if (json) {
printJsonToStdout(actorList);
printJsonToStdout({
items: actorItems,
total: jsonTotal,
count: actorItems.length,
offset: jsonOffset,
limit: jsonLimit,
desc,
});
return;
}

const table = my ? myRecentlyUsedTable : recentlyUsedTable;

const longestActorTitleLength =
actorList.items.reduce((acc, curr) => {
actorItems.reduce((acc: number, curr: HydratedListData) => {
const title = `${curr.username}/${curr.name}`;

if (title.length > acc) {
Expand All @@ -214,7 +268,7 @@ export class ActorsLsCommand extends ApifyCommand<typeof ActorsLsCommand> {
// Runs column minimum size with padding
6;

for (const item of actorList.items) {
for (const item of actorItems) {
const lastRunDisplayedTimestamp = item.stats.lastRunStartedAt
? MultilineTimestampFormatter.display(item.stats.lastRunStartedAt)
: '';
Expand Down Expand Up @@ -300,6 +354,36 @@ export class ActorsLsCommand extends ApifyCommand<typeof ActorsLsCommand> {
});
}

private async hydrateActors(items: ActorCollectionListItem[], client: ApifyClient): Promise<HydratedListData[]> {
return Promise.all(
items.map(async (actorData) => {
const actor = await client.actor(actorData.id).get();
const runs = await client
.actor(actorData.id)
.runs()
.list({ desc: true, limit: 1 })
// Throws an error if the returned actor changed publicity status
.catch(
() =>
({
count: 0,
desc: true,
items: [],
limit: 1,
offset: 0,
total: 0,
}) satisfies PaginatedList<ActorRunListItem>,
);

return {
...actorData,
actor: actor ?? null,
lastRun: (runs.items[0] ?? null) as ActorRunListItem | null,
} as HydratedListData;
}),
);
}

private sortByModifiedAt(items: HydratedListData[]) {
return items.sort((a, b) => {
const aDate = new Date(a.modifiedAt);
Expand Down
42 changes: 41 additions & 1 deletion test/e2e/commands/actors/ls.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

beforeAll(async () => {
const token = process.env.TEST_USER_TOKEN;
if (!token) throw new Error('TEST_USER_TOKEN env var is required for actors ls tests');

Check failure on line 10 in test/e2e/commands/actors/ls.test.ts

View workflow job for this annotation

GitHub Actions / E2E Tests (ubuntu-latest)

test/e2e/commands/actors/ls.test.ts > [e2e][api] actors ls

Error: TEST_USER_TOKEN env var is required for actors ls tests ❯ test/e2e/commands/actors/ls.test.ts:10:21

const authPath = `e2e-actors-ls-${randomBytes(6).toString('hex')}`;
authEnv = { __APIFY_INTERNAL_TEST_AUTH_PATH__: authPath };
Expand Down Expand Up @@ -36,6 +36,46 @@
const result = await runCli('apify', ['actors', 'ls', '--my', '--limit', '5', '--json'], { env: authEnv });

expect(result.exitCode, `stderr: ${result.stderr}`).toBe(0);
expect(() => JSON.parse(result.stdout)).not.toThrow();
const parsed = JSON.parse(result.stdout);
expect(parsed.items.length).toBeLessThanOrEqual(5);
expect(parsed.limit).toBe(5);
});

it('filters to public actors with --public flag', async () => {
const result = await runCli('apify', ['actors', 'ls', '--public', '--json'], { env: authEnv });

expect(result.exitCode, `stderr: ${result.stderr}`).toBe(0);
const parsed = JSON.parse(result.stdout);
for (const item of parsed.items) {
expect(item.actor?.isPublic).toBe(true);
}
});

it('filters to private actors with --private flag', async () => {
const result = await runCli('apify', ['actors', 'ls', '--private', '--json'], { env: authEnv });

expect(result.exitCode, `stderr: ${result.stderr}`).toBe(0);
const parsed = JSON.parse(result.stdout);
for (const item of parsed.items) {
expect(item.actor?.isPublic).toBe(false);
}
});

it('--private --limit returns correct metadata', async () => {
const result = await runCli('apify', ['actors', 'ls', '--private', '--limit', '1', '--json'], { env: authEnv });

expect(result.exitCode, `stderr: ${result.stderr}`).toBe(0);
const parsed = JSON.parse(result.stdout);
expect(parsed.items.length).toBeLessThanOrEqual(1);
expect(parsed.limit).toBe(1);
expect(parsed.offset).toBe(0);
expect(parsed.total).toBeGreaterThanOrEqual(parsed.items.length);
});

it('rejects --public and --private used together', async () => {
const result = await runCli('apify', ['actors', 'ls', '--public', '--private'], { env: authEnv });

expect(result.exitCode).not.toBe(0);
expect(result.stderr).toContain('cannot also be provided');
});
});
Loading