…isibility
- Add mutually exclusive --public and --private boolean flags
- When either flag is active, silently paginate all actors (100/page)
client-side using Actor.isPublic from the full Actor object, since the
Apify API does not expose an isPublic query parameter on GET /v2/acts
- Sort the full filtered set first, then apply --limit / --offset so
pagination always operates on a consistently ordered result
- Default limit on the filter path is the full matched set (not 20),
so `actors ls --private` returns all private actors without requiring
the user to know and supply --limit manually
- Preserve correct JSON metadata (total, offset, limit, count, desc)
for both filter and no-filter paths
- Refactor hydration into a reusable private hydrateActors() method
- Add e2e tests: public-only filter, private-only filter, limit+metadata
assertions, mutual-exclusion rejection
- Regenerate docs/reference.md via pnpm update-docs
Closes apify#1361
Summary
Adds two mutually exclusive flags — --public and --private — to apify actors ls. When either flag is provided the command silently paginates through all actors, filters by visibility client-side, sorts, then applies --limit / --offset on the result. The no-filter path (existing behavior) is completely unchanged.
Closes #1361.
Problem
apify actors ls and apify actors ls --my return a mixed list of public and private actors. There was no way to ask "show me only my private actors" from the CLI; users had to open the Apify Console and filter manually.
What users experienced:
Specifically painful for:
Why the existing flags were insufficient:
--my, --limit, --offset, and --desc all operate on raw API pagination; none of them expose Actor.isPublic. The Apify API (GET /v2/acts) does not accept an isPublic query parameter, so filtering had to be client-side.
Root Cause
The ActorCollectionListOptions type in apify-client offers my, desc, limit, and offset — no visibility filter. The full Actor object (from client.actor(id).get()) does expose isPublic, but the collection list endpoint returns lightweight ActorCollectionListItem objects that omit it.
Before this PR, the command fetched one page of results and rendered them directly. There was no mechanism to:
User: apify actors ls --my ↓ client.actors().list({ limit, offset, my }) ← single page, no visibility info ↓ Render mixed listSolution
Two new boolean flags (--public, --private) are declared with the framework's exclusive constraint so providing both is a hard error.
When either flag is active, a different execution path runs:
User: apify actors ls --my --private ↓ Filter path activated ↓ Pagination loop: client.actors().list({ limit: 100, offset }) until all pages fetched ↓ hydrateActors(): client.actor(id).get() for each item → Actor.isPublic available ↓ Client-side filter: keep items where isPublic === false ↓ sortByModifiedAt() / sortByLastRun() on the full matched set ↓ slice(offset, offset + limit) applied after sort ↓ Render or emit JSON with correct metadataKey design decisions:
Architecture / Design
Existing pattern
ActorsLsCommand.run() already owned the full fetch → hydrate → sort → render pipeline. The hydration step (fetching the full Actor object and last run per item) was inlined directly in run() as an anonymous Promise.all block.
What changed
hydrateActors() extracted to a private method (src/commands/actors/ls.ts):
ActorsLsCommand └── run() ├── [filter path] pagination loop → hydrateActors() → filter → sort → slice └── [no-filter path] single page → hydrateActors() → sort └── hydrateActors() reused by both paths └── sortByModifiedAt() unchanged └── sortByLastRun() unchangedhydrateActors() was already effectively present as an inline Promise.all — this just gives it a name and signature so both paths can call it without duplicating the Actor + runs fetch logic. No new abstraction layer was introduced; the method lives on the same class and follows the existing private-method pattern (sortByModifiedAt, sortByLastRun).
INTERNAL_PAGE_SIZE = 100 is a private static constant on the class, consistent with how other internal constants are declared in the codebase. It controls the per-page fetch size of the pagination loop in the filter path and is not exposed to users.
Two-path run() keeps jsonTotal, jsonOffset, jsonLimit as hoisted let variables populated by whichever path runs, so the JSON output block and empty-state block at the bottom of run() can use them correctly without duplication.
The change does not introduce any new file, module, or dependency.
Impact
User impact
Users can now filter by visibility directly from the terminal:
Functional impact
Compatibility impact
Fully backward-compatible. The two new flags are additive. All existing invocations (apify actors ls, apify actors ls --my, --limit, --offset, --desc, --json) use the no-filter path and are not affected.
One subtle behavioural difference in the no-filter path: --limit and --offset no longer carry framework-level defaults (default: 0 / default: 20 removed from flag declarations). The values 0 and 20 are now applied explicitly in code (offset ?? 0, limit ?? 20) to avoid ambiguity between "user did not pass the flag" and "user passed 0/20". This does not change observable behavior but fixes a latent issue where the filter path could not distinguish an explicit --limit 20 from the default.
Performance impact
The filter path makes additional API calls (one GET /v2/acts per page + one GET /v2/acts/{id} per actor). This is inherent to the design: the API does not support server-side visibility filtering. The no-filter path makes the same number of calls as before.
Security impact
None. No authentication, authorization, or credential-handling code is touched.
Operational impact
None. No configuration, deployment, or environment changes required.
What This Fixes
Testing
Build and static analysis:
E2E tests added (test/e2e/commands/actors/ls.test.ts):
Before / After
Filtering by visibility — Before:
After:
JSON metadata — Before (filter path did not exist; no-filter path):
{ "total": 80, "count": 20, "offset": 0, "limit": 20 }After (--private, no limit, 45 private actors):
{ "total": 45, "count": 45, "offset": 0, "limit": 45 }After (--private --limit 10):
{ "total": 45, "count": 10, "offset": 0, "limit": 10 }Risk / Trade-offs
Additional API calls in filter path: Fetching all actors + full Actor objects is O(n) in the number of actors. For accounts with hundreds of actors this is slow. This is a known trade-off: the Apify API does not expose server-side visibility filtering. A future API change adding ?isPublic=true to GET /v2/acts would allow eliminating the pagination loop entirely.
No streaming: Results appear only after all pages are fetched and filtered. For large accounts, the command may appear to hang. A progress indicator could be added in a follow-up.
Concurrent hydration: hydrateActors() calls client.actor(id).get() and .runs().list() concurrently via Promise.all. For the filter path this runs across all actors in a page (100). This should not cause rate-limit issues in normal usage but is worth noting.
Overall the change is low-risk: the no-filter path is untouched, the filter path is entirely new code, and the flags are additive.
Files Changed
src/commands/actors/ls.ts
test/e2e/commands/actors/ls.test.ts
docs/reference.md
Maintainer Notes
The filter path is intentionally kept separate from the no-filter path rather than merging them into a single flow. The two paths have fundamentally different pagination semantics: the no-filter path delegates pagination to the API (one call, API enforces limit/offset), while the filter path must fetch all data first (because the API cannot filter) and then paginate client-side. Merging them would add conditional complexity throughout the flow for a small DRY gain. The current structure makes it easy to replace the filter path with a server-side call if the API ever gains ?isPublic support, without touching the no-filter path at all.