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
2 changes: 2 additions & 0 deletions src/commands/actors/_index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -29,6 +30,7 @@ export class ActorsIndexCommand extends ApifyCommand<typeof ActorsIndexCommand>
ActorsPullCommand,
ActorsLsCommand,
ActorsInfoCommand,
ActorsExistsCommand,
ActorsCallCommand,
ActorsBuildCommand,
];
Expand Down
105 changes: 105 additions & 0 deletions src/commands/actors/exists.ts
Original file line number Diff line number Diff line change
@@ -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<typeof ActorsExistsCommand> {
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 "<username>/<name>" 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 "<username>/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.`,
});
}
}
}
23 changes: 22 additions & 1 deletion src/commands/actors/ls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,12 +133,17 @@ export class ActorsLsCommand extends ApifyCommand<typeof ActorsLsCommand> {
description: 'Sort Actors in descending order.',
default: false,
}),
'name-only': Flags.boolean({
description:
'Print one "<username>/<name>" 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();

Expand All @@ -150,6 +155,11 @@ export class ActorsLsCommand extends ApifyCommand<typeof ActorsLsCommand> {
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,
Expand All @@ -158,6 +168,17 @@ export class ActorsLsCommand extends ApifyCommand<typeof ActorsLsCommand> {
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<HydratedListData> = {
...rawActorList,
Expand Down
36 changes: 35 additions & 1 deletion src/commands/actors/push.ts
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,15 @@ export class ActorsPushCommand extends ApifyCommand<typeof ActorsPushCommand> {
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);
Expand All @@ -310,7 +319,32 @@ export class ActorsPushCommand extends ApifyCommand<typeof ActorsPushCommand> {
newActor.actorStandby = { isEnabled: true };
}

actor = await apifyClient.actors().create(newActor);
try {
actor = await apifyClient.actors().create(newActor);
} catch (err) {
// The pre-flight `apify.actor("<user>/<name>").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 <actor-id-or-username/name>'.\n` +
` - List names you already own: 'apify actors ls --my --name-only'.\n` +
` - Check whether a name is free: 'apify actors exists <name>'.`,
);
}
throw err;
}
actorId = actor.id;
isActorCreatedNow = true;
info({ message: `Created Actor with name ${actorConfig!.name} on Apify.` });
Expand Down
Loading