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
15 changes: 11 additions & 4 deletions docs/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -857,17 +857,24 @@ DESCRIPTION
Use --force to override newer remote versions.

USAGE
$ apify actors push [actorId] [--allow-missing-secrets]
[--apply-env-vars-to-build] [-b <value>] [--dir <value>]
[-f] [--json] [--open] [--profile <value>] [-v <value>]
[-w <value>]
$ apify actors push [actorId] [--actor-dir <value> | --all]
[--allow-missing-secrets] [--apply-env-vars-to-build]
[-b <value>] [--dir <value>] [-f] [--json] [--open]
[--profile <value>] [-v <value>] [-w <value>]

ARGUMENTS
actorId Name or ID of the Actor to push (e.g. "apify/hello-world" or
"E2jjCZBezvAZnX8Rb"). If not provided, the command will create or
modify the Actor with the name specified in '.actor/actor.json' file.

FLAGS
--actor-dir=<value> When the directory has
no Actor of its own, push the Actor in this
directory (relative to it). Repeat the flag to push
several Actors.
--all When the directory has
no Actor of its own, push every Actor found in the
directories below it.
--allow-missing-secrets Allow the command to
continue even when secret values are not found in
the local secrets storage.
Expand Down
198 changes: 182 additions & 16 deletions src/commands/actors/push.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { readFileSync, statSync, unlinkSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { existsSync, readFileSync, statSync, unlinkSync } from 'node:fs';
import { isAbsolute, join, resolve } from 'node:path';
import process from 'node:process';

import type { Actor, ActorCollectionCreateOptions, ActorDefaultRunOptions } from 'apify-client';
import { isCI } from 'ci-info';
import open from 'open';

import { fetchManifest } from '@apify/actor-templates';
Expand All @@ -21,9 +22,12 @@ import { getConsoleUrl } from '../../lib/console-url.js';
import { CommandExitCodes, DEPRECATED_LOCAL_CONFIG_NAME, LOCAL_CONFIG_PATH } from '../../lib/consts.js';
import { monorepoUnsupportedMessage, resolveDockerContext } from '../../lib/docker-context.js';
import { sumFilesSizeInBytes } from '../../lib/files.js';
import { findActorDirs, type FoundActorDir, toPosixRelative } from '../../lib/find-actor-dirs.js';
import { useAbortJobOnSignal } from '../../lib/hooks/useAbortJobOnSignal.js';
import { useActorConfig } from '../../lib/hooks/useActorConfig.js';
import { useMultiSelectFromList } from '../../lib/hooks/user-confirmations/useMultiSelectFromList.js';
import { useYesNoConfirm } from '../../lib/hooks/user-confirmations/useYesNoConfirm.js';
import { useStdin } from '../../lib/hooks/useStdin.js';
import { error, info, run, simpleLog, warning } from '../../lib/outputs.js';
import { transformEnvToEnvVars } from '../../lib/secrets.js';
import {
Expand Down Expand Up @@ -66,6 +70,21 @@ interface PushResult {
exitCode?: number;
}

/** One entry of a push from a root without an Actor of its own: the single push result, or what stopped it. */
interface SelectedPushResult extends Partial<PushResult> {
dir: string;
ok: boolean;
}

interface PushSelectionResult {
ok: boolean;
operation: 'push';
results: SelectedPushResult[];
exitCode?: number;
}

const SELECTION_HINT = 'Choose what to push: --all, or --actor-dir <path> for each Actor.';

interface PushOutcome {
resultLabel: string;
exitCode?: number;
Expand Down Expand Up @@ -154,6 +173,18 @@ const confirmGitSourceSwitch = async ({
});
};

/** Whether the collected files include an Actor config `apify push` accepts: `.actor/`, `actor.json`, or the legacy `apify.json`. */
const hasActorConfig = (filePaths: string[]) =>
[
// old apify project
DEPRECATED_LOCAL_CONFIG_NAME,
// new apify project
'actor.json',
'.actor/actor.json',
// The .actor folder existing in general
'.actor',
].some((filePath) => filePaths.some((fp) => fp === filePath || fp.startsWith(filePath)));

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

Expand Down Expand Up @@ -182,6 +213,10 @@ export class ActorsPushCommand extends ApifyCommand<typeof ActorsPushCommand> {
description: 'Deploy without waiting for the build to finish.',
command: 'apify push --no-wait-for-finish',
},
{
description: 'From the root of a repository with several Actors, deploy two of them.',
command: 'apify push --actor-dir actors/first --actor-dir actors/second',
},
];

static override docsUrl = 'https://docs.apify.com/cli/docs/reference#apify-push';
Expand Down Expand Up @@ -221,6 +256,19 @@ export class ActorsPushCommand extends ApifyCommand<typeof ActorsPushCommand> {
description: 'Directory where the Actor is located.',
required: false,
}),
all: Flags.boolean({
description: 'When the directory has no Actor of its own, push every Actor found in the directories below it.',
default: false,
required: false,
exclusive: ['actor-dir'],
}),
'actor-dir': Flags.string({
description:
'When the directory has no Actor of its own, push the Actor in this directory (relative to it). Repeat the flag to push several Actors.',
required: false,
multiple: true,
exclusive: ['all'],
}),
'allow-missing-secrets': Flags.boolean({
description: 'Allow the command to continue even when secret values are not found in the local secrets storage.',
required: false,
Expand Down Expand Up @@ -248,24 +296,141 @@ export class ActorsPushCommand extends ApifyCommand<typeof ActorsPushCommand> {
// Validate there are files before rest of the logic
const filePathsToPush = await getActorLocalFilePaths(cwd);

// A directory without an Actor of its own may hold Actors below it; then the user picks which to push.
if (!hasActorConfig(filePathsToPush)) {
const actorDirs = await findActorDirs(cwd);

if (actorDirs.length) {
await this.pushSelected(cwd, actorDirs);
return;
}
}

const result = await this.pushActor(cwd, filePathsToPush);

if (result && this.flags.json) {
printJsonToStdout(result);
}
}

/** Pushes the chosen Actors below `root` one after another; a failure does not stop the ones after it. */
private async pushSelected(root: string, actorDirs: FoundActorDir[]) {
const selected = await this.selectActorDirs(root, actorDirs);

if (!selected) {
return;
}

if (selected.length > 1 && this.args.actorId) {
error({
message: `The actorId argument applies to a single Actor, but ${selected.length} Actors are selected. Pass --actor-dir with one directory, or drop the argument.`,
});
process.exitCode = CommandExitCodes.InvalidInput;
return;
}

const results: SelectedPushResult[] = [];
let firstFailureExitCode: number | undefined;

for (const dir of selected) {
info({ message: `Pushing the Actor in ${dir}` });

// Each push reports through process.exitCode; clear it so the code read back belongs to this push.
process.exitCode = undefined;
let result: PushResult | undefined;

try {
result = await this.pushActor(join(root, dir));
} catch (err) {
error({ message: (err as Error).message });
process.exitCode ||= 1;
}

const exitCode = typeof process.exitCode === 'number' && process.exitCode !== 0 ? process.exitCode : undefined;
firstFailureExitCode ??= exitCode;

const entry: SelectedPushResult = { dir, ...(result ?? { ok: false }) };
if (exitCode !== undefined && entry.exitCode === undefined) {
entry.exitCode = exitCode;
}
results.push(entry);
}

process.exitCode = firstFailureExitCode;

if (this.flags.json) {
const summary: PushSelectionResult = { ok: results.every((entry) => entry.ok), operation: 'push', results };
const exitCode = firstFailureExitCode ?? (results.every((entry) => entry.exitCode !== undefined) ? 0 : undefined);
if (exitCode !== undefined) {
summary.exitCode = exitCode;
}
printJsonToStdout(summary);
}
}

/** Resolves the Actor directories to push from the flags or a prompt; `undefined` when the command stops here. */
private async selectActorDirs(root: string, actorDirs: FoundActorDir[]): Promise<string[] | undefined> {
if (this.flags.all) {
return actorDirs.map((actor) => actor.dir);
}

if (this.flags.actorDir?.length) {
const dirs = [...new Set(this.flags.actorDir.map((actorDir) => toPosixRelative(root, resolve(root, actorDir))))];
const outside = dirs.find((dir) => isAbsolute(dir) || dir === '..' || dir.startsWith('../'));

if (outside !== undefined) {
error({
message: `Directory "${outside}" from --actor-dir is outside the pushed directory. Pass a path below it.`,
});
process.exitCode = CommandExitCodes.NoFilesToPush;
return;
}

const missing = dirs.find((dir) => !existsSync(join(root, dir, LOCAL_CONFIG_PATH)));

if (missing !== undefined) {
error({ message: `Directory "${missing || '.'}" from --actor-dir has no .actor/actor.json.` });
process.exitCode = CommandExitCodes.NoFilesToPush;
return;
}

return dirs;
}

const label = ({ dir, name }: FoundActorDir) => (name ? `${dir} (${name})` : dir);
const { isTTY } = await useStdin();

if (isCI || !isTTY) {
simpleLog({
message: [
'This directory has no Actor of its own, but these directories below it do:',
...actorDirs.map((actor) => ` ${label(actor)}`),
].join('\n'),
});
error({ message: SELECTION_HINT });
process.exitCode = CommandExitCodes.NoFilesToPush;
return;
}

return useMultiSelectFromList({
message: 'Which Actors do you want to push?',
choices: actorDirs.map((actor) => ({ name: label(actor), value: actor.dir })),
pageSize: Math.min(actorDirs.length, 15),
loop: false,
});
}

/** The single-Actor push: `cwd` is the Actor directory. Returns the result once a build was started. */
private async pushActor(cwd: string, collectedFilePaths?: string[]): Promise<PushResult | undefined> {
const filePathsToPush = collectedFilePaths ?? (await getActorLocalFilePaths(cwd));

if (!filePathsToPush.length) {
error({ message: 'You need to call this command from a folder that has an Actor in it!' });
process.exitCode = CommandExitCodes.NoFilesToPush;
return;
}

if (
// Check that some of these files exist, cuz otherwise we cannot do much
![
// old apify project
DEPRECATED_LOCAL_CONFIG_NAME,
// new apify project
'actor.json',
'.actor/actor.json',
// The .actor folder existing in general
'.actor',
].some((filePath) => filePathsToPush.some((fp) => fp === filePath || fp.startsWith(filePath)))
) {
if (!hasActorConfig(filePathsToPush)) {
error({
message: [
'A valid Actor could not be found in the current directory. Please make sure you are in the correct directory.',
Expand Down Expand Up @@ -619,8 +784,7 @@ Skipping push. Use --force to override.`,
}

if (this.flags.json) {
printJsonToStdout(result);
return;
return result;
}

const lines = [
Expand All @@ -642,5 +806,7 @@ Skipping push. Use --force to override.`,
if (this.flags.open) {
await open(actorUrl);
}

return result;
}
}
19 changes: 18 additions & 1 deletion src/lib/command-framework/apify-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,20 @@ type InferArgTypeFromArg<Builder extends TaggedArgBuilder<ArgTag, unknown>> =
type If<T, Y, N> = T extends true ? Y : N;
type IfNotUnknown<T, Y, N> = T extends unknown ? Y : N;

// A flag that keeps every value (`multiple: true`) is typed as an array of the single-value type
type InferFlagTypeFromFlag<
Builder extends TaggedFlagBuilder<FlagTag, string[] | null, unknown, unknown>,
OptionalIfHasDefault = false,
> =
Builder extends TaggedFlagBuilder<FlagTag, string[] | null, unknown, unknown, true>
?
| Exclude<InferSingleFlagTypeFromFlag<Builder, OptionalIfHasDefault>, undefined>[]
| Extract<InferSingleFlagTypeFromFlag<Builder, OptionalIfHasDefault>, undefined>
: InferSingleFlagTypeFromFlag<Builder, OptionalIfHasDefault>;

type InferSingleFlagTypeFromFlag<
Builder extends TaggedFlagBuilder<FlagTag, string[] | null, unknown, unknown>,
OptionalIfHasDefault = false,
> =
Builder extends TaggedFlagBuilder<infer ReturnedType, never, infer Required, infer HasDefault> // Handle special case where there can be no choices
? If<
Expand Down Expand Up @@ -549,7 +560,13 @@ export abstract class ApifyCommand<T extends typeof BuiltApifyCommand = typeof B
});
}

// If you provide --a 1 --a 2, it's <currently> not allowed
// A flag that opted into `multiple` keeps every value; otherwise --a 1 --a 2 is not allowed
if (Array.isArray(rawFlag) && builderData.multiple) {
this.flags[camelCasedName] = rawFlag;

continue;
}

if (Array.isArray(rawFlag)) {
if (rawFlag.length > 1) {
throw new CommandError({
Expand Down
16 changes: 15 additions & 1 deletion src/lib/command-framework/flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ export interface BaseFlagOptions {
export interface StringFlagOptions<Choices extends readonly string[] = readonly string[]> extends BaseFlagOptions {
choices?: Choices;
default?: string;
/**
* Keeps every value of a repeated flag (`--a 1 --a 2`) as a `string[]` instead of rejecting the repeat.
* @default false
*/
multiple?: boolean;
}

export interface BooleanFlagOptions extends BaseFlagOptions {
Expand All @@ -42,12 +47,14 @@ export interface TaggedFlagBuilder<
ChoicesType extends readonly string[] | null,
Required = boolean,
HasDefault = false,
Multiple = boolean,
> {
flagTag: Tag;
builder: (objectName: string) => FlagBuilderReturn;
choices: ChoicesType;
required: Required;
hasDefault: HasDefault;
multiple?: Multiple;
stdin: StdinMode;

// Fields from the options object
Expand Down Expand Up @@ -77,7 +84,13 @@ export function YesFlag(description = 'Automatic yes to prompts; assume "yes" as

function stringFlag<const Choices extends string[], const T extends StringFlagOptions<readonly string[]>>(
options: T & { choices?: Choices },
): TaggedFlagBuilder<'string', Choices, T['default'] extends string ? true : T['required'], T['default']> {
): TaggedFlagBuilder<
'string',
Choices,
T['default'] extends string ? true : T['required'],
T['default'],
T['multiple'] extends true ? true : false
> {
return {
flagTag: 'string',
builder: (objectName) => {
Expand Down Expand Up @@ -115,6 +128,7 @@ function stringFlag<const Choices extends string[], const T extends StringFlagOp
choices: options.choices as Choices,
required: (options.required ?? false) as never,
hasDefault: options.default,
multiple: (options.multiple ?? false) as never,
stdin: options.stdin ?? StdinMode.Stringified,

description: options.description,
Expand Down
Loading
Loading