From 0691db647f2b83a7729e8afd915e7d12ffb49ec2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Fri, 16 Oct 2026 10:01:21 +0200 Subject: [PATCH 1/6] feat: let a string flag opt into keeping every value of a repeat --- src/lib/command-framework/apify-command.ts | 19 ++++- src/lib/command-framework/flags.ts | 16 ++++- .../command-framework/multiple-flag.test.ts | 72 +++++++++++++++++++ 3 files changed, 105 insertions(+), 2 deletions(-) create mode 100644 test/local/lib/command-framework/multiple-flag.test.ts diff --git a/src/lib/command-framework/apify-command.ts b/src/lib/command-framework/apify-command.ts index f6b94f60e..2e64a76ab 100644 --- a/src/lib/command-framework/apify-command.ts +++ b/src/lib/command-framework/apify-command.ts @@ -49,9 +49,20 @@ type InferArgTypeFromArg> = type If = T extends true ? Y : N; type IfNotUnknown = 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, OptionalIfHasDefault = false, +> = + Builder extends TaggedFlagBuilder + ? + | Exclude, undefined>[] + | Extract, undefined> + : InferSingleFlagTypeFromFlag; + +type InferSingleFlagTypeFromFlag< + Builder extends TaggedFlagBuilder, + OptionalIfHasDefault = false, > = Builder extends TaggedFlagBuilder // Handle special case where there can be no choices ? If< @@ -549,7 +560,13 @@ export abstract class ApifyCommand 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({ diff --git a/src/lib/command-framework/flags.ts b/src/lib/command-framework/flags.ts index 38e269a84..9babccb83 100644 --- a/src/lib/command-framework/flags.ts +++ b/src/lib/command-framework/flags.ts @@ -24,6 +24,11 @@ export interface BaseFlagOptions { export interface StringFlagOptions 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 { @@ -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 @@ -77,7 +84,13 @@ export function YesFlag(description = 'Automatic yes to prompts; assume "yes" as function stringFlag>( 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) => { @@ -115,6 +128,7 @@ function stringFlag = {}; + +class CommandWithRepeatableFlag extends BuiltApifyCommand { + static override name = 'repeatable' as const; + static override description = 'Has a repeatable flag and a plain one.'; + static override flags = { + 'actor-dir': Flags.string({ description: 'repeatable', multiple: true }), + once: Flags.string({ description: 'plain' }), + }; + + async run() { + receivedFlags = this.flags; + } +} + +async function runWith(args: string[]) { + const instance = new CommandWithRepeatableFlag( + 'apify', + CommandWithRepeatableFlag.name, + CommandWithRepeatableFlag.name, + ); + // eslint-disable-next-line dot-notation + instance['skipTelemetry'] = true; + // eslint-disable-next-line dot-notation + instance['skipNotices'] = true; + // eslint-disable-next-line dot-notation + const parsed = parseArgs({ ...instance['_buildParseArgsOption'](), args }); + // eslint-disable-next-line dot-notation + await instance['_run'](parsed); +} + +describe('Flags.string({ multiple: true })', () => { + beforeEach(() => { + receivedFlags = {}; + }); + + test('keeps every value of a repeated flag, in order', async () => { + await runWith(['--actor-dir', 'actors/b', '--actor-dir', 'actors/a', '--actor-dir=actors/c']); + + expect(receivedFlags.actorDir).toEqual(['actors/b', 'actors/a', 'actors/c']); + }); + + test('wraps a single value in an array', async () => { + await runWith(['--actor-dir', 'actors/a']); + + expect(receivedFlags.actorDir).toEqual(['actors/a']); + }); + + test('leaves the flag undefined when it is not passed', async () => { + await runWith([]); + + expect(receivedFlags.actorDir).toBeUndefined(); + }); + + test('still rejects a repeated flag that did not opt in', async () => { + await expect(runWith(['--once', 'a', '--once', 'b'])).rejects.toMatchObject({ + code: CommandErrorCode.APIFY_FLAG_PROVIDED_MULTIPLE_TIMES, + }); + }); +}); From f4a8f16b5dd643b3332c64e1573946a102dce77f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Fri, 16 Oct 2026 10:01:23 +0200 Subject: [PATCH 2/6] feat: add Actor directory discovery and a multi-select prompt --- src/lib/find-actor-dirs.ts | 58 ++++++++++ .../useMultiSelectFromList.ts | 20 ++++ src/lib/utils.ts | 4 +- src/lib/utils/inquirer/checkboxImpl.ts | 107 ++++++++++++++++++ test/local/lib/find-actor-dirs.test.ts | 94 +++++++++++++++ 5 files changed, 282 insertions(+), 1 deletion(-) create mode 100644 src/lib/find-actor-dirs.ts create mode 100644 src/lib/hooks/user-confirmations/useMultiSelectFromList.ts create mode 100644 src/lib/utils/inquirer/checkboxImpl.ts create mode 100644 test/local/lib/find-actor-dirs.test.ts diff --git a/src/lib/find-actor-dirs.ts b/src/lib/find-actor-dirs.ts new file mode 100644 index 000000000..735c0da6f --- /dev/null +++ b/src/lib/find-actor-dirs.ts @@ -0,0 +1,58 @@ +import { existsSync } from 'node:fs'; +import { readdir } from 'node:fs/promises'; +import { join, relative, sep } from 'node:path'; + +import { LOCAL_CONFIG_PATH } from './consts.js'; +import { ACTOR_FILES_HARDCODED_IGNORE, getJsonFileContent } from './utils.js'; + +const SKIPPED_DIRECTORY_NAMES = new Set(ACTOR_FILES_HARDCODED_IGNORE.map((pattern) => pattern.replace(/\/\*\*$/, ''))); + +export interface FoundActorDir { + /** Path of the Actor directory relative to the root, with `/` separators. */ + dir: string; + /** The `name` from its `.actor/actor.json`, when the file could be read. */ + name: string | undefined; +} + +/** Turns a directory below `root` into the `/`-separated relative path used to address it. */ +export function toPosixRelative(root: string, dir: string): string { + return relative(root, dir).split(sep).join('/'); +} + +function readActorName(dir: string): string | undefined { + try { + const name = getJsonFileContent(join(dir, LOCAL_CONFIG_PATH))?.name; + + return typeof name === 'string' ? name : undefined; + } catch { + return undefined; + } +} + +/** + * Finds every directory below `root` with a `.actor/actor.json`, sorted by path. Skips the directories + * `getActorLocalFilePaths` always ignores and does not descend below a found Actor. + */ +export async function findActorDirs(root: string): Promise { + const found: FoundActorDir[] = []; + + const walk = async (dir: string) => { + for (const entry of await readdir(dir, { withFileTypes: true })) { + if (!entry.isDirectory() || SKIPPED_DIRECTORY_NAMES.has(entry.name)) { + continue; + } + + const child = join(dir, entry.name); + + if (existsSync(join(child, LOCAL_CONFIG_PATH))) { + found.push({ dir: toPosixRelative(root, child), name: readActorName(child) }); + } else { + await walk(child); + } + } + }; + + await walk(root); + + return found.sort((a, b) => (a.dir < b.dir ? -1 : a.dir > b.dir ? 1 : 0)); +} diff --git a/src/lib/hooks/user-confirmations/useMultiSelectFromList.ts b/src/lib/hooks/user-confirmations/useMultiSelectFromList.ts new file mode 100644 index 000000000..ade74e567 --- /dev/null +++ b/src/lib/hooks/user-confirmations/useMultiSelectFromList.ts @@ -0,0 +1,20 @@ +import { type CheckboxChoice, checkboxImpl } from '../../utils/inquirer/checkboxImpl.js'; +import { promptContext, type StdinCheckWrapperInput, stdinCheckWrapper } from './_stdinCheckWrapper.js'; + +interface UseMultiSelectFromListInput { + message: string; + choices: CheckboxChoice[]; + pageSize?: number; + loop?: boolean; +} + +export const useMultiSelectFromList = stdinCheckWrapper( + async ({ message, choices, pageSize, loop }: UseMultiSelectFromListInput) => { + const result = await checkboxImpl({ message, choices, pageSize, loop }, promptContext); + + return result; + }, + { + errorMessageForStdin: 'Please provide the selection using the command options.', + }, +) as (input: UseMultiSelectFromListInput & StdinCheckWrapperInput) => Promise; diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 56524e708..959fa4020 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -402,10 +402,12 @@ const parseActorIgnore = async (cwd: string): Promise => { * Get Actor local files, omit files defined in .gitignore, .actorignore and .git folder * All dot files(.file) and folders(.folder/) are included. */ +export const ACTOR_FILES_HARDCODED_IGNORE = ['.git/**', 'apify_storage', 'node_modules', 'storage', 'crawlee_storage']; + export const getActorLocalFilePaths = async (cwd?: string) => { const resolvedCwd = cwd ?? process.cwd(); - const hardcodedIgnore = ['.git/**', 'apify_storage', 'node_modules', 'storage', 'crawlee_storage']; + const hardcodedIgnore = ACTOR_FILES_HARDCODED_IGNORE; // Parse .actorignore early to get both exclude filter and force-include patterns const { excludeFilter: actorignoreFilter, forceIncludePatterns } = await parseActorIgnore(resolvedCwd); diff --git a/src/lib/utils/inquirer/checkboxImpl.ts b/src/lib/utils/inquirer/checkboxImpl.ts new file mode 100644 index 000000000..2d1929d7a --- /dev/null +++ b/src/lib/utils/inquirer/checkboxImpl.ts @@ -0,0 +1,107 @@ +import { + createPrompt, + isDownKey, + isEnterKey, + isSpaceKey, + isUpKey, + makeTheme, + type Status, + useKeypress, + usePagination, + usePrefix, + useState, +} from '@inquirer/core'; + +export interface CheckboxChoice { + name: string; + value: Value; + checked?: boolean; +} + +interface CheckboxOptions { + message: string; + choices: CheckboxChoice[]; + pageSize?: number; + loop?: boolean; +} + +const HELP_TIP = '(space to toggle, a for all, i to invert, enter to confirm)'; + +/** + * A multi-select prompt on `@inquirer/core`, so the CLI does not pull in `@inquirer/checkbox`. Up and down + * move, space toggles the active item, `a` toggles every item, `i` inverts the selection, enter confirms. + */ +export const checkboxImpl: ReturnType> = createPrompt< + unknown[], + CheckboxOptions +>((config, done) => { + const { choices, pageSize = 7, loop = true } = config; + const theme = makeTheme(); + const [status, setStatus] = useState('idle'); + const [active, setActive] = useState(0); + const [checked, setChecked] = useState(() => choices.map((choice) => !!choice.checked)); + const prefix = usePrefix({ status, theme }); + + useKeypress((key) => { + if (isEnterKey(key)) { + setStatus('done'); + done(choices.filter((_, index) => checked[index]).map((choice) => choice.value)); + + return; + } + + if (isUpKey(key) || isDownKey(key)) { + const next = active + (isUpKey(key) ? -1 : 1); + + if (loop) { + setActive((next + choices.length) % choices.length); + } else if (next >= 0 && next < choices.length) { + setActive(next); + } + + return; + } + + if (isSpaceKey(key)) { + setChecked(checked.map((value, index) => (index === active ? !value : value))); + + return; + } + + if (key.name === 'a') { + const allChecked = checked.every(Boolean); + setChecked(checked.map(() => !allChecked)); + + return; + } + + if (key.name === 'i') { + setChecked(checked.map((value) => !value)); + } + }); + + const page = usePagination({ + items: choices, + active, + pageSize, + loop, + renderItem: ({ item, index, isActive }) => { + const line = `${checked[index] ? '[x]' : '[ ]'} ${item.name}`; + + return isActive ? theme.style.highlight(`> ${line}`) : ` ${line}`; + }, + }); + + const message = theme.style.message(config.message, status); + + if (status === 'done') { + const answer = choices + .filter((_, index) => checked[index]) + .map((choice) => choice.name) + .join(', '); + + return `${prefix} ${message} ${theme.style.answer(answer)}`; + } + + return `${prefix} ${message} ${theme.style.help(HELP_TIP)}\n${page}`; +}); diff --git a/test/local/lib/find-actor-dirs.test.ts b/test/local/lib/find-actor-dirs.test.ts new file mode 100644 index 000000000..c551b321d --- /dev/null +++ b/test/local/lib/find-actor-dirs.test.ts @@ -0,0 +1,94 @@ +import { mkdir, writeFile } from 'node:fs/promises'; +import { dirname } from 'node:path'; + +import { findActorDirs, toPosixRelative } from '../../../src/lib/find-actor-dirs.js'; +import { useTempPath } from '../../__setup__/hooks/useTempPath.js'; + +const { beforeAllCalls, afterAllCalls, joinPath, tmpPath } = useTempPath('find-actor-dirs', { + create: true, + remove: true, + cwd: false, + cwdParent: false, +}); + +async function write(relativePath: string, content = '') { + await mkdir(dirname(joinPath(relativePath)), { recursive: true }); + await writeFile(joinPath(relativePath), content); +} + +async function writeActor(dir: string, name: string | undefined = dir.split('/').pop()) { + await write(`${dir}/.actor/actor.json`, JSON.stringify({ actorSpecification: 1, name })); +} + +beforeEach(async () => { + await beforeAllCalls(); +}); + +afterEach(async () => { + await afterAllCalls(); +}); + +describe('findActorDirs', () => { + it('returns the Actor directories below the root sorted by path, with their names', async () => { + await writeActor('actors/zeta'); + await writeActor('actors/alpha'); + await writeActor('tools/beta', 'tool-beta'); + await write('packages/shared/index.js'); + + await expect(findActorDirs(tmpPath)).resolves.toEqual([ + { dir: 'actors/alpha', name: 'alpha' }, + { dir: 'actors/zeta', name: 'zeta' }, + { dir: 'tools/beta', name: 'tool-beta' }, + ]); + }); + + it('skips the directories apify push always ignores', async () => { + await writeActor('actors/alpha'); + await writeActor('node_modules/dep'); + await writeActor('.git/hooks'); + await writeActor('apify_storage/x'); + await writeActor('storage/x'); + await writeActor('crawlee_storage/x'); + await writeActor('packages/node_modules/nested'); + + const dirs = (await findActorDirs(tmpPath)).map((actor) => actor.dir); + + expect(dirs).toEqual(['actors/alpha']); + }); + + it('does not descend below a found Actor', async () => { + await writeActor('actors/alpha'); + await writeActor('actors/alpha/vendored/inner'); + + const dirs = (await findActorDirs(tmpPath)).map((actor) => actor.dir); + + expect(dirs).toEqual(['actors/alpha']); + }); + + it('reports an unreadable or nameless actor.json without a name', async () => { + await write('broken/.actor/actor.json', '{ not json'); + await write('nameless/.actor/actor.json', '{}'); + + await expect(findActorDirs(tmpPath)).resolves.toEqual([ + { dir: 'broken', name: undefined }, + { dir: 'nameless', name: undefined }, + ]); + }); + + it('returns an empty list when nothing below the root is an Actor', async () => { + await write('packages/shared/index.js'); + await write('README.md'); + + await expect(findActorDirs(tmpPath)).resolves.toEqual([]); + }); + + it('uses / as the separator, also on Windows', async () => { + await writeActor('group/sub/actor'); + + const [found] = await findActorDirs(tmpPath); + + expect(found.dir).toBe('group/sub/actor'); + expect(found.dir).not.toContain('\\'); + expect(toPosixRelative(tmpPath, joinPath('group', 'sub', 'actor'))).toBe('group/sub/actor'); + }); +}); From 68265332bd077c1b9058ee166fe2756d04fda9dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Fri, 16 Oct 2026 10:01:23 +0200 Subject: [PATCH 3/6] feat: let apify push select which Actors to push in a repository with several --- src/commands/actors/push.ts | 186 ++++++++++- test/e2e/commands/push-select.test.ts | 46 +++ test/local/commands/push-select.test.ts | 392 ++++++++++++++++++++++++ 3 files changed, 609 insertions(+), 15 deletions(-) create mode 100644 test/e2e/commands/push-select.test.ts create mode 100644 test/local/commands/push-select.test.ts diff --git a/src/commands/actors/push.ts b/src/commands/actors/push.ts index 2489eacc4..41bbe1fbb 100644 --- a/src/commands/actors/push.ts +++ b/src/commands/actors/push.ts @@ -1,8 +1,9 @@ -import { readFileSync, statSync, unlinkSync } from 'node:fs'; +import { existsSync, readFileSync, statSync, unlinkSync } from 'node:fs'; import { 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'; @@ -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 { @@ -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 { + 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 for each Actor.'; + interface PushOutcome { resultLabel: string; exitCode?: number; @@ -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 { static override name = 'push' as const; @@ -182,6 +213,10 @@ export class ActorsPushCommand extends ApifyCommand { 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'; @@ -221,6 +256,19 @@ export class ActorsPushCommand extends ApifyCommand { 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, @@ -248,24 +296,131 @@ export class ActorsPushCommand extends ApifyCommand { // 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 { + 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 missing = dirs.find((dir) => !existsSync(join(root, dir, LOCAL_CONFIG_PATH))); + + if (missing !== undefined) { + error({ message: `Directory "${missing || '.'}" from --actor-dir has no ${LOCAL_CONFIG_PATH}.` }); + 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 { + 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.', @@ -619,8 +774,7 @@ Skipping push. Use --force to override.`, } if (this.flags.json) { - printJsonToStdout(result); - return; + return result; } const lines = [ @@ -642,5 +796,7 @@ Skipping push. Use --force to override.`, if (this.flags.open) { await open(actorUrl); } + + return result; } } diff --git a/test/e2e/commands/push-select.test.ts b/test/e2e/commands/push-select.test.ts new file mode 100644 index 000000000..8236660d0 --- /dev/null +++ b/test/e2e/commands/push-select.test.ts @@ -0,0 +1,46 @@ +import { randomBytes } from 'node:crypto'; +import { mkdir, rm, writeFile } from 'node:fs/promises'; +import path from 'node:path'; + +import { runCli } from '../__helpers__/run-cli.js'; +import { TestTmpRoot } from '../__helpers__/tmp.js'; + +describe('[e2e] apify push in a repository with several Actors', () => { + const root = path.join(TestTmpRoot, `push-select-${randomBytes(6).toString('hex')}`); + + async function writeActor(dir: string, name: string) { + await mkdir(path.join(root, dir, '.actor'), { recursive: true }); + await writeFile(path.join(root, dir, '.actor', 'actor.json'), JSON.stringify({ actorSpecification: 1, name })); + await writeFile(path.join(root, dir, 'main.js'), `console.log("${name}")`); + } + + beforeAll(async () => { + await writeActor('actors/first', 'first-actor'); + await writeActor('actors/second', 'second-actor'); + await mkdir(path.join(root, 'packages', 'shared'), { recursive: true }); + await writeFile(path.join(root, 'packages', 'shared', 'index.js'), 'module.exports = {};'); + }); + + afterAll(async () => { + await rm(root, { recursive: true, force: true }); + }); + + it('lists the Actors below the root and exits with code 4 without a token', async () => { + const result = await runCli('apify', ['push'], { cwd: root }); + + expect(result.exitCode, `stdout: ${result.stdout}\nstderr: ${result.stderr}`).toBe(4); + expect(result.stderr).toContain('actors/first (first-actor)'); + expect(result.stderr).toContain('actors/second (second-actor)'); + expect(result.stderr).toContain('Choose what to push: --all, or --actor-dir for each Actor.'); + expect(result.stderr).not.toContain('login'); + expect(result.stdout).toBe(''); + }); + + it('exits with code 4 for an --actor-dir that is not an Actor', async () => { + const result = await runCli('apify', ['push', '--actor-dir', 'packages/shared'], { cwd: root }); + + expect(result.exitCode, `stdout: ${result.stdout}\nstderr: ${result.stderr}`).toBe(4); + expect(result.stderr).toContain('Directory "packages/shared" from --actor-dir has no .actor/actor.json.'); + expect(result.stdout).toBe(''); + }); +}); diff --git a/test/local/commands/push-select.test.ts b/test/local/commands/push-select.test.ts new file mode 100644 index 000000000..153c0e0f4 --- /dev/null +++ b/test/local/commands/push-select.test.ts @@ -0,0 +1,392 @@ +import { mkdir, writeFile } from 'node:fs/promises'; +import { dirname } from 'node:path'; + +import { testRunCommand } from '../../../src/lib/command-framework/apify-command.js'; +import { CommandExitCodes, LOCAL_CONFIG_PATH } from '../../../src/lib/consts.js'; +import { cwdCache } from '../../../src/lib/hooks/useActorConfig.js'; +import { useConsoleSpy } from '../../__setup__/hooks/useConsoleSpy.js'; +import { useTempPath } from '../../__setup__/hooks/useTempPath.js'; + +// Whether the terminal is interactive is decided by `useStdin` and `ci-info`; both are under test control. +const stdinState = vitest.hoisted(() => ({ isTTY: false })); +vitest.mock('../../../src/lib/hooks/useStdin.js', () => ({ + useStdin: async () => ({ isTTY: stdinState.isTTY, hasData: false, waitDelay: 0 }), +})); +vitest.mock('ci-info', async (importOriginal) => { + const original = await importOriginal(); + return { ...original, isCI: false }; +}); + +const multiSelect = vitest.hoisted(() => vitest.fn()); +vitest.mock('../../../src/lib/hooks/user-confirmations/useMultiSelectFromList.js', () => ({ + useMultiSelectFromList: multiSelect, +})); + +interface PlatformActor { + id: string; + name: string; + modifiedAt: Date; +} + +const ACTOR_A = { id: 'actorIdAAAAAAAAA', name: 'actor-a', modifiedAt: new Date(0) }; +const ACTOR_B = { id: 'actorIdBBBBBBBBB', name: 'actor-b', modifiedAt: new Date(0) }; +const ACTOR_C = { id: 'actorIdCCCCCCCCC', name: 'actor-c', modifiedAt: new Date(0) }; + +// Hoisted so the `vitest.mock` factory below can reach it: the Actors on the platform, the status each +// build ends with, and every version write with its Actor. +const platform = vitest.hoisted(() => { + const state = { + actors: [] as { id: string; name: string; modifiedAt: Date }[], + buildStatuses: {} as Record, + }; + + const buildFor = (actorId: string) => ({ + id: `build-${actorId}`, + actId: actorId, + buildNumber: '0.0.1', + status: state.buildStatuses[actorId] ?? 'SUCCEEDED', + }); + + const versionUpdate = vitest.fn(async (_actorId: string, _version: string, _modifier: unknown) => ({})); + + const getLoggedClientOrThrow = vitest.fn(async () => ({ + baseUrl: 'https://api.apify.com/v2', + actor: (idOrName: string) => { + const actor = state.actors.find( + (candidate) => candidate.id === idOrName || idOrName.endsWith(`/${candidate.name}`), + ); + return { + get: async () => + actor && { + ...actor, + taggedBuilds: { latest: { buildId: `build-${actor.id}` }, beta: { buildId: `build-${actor.id}` } }, + }, + version: (version: string) => ({ + get: async () => ({ versionNumber: version, sourceType: 'SOURCE_FILES' }), + update: async (modifier: unknown) => versionUpdate(actor!.id, version, modifier), + }), + build: async () => buildFor(actor!.id), + }; + }, + build: (buildId: string) => ({ get: async () => buildFor(buildId.replace('build-', '')) }), + log: () => ({ get: async () => 'npm ERR! boom\n' }), + })); + + return { state, versionUpdate, getLoggedClientOrThrow }; +}); + +const { versionUpdate, getLoggedClientOrThrow } = platform; + +// Stands in for the platform: three Actors exist, and every version write is recorded with its Actor. +vitest.mock('../../../src/lib/utils.js', async (importOriginal) => ({ + ...(await importOriginal()), + getCurrentUserInfo: vitest.fn(async () => ({ id: 'userId', username: 'user' })), + outputJobLog: vitest.fn(async () => {}), + getLoggedClientOrThrow: platform.getLoggedClientOrThrow, +})); + +// The mocked `node:process` copy has no signal handlers; nothing here needs them. +vitest.mock('../../../src/lib/hooks/useAbortJobOnSignal.js', () => ({ + useAbortJobOnSignal: () => ({ [Symbol.dispose]() {} }), +})); + +const { beforeAllCalls, afterAllCalls, joinPath, forceNewCwd } = useTempPath('push-select-root', { + create: true, + remove: true, + cwd: true, + cwdParent: false, +}); + +const { logMessages } = useConsoleSpy(); + +const { ActorsPushCommand } = await import('../../../src/commands/actors/push.js'); +// The command sets the exit code on this mocked copy of `node:process`, not on the global one. +const { default: commandProcess } = await import('node:process'); + +async function write(relativePath: string, content: string) { + await mkdir(dirname(joinPath(relativePath)), { recursive: true }); + await writeFile(joinPath(relativePath), content); +} + +async function writeActor(dir: string, name: string) { + await write( + `${dir}/${LOCAL_CONFIG_PATH}`, + JSON.stringify({ actorSpecification: 1, name, version: '0.0', buildTag: 'latest' }), + ); + await write(`${dir}/src/main.js`, `console.log("${name}")`); +} + +const run = (argsFlags: Parameters>[1] = {}) => + testRunCommand(ActorsPushCommand, argsFlags); + +const stderr = () => logMessages.error.join('\n'); +const stdoutJson = () => { + expect(logMessages.log).toHaveLength(1); + return JSON.parse(logMessages.log[0]); +}; +const pushedActorIds = () => versionUpdate.mock.calls.map(([actorId]) => actorId); + +let originalExitCode: typeof process.exitCode; + +beforeEach(async () => { + originalExitCode = commandProcess.exitCode; + stdinState.isTTY = false; + platform.state.actors = [ACTOR_A, ACTOR_B, ACTOR_C]; + platform.state.buildStatuses = {}; + versionUpdate.mockClear(); + getLoggedClientOrThrow.mockClear(); + multiSelect.mockReset(); + cwdCache.clear(); + await beforeAllCalls(); + + // A repository with Actors in subfolders, shared code beside them, and an ignored copy of an Actor. + await write('package.json', '{"workspaces":["actors/*"]}'); + await write('packages/shared/index.js', 'module.exports = {};'); + await writeActor('actors/b', ACTOR_B.name); + await writeActor('actors/a', ACTOR_A.name); + await writeActor('tools/c', ACTOR_C.name); + await writeActor('node_modules/dep', 'ignored-dep'); +}); + +afterEach(async () => { + commandProcess.exitCode = originalExitCode; + await afterAllCalls(); +}); + +describe('apify push in a repository with several Actors', () => { + describe('choosing what to push', () => { + it('lists the Actors and exits 4 without calling the API when it cannot ask', async () => { + await run(); + + expect(commandProcess.exitCode).toBe(CommandExitCodes.NoFilesToPush); + expect(getLoggedClientOrThrow).not.toHaveBeenCalled(); + expect(versionUpdate).not.toHaveBeenCalled(); + expect(logMessages.log).toEqual([]); + const output = stderr(); + expect(output).toContain('actors/a (actor-a)'); + expect(output).toContain('actors/b (actor-b)'); + expect(output).toContain('tools/c (actor-c)'); + expect(output).not.toContain('node_modules'); + expect(output).toContain('Choose what to push: --all, or --actor-dir for each Actor.'); + expect(output.indexOf('actors/a')).toBeLessThan(output.indexOf('actors/b')); + expect(output.indexOf('actors/b')).toBeLessThan(output.indexOf('tools/c')); + }); + + it('exits 4 with a single Actor below too, instead of guessing', async () => { + await writeActor('single/one', ACTOR_A.name); + forceNewCwd('single'); + + await run(); + + expect(commandProcess.exitCode).toBe(CommandExitCodes.NoFilesToPush); + expect(stderr()).toContain('one (actor-a)'); + expect(getLoggedClientOrThrow).not.toHaveBeenCalled(); + }); + + it('keeps the exit-4 error for a directory with no Actors anywhere below it', async () => { + forceNewCwd('packages'); + await run(); + + expect(commandProcess.exitCode).toBe(CommandExitCodes.NoFilesToPush); + expect(stderr()).toContain('A valid Actor could not be found in the current directory.'); + expect(stderr()).not.toContain('Choose what to push'); + expect(getLoggedClientOrThrow).not.toHaveBeenCalled(); + }); + + it('rejects an --actor-dir without an actor.json before the login check', async () => { + await run({ flags_actorDir: ['actors/a', 'packages/shared'] }); + + expect(commandProcess.exitCode).toBe(CommandExitCodes.NoFilesToPush); + expect(stderr()).toContain('Directory "packages/shared" from --actor-dir has no .actor/actor.json.'); + expect(getLoggedClientOrThrow).not.toHaveBeenCalled(); + expect(versionUpdate).not.toHaveBeenCalled(); + }); + + it('rejects the actorId argument when more than one Actor is selected', async () => { + await run({ args_actorId: ACTOR_A.id, flags_all: true }); + + expect(commandProcess.exitCode).toBe(CommandExitCodes.InvalidInput); + expect(stderr()).toContain('actorId argument applies to a single Actor'); + expect(getLoggedClientOrThrow).not.toHaveBeenCalled(); + }); + + it('pushes to the actorId argument when exactly one Actor is selected', async () => { + await run({ args_actorId: ACTOR_C.id, flags_actorDir: ['actors/a'] }); + + expect(commandProcess.exitCode).toBeFalsy(); + expect(pushedActorIds()).toEqual([ACTOR_C.id]); + }); + }); + + describe('pushing the selection', () => { + it('pushes every Actor in path order with --all', async () => { + await run({ flags_all: true }); + + expect(commandProcess.exitCode).toBeFalsy(); + expect(pushedActorIds()).toEqual([ACTOR_A.id, ACTOR_B.id, ACTOR_C.id]); + const files = versionUpdate.mock.calls.map(([, , modifier]) => + (modifier as { sourceFiles: { name: string }[] }).sourceFiles.map((file) => file.name).sort(), + ); + expect(files).toEqual([ + ['.actor/actor.json', 'src/main.js'], + ['.actor/actor.json', 'src/main.js'], + ['.actor/actor.json', 'src/main.js'], + ]); + expect(logMessages.log.join('\n')).toContain('Apify push result: SUCCEEDED'); + }); + + it('pushes the --actor-dir directories, normalized and without duplicates', async () => { + await run({ flags_actorDir: ['./tools/c/', 'actors/a', 'tools/c'] }); + + expect(commandProcess.exitCode).toBeFalsy(); + expect(pushedActorIds()).toEqual([ACTOR_C.id, ACTOR_A.id]); + }); + + it('passes --version and --build-tag to every version payload', async () => { + await run({ flags_all: true, flags_version: '1.5', flags_buildTag: 'beta' }); + + expect(commandProcess.exitCode).toBeFalsy(); + expect(versionUpdate.mock.calls.map(([, version]) => version)).toEqual(['1.5', '1.5', '1.5']); + for (const [, , modifier] of versionUpdate.mock.calls) { + expect(modifier).toMatchObject({ buildTag: 'beta' }); + } + }); + + it("keeps going after a failed build and exits with that build's code", async () => { + platform.state.buildStatuses[ACTOR_A.id] = 'FAILED'; + + await run({ flags_all: true, flags_json: true }); + + expect(commandProcess.exitCode).toBe(CommandExitCodes.BuildFailed); + expect(pushedActorIds()).toEqual([ACTOR_A.id, ACTOR_B.id, ACTOR_C.id]); + const summary = stdoutJson(); + expect(summary).toMatchObject({ ok: false, operation: 'push', exitCode: CommandExitCodes.BuildFailed }); + expect( + summary.results.map((entry: { dir: string; ok: boolean; exitCode?: number }) => [ + entry.dir, + entry.ok, + entry.exitCode, + ]), + ).toEqual([ + ['actors/a', false, CommandExitCodes.BuildFailed], + ['actors/b', true, 0], + ['tools/c', true, 0], + ]); + expect(summary.results[0].error).toMatchObject({ phase: 'build', message: 'Build failed' }); + }); + + it('keeps going after an Actor that stops before the build, with the first failure as the exit code', async () => { + platform.state.buildStatuses[ACTOR_C.id] = 'TIMED-OUT'; + platform.state.actors = [{ ...ACTOR_A, modifiedAt: new Date(Date.now() + 60 * 60 * 1000) }, ACTOR_B, ACTOR_C]; + + await run({ flags_all: true, flags_json: true }); + + expect(commandProcess.exitCode).toBe(1); + expect(pushedActorIds()).toEqual([ACTOR_B.id, ACTOR_C.id]); + expect(stderr()).toContain('has newer changes than your local copy'); + const summary = stdoutJson(); + expect(summary.exitCode).toBe(1); + expect(summary.results[0]).toEqual({ dir: 'actors/a', ok: false, exitCode: 1 }); + expect(summary.results[2]).toMatchObject({ dir: 'tools/c', ok: false, exitCode: CommandExitCodes.BuildTimedOut }); + }); + }); + + describe('--json', () => { + it('prints one document with an entry per Actor and nothing else on stdout', async () => { + await run({ flags_all: true, flags_json: true }); + + expect(commandProcess.exitCode).toBeFalsy(); + const summary = stdoutJson(); + expect(Object.keys(summary)).toEqual(['ok', 'operation', 'results', 'exitCode']); + expect(summary).toMatchObject({ ok: true, operation: 'push', exitCode: 0 }); + expect(summary.results).toHaveLength(3); + expect(summary.results[0]).toEqual({ + dir: 'actors/a', + ok: true, + operation: 'push', + actor: { id: ACTOR_A.id, url: expect.stringContaining(ACTOR_A.id) }, + build: { + id: `build-${ACTOR_A.id}`, + number: '0.0.1', + status: 'SUCCEEDED', + url: expect.stringContaining('#/builds/0.0.1'), + }, + exitCode: 0, + }); + }); + + it('wraps even a single --actor-dir push in the results document', async () => { + await run({ flags_actorDir: ['actors/b'], flags_json: true }); + + const summary = stdoutJson(); + expect(summary.results).toEqual([expect.objectContaining({ dir: 'actors/b', ok: true })]); + }); + + it('prints nothing on stdout when it cannot ask', async () => { + await run({ flags_json: true }); + + expect(commandProcess.exitCode).toBe(CommandExitCodes.NoFilesToPush); + expect(logMessages.log).toEqual([]); + }); + + it('leaves the single-Actor envelope unchanged in an Actor directory', async () => { + forceNewCwd('actors/a'); + + await run({ flags_json: true }); + + expect(commandProcess.exitCode).toBeFalsy(); + expect(stdoutJson()).toEqual({ + ok: true, + operation: 'push', + actor: { id: ACTOR_A.id, url: expect.stringContaining(ACTOR_A.id) }, + build: { + id: `build-${ACTOR_A.id}`, + number: '0.0.1', + status: 'SUCCEEDED', + url: expect.stringContaining('#/builds/0.0.1'), + }, + exitCode: 0, + }); + }); + }); + + describe('the interactive prompt', () => { + beforeEach(() => { + stdinState.isTTY = true; + }); + + it('offers every Actor with its name and pushes the chosen ones', async () => { + multiSelect.mockResolvedValueOnce(['tools/c']); + + await run(); + + expect(commandProcess.exitCode).toBeFalsy(); + expect(multiSelect).toHaveBeenCalledTimes(1); + expect(multiSelect.mock.calls[0][0]).toMatchObject({ + choices: [ + { name: 'actors/a (actor-a)', value: 'actors/a' }, + { name: 'actors/b (actor-b)', value: 'actors/b' }, + { name: 'tools/c (actor-c)', value: 'tools/c' }, + ], + }); + expect(pushedActorIds()).toEqual([ACTOR_C.id]); + }); + + it('pushes nothing and exits 0 on an empty selection', async () => { + multiSelect.mockResolvedValueOnce([]); + + await run({ flags_json: true }); + + expect(commandProcess.exitCode).toBeFalsy(); + expect(getLoggedClientOrThrow).not.toHaveBeenCalled(); + expect(stdoutJson()).toEqual({ ok: true, operation: 'push', results: [], exitCode: 0 }); + }); + + it('is skipped when a flag already chose', async () => { + await run({ flags_all: true }); + + expect(multiSelect).not.toHaveBeenCalled(); + expect(pushedActorIds()).toHaveLength(3); + }); + }); +}); From e457250e319b07e75940f596e486dd4dc1ec1af5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Fri, 16 Oct 2026 10:01:24 +0200 Subject: [PATCH 4/6] docs: regenerate the reference for the push selection flags --- docs/reference.md | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/docs/reference.md b/docs/reference.md index 425937b51..64c475535 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -857,10 +857,10 @@ DESCRIPTION Use --force to override newer remote versions. USAGE - $ apify actors push [actorId] [--allow-missing-secrets] - [--apply-env-vars-to-build] [-b ] [--dir ] - [-f] [--json] [--open] [--profile ] [-v ] - [-w ] + $ apify actors push [actorId] [--actor-dir | --all] + [--allow-missing-secrets] [--apply-env-vars-to-build] + [-b ] [--dir ] [-f] [--json] [--open] + [--profile ] [-v ] [-w ] ARGUMENTS actorId Name or ID of the Actor to push (e.g. "apify/hello-world" or @@ -868,6 +868,13 @@ ARGUMENTS modify the Actor with the name specified in '.actor/actor.json' file. FLAGS + --actor-dir= 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. From ab27ec9adaaebde58d3812311d3d1a4da6d7eedf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Fri, 16 Oct 2026 10:07:40 +0200 Subject: [PATCH 5/6] fix: reject an --actor-dir outside the pushed directory before the login check --- src/commands/actors/push.ts | 12 +++++++++++- test/e2e/commands/push-select.test.ts | 9 +++++++++ test/local/commands/push-select.test.ts | 19 ++++++++++++++++++- 3 files changed, 38 insertions(+), 2 deletions(-) diff --git a/src/commands/actors/push.ts b/src/commands/actors/push.ts index 41bbe1fbb..f283730d0 100644 --- a/src/commands/actors/push.ts +++ b/src/commands/actors/push.ts @@ -1,5 +1,5 @@ import { existsSync, readFileSync, statSync, unlinkSync } from 'node:fs'; -import { join, resolve } from 'node:path'; +import { isAbsolute, join, resolve } from 'node:path'; import process from 'node:process'; import type { Actor, ActorCollectionCreateOptions, ActorDefaultRunOptions } from 'apify-client'; @@ -376,6 +376,16 @@ export class ActorsPushCommand extends ApifyCommand { 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) { diff --git a/test/e2e/commands/push-select.test.ts b/test/e2e/commands/push-select.test.ts index 8236660d0..a02cfcd46 100644 --- a/test/e2e/commands/push-select.test.ts +++ b/test/e2e/commands/push-select.test.ts @@ -36,6 +36,15 @@ describe('[e2e] apify push in a repository with several Actors', () => { expect(result.stdout).toBe(''); }); + it('exits with code 4 for an --actor-dir outside the root', async () => { + const result = await runCli('apify', ['push', '--actor-dir', '../outside', '--json'], { cwd: root }); + + expect(result.exitCode, `stdout: ${result.stdout}\nstderr: ${result.stderr}`).toBe(4); + expect(result.stderr).toContain('Directory "../outside" from --actor-dir is outside the pushed directory.'); + expect(result.stderr).not.toContain('login'); + expect(result.stdout).toBe(''); + }); + it('exits with code 4 for an --actor-dir that is not an Actor', async () => { const result = await runCli('apify', ['push', '--actor-dir', 'packages/shared'], { cwd: root }); diff --git a/test/local/commands/push-select.test.ts b/test/local/commands/push-select.test.ts index 153c0e0f4..492293278 100644 --- a/test/local/commands/push-select.test.ts +++ b/test/local/commands/push-select.test.ts @@ -1,4 +1,4 @@ -import { mkdir, writeFile } from 'node:fs/promises'; +import { mkdir, rm, writeFile } from 'node:fs/promises'; import { dirname } from 'node:path'; import { testRunCommand } from '../../../src/lib/command-framework/apify-command.js'; @@ -202,6 +202,23 @@ describe('apify push in a repository with several Actors', () => { expect(versionUpdate).not.toHaveBeenCalled(); }); + it('rejects an --actor-dir outside the root before the login check', async () => { + await writeActor('../push-select-outside', ACTOR_B.name); + + try { + await run({ flags_actorDir: ['actors/a', '../push-select-outside'] }); + } finally { + await rm(joinPath('..', 'push-select-outside'), { recursive: true, force: true }); + } + + expect(commandProcess.exitCode).toBe(CommandExitCodes.NoFilesToPush); + expect(stderr()).toContain( + 'Directory "../push-select-outside" from --actor-dir is outside the pushed directory.', + ); + expect(getLoggedClientOrThrow).not.toHaveBeenCalled(); + expect(versionUpdate).not.toHaveBeenCalled(); + }); + it('rejects the actorId argument when more than one Actor is selected', async () => { await run({ args_actorId: ACTOR_A.id, flags_all: true }); From bdbfdd14602a8696a67f63dbe3cd79719c31ebdb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Fri, 16 Oct 2026 10:23:01 +0200 Subject: [PATCH 6/6] fix: print the Actor config path with a slash in the --actor-dir error --- src/commands/actors/push.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/actors/push.ts b/src/commands/actors/push.ts index f283730d0..5a277053e 100644 --- a/src/commands/actors/push.ts +++ b/src/commands/actors/push.ts @@ -389,7 +389,7 @@ export class ActorsPushCommand extends ApifyCommand { const missing = dirs.find((dir) => !existsSync(join(root, dir, LOCAL_CONFIG_PATH))); if (missing !== undefined) { - error({ message: `Directory "${missing || '.'}" from --actor-dir has no ${LOCAL_CONFIG_PATH}.` }); + error({ message: `Directory "${missing || '.'}" from --actor-dir has no .actor/actor.json.` }); process.exitCode = CommandExitCodes.NoFilesToPush; return; }