From 75531f09699ce61562d50d9fc3a0fdc6ac23a9d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Sun, 5 Jul 2026 00:59:10 +0200 Subject: [PATCH] feat(actors): add `apify actors exists`, add `--name-only` to `apify actors ls`, and improve name-collision UX on push MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scripting-friendly name checks ------------------------------ `apify actors ls` and `apify push` are used in automation (CI, agent scripts, template scaffolding) that needs to answer "is this Actor name free?" before creating one. Today there is no built-in way to do that: - `apify actors ls --my` prints a decorated table (Runs / Last run / Build columns) intended for a terminal, and does an extra `.get()` + runs list per row for hydration. Callers scripting a collision check have to reach for `curl` against `/v2/acts?my=1` or grep the table. - There is no cheap "does this name exist?" primitive at all, so scripts fall back to grepping `apify actors info `'s error output. This adds: - `apify actors ls --name-only` — prints one `/` per line and short-circuits before the per-row hydration `Promise.all`. Composes with `--my`, `--limit`, `--offset`, `--desc`. - `apify actors exists ` — new subcommand. Exits 0 if the Actor exists, 1 if not. Accepts a short name (resolved in the logged-in user's namespace), a fully qualified `/`, or an Actor ID. `--json` emits `{ exists, query, resolvedId, actor }`. Combined, `apify actors exists my-crawler || apify push` is now the canonical scripted collision check, replacing the raw-API workaround. `apify push` collision UX ------------------------- Two changes to make destination + failure modes obvious in the CLI transcript: 1. When `apify push` finds an existing Actor under the current user's namespace and reuses it, it now emits an `Info:` line ("Updating existing Actor / (id=…)."). Previously this branch was silent and looked identical to creating a new Actor, so a user who forgot they had already claimed a name would silently overwrite their prior version. 2. When `apifyClient.actors().create()` fails with a name-conflict error (reserved name, race with a parallel process, etc.), the raw API message ("Actor name is already used") is now rewritten with actionable next steps: rename in `.actor/actor.json`, push to an existing Actor by id, list your own names with the new `--name-only` flag, or query with `apify actors exists`. Surfaced during an evaluation of Apify surfaces for agent-driven Actor development. Co-Authored-By: Claude Opus 4.7 --- src/commands/actors/_index.ts | 2 + src/commands/actors/exists.ts | 105 ++++++++++++++++++++++++++++++++++ src/commands/actors/ls.ts | 23 +++++++- src/commands/actors/push.ts | 36 +++++++++++- 4 files changed, 164 insertions(+), 2 deletions(-) create mode 100644 src/commands/actors/exists.ts diff --git a/src/commands/actors/_index.ts b/src/commands/actors/_index.ts index e6a08ddcf..5f2b35ca0 100644 --- a/src/commands/actors/_index.ts +++ b/src/commands/actors/_index.ts @@ -1,6 +1,7 @@ import { ApifyCommand } from '../../lib/command-framework/apify-command.js'; import { ActorsBuildCommand } from './build.js'; import { ActorsCallCommand } from './call.js'; +import { ActorsExistsCommand } from './exists.js'; import { ActorsInfoCommand } from './info.js'; import { ActorsLsCommand } from './ls.js'; import { ActorsPullCommand } from './pull.js'; @@ -29,6 +30,7 @@ export class ActorsIndexCommand extends ApifyCommand ActorsPullCommand, ActorsLsCommand, ActorsInfoCommand, + ActorsExistsCommand, ActorsCallCommand, ActorsBuildCommand, ]; diff --git a/src/commands/actors/exists.ts b/src/commands/actors/exists.ts new file mode 100644 index 000000000..2228deb93 --- /dev/null +++ b/src/commands/actors/exists.ts @@ -0,0 +1,105 @@ +import process from 'node:process'; + +import { ApifyCommand } from '../../lib/command-framework/apify-command.js'; +import { Args } from '../../lib/command-framework/args.js'; +import { Flags } from '../../lib/command-framework/flags.js'; +import { info, simpleLog } from '../../lib/outputs.js'; +import { getLoggedClientOrThrow, getLocalUserInfo, printJsonToStdout } from '../../lib/utils.js'; + +// Exit code 1 = the name is available (not taken). Chosen so that a pure +// `apify actors exists foo && echo taken || echo free` shell idiom works +// without extra flags, matching the convention of tools like `test`, `grep`, +// and `gh repo view`. +const NOT_FOUND_EXIT_CODE = 1; + +export class ActorsExistsCommand extends ApifyCommand { + static override name = 'exists' as const; + + static override description = + `Check whether an Actor exists on the Apify platform.\n` + + `Exits 0 if the Actor exists (name is taken), 1 if not.\n` + + `Intended for scripting a pre-flight check before 'apify push' so callers can pick a free name without triggering a duplicate-name error.`; + + static override examples = [ + { + description: 'Check whether a name in your own namespace is available.', + command: 'apify actors exists my-crawler', + }, + { + description: 'Check a fully qualified Actor identifier.', + command: 'apify actors exists apify/hello-world', + }, + { + description: 'Use in a shell script to avoid a name collision.', + command: 'apify actors exists my-crawler || apify push', + }, + ]; + + static override docsUrl = 'https://docs.apify.com/cli/docs/reference#apify-actors-exists'; + + static override enableJsonFlag = true; + + static override args = { + actorId: Args.string({ + description: + 'Actor name (short "my-crawler" — resolved in the logged-in user\'s namespace) ' + + 'or fully qualified "/" identifier, or Actor ID.', + required: true, + }), + }; + + static override flags = { + quiet: Flags.boolean({ + char: 'q', + description: 'Suppress the human-readable message; only the exit code is meaningful.', + default: false, + }), + }; + + async run() { + const { actorId } = this.args; + const { quiet, json } = this.flags; + + const apifyClient = await getLoggedClientOrThrow(); + + // Resolve short names ("my-crawler") to "/my-crawler" so callers + // can query their own namespace without repeating their username. + let lookupId = actorId; + if (!actorId.includes('/') && !/^[A-Za-z0-9]{17}$/.test(actorId)) { + const userInfo = await getLocalUserInfo(); + const usernameOrId = userInfo.username || userInfo.id; + lookupId = `${usernameOrId}/${actorId}`; + } + + const actor = await apifyClient.actor(lookupId).get(); + + if (json) { + printJsonToStdout({ + exists: Boolean(actor), + query: actorId, + resolvedId: lookupId, + actor: actor ? { id: actor.id, name: actor.name, username: actor.username, title: actor.title } : null, + }); + if (!actor) process.exitCode = NOT_FOUND_EXIT_CODE; + return; + } + + if (actor) { + if (!quiet) { + info({ + stdout: true, + message: `Actor "${actor.username}/${actor.name}" exists (id=${actor.id}).`, + }); + } + return; + } + + process.exitCode = NOT_FOUND_EXIT_CODE; + if (!quiet) { + simpleLog({ + stdout: true, + message: `Actor "${lookupId}" does not exist.`, + }); + } + } +} diff --git a/src/commands/actors/ls.ts b/src/commands/actors/ls.ts index fee970d26..0cc44951a 100644 --- a/src/commands/actors/ls.ts +++ b/src/commands/actors/ls.ts @@ -133,12 +133,17 @@ export class ActorsLsCommand extends ApifyCommand { description: 'Sort Actors in descending order.', default: false, }), + 'name-only': Flags.boolean({ + description: + 'Print one "/" per line and skip the table/hydration. Useful for scripting (e.g. detecting name collisions before `apify push`).', + default: false, + }), }; static override enableJsonFlag = true; async run() { - const { desc, limit, offset, my, json } = this.flags; + const { desc, limit, offset, my, json, nameOnly } = this.flags; const client = await getLoggedClientOrThrow(); @@ -150,6 +155,11 @@ export class ActorsLsCommand extends ApifyCommand { return; } + if (nameOnly) { + // Nothing to print; exit cleanly so scripts can rely on empty output. + return; + } + info({ message: my ? "You don't have any Actors yet!" : 'There are no recent Actors used by you.', stdout: true, @@ -158,6 +168,17 @@ export class ActorsLsCommand extends ApifyCommand { return; } + // --name-only short-circuits before the per-actor hydration Promise.all, + // which fetches an extra Actor + runs list for every row. That work is + // wasted when a caller only needs the identifiers. + if (nameOnly && !json) { + simpleLog({ + stdout: true, + message: rawActorList.items.map((a) => `${a.username}/${a.name}`).join('\n'), + }); + return; + } + // Fetch the last run for actors const actorList: PaginatedList = { ...rawActorList, diff --git a/src/commands/actors/push.ts b/src/commands/actors/push.ts index 683462834..20aeb0c36 100644 --- a/src/commands/actors/push.ts +++ b/src/commands/actors/push.ts @@ -285,6 +285,15 @@ export class ActorsPushCommand extends ApifyCommand { actor = (await apifyClient.actor(`${usernameOrId}/${actorConfig!.name}`).get())!; if (actor) { actorId = actor.id; + // Previously this branch silently reused the existing Actor, which + // looked identical in the CLI output to creating a new one. When a + // user (or an agent) intended to create a fresh Actor and forgot + // they had already used that name, their prior version would be + // overwritten without any visible signal. Log it explicitly so the + // destination is obvious in the transcript. + info({ + message: `Updating existing Actor ${usernameOrId}/${actorConfig!.name} (id=${actor.id}).`, + }); } else { const { templates } = await fetchManifest(); const actorTemplate = templates.find((t) => t.name === actorConfig!.template); @@ -310,7 +319,32 @@ export class ActorsPushCommand extends ApifyCommand { newActor.actorStandby = { isEnabled: true }; } - actor = await apifyClient.actors().create(newActor); + try { + actor = await apifyClient.actors().create(newActor); + } catch (err) { + // The pre-flight `apify.actor("/").get()` above misses + // two cases: (1) the name is reserved / conflicts with a global + // Actor (e.g. `apify/hello-world`); (2) a race where a parallel + // process claimed the name between the `.get()` and the create. + // The raw API error is opaque ("Actor name is already used"), so + // rewrite it with actionable next steps. + const message = (err as Error)?.message ?? String(err); + const looksLikeNameConflict = + /already\s+us(ed|es)/i.test(message) || + /name\s+is\s+not\s+available/i.test(message) || + /duplicate/i.test(message); + if (looksLikeNameConflict) { + throw new Error( + `Cannot create Actor "${actorConfig!.name}": the name is unavailable on the Apify platform (${message}).\n` + + `Next steps:\n` + + ` - Rename the Actor: edit the "name" field in ${LOCAL_CONFIG_PATH} and run 'apify push' again.\n` + + ` - Push to an existing Actor: 'apify push '.\n` + + ` - List names you already own: 'apify actors ls --my --name-only'.\n` + + ` - Check whether a name is free: 'apify actors exists '.`, + ); + } + throw err; + } actorId = actor.id; isActorCreatedNow = true; info({ message: `Created Actor with name ${actorConfig!.name} on Apify.` });