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
391 changes: 284 additions & 107 deletions docs/reference.md

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions scripts/generate-cli-docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ const categories: Record<string, CommandsInCategory[]> = {
{ command: Commands.auth },
{ command: Commands.authLogin, aliases: [Commands.login] },
{ command: Commands.authLogout, aliases: [Commands.logout] },
{ command: Commands.authList },
{ command: Commands.authSwitch },
{ command: Commands.authToken },
{ command: Commands.info },
{ command: Commands.secrets },
Expand Down
2 changes: 2 additions & 0 deletions src/commands/actors/call.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ import { getLocalConfig, getCurrentUserInfo, getLoggedClientOrThrow, TimestampFo
export class ActorsCallCommand extends ApifyCommand<typeof ActorsCallCommand> {
static override name = 'call' as const;

static override enableProfileFlag = true;

static override description =
'Executes Actor remotely using your authenticated account.\n' +
'Reads input from local key-value store by default.\n' +
Expand Down
2 changes: 2 additions & 0 deletions src/commands/actors/info.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ const payPerEventTable = new ResponsiveTable({
export class ActorsInfoCommand extends ApifyCommand<typeof ActorsInfoCommand> {
static override name = 'info' as const;

static override enableProfileFlag = true;

static override description = 'Get information about an Actor.';

static override examples = [
Expand Down
2 changes: 2 additions & 0 deletions src/commands/actors/ls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,8 @@ interface HydratedListData {
export class ActorsLsCommand extends ApifyCommand<typeof ActorsLsCommand> {
static override name = 'ls' as const;

static override enableProfileFlag = true;

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

static override examples = [
Expand Down
2 changes: 2 additions & 0 deletions src/commands/actors/pull.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ const extractGitHubZip = async (url: string, directoryPath: string) => {
export class ActorsPullCommand extends ApifyCommand<typeof ActorsPullCommand> {
static override name = 'pull' as const;

static override enableProfileFlag = true;

static override description =
'Download Actor code to current directory. ' +
'Clones Git repositories or fetches Actor files based on the source type.';
Expand Down
2 changes: 2 additions & 0 deletions src/commands/actors/push.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,8 @@ const confirmGitSourceSwitch = async ({
export class ActorsPushCommand extends ApifyCommand<typeof ActorsPushCommand> {
static override name = 'push' as const;

static override enableProfileFlag = true;

static override description =
`Deploys Actor to Apify platform using settings from '${LOCAL_CONFIG_PATH}'.\n` +
`Files under '${MAX_MULTIFILE_BYTES / 1024 ** 2}' MB upload as "Multiple source files"; ` +
Expand Down
2 changes: 2 additions & 0 deletions src/commands/actors/rm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import { getLoggedClientOrThrow } from '../../lib/utils.js';
export class ActorsRmCommand extends ApifyCommand<typeof ActorsRmCommand> {
static override name = 'rm' as const;

static override enableProfileFlag = true;

static override description = 'Permanently removes an Actor from your account.';

static override interactive = true;
Expand Down
2 changes: 2 additions & 0 deletions src/commands/actors/start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import { ActorsCallCommand } from './call.js';
export class ActorsStartCommand extends ApifyCommand<typeof ActorsStartCommand> {
static override name = 'start' as const;

static override enableProfileFlag = true;

static override description =
'Starts Actor remotely and returns run details immediately.\n' +
'Uses authenticated account and local key-value store for input.';
Expand Down
2 changes: 2 additions & 0 deletions src/commands/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,8 @@ export class ApiCommand extends ApifyCommand<typeof ApiCommand> {

static override name = 'api' as const;

static override enableProfileFlag = true;

static override description =
'Makes an authenticated HTTP request to the Apify API and prints the response.\n' +
'The endpoint can be a relative path (e.g. "acts", "v2/acts", or "/v2/acts"); ' +
Expand Down
12 changes: 10 additions & 2 deletions src/commands/auth/_index.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,27 @@
import { ApifyCommand } from '../../lib/command-framework/apify-command.js';
import { AuthListCommand } from './list.js';
import { AuthLoginCommand } from './login.js';
import { AuthLogoutCommand } from './logout.js';
import { AuthSwitchCommand } from './switch.js';
import { AuthTokenCommand } from './token.js';

export class AuthIndexCommand extends ApifyCommand<typeof AuthIndexCommand> {
static override name = 'auth' as const;

static override description =
'Log in, log out, and inspect your stored Apify API token. Also available as `apify login` / `apify logout`.';
'Log in, log out, switch between stored accounts, and inspect your stored Apify API token. Also available as `apify login` / `apify logout`.';

static override group = 'Authentication';

static override docsUrl = 'https://docs.apify.com/cli/docs/reference#apify-auth';

static override subcommands = [AuthLoginCommand, AuthLogoutCommand, AuthTokenCommand];
static override subcommands = [
AuthLoginCommand,
AuthLogoutCommand,
AuthListCommand,
AuthSwitchCommand,
AuthTokenCommand,
];

async run() {
this.printHelp();
Expand Down
100 changes: 100 additions & 0 deletions src/commands/auth/list.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import chalk from 'chalk';

import { APIFY_ENV_VARS } from '@apify/consts';

import {
ensureAuthFileCurrent,
getActiveProfileId,
listProfiles,
profileLabel,
readAuthFile,
} from '../../lib/auth-file.js';
import { invalidEnvTokenMessage, readEnvToken } from '../../lib/auth.js';
import { ApifyCommand } from '../../lib/command-framework/apify-command.js';
import { CompactMode, ResponsiveTable } from '../../lib/commands/responsive-table.js';
import { ensureMigrated, ensureSecretsKeyed } from '../../lib/credentials.js';
import { simpleLog, warning } from '../../lib/outputs.js';
import { printJsonToStdout, TimestampFormatter } from '../../lib/utils.js';

const table = new ResponsiveTable({
allColumns: ['Name', 'User ID', 'Type', 'Last login', 'Storage'],
mandatoryColumns: ['Name', 'User ID'],
});

export class AuthListCommand extends ApifyCommand<typeof AuthListCommand> {
static override name = 'list' as const;

static override description =
'Lists the stored Apify accounts and marks the active one. Reads only the local login, so it works offline and does not check that the tokens are still valid.';

static override enableJsonFlag = true;

static override group = 'Authentication';

static override examples = [
{
description: 'List the stored accounts.',
command: 'apify auth list',
},
{
description: 'List the stored accounts as JSON, for scripts.',
command: 'apify auth list --json',
},
];

static override docsUrl = 'https://docs.apify.com/cli/docs/reference#apify-auth-list';

async run() {
await ensureMigrated();
await ensureAuthFileCurrent();
await ensureSecretsKeyed();

const file = readAuthFile();
const activeId = getActiveProfileId();
const envToken = readEnvToken();

const profiles = listProfiles().map((profile) => ({
id: profile.id,
name: profileLabel(profile),
username: profile.username ?? null,
active: profile.id === activeId,
isOrganization: Boolean(profile.organizationOwnerUserId),
organizationOwnerUserId: profile.organizationOwnerUserId ?? null,
loggedInAt: profile.loggedInAt,
// A file without the marker predates the keyring, so its secrets are still in it.
secretsBackend: profile.secretsBackend ?? file.secretsBackend ?? 'file',
}));

if (this.flags.json) {
printJsonToStdout({ envTokenInUse: envToken.kind !== 'unset', profiles });
return;
}

if (envToken.kind === 'token') {
warning({
message: `${APIFY_ENV_VARS.TOKEN} is set, so commands use it instead of the active account.`,
});
} else if (envToken.kind === 'invalid') {
warning({ message: invalidEnvTokenMessage(envToken.raw) });
}

if (!profiles.length) {
simpleLog({ message: 'No accounts are stored. Run "apify login" to add one.', stdout: true });
return;
}

for (const profile of profiles) {
table.pushRow({
Name: profile.active ? `${chalk.bold(profile.name)} ${chalk.green('(active)')}` : profile.name,
'User ID': chalk.gray(profile.id),
Type: profile.isOrganization ? 'Organization' : 'Personal',
'Last login': profile.loggedInAt
? TimestampFormatter.display(new Date(profile.loggedInAt))
: chalk.gray('Unknown'),
Storage: profile.secretsBackend === 'keyring' ? 'OS keyring' : 'auth.json',
});
}

simpleLog({ message: table.render(CompactMode.WebLikeCompact), stdout: true });
}
}
114 changes: 98 additions & 16 deletions src/commands/auth/logout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,21 @@ import process from 'node:process';

import { APIFY_ENV_VARS } from '@apify/consts';

import { getActiveProfileId, profileLabel, removeActiveProfile } from '../../lib/auth-file.js';
import { invalidEnvTokenMessage, readEnvToken } from '../../lib/auth.js';
import {
getActiveProfileId,
listProfiles,
profileLabel,
removeAllProfiles,
removeProfile,
} from '../../lib/auth-file.js';
import { invalidEnvTokenMessage, readEnvToken, requireProfile } from '../../lib/auth.js';
import { ApifyCommand } from '../../lib/command-framework/apify-command.js';
import { Flags, YesFlag } from '../../lib/command-framework/flags.js';
import { AUTH_FILE_PATH, CommandExitCodes } from '../../lib/consts.js';
import { clearKeyringSecrets } from '../../lib/credentials.js';
import { updateUserId } from '../../lib/hooks/telemetry/useTelemetryState.js';
import { error, success, warning } from '../../lib/outputs.js';
import { useYesNoConfirm } from '../../lib/hooks/user-confirmations/useYesNoConfirm.js';
import { error, info, success, warning } from '../../lib/outputs.js';
import { tildify } from '../../lib/utils.js';

export class AuthLogoutCommand extends ApifyCommand<typeof AuthLogoutCommand> {
Expand All @@ -26,37 +34,80 @@ export class AuthLogoutCommand extends ApifyCommand<typeof AuthLogoutCommand> {
description: 'Remove the stored Apify credentials.',
command: 'apify logout',
},
{
description: 'Log out of one stored account and keep the active one.',
command: 'apify logout --profile my-org',
},
{
description: 'Log out of every stored account without a prompt.',
command: 'apify logout --all --yes',
},
];

static override flags = {
profile: Flags.string({
description: 'The stored account to log out of, by name or user ID. See "apify auth list".',
exclusive: ['all'],
}),
all: Flags.boolean({
description: 'Log out of every stored account.',
exclusive: ['profile'],
}),
...YesFlag(),
};

static override docsUrl = 'https://docs.apify.com/cli/docs/reference#apify-logout';

async run() {
const done = this.flags.all ? await this.logOutOfAll() : await this.logOutOf(this.flags.profile);
if (!done) return;

const envToken = readEnvToken();
if (envToken.kind === 'token') {
warning({
message: `${APIFY_ENV_VARS.TOKEN} is still set, so commands stay authenticated with that token.`,
});
} else if (envToken.kind === 'invalid') {
warning({ message: invalidEnvTokenMessage(envToken.raw) });
}
}

private async logOutOf(nameOrId: string | undefined): Promise<boolean> {
// Read before either step runs: once the profile is gone, nothing names the keyring entries it owns.
const activeProfileId = getActiveProfileId();
const targetId = nameOrId ? (await requireProfile(nameOrId)).id : activeProfileId;
const isActive = targetId === activeProfileId;

// Both steps are attempted even when the first one fails, so neither the secrets nor the
// profile are left behind just because the other could not be removed.
const keyringError = await clearKeyringSecrets(activeProfileId).then(
const keyringError = await clearKeyringSecrets(targetId, { keepLegacy: !isActive }).then(
() => null,
(err: unknown) => err,
);

let profileError: unknown = null;
let result: ReturnType<typeof removeActiveProfile> = {};
let result: ReturnType<typeof removeProfile> = {};
try {
result = removeActiveProfile();
result = removeProfile(targetId);
} catch (err) {
profileError = err;
}

if (keyringError || profileError) {
error({ message: partialLogoutMessage(activeProfileId, keyringError, profileError) });
error({ message: partialLogoutMessage(targetId, keyringError, profileError) });
process.exitCode = CommandExitCodes.RunFailed;
return;
return false;
}

const { removed, active } = result;

if (!isActive) {
success({
message: `You are logged out of ${profileLabel(removed!)}.${active ? ` ${profileLabel(active)} is still the active account.` : ''}`,
});
return true;
}

await updateUserId(active?.id ?? null);

if (active) {
Expand All @@ -67,24 +118,55 @@ export class AuthLogoutCommand extends ApifyCommand<typeof AuthLogoutCommand> {
success({ message: 'You are logged out from your Apify account.' });
}

const envToken = readEnvToken();
if (envToken.kind === 'token') {
warning({
message: `${APIFY_ENV_VARS.TOKEN} is still set, so commands stay authenticated with that token.`,
return true;
}

private async logOutOfAll(): Promise<boolean> {
const profiles = listProfiles();

if (profiles.length && !this.flags.yes) {
const confirmed = await useYesNoConfirm({
message: `Log out of ${profiles.length === 1 ? 'your stored account' : `all ${profiles.length} stored accounts`}?`,
errorMessageForStdin: 'Use --yes to log out of every stored account without a prompt.',
});
} else if (envToken.kind === 'invalid') {
warning({ message: invalidEnvTokenMessage(envToken.raw) });

if (!confirmed) {
info({ message: 'Logout was cancelled.' });
return false;
}
}

const keyringErrors: unknown[] = [];
for (const id of new Set([getActiveProfileId(), ...profiles.map((p) => p.id)])) {
await clearKeyringSecrets(id).catch((err: unknown) => keyringErrors.push(err));
}

let profileError: unknown = null;
try {
removeAllProfiles();
} catch (err) {
profileError = err;
}

if (keyringErrors.length || profileError) {
error({ message: partialLogoutMessage(undefined, keyringErrors[0], profileError) });
process.exitCode = CommandExitCodes.RunFailed;
return false;
}

await updateUserId(null);
success({ message: 'You are logged out of all your Apify accounts.' });
return true;
}
}

function reasonOf(err: unknown) {
return err instanceof Error ? err.message : String(err);
}

function partialLogoutMessage(activeProfileId: string | undefined, keyringError: unknown, profileError: unknown) {
function partialLogoutMessage(profileId: string | undefined, keyringError: unknown, profileError: unknown) {
const keyringPart = keyringError
? `Your secrets are still in the OS keyring${activeProfileId ? ` under the account ${activeProfileId}` : ''}; delete them with your OS keyring app.`
? `Your secrets are still in the OS keyring${profileId ? ` under the account ${profileId}` : ''}; delete them with your OS keyring app.`
: 'Your secrets were removed from the OS keyring.';

const profilePart = profileError
Expand Down
Loading