From d71b15807ec9bfc5b79e440d09a1232cb45aadf3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Fri, 16 Oct 2026 09:03:18 +0200 Subject: [PATCH 01/44] feat: add the dockerContextDir resolver and Docker context file collector --- src/lib/docker-context.ts | 165 ++++++++++++++++++++ test/local/lib/docker-context-files.test.ts | 142 +++++++++++++++++ test/local/lib/docker-context.test.ts | 130 +++++++++++++++ 3 files changed, 437 insertions(+) create mode 100644 src/lib/docker-context.ts create mode 100644 test/local/lib/docker-context-files.test.ts create mode 100644 test/local/lib/docker-context.test.ts diff --git a/src/lib/docker-context.ts b/src/lib/docker-context.ts new file mode 100644 index 000000000..eb7650219 --- /dev/null +++ b/src/lib/docker-context.ts @@ -0,0 +1,165 @@ +import { execFileSync } from 'node:child_process'; +import { existsSync, lstatSync, realpathSync } from 'node:fs'; +import { isAbsolute, join, relative, resolve, sep } from 'node:path'; + +import { ACTOR_SPECIFICATION_FOLDER } from './consts.js'; +import { getActorLocalFilePaths } from './utils.js'; + +/** Tracks pushing Actors with `dockerContextDir` (monorepo Actors) to the Apify platform. */ +export const MONOREPO_PLATFORM_SUPPORT_ISSUE_URL = 'https://github.com/apify/apify-core/issues/28685'; + +export type DockerContextResolution = + | { kind: 'none' } + | { + kind: 'context'; + contextRoot: string; + /** The Git repository's root, else the context. */ + sourceRoot: string; + /** Relative to `sourceRoot`. */ + actorPath: string; + } + | { kind: 'invalid'; message: string }; + +/** + * `dockerContextDir` is relative to `.actor/`, as on the platform. A context at or below the Actor folder is an + * ordinary push, which the platform builds as it is; one above or beside it is a monorepo Actor. + */ +export function resolveDockerContext(actorDir: string, dockerContextDir: unknown): DockerContextResolution { + if (dockerContextDir === undefined || dockerContextDir === null || dockerContextDir === '') return { kind: 'none' }; + if (typeof dockerContextDir !== 'string') { + return { kind: 'invalid', message: `"dockerContextDir" in .actor/actor.json must be a string.` }; + } + + const contextRoot = resolve(actorDir, ACTOR_SPECIFICATION_FOLDER, dockerContextDir); + const contextPathInActor = relative(actorDir, contextRoot); + if (contextPathInActor === '' || !isOutside(contextPathInActor)) return { kind: 'none' }; + + if (!existsSync(contextRoot)) { + return { + kind: 'invalid', + message: `"dockerContextDir" in .actor/actor.json points to ${contextRoot}, which does not exist.`, + }; + } + + // The platform's clone holds nothing above the repository root. + const repoRoot = git(actorDir, ['rev-parse', '--show-toplevel']); + if (repoRoot) { + const sourceRoot = realpathSync(repoRoot); + if (isOutside(relative(sourceRoot, realpathSync(contextRoot)))) { + return { + kind: 'invalid', + message: + `"dockerContextDir" in .actor/actor.json points to ${contextRoot}, outside the Git repository ${repoRoot}. ` + + 'The Apify platform builds from a clone of the repository, so it could not build this Actor.', + }; + } + return { + kind: 'context', + contextRoot, + sourceRoot, + actorPath: toPosix(relative(sourceRoot, realpathSync(actorDir))), + }; + } + + const actorPath = relative(contextRoot, actorDir); + if (isOutside(actorPath)) { + return { + kind: 'invalid', + message: `"dockerContextDir" in .actor/actor.json points to ${contextRoot}, which does not contain the Actor's folder.`, + }; + } + return { kind: 'context', contextRoot, sourceRoot: contextRoot, actorPath: toPosix(actorPath) }; +} + +function isOutside(relativePath: string): boolean { + return isAbsolute(relativePath) || relativePath === '..' || relativePath.startsWith(`..${sep}`); +} + +function toPosix(filePath: string): string { + return filePath.split(sep).join('/'); +} + +export interface GitProvenance { + remoteUrl?: string; + branch?: string; + commit?: string; + dirty?: boolean; +} + +function git(cwd: string, args: string[]): string | undefined { + try { + const output = execFileSync('git', args, { cwd, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }); + return output.trim(); + } catch { + return undefined; + } +} + +/** A remote URL may carry credentials (`https://user:token@host/...`); they never leave this machine. */ +function withoutCredentials(remoteUrl: string): string { + try { + const url = new URL(remoteUrl); + url.username = ''; + url.password = ''; + return url.toString(); + } catch { + // scp-like `git@host:owner/repo.git` has no password to strip. + return remoteUrl; + } +} + +export function readGitProvenance(dir: string): GitProvenance { + const commit = git(dir, ['rev-parse', 'HEAD']); + if (!commit) return {}; + + const provenance: GitProvenance = { commit }; + const branch = git(dir, ['rev-parse', '--abbrev-ref', 'HEAD']); + if (branch && branch !== 'HEAD') provenance.branch = branch; + const remoteUrl = git(dir, ['remote', 'get-url', 'origin']); + if (remoteUrl) provenance.remoteUrl = withoutCredentials(remoteUrl); + const status = git(dir, ['status', '--porcelain', '--', '.']); + if (status !== undefined) provenance.dirty = status.length > 0; + return provenance; +} + +/** Untracked, these are never on the platform and too big to push by accident. */ +const UNTRACKED_IGNORED_SEGMENTS = new Set(['node_modules', 'storage', 'apify_storage', 'crawlee_storage']); + +function gitFileList(root: string, args: string[]): string[] | undefined { + const output = git(root, ['ls-files', '-z', ...args]); + return output?.split('\0').filter(Boolean); +} + +/** What a Git clone of `root` has, as on disk now, plus untracked files Git does not ignore; `.actorignore` + * does not apply, as on the platform. Outside Git, what an ordinary push of `root` sends. */ +export async function getContextFilePaths(root: string): Promise { + const tracked = gitFileList(root, ['--cached']); + const untracked = gitFileList(root, ['--others', '--exclude-standard']); + if (!tracked || !untracked) return getActorLocalFilePaths(root); + + // Skips files deleted locally and submodules (listed as directories). + const isPushable = (filePath: string) => { + try { + const stats = lstatSync(join(root, filePath)); + return stats.isSymbolicLink() || stats.isFile(); + } catch { + return false; + } + }; + return [ + ...tracked.filter(isPushable), + ...untracked.filter( + (filePath) => + isPushable(filePath) && !filePath.split('/').some((segment) => UNTRACKED_IGNORED_SEGMENTS.has(segment)), + ), + ].map((filePath) => filePath.split('/').join(sep)); +} + +export function monorepoUnsupportedMessage(actorName: string): string { + return [ + `Actor ${actorName} sets "dockerContextDir" in .actor/actor.json, so it builds from a Docker context outside its own folder (a monorepo Actor).`, + 'apify push does not support such Actors on the Apify platform yet. Follow the progress at:', + ` ${MONOREPO_PLATFORM_SUPPORT_ISSUE_URL}`, + `Until then, set the Actor's source in Apify Console to the Git repository with the folder (#:).`, + ].join('\n'); +} diff --git a/test/local/lib/docker-context-files.test.ts b/test/local/lib/docker-context-files.test.ts new file mode 100644 index 000000000..35260cf0b --- /dev/null +++ b/test/local/lib/docker-context-files.test.ts @@ -0,0 +1,142 @@ +import { execFileSync } from 'node:child_process'; +import { mkdirSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; + +import { getContextFilePaths } from '../../../src/lib/docker-context.js'; +import { useTempPath } from '../../__setup__/hooks/useTempPath.js'; + +// vi.mock is hoisted before imports, so both the context collector and utils.ts get the mocked version. With +// `gitAvailable` off, every git call fails as it does without git, which sends the collector down its fallback. +const gitState = vi.hoisted(() => ({ gitAvailable: true })); +vi.mock('node:child_process', async (importOriginal) => { + const original = await importOriginal(); + const unavailable = () => { + throw new Error('not a git repository'); + }; + return { + ...original, + execSync: ((...args: Parameters) => + gitState.gitAvailable ? original.execSync(...args) : unavailable()) as typeof original.execSync, + execFileSync: ((...args: Parameters) => + gitState.gitAvailable ? original.execFileSync(...args) : unavailable()) as typeof original.execFileSync, + }; +}); + +const ACTOR_PATH = 'actors/x'; + +describe('getContextFilePaths', () => { + describe('inside a Git repository', () => { + const { tmpPath, joinPath, beforeAllCalls, afterAllCalls } = useTempPath('docker-context-files-test-dir', { + create: true, + remove: true, + cwd: false, + cwdParent: false, + }); + + const write = (relativePath: string, content = 'content') => { + mkdirSync(dirname(joinPath(relativePath)), { recursive: true }); + writeFileSync(joinPath(relativePath), content); + }; + const git = (...args: string[]) => execFileSync('git', args, { cwd: tmpPath, stdio: 'ignore' }); + + beforeAll(async () => { + await beforeAllCalls(); + + write('package.json', '{}'); + write('.gitignore', 'dist\n'); + write('shared/Dockerfile', 'FROM apify/actor-node:20\n'); + write(`${ACTOR_PATH}/.actor/actor.json`, '{}'); + write(`${ACTOR_PATH}/src/index.ts`); + write(`${ACTOR_PATH}/.actorignore`, '!dist/\nsrc/index.ts\n'); + write('packages/utils/src/index.ts'); + write('node_modules/tracked/index.js'); + git('init', '--quiet'); + git('add', '.'); + git('-c', 'user.name=Test', '-c', 'user.email=test@example.com', 'commit', '--quiet', '-m', 'init'); + + // After the commit: untracked, git-ignored, deleted and linked files, as a working copy has them. + write('shared/start.sh'); + write(`${ACTOR_PATH}/dist/main.js`); + write('node_modules/untracked/index.js'); + write('storage/key_value_stores/default/INPUT.json'); + rmSync(joinPath('packages/utils/src/index.ts')); + symlinkSync('../../shared', joinPath(`${ACTOR_PATH}/shared`)); + }); + + afterAll(async () => { + await afterAllCalls(); + }); + + it('lists the tracked files and the untracked files Git does not ignore', async () => { + const paths = await getContextFilePaths(tmpPath); + + expect(paths).toEqual( + expect.arrayContaining([ + 'package.json', + '.gitignore', + join('shared', 'Dockerfile'), + join(ACTOR_PATH, '.actor', 'actor.json'), + join(ACTOR_PATH, 'src', 'index.ts'), + join('shared', 'start.sh'), + ]), + ); + expect(paths).not.toContain(join(ACTOR_PATH, 'dist', 'main.js')); + }); + + it('skips a tracked file that is deleted locally', async () => { + expect(await getContextFilePaths(tmpPath)).not.toContain(join('packages', 'utils', 'src', 'index.ts')); + }); + + it('drops node_modules and storage folders from the untracked files only', async () => { + const paths = await getContextFilePaths(tmpPath); + + expect(paths).toContain(join('node_modules', 'tracked', 'index.js')); + expect(paths).not.toContain(join('node_modules', 'untracked', 'index.js')); + expect(paths).not.toContain(join('storage', 'key_value_stores', 'default', 'INPUT.json')); + }); + + it('does not apply .actorignore, as the platform clone has none of its effects', async () => { + const paths = await getContextFilePaths(tmpPath); + + // Tracked, so in the clone, although .actorignore excludes it. + expect(paths).toContain(join(ACTOR_PATH, 'src', 'index.ts')); + // Git-ignored, so not in the clone, whatever .actorignore force-includes. + expect(paths).not.toContain(join(ACTOR_PATH, 'dist', 'main.js')); + }); + + it('keeps a symlink, as a Git clone does', async () => { + expect(await getContextFilePaths(tmpPath)).toContain(join(ACTOR_PATH, 'shared')); + }); + }); + + describe('without git', () => { + const { tmpPath, joinPath, beforeAllCalls, afterAllCalls } = useTempPath('docker-context-files-no-git-dir', { + create: true, + remove: true, + cwd: false, + cwdParent: false, + }); + + beforeAll(async () => { + await beforeAllCalls(); + mkdirSync(joinPath('src'), { recursive: true }); + writeFileSync(joinPath('main.js'), 'content'); + writeFileSync(joinPath('src/index.js'), 'content'); + writeFileSync(joinPath('debug.log'), 'content'); + writeFileSync(joinPath('.actorignore'), 'debug.log\n'); + gitState.gitAvailable = false; + }); + + afterAll(async () => { + gitState.gitAvailable = true; + await afterAllCalls(); + }); + + it('falls back to the ordinary file list, which applies .actorignore', async () => { + const paths = await getContextFilePaths(tmpPath); + + expect(paths).toEqual(expect.arrayContaining(['main.js', 'src/index.js'])); + expect(paths).not.toContain('debug.log'); + }); + }); +}); diff --git a/test/local/lib/docker-context.test.ts b/test/local/lib/docker-context.test.ts new file mode 100644 index 000000000..4b1477b3e --- /dev/null +++ b/test/local/lib/docker-context.test.ts @@ -0,0 +1,130 @@ +import { execFileSync } from 'node:child_process'; +import { mkdirSync, mkdtempSync, realpathSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; + +import { resolveDockerContext } from '../../../src/lib/docker-context.js'; +import { useTempPath } from '../../__setup__/hooks/useTempPath.js'; + +const ACTOR_PATH = 'actors/x'; + +describe('resolveDockerContext', () => { + describe('inside a Git repository', () => { + const { tmpPath, joinPath, beforeAllCalls, afterAllCalls } = useTempPath('docker-context-test-dir', { + create: true, + remove: true, + cwd: false, + cwdParent: false, + }); + + const actorDir = () => joinPath(ACTOR_PATH); + + beforeAll(async () => { + await beforeAllCalls(); + mkdirSync(joinPath(ACTOR_PATH, '.actor'), { recursive: true }); + mkdirSync(joinPath(ACTOR_PATH, 'src'), { recursive: true }); + mkdirSync(joinPath('shared'), { recursive: true }); + execFileSync('git', ['init', '--quiet'], { cwd: tmpPath, stdio: 'ignore' }); + }); + + afterAll(async () => { + await afterAllCalls(); + }); + + it('treats a missing field, or a context at or below the Actor folder, as an ordinary push', () => { + expect(resolveDockerContext(actorDir(), undefined)).toEqual({ kind: 'none' }); + expect(resolveDockerContext(actorDir(), '')).toEqual({ kind: 'none' }); + expect(resolveDockerContext(actorDir(), '..')).toEqual({ kind: 'none' }); + expect(resolveDockerContext(actorDir(), '.')).toEqual({ kind: 'none' }); + expect(resolveDockerContext(actorDir(), '../src')).toEqual({ kind: 'none' }); + }); + + it('resolves a context above the Actor folder to the repository root and the Actor path in it', () => { + expect(resolveDockerContext(actorDir(), '../../..')).toEqual({ + kind: 'context', + contextRoot: tmpPath, + sourceRoot: realpathSync(tmpPath), + actorPath: ACTOR_PATH, + }); + }); + + it('keeps the repository root as the source for a context elsewhere in the repository', () => { + expect(resolveDockerContext(actorDir(), '../../../shared')).toEqual({ + kind: 'context', + contextRoot: joinPath('shared'), + sourceRoot: realpathSync(tmpPath), + actorPath: ACTOR_PATH, + }); + }); + + it('rejects a context outside the Git repository', () => { + const repoRoot = execFileSync('git', ['rev-parse', '--show-toplevel'], { + cwd: actorDir(), + encoding: 'utf-8', + }).trim(); + + expect(resolveDockerContext(actorDir(), '../../../..')).toEqual({ + kind: 'invalid', + message: + `"dockerContextDir" in .actor/actor.json points to ${resolve(tmpPath, '..')}, outside the Git repository ${repoRoot}. ` + + 'The Apify platform builds from a clone of the repository, so it could not build this Actor.', + }); + }); + + it('rejects a context that does not exist', () => { + expect(resolveDockerContext(actorDir(), '../../../missing')).toEqual({ + kind: 'invalid', + message: `"dockerContextDir" in .actor/actor.json points to ${joinPath('missing')}, which does not exist.`, + }); + }); + + it('rejects a field that is not a string', () => { + expect(resolveDockerContext(actorDir(), 42)).toEqual({ + kind: 'invalid', + message: '"dockerContextDir" in .actor/actor.json must be a string.', + }); + }); + }); + + describe('outside a Git repository', () => { + let root: string; + let actorDir: string; + + beforeAll(() => { + root = mkdtempSync(join(tmpdir(), 'apify-cli-docker-context-')); + actorDir = join(root, ACTOR_PATH); + mkdirSync(join(actorDir, '.actor'), { recursive: true }); + mkdirSync(join(root, 'other'), { recursive: true }); + }); + + afterAll(() => { + rmSync(root, { recursive: true, force: true }); + }); + + it('makes the context the source when it contains the Actor folder', () => { + expect(resolveDockerContext(actorDir, '../../..')).toEqual({ + kind: 'context', + contextRoot: root, + sourceRoot: root, + actorPath: ACTOR_PATH, + }); + }); + + it('rejects a sibling directory, which does not contain the Actor folder', () => { + expect(resolveDockerContext(actorDir, '../../../other')).toEqual({ + kind: 'invalid', + message: `"dockerContextDir" in .actor/actor.json points to ${join(root, 'other')}, which does not contain the Actor's folder.`, + }); + }); + + it('rejects a context that does not exist', () => { + // `resolve`, not `join`: on Windows the resolved context carries the drive (`D:\repo`), as the command resolves it. + const repoDir = resolve('/repo'); + + expect(resolveDockerContext(join(repoDir, 'actors', 'a'), '../../..')).toEqual({ + kind: 'invalid', + message: `"dockerContextDir" in .actor/actor.json points to ${repoDir}, which does not exist.`, + }); + }); + }); +}); From d49e15e21327721067917894dc4002bc3dd45fb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Fri, 16 Oct 2026 09:03:22 +0200 Subject: [PATCH 02/44] feat: refuse monorepo Actors in apify push before any platform write --- src/commands/actors/push.ts | 14 ++ test/local/commands/push-monorepo.test.ts | 167 ++++++++++++++++++++++ 2 files changed, 181 insertions(+) create mode 100644 test/local/commands/push-monorepo.test.ts diff --git a/src/commands/actors/push.ts b/src/commands/actors/push.ts index 03c25e4ab..2489eacc4 100644 --- a/src/commands/actors/push.ts +++ b/src/commands/actors/push.ts @@ -19,6 +19,7 @@ import { Args } from '../../lib/command-framework/args.js'; import { Flags } from '../../lib/command-framework/flags.js'; 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 { useAbortJobOnSignal } from '../../lib/hooks/useAbortJobOnSignal.js'; import { useActorConfig } from '../../lib/hooks/useActorConfig.js'; @@ -288,6 +289,19 @@ export class ActorsPushCommand extends ApifyCommand { const { config: actorConfig } = actorConfigResult.unwrap(); + // A monorepo Actor builds from a Docker context above its folder, which a push of that folder cannot carry. + const dockerContext = resolveDockerContext(cwd, actorConfig?.dockerContextDir); + if (dockerContext.kind === 'invalid') { + error({ message: dockerContext.message }); + process.exitCode = CommandExitCodes.InvalidActorJson; + return; + } + if (dockerContext.kind === 'context') { + error({ message: monorepoUnsupportedMessage(actorConfig!.name as string) }); + process.exitCode = CommandExitCodes.NotImplemented; + return; + } + const userInfo = await getCurrentUserInfo(); const isOrganizationLoggedIn = !!userInfo.organizationOwnerUserId; const redirectUrlPart = isOrganizationLoggedIn ? `/organization/${userInfo.id}` : ''; diff --git a/test/local/commands/push-monorepo.test.ts b/test/local/commands/push-monorepo.test.ts new file mode 100644 index 000000000..6d3eab22d --- /dev/null +++ b/test/local/commands/push-monorepo.test.ts @@ -0,0 +1,167 @@ +import { execFileSync } from 'node:child_process'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { dirname } from 'node:path'; + +import { ACTOR_SOURCE_TYPES } from '@apify/consts'; + +import { testRunCommand } from '../../../src/lib/command-framework/apify-command.js'; +import { CommandExitCodes } from '../../../src/lib/consts.js'; +import { MONOREPO_PLATFORM_SUPPORT_ISSUE_URL } from '../../../src/lib/docker-context.js'; +import { cwdCache } from '../../../src/lib/hooks/useActorConfig.js'; +import { useConsoleSpy } from '../../__setup__/hooks/useConsoleSpy.js'; +import { useTempPath } from '../../__setup__/hooks/useTempPath.js'; + +const actName = 'push-monorepo-actor'; +const ACTOR_ID = 'mOnO1234efGh'; +const ACTOR_PATH = 'actors/typescript-actor'; + +const actor = { + id: ACTOR_ID, + name: actName, + // Older than any local file, so the "modified on the platform" check never trips. + modifiedAt: new Date(0), + taggedBuilds: { latest: { buildId: 'build1' } }, +}; +const build = { id: 'build1', actId: ACTOR_ID, buildNumber: '0.0.1', status: 'SUCCEEDED' }; +const currentVersion = { versionNumber: '0.0', sourceType: ACTOR_SOURCE_TYPES.SOURCE_FILES }; + +const actorGet = vitest.fn(async () => actor); +const versionUpdate = vitest.fn(async (_modifier: unknown) => ({})); +const versionCreate = vitest.fn(async (created: unknown) => created); +const keyValueStoreGetOrCreate = vitest.fn(async () => ({ id: 'store1' })); + +// Stands in for the platform: the Actor and its version exist, and every write is recorded. +vitest.mock('../../../src/lib/utils.js', async (importOriginal) => ({ + ...(await importOriginal()), + getCurrentUserInfo: vitest.fn(async () => ({ id: 'userId', username: 'user' })), + outputJobLog: vitest.fn(async () => {}), + getLoggedClientOrThrow: vitest.fn(async () => ({ + baseUrl: 'https://api.apify.com/v2', + token: 'my-token', + actor: () => ({ + get: actorGet, + version: () => ({ get: async () => currentVersion, update: versionUpdate }), + versions: () => ({ create: versionCreate }), + build: async () => build, + }), + keyValueStores: () => ({ getOrCreate: keyValueStoreGetOrCreate }), + build: () => ({ get: async () => build }), + })), +})); + +// 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, tmpPath, forceNewCwd } = useTempPath(actName, { + 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 writeActorConfig(fields: Record) { + await write( + `${ACTOR_PATH}/.actor/actor.json`, + JSON.stringify({ actorSpecification: 1, name: actName, version: '0.0', buildTag: 'latest', ...fields }), + ); +} + +function git(...args: string[]) { + execFileSync('git', args, { cwd: tmpPath, stdio: 'ignore' }); +} + +let originalExitCode: typeof process.exitCode; + +beforeEach(async () => { + originalExitCode = commandProcess.exitCode; + actorGet.mockClear(); + versionUpdate.mockClear(); + versionCreate.mockClear(); + keyValueStoreGetOrCreate.mockClear(); + cwdCache.clear(); + await beforeAllCalls(); + + // The layout of apify/actor-monorepo-example, as its own Git repository. + await write('package.json', '{"workspaces":["actors/**","packages/**"]}'); + await write('.gitignore', 'node_modules\n'); + await write('shared/TypeScript_Dockerfile', 'FROM apify/actor-node:20\n'); + await write('packages/typescript-utils/src/index.ts', 'export const x = 1;\n'); + await write('node_modules/ignored/index.js', ''); + await writeActorConfig({ dockerContextDir: '../../..', dockerfile: '../../../shared/TypeScript_Dockerfile' }); + await write(`${ACTOR_PATH}/src/index.ts`, 'console.log(1);\n'); + git('init', '--quiet', '--initial-branch=main'); + git('add', '.'); + git('-c', 'user.name=Test', '-c', 'user.email=test@example.com', 'commit', '--quiet', '-m', 'init'); + + forceNewCwd(ACTOR_PATH); +}); + +afterEach(async () => { + commandProcess.exitCode = originalExitCode; + await afterAllCalls(); +}); + +describe('apify push of a monorepo Actor', () => { + it('stops before touching the platform, pointing at the tracking issue', async () => { + await testRunCommand(ActorsPushCommand, {}); + + expect(commandProcess.exitCode).toBe(CommandExitCodes.NotImplemented); + const stderr = logMessages.error.join('\n'); + expect(stderr).toContain( + `Actor ${actName} sets "dockerContextDir" in .actor/actor.json, so it builds from a Docker context outside its own folder (a monorepo Actor).`, + ); + expect(stderr).toContain('apify push does not support such Actors on the Apify platform yet'); + expect(stderr).toContain(MONOREPO_PLATFORM_SUPPORT_ISSUE_URL); + expect(stderr).toContain("set the Actor's source in Apify Console to the Git repository with the folder"); + expect(actorGet).not.toHaveBeenCalled(); + expect(versionCreate).not.toHaveBeenCalled(); + expect(versionUpdate).not.toHaveBeenCalled(); + expect(keyValueStoreGetOrCreate).not.toHaveBeenCalled(); + }); + + it('keeps stdout clean with --json', async () => { + await testRunCommand(ActorsPushCommand, { flags_json: true }); + + expect(commandProcess.exitCode).toBe(CommandExitCodes.NotImplemented); + expect(logMessages.log).toEqual([]); + expect(logMessages.error.join('\n')).toContain(MONOREPO_PLATFORM_SUPPORT_ISSUE_URL); + expect(versionUpdate).not.toHaveBeenCalled(); + }); + + it('rejects a dockerContextDir outside the Git repository, which the platform could not build', async () => { + await writeActorConfig({ dockerContextDir: '../../../..' }); + + await testRunCommand(ActorsPushCommand, {}); + + expect(commandProcess.exitCode).toBe(CommandExitCodes.InvalidActorJson); + expect(logMessages.error.join('\n')).toContain('outside the Git repository'); + expect(actorGet).not.toHaveBeenCalled(); + expect(versionUpdate).not.toHaveBeenCalled(); + }); + + it('pushes the Actor folder as usual when the Docker context lies inside it', async () => { + await writeActorConfig({ dockerContextDir: '../src' }); + + await testRunCommand(ActorsPushCommand, {}); + + expect(commandProcess.exitCode).toBeFalsy(); + expect(versionUpdate).toHaveBeenCalledTimes(1); + const modifier = versionUpdate.mock.calls[0][0] as { sourceType: string; sourceFiles: { name: string }[] }; + expect(modifier.sourceType).toBe(ACTOR_SOURCE_TYPES.SOURCE_FILES); + expect(modifier.sourceFiles.map((file) => file.name).sort()).toEqual(['.actor/actor.json', 'src/index.ts']); + expect(logMessages.log.join('\n')).toContain('Apify push result: SUCCEEDED'); + }); +}); 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 03/44] 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 04/44] 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 05/44] 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 06/44] 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 07/44] 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 08/44] 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; } From 8312f663c7c9a5833ed991072a9a229b1bdfca29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Mon, 26 Oct 2026 12:27:04 +0100 Subject: [PATCH 09/44] fix: resolve the Actor directory's real path and require a directory as the Docker context --- src/lib/docker-context.ts | 40 +++++++--- test/local/lib/docker-context.test.ts | 103 ++++++++++++++++++-------- 2 files changed, 102 insertions(+), 41 deletions(-) diff --git a/src/lib/docker-context.ts b/src/lib/docker-context.ts index eb7650219..2dcf0e8c6 100644 --- a/src/lib/docker-context.ts +++ b/src/lib/docker-context.ts @@ -1,5 +1,5 @@ import { execFileSync } from 'node:child_process'; -import { existsSync, lstatSync, realpathSync } from 'node:fs'; +import { lstatSync, realpathSync, statSync } from 'node:fs'; import { isAbsolute, join, relative, resolve, sep } from 'node:path'; import { ACTOR_SPECIFICATION_FOLDER } from './consts.js'; @@ -30,45 +30,61 @@ export function resolveDockerContext(actorDir: string, dockerContextDir: unknown return { kind: 'invalid', message: `"dockerContextDir" in .actor/actor.json must be a string.` }; } - const contextRoot = resolve(actorDir, ACTOR_SPECIFICATION_FOLDER, dockerContextDir); - const contextPathInActor = relative(actorDir, contextRoot); + // The platform resolves the context from the Actor folder's real path, so a symlinked `--dir` must too. + let realActorDir: string; + try { + realActorDir = realpathSync(actorDir); + } catch { + return { kind: 'invalid', message: `The Actor folder ${actorDir} does not exist.` }; + } + + const contextRoot = resolve(realActorDir, ACTOR_SPECIFICATION_FOLDER, dockerContextDir); + const contextPathInActor = relative(realActorDir, contextRoot); if (contextPathInActor === '' || !isOutside(contextPathInActor)) return { kind: 'none' }; - if (!existsSync(contextRoot)) { + const contextStats = statSync(contextRoot, { throwIfNoEntry: false }); + if (!contextStats) { return { kind: 'invalid', message: `"dockerContextDir" in .actor/actor.json points to ${contextRoot}, which does not exist.`, }; } + if (!contextStats.isDirectory()) { + return { + kind: 'invalid', + message: `"dockerContextDir" in .actor/actor.json points to ${contextRoot}, which is not a directory.`, + }; + } + const realContextRoot = realpathSync(contextRoot); // The platform's clone holds nothing above the repository root. - const repoRoot = git(actorDir, ['rev-parse', '--show-toplevel']); + const repoRoot = git(realActorDir, ['rev-parse', '--show-toplevel']); if (repoRoot) { const sourceRoot = realpathSync(repoRoot); - if (isOutside(relative(sourceRoot, realpathSync(contextRoot)))) { + if (isOutside(relative(sourceRoot, realContextRoot))) { return { kind: 'invalid', message: - `"dockerContextDir" in .actor/actor.json points to ${contextRoot}, outside the Git repository ${repoRoot}. ` + + `"dockerContextDir" in .actor/actor.json points to ${realContextRoot}, outside the Git repository ${sourceRoot}. ` + 'The Apify platform builds from a clone of the repository, so it could not build this Actor.', }; } return { kind: 'context', - contextRoot, + contextRoot: realContextRoot, sourceRoot, - actorPath: toPosix(relative(sourceRoot, realpathSync(actorDir))), + actorPath: toPosix(relative(sourceRoot, realActorDir)), }; } - const actorPath = relative(contextRoot, actorDir); + const actorPath = relative(realContextRoot, realActorDir); if (isOutside(actorPath)) { return { kind: 'invalid', - message: `"dockerContextDir" in .actor/actor.json points to ${contextRoot}, which does not contain the Actor's folder.`, + message: `"dockerContextDir" in .actor/actor.json points to ${realContextRoot}, which does not contain the Actor's folder.`, }; } - return { kind: 'context', contextRoot, sourceRoot: contextRoot, actorPath: toPosix(actorPath) }; + return { kind: 'context', contextRoot: realContextRoot, sourceRoot: realContextRoot, actorPath: toPosix(actorPath) }; } function isOutside(relativePath: string): boolean { diff --git a/test/local/lib/docker-context.test.ts b/test/local/lib/docker-context.test.ts index 4b1477b3e..180487b84 100644 --- a/test/local/lib/docker-context.test.ts +++ b/test/local/lib/docker-context.test.ts @@ -1,11 +1,27 @@ import { execFileSync } from 'node:child_process'; -import { mkdirSync, mkdtempSync, realpathSync, rmSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join, resolve } from 'node:path'; +import { mkdirSync, realpathSync, symlinkSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; import { resolveDockerContext } from '../../../src/lib/docker-context.js'; import { useTempPath } from '../../__setup__/hooks/useTempPath.js'; +// vi.mock is hoisted before imports, so the resolver gets the mocked version. With `gitAvailable` off, every git +// call fails as it does without git, which sends the resolver down its non-Git branch. +const gitState = vi.hoisted(() => ({ gitAvailable: true })); +vi.mock('node:child_process', async (importOriginal) => { + const original = await importOriginal(); + const unavailable = () => { + throw new Error('not a git repository'); + }; + return { + ...original, + execSync: ((...args: Parameters) => + gitState.gitAvailable ? original.execSync(...args) : unavailable()) as typeof original.execSync, + execFileSync: ((...args: Parameters) => + gitState.gitAvailable ? original.execFileSync(...args) : unavailable()) as typeof original.execFileSync, + }; +}); + const ACTOR_PATH = 'actors/x'; describe('resolveDockerContext', () => { @@ -17,6 +33,8 @@ describe('resolveDockerContext', () => { cwdParent: false, }); + // The resolver works from real paths, so the expectations do too. + let root: string; const actorDir = () => joinPath(ACTOR_PATH); beforeAll(async () => { @@ -24,7 +42,10 @@ describe('resolveDockerContext', () => { mkdirSync(joinPath(ACTOR_PATH, '.actor'), { recursive: true }); mkdirSync(joinPath(ACTOR_PATH, 'src'), { recursive: true }); mkdirSync(joinPath('shared'), { recursive: true }); + writeFileSync(joinPath('Dockerfile'), 'FROM apify/actor-node:20\n'); + symlinkSync(ACTOR_PATH, joinPath('linked-actor')); execFileSync('git', ['init', '--quiet'], { cwd: tmpPath, stdio: 'ignore' }); + root = realpathSync(tmpPath); }); afterAll(async () => { @@ -42,8 +63,8 @@ describe('resolveDockerContext', () => { it('resolves a context above the Actor folder to the repository root and the Actor path in it', () => { expect(resolveDockerContext(actorDir(), '../../..')).toEqual({ kind: 'context', - contextRoot: tmpPath, - sourceRoot: realpathSync(tmpPath), + contextRoot: root, + sourceRoot: root, actorPath: ACTOR_PATH, }); }); @@ -51,22 +72,26 @@ describe('resolveDockerContext', () => { it('keeps the repository root as the source for a context elsewhere in the repository', () => { expect(resolveDockerContext(actorDir(), '../../../shared')).toEqual({ kind: 'context', - contextRoot: joinPath('shared'), - sourceRoot: realpathSync(tmpPath), + contextRoot: join(root, 'shared'), + sourceRoot: root, actorPath: ACTOR_PATH, }); }); - it('rejects a context outside the Git repository', () => { - const repoRoot = execFileSync('git', ['rev-parse', '--show-toplevel'], { - cwd: actorDir(), - encoding: 'utf-8', - }).trim(); + it('resolves a symlinked Actor folder from its real path, as the platform does', () => { + expect(resolveDockerContext(joinPath('linked-actor'), '../../..')).toEqual({ + kind: 'context', + contextRoot: root, + sourceRoot: root, + actorPath: ACTOR_PATH, + }); + }); + it('rejects a context outside the Git repository', () => { expect(resolveDockerContext(actorDir(), '../../../..')).toEqual({ kind: 'invalid', message: - `"dockerContextDir" in .actor/actor.json points to ${resolve(tmpPath, '..')}, outside the Git repository ${repoRoot}. ` + + `"dockerContextDir" in .actor/actor.json points to ${dirname(root)}, outside the Git repository ${root}. ` + 'The Apify platform builds from a clone of the repository, so it could not build this Actor.', }); }); @@ -74,7 +99,21 @@ describe('resolveDockerContext', () => { it('rejects a context that does not exist', () => { expect(resolveDockerContext(actorDir(), '../../../missing')).toEqual({ kind: 'invalid', - message: `"dockerContextDir" in .actor/actor.json points to ${joinPath('missing')}, which does not exist.`, + message: `"dockerContextDir" in .actor/actor.json points to ${join(root, 'missing')}, which does not exist.`, + }); + }); + + it('rejects a context that is not a directory', () => { + expect(resolveDockerContext(actorDir(), '../../../Dockerfile')).toEqual({ + kind: 'invalid', + message: `"dockerContextDir" in .actor/actor.json points to ${join(root, 'Dockerfile')}, which is not a directory.`, + }); + }); + + it('rejects an Actor folder that does not exist', () => { + expect(resolveDockerContext(joinPath('missing-actor'), '../../..')).toEqual({ + kind: 'invalid', + message: `The Actor folder ${joinPath('missing-actor')} does not exist.`, }); }); @@ -87,22 +126,31 @@ describe('resolveDockerContext', () => { }); describe('outside a Git repository', () => { + const { tmpPath, joinPath, beforeAllCalls, afterAllCalls } = useTempPath('docker-context-no-git-dir', { + create: true, + remove: true, + cwd: false, + cwdParent: false, + }); + let root: string; - let actorDir: string; + const actorDir = () => joinPath(ACTOR_PATH); - beforeAll(() => { - root = mkdtempSync(join(tmpdir(), 'apify-cli-docker-context-')); - actorDir = join(root, ACTOR_PATH); - mkdirSync(join(actorDir, '.actor'), { recursive: true }); - mkdirSync(join(root, 'other'), { recursive: true }); + beforeAll(async () => { + await beforeAllCalls(); + mkdirSync(joinPath(ACTOR_PATH, '.actor'), { recursive: true }); + mkdirSync(joinPath('other'), { recursive: true }); + root = realpathSync(tmpPath); + gitState.gitAvailable = false; }); - afterAll(() => { - rmSync(root, { recursive: true, force: true }); + afterAll(async () => { + gitState.gitAvailable = true; + await afterAllCalls(); }); it('makes the context the source when it contains the Actor folder', () => { - expect(resolveDockerContext(actorDir, '../../..')).toEqual({ + expect(resolveDockerContext(actorDir(), '../../..')).toEqual({ kind: 'context', contextRoot: root, sourceRoot: root, @@ -111,19 +159,16 @@ describe('resolveDockerContext', () => { }); it('rejects a sibling directory, which does not contain the Actor folder', () => { - expect(resolveDockerContext(actorDir, '../../../other')).toEqual({ + expect(resolveDockerContext(actorDir(), '../../../other')).toEqual({ kind: 'invalid', message: `"dockerContextDir" in .actor/actor.json points to ${join(root, 'other')}, which does not contain the Actor's folder.`, }); }); it('rejects a context that does not exist', () => { - // `resolve`, not `join`: on Windows the resolved context carries the drive (`D:\repo`), as the command resolves it. - const repoDir = resolve('/repo'); - - expect(resolveDockerContext(join(repoDir, 'actors', 'a'), '../../..')).toEqual({ + expect(resolveDockerContext(actorDir(), '../../../missing')).toEqual({ kind: 'invalid', - message: `"dockerContextDir" in .actor/actor.json points to ${repoDir}, which does not exist.`, + message: `"dockerContextDir" in .actor/actor.json points to ${join(root, 'missing')}, which does not exist.`, }); }); }); From feb2cec125102254941fcdb2ac0d4abf87f3bf1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Mon, 26 Oct 2026 12:27:18 +0100 Subject: [PATCH 10/44] fix: return forward-slash paths from the Docker context file collector --- src/lib/docker-context.ts | 12 +++++-- test/local/lib/docker-context-files.test.ts | 35 +++++++++------------ 2 files changed, 23 insertions(+), 24 deletions(-) diff --git a/src/lib/docker-context.ts b/src/lib/docker-context.ts index 2dcf0e8c6..9a7176897 100644 --- a/src/lib/docker-context.ts +++ b/src/lib/docker-context.ts @@ -1,3 +1,6 @@ +// The Docker context of an Actor: where `dockerContextDir` in `.actor/actor.json` points and what a push of it +// would carry. `readGitProvenance` and `getContextFilePaths` have no caller in `apify push` yet; they serve the +// stage 2 upload of the context and the `actor-runtime` branch. import { execFileSync } from 'node:child_process'; import { lstatSync, realpathSync, statSync } from 'node:fs'; import { isAbsolute, join, relative, resolve, sep } from 'node:path'; @@ -146,8 +149,11 @@ function gitFileList(root: string, args: string[]): string[] | undefined { return output?.split('\0').filter(Boolean); } -/** What a Git clone of `root` has, as on disk now, plus untracked files Git does not ignore; `.actorignore` - * does not apply, as on the platform. Outside Git, what an ordinary push of `root` sends. */ +/** + * What a Git clone of `root` has, as on disk now, plus untracked files Git does not ignore; `.actorignore` does not + * apply, as on the platform. Outside Git, what an ordinary push of `root` sends. The paths are relative to `root` + * with forward slashes on every platform, as `getActorLocalFilePaths` returns them. + */ export async function getContextFilePaths(root: string): Promise { const tracked = gitFileList(root, ['--cached']); const untracked = gitFileList(root, ['--others', '--exclude-standard']); @@ -168,7 +174,7 @@ export async function getContextFilePaths(root: string): Promise { (filePath) => isPushable(filePath) && !filePath.split('/').some((segment) => UNTRACKED_IGNORED_SEGMENTS.has(segment)), ), - ].map((filePath) => filePath.split('/').join(sep)); + ]; } export function monorepoUnsupportedMessage(actorName: string): string { diff --git a/test/local/lib/docker-context-files.test.ts b/test/local/lib/docker-context-files.test.ts index 35260cf0b..0632678e2 100644 --- a/test/local/lib/docker-context-files.test.ts +++ b/test/local/lib/docker-context-files.test.ts @@ -1,6 +1,6 @@ import { execFileSync } from 'node:child_process'; import { mkdirSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; -import { dirname, join } from 'node:path'; +import { dirname } from 'node:path'; import { getContextFilePaths } from '../../../src/lib/docker-context.js'; import { useTempPath } from '../../__setup__/hooks/useTempPath.js'; @@ -67,45 +67,38 @@ describe('getContextFilePaths', () => { await afterAllCalls(); }); - it('lists the tracked files and the untracked files Git does not ignore', async () => { + it('lists the tracked files and the untracked files Git does not ignore, with forward slashes', async () => { const paths = await getContextFilePaths(tmpPath); expect(paths).toEqual( expect.arrayContaining([ 'package.json', '.gitignore', - join('shared', 'Dockerfile'), - join(ACTOR_PATH, '.actor', 'actor.json'), - join(ACTOR_PATH, 'src', 'index.ts'), - join('shared', 'start.sh'), + 'shared/Dockerfile', + `${ACTOR_PATH}/.actor/actor.json`, + // Tracked, so in the clone, although .actorignore excludes it. + `${ACTOR_PATH}/src/index.ts`, + 'shared/start.sh', ]), ); - expect(paths).not.toContain(join(ACTOR_PATH, 'dist', 'main.js')); + // Git-ignored, so not in the clone, whatever .actorignore force-includes. + expect(paths).not.toContain(`${ACTOR_PATH}/dist/main.js`); }); it('skips a tracked file that is deleted locally', async () => { - expect(await getContextFilePaths(tmpPath)).not.toContain(join('packages', 'utils', 'src', 'index.ts')); + expect(await getContextFilePaths(tmpPath)).not.toContain('packages/utils/src/index.ts'); }); it('drops node_modules and storage folders from the untracked files only', async () => { const paths = await getContextFilePaths(tmpPath); - expect(paths).toContain(join('node_modules', 'tracked', 'index.js')); - expect(paths).not.toContain(join('node_modules', 'untracked', 'index.js')); - expect(paths).not.toContain(join('storage', 'key_value_stores', 'default', 'INPUT.json')); - }); - - it('does not apply .actorignore, as the platform clone has none of its effects', async () => { - const paths = await getContextFilePaths(tmpPath); - - // Tracked, so in the clone, although .actorignore excludes it. - expect(paths).toContain(join(ACTOR_PATH, 'src', 'index.ts')); - // Git-ignored, so not in the clone, whatever .actorignore force-includes. - expect(paths).not.toContain(join(ACTOR_PATH, 'dist', 'main.js')); + expect(paths).toContain('node_modules/tracked/index.js'); + expect(paths).not.toContain('node_modules/untracked/index.js'); + expect(paths).not.toContain('storage/key_value_stores/default/INPUT.json'); }); it('keeps a symlink, as a Git clone does', async () => { - expect(await getContextFilePaths(tmpPath)).toContain(join(ACTOR_PATH, 'shared')); + expect(await getContextFilePaths(tmpPath)).toContain(`${ACTOR_PATH}/shared`); }); }); From b2ee9514a0846b97e1d5cd1b046922db15693c93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Mon, 26 Oct 2026 12:27:25 +0100 Subject: [PATCH 11/44] fix: name the Actor folder in the monorepo refusal when actor.json has no name --- src/commands/actors/push.ts | 5 +++-- test/local/commands/push-monorepo.test.ts | 10 ++++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/commands/actors/push.ts b/src/commands/actors/push.ts index 2489eacc4..c1c0e848d 100644 --- a/src/commands/actors/push.ts +++ b/src/commands/actors/push.ts @@ -1,5 +1,5 @@ import { readFileSync, statSync, unlinkSync } from 'node:fs'; -import { join, resolve } from 'node:path'; +import { basename, join, resolve } from 'node:path'; import process from 'node:process'; import type { Actor, ActorCollectionCreateOptions, ActorDefaultRunOptions } from 'apify-client'; @@ -297,7 +297,8 @@ export class ActorsPushCommand extends ApifyCommand { return; } if (dockerContext.kind === 'context') { - error({ message: monorepoUnsupportedMessage(actorConfig!.name as string) }); + // Pushing by ID, actor.json may have no name; the folder then names the Actor. + error({ message: monorepoUnsupportedMessage((actorConfig?.name as string | undefined) ?? basename(cwd)) }); process.exitCode = CommandExitCodes.NotImplemented; return; } diff --git a/test/local/commands/push-monorepo.test.ts b/test/local/commands/push-monorepo.test.ts index 6d3eab22d..c405eaa19 100644 --- a/test/local/commands/push-monorepo.test.ts +++ b/test/local/commands/push-monorepo.test.ts @@ -141,6 +141,16 @@ describe('apify push of a monorepo Actor', () => { expect(versionUpdate).not.toHaveBeenCalled(); }); + it('names the Actor folder when actor.json has no name', async () => { + await writeActorConfig({ name: undefined, dockerContextDir: '../../..' }); + + await testRunCommand(ActorsPushCommand, { args_actorId: ACTOR_ID }); + + expect(commandProcess.exitCode).toBe(CommandExitCodes.NotImplemented); + expect(logMessages.error.join('\n')).toContain('Actor typescript-actor sets "dockerContextDir"'); + expect(actorGet).not.toHaveBeenCalled(); + }); + it('rejects a dockerContextDir outside the Git repository, which the platform could not build', async () => { await writeActorConfig({ dockerContextDir: '../../../..' }); From d1fec8dcc7632065ec973b59069b9535b8baddac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Mon, 26 Oct 2026 12:27:26 +0100 Subject: [PATCH 12/44] test: cover readGitProvenance and tidy the monorepo push test --- test/local/commands/push-monorepo.test.ts | 18 +++-- .../lib/docker-context-provenance.test.ts | 75 +++++++++++++++++++ 2 files changed, 88 insertions(+), 5 deletions(-) create mode 100644 test/local/lib/docker-context-provenance.test.ts diff --git a/test/local/commands/push-monorepo.test.ts b/test/local/commands/push-monorepo.test.ts index c405eaa19..6c98db6cf 100644 --- a/test/local/commands/push-monorepo.test.ts +++ b/test/local/commands/push-monorepo.test.ts @@ -83,10 +83,13 @@ function git(...args: string[]) { execFileSync('git', args, { cwd: tmpPath, stdio: 'ignore' }); } +// The command sets its exit code on the mocked copy, but the framework records a thrown error on the real `process`. let originalExitCode: typeof process.exitCode; +let originalRealExitCode: typeof process.exitCode; beforeEach(async () => { originalExitCode = commandProcess.exitCode; + originalRealExitCode = process.exitCode; actorGet.mockClear(); versionUpdate.mockClear(); versionCreate.mockClear(); @@ -99,10 +102,9 @@ beforeEach(async () => { await write('.gitignore', 'node_modules\n'); await write('shared/TypeScript_Dockerfile', 'FROM apify/actor-node:20\n'); await write('packages/typescript-utils/src/index.ts', 'export const x = 1;\n'); - await write('node_modules/ignored/index.js', ''); await writeActorConfig({ dockerContextDir: '../../..', dockerfile: '../../../shared/TypeScript_Dockerfile' }); await write(`${ACTOR_PATH}/src/index.ts`, 'console.log(1);\n'); - git('init', '--quiet', '--initial-branch=main'); + git('init', '--quiet'); git('add', '.'); git('-c', 'user.name=Test', '-c', 'user.email=test@example.com', 'commit', '--quiet', '-m', 'init'); @@ -111,6 +113,7 @@ beforeEach(async () => { afterEach(async () => { commandProcess.exitCode = originalExitCode; + process.exitCode = originalRealExitCode; await afterAllCalls(); }); @@ -151,13 +154,17 @@ describe('apify push of a monorepo Actor', () => { expect(actorGet).not.toHaveBeenCalled(); }); - it('rejects a dockerContextDir outside the Git repository, which the platform could not build', async () => { - await writeActorConfig({ dockerContextDir: '../../../..' }); + it.each([ + ['outside the Git repository, which the platform could not build', '../../../..', 'outside the Git repository'], + ['that does not exist', '../../../missing', 'which does not exist'], + ['that is not a string', 42, 'must be a string'], + ])('rejects a dockerContextDir %s as an invalid actor.json', async (_, dockerContextDir, message) => { + await writeActorConfig({ dockerContextDir }); await testRunCommand(ActorsPushCommand, {}); expect(commandProcess.exitCode).toBe(CommandExitCodes.InvalidActorJson); - expect(logMessages.error.join('\n')).toContain('outside the Git repository'); + expect(logMessages.error.join('\n')).toContain(message); expect(actorGet).not.toHaveBeenCalled(); expect(versionUpdate).not.toHaveBeenCalled(); }); @@ -168,6 +175,7 @@ describe('apify push of a monorepo Actor', () => { await testRunCommand(ActorsPushCommand, {}); expect(commandProcess.exitCode).toBeFalsy(); + expect(process.exitCode).toBeFalsy(); expect(versionUpdate).toHaveBeenCalledTimes(1); const modifier = versionUpdate.mock.calls[0][0] as { sourceType: string; sourceFiles: { name: string }[] }; expect(modifier.sourceType).toBe(ACTOR_SOURCE_TYPES.SOURCE_FILES); diff --git a/test/local/lib/docker-context-provenance.test.ts b/test/local/lib/docker-context-provenance.test.ts new file mode 100644 index 000000000..359f9d8f1 --- /dev/null +++ b/test/local/lib/docker-context-provenance.test.ts @@ -0,0 +1,75 @@ +import { execFileSync } from 'node:child_process'; +import { writeFileSync } from 'node:fs'; + +import { readGitProvenance } from '../../../src/lib/docker-context.js'; +import { useTempPath } from '../../__setup__/hooks/useTempPath.js'; + +// vi.mock is hoisted before imports, so the reader gets the mocked version. With `gitAvailable` off, every git +// call fails as it does without git. +const gitState = vi.hoisted(() => ({ gitAvailable: true })); +vi.mock('node:child_process', async (importOriginal) => { + const original = await importOriginal(); + const unavailable = () => { + throw new Error('not a git repository'); + }; + return { + ...original, + execSync: ((...args: Parameters) => + gitState.gitAvailable ? original.execSync(...args) : unavailable()) as typeof original.execSync, + execFileSync: ((...args: Parameters) => + gitState.gitAvailable ? original.execFileSync(...args) : unavailable()) as typeof original.execFileSync, + }; +}); + +describe('readGitProvenance', () => { + const { tmpPath, joinPath, beforeAllCalls, afterAllCalls } = useTempPath('docker-context-provenance-test-dir', { + create: true, + remove: true, + cwd: false, + cwdParent: false, + }); + + const git = (...args: string[]) => + execFileSync('git', args, { cwd: tmpPath, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); + + beforeAll(async () => { + await beforeAllCalls(); + writeFileSync(joinPath('README.md'), 'content'); + git('init', '--quiet', '--initial-branch=main'); + git('add', '.'); + git('-c', 'user.name=Test', '-c', 'user.email=test@example.com', 'commit', '--quiet', '-m', 'init'); + }); + + afterAll(async () => { + gitState.gitAvailable = true; + await afterAllCalls(); + }); + + it('reads the commit and the branch of a clean checkout with no remote', () => { + expect(readGitProvenance(tmpPath)).toEqual({ commit: git('rev-parse', 'HEAD'), branch: 'main', dirty: false }); + }); + + it('strips credentials from the origin URL and leaves an scp-like one alone', () => { + git('remote', 'add', 'origin', 'https://user:token@github.com/apify/actor-monorepo-example.git'); + expect(readGitProvenance(tmpPath).remoteUrl).toBe('https://github.com/apify/actor-monorepo-example.git'); + + git('remote', 'set-url', 'origin', 'git@github.com:apify/actor-monorepo-example.git'); + expect(readGitProvenance(tmpPath).remoteUrl).toBe('git@github.com:apify/actor-monorepo-example.git'); + }); + + it('flags an untracked file as dirty', () => { + writeFileSync(joinPath('notes.txt'), 'content'); + expect(readGitProvenance(tmpPath).dirty).toBe(true); + }); + + it('reports no branch on a detached HEAD', () => { + git('checkout', '--quiet', '--detach'); + expect(readGitProvenance(tmpPath)).not.toHaveProperty('branch'); + expect(readGitProvenance(tmpPath).commit).toBe(git('rev-parse', 'HEAD')); + }); + + it('reads nothing without git', () => { + gitState.gitAvailable = false; + expect(readGitProvenance(tmpPath)).toEqual({}); + }); +}); From db2cb8f528198dad80b746e633039fc6675185d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Mon, 26 Oct 2026 13:20:32 +0100 Subject: [PATCH 13/44] refactor: split the Docker context resolution into none, within, outside, and invalid --- src/commands/actors/push.ts | 2 +- src/lib/docker-context.ts | 26 ++++++++++++++++++-------- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/src/commands/actors/push.ts b/src/commands/actors/push.ts index c1c0e848d..5c328bb6f 100644 --- a/src/commands/actors/push.ts +++ b/src/commands/actors/push.ts @@ -296,7 +296,7 @@ export class ActorsPushCommand extends ApifyCommand { process.exitCode = CommandExitCodes.InvalidActorJson; return; } - if (dockerContext.kind === 'context') { + if (dockerContext.kind === 'outside') { // Pushing by ID, actor.json may have no name; the folder then names the Actor. error({ message: monorepoUnsupportedMessage((actorConfig?.name as string | undefined) ?? basename(cwd)) }); process.exitCode = CommandExitCodes.NotImplemented; diff --git a/src/lib/docker-context.ts b/src/lib/docker-context.ts index 9a7176897..3898c42a1 100644 --- a/src/lib/docker-context.ts +++ b/src/lib/docker-context.ts @@ -12,20 +12,28 @@ import { getActorLocalFilePaths } from './utils.js'; export const MONOREPO_PLATFORM_SUPPORT_ISSUE_URL = 'https://github.com/apify/apify-core/issues/28685'; export type DockerContextResolution = + /** `dockerContextDir` is absent: undefined, null, or an empty string. */ | { kind: 'none' } + /** The context is the Actor folder itself or a directory inside it, so a push of the folder carries it. */ + | { kind: 'within'; contextRoot: string } + /** The context lies beyond the Actor folder (a monorepo Actor): above it, or elsewhere in the Git repository. */ | { - kind: 'context'; + kind: 'outside'; contextRoot: string; /** The Git repository's root, else the context. */ sourceRoot: string; /** Relative to `sourceRoot`. */ actorPath: string; } + /** The field is not a string, or the directory it names is missing, not a directory, or out of reach. */ | { kind: 'invalid'; message: string }; /** - * `dockerContextDir` is relative to `.actor/`, as on the platform. A context at or below the Actor folder is an - * ordinary push, which the platform builds as it is; one above or beside it is a monorepo Actor. + * `dockerContextDir` is relative to `.actor/`, as on the platform. An absent field is `none`, with no path work. A + * context at or below the Actor folder is `within`: an ordinary push, which the platform builds as it is. A context + * above or beside the Actor folder is `outside`: a monorepo Actor, with the source root a push would have to carry. + * A context that cannot be used is `invalid`, with the message to show. Paths come back real, as the platform sees + * them. */ export function resolveDockerContext(actorDir: string, dockerContextDir: unknown): DockerContextResolution { if (dockerContextDir === undefined || dockerContextDir === null || dockerContextDir === '') return { kind: 'none' }; @@ -42,9 +50,6 @@ export function resolveDockerContext(actorDir: string, dockerContextDir: unknown } const contextRoot = resolve(realActorDir, ACTOR_SPECIFICATION_FOLDER, dockerContextDir); - const contextPathInActor = relative(realActorDir, contextRoot); - if (contextPathInActor === '' || !isOutside(contextPathInActor)) return { kind: 'none' }; - const contextStats = statSync(contextRoot, { throwIfNoEntry: false }); if (!contextStats) { return { @@ -60,6 +65,11 @@ export function resolveDockerContext(actorDir: string, dockerContextDir: unknown } const realContextRoot = realpathSync(contextRoot); + const contextPathInActor = relative(realActorDir, contextRoot); + if (contextPathInActor === '' || !isOutside(contextPathInActor)) { + return { kind: 'within', contextRoot: realContextRoot }; + } + // The platform's clone holds nothing above the repository root. const repoRoot = git(realActorDir, ['rev-parse', '--show-toplevel']); if (repoRoot) { @@ -73,7 +83,7 @@ export function resolveDockerContext(actorDir: string, dockerContextDir: unknown }; } return { - kind: 'context', + kind: 'outside', contextRoot: realContextRoot, sourceRoot, actorPath: toPosix(relative(sourceRoot, realActorDir)), @@ -87,7 +97,7 @@ export function resolveDockerContext(actorDir: string, dockerContextDir: unknown message: `"dockerContextDir" in .actor/actor.json points to ${realContextRoot}, which does not contain the Actor's folder.`, }; } - return { kind: 'context', contextRoot: realContextRoot, sourceRoot: realContextRoot, actorPath: toPosix(actorPath) }; + return { kind: 'outside', contextRoot: realContextRoot, sourceRoot: realContextRoot, actorPath: toPosix(actorPath) }; } function isOutside(relativePath: string): boolean { From f7288865ab107f80a3411cc016b56a269b83dc56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Mon, 26 Oct 2026 13:20:33 +0100 Subject: [PATCH 14/44] test: cover the none and within Docker context kinds --- test/local/commands/push-monorepo.test.ts | 7 ++-- test/local/lib/docker-context.test.ts | 39 ++++++++++++++++++----- 2 files changed, 36 insertions(+), 10 deletions(-) diff --git a/test/local/commands/push-monorepo.test.ts b/test/local/commands/push-monorepo.test.ts index 6c98db6cf..d1813d373 100644 --- a/test/local/commands/push-monorepo.test.ts +++ b/test/local/commands/push-monorepo.test.ts @@ -169,8 +169,11 @@ describe('apify push of a monorepo Actor', () => { expect(versionUpdate).not.toHaveBeenCalled(); }); - it('pushes the Actor folder as usual when the Docker context lies inside it', async () => { - await writeActorConfig({ dockerContextDir: '../src' }); + it.each([ + ['lies inside it', { dockerContextDir: '../src' }], + ['is not set', {}], + ])('pushes the Actor folder as usual when the Docker context %s', async (_, fields) => { + await writeActorConfig(fields); await testRunCommand(ActorsPushCommand, {}); diff --git a/test/local/lib/docker-context.test.ts b/test/local/lib/docker-context.test.ts index 180487b84..e5826da47 100644 --- a/test/local/lib/docker-context.test.ts +++ b/test/local/lib/docker-context.test.ts @@ -43,6 +43,7 @@ describe('resolveDockerContext', () => { mkdirSync(joinPath(ACTOR_PATH, 'src'), { recursive: true }); mkdirSync(joinPath('shared'), { recursive: true }); writeFileSync(joinPath('Dockerfile'), 'FROM apify/actor-node:20\n'); + writeFileSync(joinPath(ACTOR_PATH, 'Dockerfile'), 'FROM apify/actor-node:20\n'); symlinkSync(ACTOR_PATH, joinPath('linked-actor')); execFileSync('git', ['init', '--quiet'], { cwd: tmpPath, stdio: 'ignore' }); root = realpathSync(tmpPath); @@ -52,17 +53,39 @@ describe('resolveDockerContext', () => { await afterAllCalls(); }); - it('treats a missing field, or a context at or below the Actor folder, as an ordinary push', () => { + it('reports a missing field as none', () => { expect(resolveDockerContext(actorDir(), undefined)).toEqual({ kind: 'none' }); + expect(resolveDockerContext(actorDir(), null)).toEqual({ kind: 'none' }); expect(resolveDockerContext(actorDir(), '')).toEqual({ kind: 'none' }); - expect(resolveDockerContext(actorDir(), '..')).toEqual({ kind: 'none' }); - expect(resolveDockerContext(actorDir(), '.')).toEqual({ kind: 'none' }); - expect(resolveDockerContext(actorDir(), '../src')).toEqual({ kind: 'none' }); + }); + + it('reports a context at or below the Actor folder as within, with its real path', () => { + const actorRoot = join(root, ACTOR_PATH); + expect(resolveDockerContext(actorDir(), '..')).toEqual({ kind: 'within', contextRoot: actorRoot }); + expect(resolveDockerContext(actorDir(), '.')).toEqual({ kind: 'within', contextRoot: join(actorRoot, '.actor') }); + expect(resolveDockerContext(actorDir(), '../src')).toEqual({ + kind: 'within', + contextRoot: join(actorRoot, 'src'), + }); + }); + + it('rejects a context inside the Actor folder that does not exist', () => { + expect(resolveDockerContext(actorDir(), '../missing')).toEqual({ + kind: 'invalid', + message: `"dockerContextDir" in .actor/actor.json points to ${join(root, ACTOR_PATH, 'missing')}, which does not exist.`, + }); + }); + + it('rejects a context inside the Actor folder that is not a directory', () => { + expect(resolveDockerContext(actorDir(), '../Dockerfile')).toEqual({ + kind: 'invalid', + message: `"dockerContextDir" in .actor/actor.json points to ${join(root, ACTOR_PATH, 'Dockerfile')}, which is not a directory.`, + }); }); it('resolves a context above the Actor folder to the repository root and the Actor path in it', () => { expect(resolveDockerContext(actorDir(), '../../..')).toEqual({ - kind: 'context', + kind: 'outside', contextRoot: root, sourceRoot: root, actorPath: ACTOR_PATH, @@ -71,7 +94,7 @@ describe('resolveDockerContext', () => { it('keeps the repository root as the source for a context elsewhere in the repository', () => { expect(resolveDockerContext(actorDir(), '../../../shared')).toEqual({ - kind: 'context', + kind: 'outside', contextRoot: join(root, 'shared'), sourceRoot: root, actorPath: ACTOR_PATH, @@ -80,7 +103,7 @@ describe('resolveDockerContext', () => { it('resolves a symlinked Actor folder from its real path, as the platform does', () => { expect(resolveDockerContext(joinPath('linked-actor'), '../../..')).toEqual({ - kind: 'context', + kind: 'outside', contextRoot: root, sourceRoot: root, actorPath: ACTOR_PATH, @@ -151,7 +174,7 @@ describe('resolveDockerContext', () => { it('makes the context the source when it contains the Actor folder', () => { expect(resolveDockerContext(actorDir(), '../../..')).toEqual({ - kind: 'context', + kind: 'outside', contextRoot: root, sourceRoot: root, actorPath: ACTOR_PATH, From a9f2d19c5ee7a37dafcb62cba598cc195180e8db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Mon, 26 Oct 2026 13:43:15 +0100 Subject: [PATCH 15/44] refactor: rename readGitProvenance to readGitState --- src/lib/docker-context.ts | 16 +++++++-------- ...e.test.ts => docker-context-state.test.ts} | 20 +++++++++---------- 2 files changed, 18 insertions(+), 18 deletions(-) rename test/local/lib/{docker-context-provenance.test.ts => docker-context-state.test.ts} (75%) diff --git a/src/lib/docker-context.ts b/src/lib/docker-context.ts index 3898c42a1..313ec90a4 100644 --- a/src/lib/docker-context.ts +++ b/src/lib/docker-context.ts @@ -1,5 +1,5 @@ // The Docker context of an Actor: where `dockerContextDir` in `.actor/actor.json` points and what a push of it -// would carry. `readGitProvenance` and `getContextFilePaths` have no caller in `apify push` yet; they serve the +// would carry. `readGitState` and `getContextFilePaths` have no caller in `apify push` yet; they serve the // stage 2 upload of the context and the `actor-runtime` branch. import { execFileSync } from 'node:child_process'; import { lstatSync, realpathSync, statSync } from 'node:fs'; @@ -108,7 +108,7 @@ function toPosix(filePath: string): string { return filePath.split(sep).join('/'); } -export interface GitProvenance { +export interface GitState { remoteUrl?: string; branch?: string; commit?: string; @@ -137,18 +137,18 @@ function withoutCredentials(remoteUrl: string): string { } } -export function readGitProvenance(dir: string): GitProvenance { +export function readGitState(dir: string): GitState { const commit = git(dir, ['rev-parse', 'HEAD']); if (!commit) return {}; - const provenance: GitProvenance = { commit }; + const state: GitState = { commit }; const branch = git(dir, ['rev-parse', '--abbrev-ref', 'HEAD']); - if (branch && branch !== 'HEAD') provenance.branch = branch; + if (branch && branch !== 'HEAD') state.branch = branch; const remoteUrl = git(dir, ['remote', 'get-url', 'origin']); - if (remoteUrl) provenance.remoteUrl = withoutCredentials(remoteUrl); + if (remoteUrl) state.remoteUrl = withoutCredentials(remoteUrl); const status = git(dir, ['status', '--porcelain', '--', '.']); - if (status !== undefined) provenance.dirty = status.length > 0; - return provenance; + if (status !== undefined) state.dirty = status.length > 0; + return state; } /** Untracked, these are never on the platform and too big to push by accident. */ diff --git a/test/local/lib/docker-context-provenance.test.ts b/test/local/lib/docker-context-state.test.ts similarity index 75% rename from test/local/lib/docker-context-provenance.test.ts rename to test/local/lib/docker-context-state.test.ts index 359f9d8f1..322f3ac68 100644 --- a/test/local/lib/docker-context-provenance.test.ts +++ b/test/local/lib/docker-context-state.test.ts @@ -1,7 +1,7 @@ import { execFileSync } from 'node:child_process'; import { writeFileSync } from 'node:fs'; -import { readGitProvenance } from '../../../src/lib/docker-context.js'; +import { readGitState } from '../../../src/lib/docker-context.js'; import { useTempPath } from '../../__setup__/hooks/useTempPath.js'; // vi.mock is hoisted before imports, so the reader gets the mocked version. With `gitAvailable` off, every git @@ -21,8 +21,8 @@ vi.mock('node:child_process', async (importOriginal) => { }; }); -describe('readGitProvenance', () => { - const { tmpPath, joinPath, beforeAllCalls, afterAllCalls } = useTempPath('docker-context-provenance-test-dir', { +describe('readGitState', () => { + const { tmpPath, joinPath, beforeAllCalls, afterAllCalls } = useTempPath('docker-context-state-test-dir', { create: true, remove: true, cwd: false, @@ -46,30 +46,30 @@ describe('readGitProvenance', () => { }); it('reads the commit and the branch of a clean checkout with no remote', () => { - expect(readGitProvenance(tmpPath)).toEqual({ commit: git('rev-parse', 'HEAD'), branch: 'main', dirty: false }); + expect(readGitState(tmpPath)).toEqual({ commit: git('rev-parse', 'HEAD'), branch: 'main', dirty: false }); }); it('strips credentials from the origin URL and leaves an scp-like one alone', () => { git('remote', 'add', 'origin', 'https://user:token@github.com/apify/actor-monorepo-example.git'); - expect(readGitProvenance(tmpPath).remoteUrl).toBe('https://github.com/apify/actor-monorepo-example.git'); + expect(readGitState(tmpPath).remoteUrl).toBe('https://github.com/apify/actor-monorepo-example.git'); git('remote', 'set-url', 'origin', 'git@github.com:apify/actor-monorepo-example.git'); - expect(readGitProvenance(tmpPath).remoteUrl).toBe('git@github.com:apify/actor-monorepo-example.git'); + expect(readGitState(tmpPath).remoteUrl).toBe('git@github.com:apify/actor-monorepo-example.git'); }); it('flags an untracked file as dirty', () => { writeFileSync(joinPath('notes.txt'), 'content'); - expect(readGitProvenance(tmpPath).dirty).toBe(true); + expect(readGitState(tmpPath).dirty).toBe(true); }); it('reports no branch on a detached HEAD', () => { git('checkout', '--quiet', '--detach'); - expect(readGitProvenance(tmpPath)).not.toHaveProperty('branch'); - expect(readGitProvenance(tmpPath).commit).toBe(git('rev-parse', 'HEAD')); + expect(readGitState(tmpPath)).not.toHaveProperty('branch'); + expect(readGitState(tmpPath).commit).toBe(git('rev-parse', 'HEAD')); }); it('reads nothing without git', () => { gitState.gitAvailable = false; - expect(readGitProvenance(tmpPath)).toEqual({}); + expect(readGitState(tmpPath)).toEqual({}); }); }); From 13e2da932cd635bf7cacaeb141a4ac1b3ef62607 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Mon, 26 Oct 2026 15:08:31 +0100 Subject: [PATCH 16/44] fix: skip directories Actor discovery cannot read --- src/lib/find-actor-dirs.ts | 18 +++++++++++++++--- test/local/lib/find-actor-dirs.test.ts | 26 ++++++++++++++++++++++++-- 2 files changed, 39 insertions(+), 5 deletions(-) diff --git a/src/lib/find-actor-dirs.ts b/src/lib/find-actor-dirs.ts index 735c0da6f..7511b0522 100644 --- a/src/lib/find-actor-dirs.ts +++ b/src/lib/find-actor-dirs.ts @@ -4,6 +4,7 @@ import { join, relative, sep } from 'node:path'; import { LOCAL_CONFIG_PATH } from './consts.js'; import { ACTOR_FILES_HARDCODED_IGNORE, getJsonFileContent } from './utils.js'; +import { cliDebugPrint } from './utils/cliDebugPrint.js'; const SKIPPED_DIRECTORY_NAMES = new Set(ACTOR_FILES_HARDCODED_IGNORE.map((pattern) => pattern.replace(/\/\*\*$/, ''))); @@ -30,14 +31,25 @@ function readActorName(dir: string): string | 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. + * Finds every directory below `root` with a `.actor/actor.json`, sorted by path. Skips the directory names + * `getActorLocalFilePaths` always ignores (at any depth, not only at the root) and does not descend below a + * found Actor. A directory that cannot be read is skipped; when that is the root, nothing is found. */ export async function findActorDirs(root: string): Promise { const found: FoundActorDir[] = []; const walk = async (dir: string) => { - for (const entry of await readdir(dir, { withFileTypes: true })) { + let entries; + + try { + entries = await readdir(dir, { withFileTypes: true }); + } catch (err) { + cliDebugPrint('findActorDirs', { event: 'unreadable-directory', dir, err }); + + return; + } + + for (const entry of entries) { if (!entry.isDirectory() || SKIPPED_DIRECTORY_NAMES.has(entry.name)) { continue; } diff --git a/test/local/lib/find-actor-dirs.test.ts b/test/local/lib/find-actor-dirs.test.ts index c551b321d..7d37f991c 100644 --- a/test/local/lib/find-actor-dirs.test.ts +++ b/test/local/lib/find-actor-dirs.test.ts @@ -1,5 +1,6 @@ -import { mkdir, writeFile } from 'node:fs/promises'; -import { dirname } from 'node:path'; +import { chmod, mkdir, writeFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import process from 'node:process'; import { findActorDirs, toPosixRelative } from '../../../src/lib/find-actor-dirs.js'; import { useTempPath } from '../../__setup__/hooks/useTempPath.js'; @@ -82,6 +83,27 @@ describe('findActorDirs', () => { await expect(findActorDirs(tmpPath)).resolves.toEqual([]); }); + // chmod 000 has no effect on Windows and root reads the directory anyway. + const canMakeUnreadable = process.platform !== 'win32' && process.getuid?.() !== 0; + + it.skipIf(!canMakeUnreadable)('skips a directory it cannot read and keeps the rest', async () => { + await writeActor('actors/alpha'); + await mkdir(joinPath('locked', 'inner'), { recursive: true }); + await chmod(joinPath('locked'), 0o000); + + try { + const dirs = (await findActorDirs(tmpPath)).map((actor) => actor.dir); + + expect(dirs).toEqual(['actors/alpha']); + } finally { + await chmod(joinPath('locked'), 0o755); + } + }); + + it('returns an empty list for a root that does not exist', async () => { + await expect(findActorDirs(join(tmpPath, 'missing'))).resolves.toEqual([]); + }); + it('uses / as the separator, also on Windows', async () => { await writeActor('group/sub/actor'); From c10f77e2515b08157ccdc455a98d0ae32508c4f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Mon, 26 Oct 2026 15:08:32 +0100 Subject: [PATCH 17/44] fix: clear the readline buffer after each key in the checkbox prompt --- src/lib/utils/inquirer/checkboxImpl.ts | 23 +++++++---------------- 1 file changed, 7 insertions(+), 16 deletions(-) diff --git a/src/lib/utils/inquirer/checkboxImpl.ts b/src/lib/utils/inquirer/checkboxImpl.ts index 2d1929d7a..47d677792 100644 --- a/src/lib/utils/inquirer/checkboxImpl.ts +++ b/src/lib/utils/inquirer/checkboxImpl.ts @@ -42,7 +42,7 @@ export const checkboxImpl: ReturnType(() => choices.map((choice) => !!choice.checked)); const prefix = usePrefix({ status, theme }); - useKeypress((key) => { + useKeypress((key, rl) => { if (isEnterKey(key)) { setStatus('done'); done(choices.filter((_, index) => checked[index]).map((choice) => choice.value)); @@ -58,26 +58,17 @@ export const checkboxImpl: ReturnType= 0 && next < choices.length) { setActive(next); } - - return; - } - - if (isSpaceKey(key)) { + } else if (isSpaceKey(key)) { setChecked(checked.map((value, index) => (index === active ? !value : value))); - - return; - } - - if (key.name === 'a') { + } else if (key.name === 'a') { const allChecked = checked.every(Boolean); setChecked(checked.map(() => !allChecked)); - - return; - } - - if (key.name === 'i') { + } else if (key.name === 'i') { setChecked(checked.map((value) => !value)); } + + // Readline still buffers every key; drop it so the cursor placement does not drift, as confirmImpl does. + rl.clearLine(0); }); const page = usePagination({ From 060f196b87c8cf16564c1e0caeafc069164a22a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Mon, 26 Oct 2026 15:08:32 +0100 Subject: [PATCH 18/44] fix: strip the short-form = and validate choices for multiple flags --- src/lib/command-framework/apify-command.ts | 35 ++++++++++++------- .../command-framework/multiple-flag.test.ts | 21 ++++++++++- 2 files changed, 42 insertions(+), 14 deletions(-) diff --git a/src/lib/command-framework/apify-command.ts b/src/lib/command-framework/apify-command.ts index 2e64a76ab..b10e24cf3 100644 --- a/src/lib/command-framework/apify-command.ts +++ b/src/lib/command-framework/apify-command.ts @@ -560,8 +560,29 @@ export abstract class ApifyCommand + new CommandError({ + code: CommandErrorCode.APIFY_INVALID_CHOICE, + command: this.ctor, + metadata: { + flag: baseFlagName, + choices: choices.map((choice) => chalk.white.bold(choice)).join(', '), + }, + }); + + // -i='{"foo":"bar"}' + if (Array.isArray(rawFlag) && usedShortFormOfTheFlag) { + rawFlag = rawFlag.map((value) => (typeof value === 'string' && value.startsWith('=') ? value.slice(1) : value)); + } + // A flag that opted into `multiple` keeps every value; otherwise --a 1 --a 2 is not allowed if (Array.isArray(rawFlag) && builderData.multiple) { + const { choices } = builderData; + + if (choices && rawFlag.some((value) => !choices.includes(value as never))) { + throw invalidChoiceError(choices); + } + this.flags[camelCasedName] = rawFlag; continue; @@ -581,11 +602,6 @@ export abstract class ApifyCommand chalk.white.bold(choice)).join(', '), - }, - }); + throw invalidChoiceError(builderData.choices); } // Re-validate required (we don't have the flag and it's either required or passed in) diff --git a/test/local/lib/command-framework/multiple-flag.test.ts b/test/local/lib/command-framework/multiple-flag.test.ts index e65f0c74b..5deb48c54 100644 --- a/test/local/lib/command-framework/multiple-flag.test.ts +++ b/test/local/lib/command-framework/multiple-flag.test.ts @@ -16,7 +16,8 @@ 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 }), + 'actor-dir': Flags.string({ char: 'd', description: 'repeatable', multiple: true }), + kind: Flags.string({ description: 'repeatable with choices', multiple: true, choices: ['x', 'y'] }), once: Flags.string({ description: 'plain' }), }; @@ -64,6 +65,24 @@ describe('Flags.string({ multiple: true })', () => { expect(receivedFlags.actorDir).toBeUndefined(); }); + test('strips the = of the short form from every value', async () => { + await runWith(['-d=actors/a', '-d=actors/b']); + + expect(receivedFlags.actorDir).toEqual(['actors/a', 'actors/b']); + }); + + test('accepts repeated values that are all among the choices', async () => { + await runWith(['--kind', 'x', '--kind', 'y']); + + expect(receivedFlags.kind).toEqual(['x', 'y']); + }); + + test('rejects a repeated flag when one value is not among the choices', async () => { + await expect(runWith(['--kind', 'x', '--kind', 'z'])).rejects.toMatchObject({ + code: CommandErrorCode.APIFY_INVALID_CHOICE, + }); + }); + 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 750bbe4a582963dc6e29f29455f8f9663ec9807f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Mon, 26 Oct 2026 15:09:01 +0100 Subject: [PATCH 19/44] fix: decide selection mode on the directory's own config files and honor the selection flags there --- src/commands/actors/push.ts | 28 +++++++++--- test/local/commands/push-select.test.ts | 57 ++++++++++++++++++++++++- 2 files changed, 78 insertions(+), 7 deletions(-) diff --git a/src/commands/actors/push.ts b/src/commands/actors/push.ts index 6e76ef7db..44c6f189d 100644 --- a/src/commands/actors/push.ts +++ b/src/commands/actors/push.ts @@ -173,6 +173,11 @@ const confirmGitSourceSwitch = async ({ }); }; +/** The config files that make a directory an Actor of its own: `.actor/actor.json`, `actor.json`, or the legacy `apify.json`. */ +const OWN_CONFIG_FILES = [DEPRECATED_LOCAL_CONFIG_NAME, 'actor.json', '.actor/actor.json']; + +const hasOwnConfigFile = (filePaths: string[]) => filePaths.some((filePath) => OWN_CONFIG_FILES.includes(filePath)); + /** Whether the collected files include an Actor config `apify push` accepts: `.actor/`, `actor.json`, or the legacy `apify.json`. */ const hasActorConfig = (filePaths: string[]) => [ @@ -297,10 +302,21 @@ export class ActorsPushCommand extends ApifyCommand { 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)) { + // The selection flags have no meaning in an Actor directory, so they fail there instead of being ignored. + if (hasOwnConfigFile(filePathsToPush)) { + if (this.flags.all || this.flags.actorDir?.length) { + error({ + message: + 'This directory is an Actor itself, so --all and --actor-dir do not apply. Run the command without them, or from a directory above the Actors.', + }); + process.exitCode = CommandExitCodes.InvalidInput; + return; + } + } else { const actorDirs = await findActorDirs(cwd); - if (actorDirs.length) { + // An explicit --actor-dir may name an Actor discovery does not reach (a symlink, an ignored folder). + if (actorDirs.length || this.flags.actorDir?.length) { await this.pushSelected(cwd, actorDirs); return; } @@ -370,10 +386,6 @@ export class ActorsPushCommand extends ApifyCommand { /** 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 outside = dirs.find((dir) => isAbsolute(dir) || dir === '..' || dir.startsWith('../')); @@ -397,6 +409,10 @@ export class ActorsPushCommand extends ApifyCommand { return dirs; } + if (this.flags.all) { + return actorDirs.map((actor) => actor.dir); + } + const label = ({ dir, name }: FoundActorDir) => (name ? `${dir} (${name})` : dir); const { isTTY } = await useStdin(); diff --git a/test/local/commands/push-select.test.ts b/test/local/commands/push-select.test.ts index 492293278..7a9907f58 100644 --- a/test/local/commands/push-select.test.ts +++ b/test/local/commands/push-select.test.ts @@ -1,4 +1,4 @@ -import { mkdir, rm, writeFile } from 'node:fs/promises'; +import { mkdir, rm, symlink, writeFile } from 'node:fs/promises'; import { dirname } from 'node:path'; import { testRunCommand } from '../../../src/lib/command-framework/apify-command.js'; @@ -183,6 +183,28 @@ describe('apify push in a repository with several Actors', () => { expect(getLoggedClientOrThrow).not.toHaveBeenCalled(); }); + it('enters selection mode at a root whose only .actor-prefixed path is a .actorignore', async () => { + await write('.actorignore', ''); + + await run(); + + expect(commandProcess.exitCode).toBe(CommandExitCodes.NoFilesToPush); + expect(stderr()).toContain('Choose what to push'); + expect(stderr()).toContain('actors/a (actor-a)'); + expect(getLoggedClientOrThrow).not.toHaveBeenCalled(); + }); + + it('enters selection mode at a root whose .actor folder holds only shared files', async () => { + await write('.actor/input_schema.json', '{}'); + + await run(); + + expect(commandProcess.exitCode).toBe(CommandExitCodes.NoFilesToPush); + expect(stderr()).toContain('Choose what to push'); + expect(stderr()).toContain('tools/c (actor-c)'); + expect(getLoggedClientOrThrow).not.toHaveBeenCalled(); + }); + it('keeps the exit-4 error for a directory with no Actors anywhere below it', async () => { forceNewCwd('packages'); await run(); @@ -193,6 +215,39 @@ describe('apify push in a repository with several Actors', () => { expect(getLoggedClientOrThrow).not.toHaveBeenCalled(); }); + it('rejects --all in a directory that is an Actor itself', async () => { + forceNewCwd('actors/a'); + + await run({ flags_all: true }); + + expect(commandProcess.exitCode).toBe(CommandExitCodes.InvalidInput); + expect(stderr()).toContain('This directory is an Actor itself, so --all and --actor-dir do not apply.'); + expect(getLoggedClientOrThrow).not.toHaveBeenCalled(); + expect(versionUpdate).not.toHaveBeenCalled(); + }); + + it('rejects --actor-dir in a directory that is an Actor itself', async () => { + forceNewCwd('actors/a'); + + await run({ flags_actorDir: ['../b'] }); + + expect(commandProcess.exitCode).toBe(CommandExitCodes.InvalidInput); + expect(stderr()).toContain('This directory is an Actor itself, so --all and --actor-dir do not apply.'); + expect(getLoggedClientOrThrow).not.toHaveBeenCalled(); + expect(versionUpdate).not.toHaveBeenCalled(); + }); + + it('pushes an --actor-dir that discovery does not reach, such as a symlinked Actor', async () => { + await mkdir(joinPath('solo'), { recursive: true }); + await symlink(joinPath('actors', 'b'), joinPath('solo', 'linked'), 'junction'); + forceNewCwd('solo'); + + await run({ flags_actorDir: ['linked'] }); + + expect(commandProcess.exitCode).toBeFalsy(); + expect(pushedActorIds()).toEqual([ACTOR_B.id]); + }); + it('rejects an --actor-dir without an actor.json before the login check', async () => { await run({ flags_actorDir: ['actors/a', 'packages/shared'] }); From 345c77f8072e06b04f7eb99bc5fd4d8262325d46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Mon, 26 Oct 2026 15:09:25 +0100 Subject: [PATCH 20/44] fix: stop pushing the remaining Actors after an interrupt signal --- src/commands/actors/push.ts | 29 ++++++++++++++++++++++-- test/local/commands/push-select.test.ts | 30 +++++++++++++++++++++---- 2 files changed, 53 insertions(+), 6 deletions(-) diff --git a/src/commands/actors/push.ts b/src/commands/actors/push.ts index 44c6f189d..855d4d48b 100644 --- a/src/commands/actors/push.ts +++ b/src/commands/actors/push.ts @@ -19,7 +19,12 @@ 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 { getConsoleUrl } from '../../lib/console-url.js'; -import { CommandExitCodes, DEPRECATED_LOCAL_CONFIG_NAME, LOCAL_CONFIG_PATH } from '../../lib/consts.js'; +import { + CommandExitCodes, + DEPRECATED_LOCAL_CONFIG_NAME, + INTERRUPT_SIGNALS, + 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'; @@ -27,6 +32,7 @@ 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 { useSignalHandler } from '../../lib/hooks/useSignalHandler.js'; import { useStdin } from '../../lib/hooks/useStdin.js'; import { error, info, run, simpleLog, warning } from '../../lib/outputs.js'; import { transformEnvToEnvVars } from '../../lib/secrets.js'; @@ -348,7 +354,18 @@ export class ActorsPushCommand extends ApifyCommand { const results: SelectedPushResult[] = []; let firstFailureExitCode: number | undefined; - for (const dir of selected) { + // Each push aborts its own build on Ctrl+C and returns; without this, the loop would then push the next Actor. + let interrupted = false; + using _interruptWatcher = useSignalHandler({ + signals: INTERRUPT_SIGNALS, + once: false, + cleanTerminalLine: false, + handler: () => { + interrupted = true; + }, + }); + + for (const [index, dir] of selected.entries()) { info({ message: `Pushing the Actor in ${dir}` }); // Each push reports through process.exitCode; clear it so the code read back belongs to this push. @@ -370,6 +387,14 @@ export class ActorsPushCommand extends ApifyCommand { entry.exitCode = exitCode; } results.push(entry); + + if (interrupted) { + const notPushed = selected.slice(index + 1); + if (notPushed.length) { + warning({ message: `Interrupted. Not pushed: ${notPushed.join(', ')}` }); + } + break; + } } process.exitCode = firstFailureExitCode; diff --git a/test/local/commands/push-select.test.ts b/test/local/commands/push-select.test.ts index 7a9907f58..5bc06eda8 100644 --- a/test/local/commands/push-select.test.ts +++ b/test/local/commands/push-select.test.ts @@ -33,7 +33,7 @@ const ACTOR_B = { id: 'actorIdBBBBBBBBB', name: 'actor-b', modifiedAt: new Date( 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. +// build ends with, the build wait, and every version write with its Actor. const platform = vitest.hoisted(() => { const state = { actors: [] as { id: string; name: string; modifiedAt: Date }[], @@ -48,6 +48,8 @@ const platform = vitest.hoisted(() => { }); const versionUpdate = vitest.fn(async (_actorId: string, _version: string, _modifier: unknown) => ({})); + // Stands in for the build wait; a test can emit a signal from here to interrupt the push. + const outputJobLog = vitest.fn(async () => {}); const getLoggedClientOrThrow = vitest.fn(async () => ({ baseUrl: 'https://api.apify.com/v2', @@ -72,16 +74,16 @@ const platform = vitest.hoisted(() => { log: () => ({ get: async () => 'npm ERR! boom\n' }), })); - return { state, versionUpdate, getLoggedClientOrThrow }; + return { state, versionUpdate, outputJobLog, getLoggedClientOrThrow }; }); -const { versionUpdate, getLoggedClientOrThrow } = platform; +const { versionUpdate, outputJobLog, 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 () => {}), + outputJobLog: platform.outputJobLog, getLoggedClientOrThrow: platform.getLoggedClientOrThrow, })); @@ -134,6 +136,7 @@ beforeEach(async () => { platform.state.actors = [ACTOR_A, ACTOR_B, ACTOR_C]; platform.state.buildStatuses = {}; versionUpdate.mockClear(); + outputJobLog.mockClear(); getLoggedClientOrThrow.mockClear(); multiSelect.mockReset(); cwdCache.clear(); @@ -361,6 +364,25 @@ describe('apify push in a repository with several Actors', () => { 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 }); }); + + it('stops after the interrupted Actor and names the ones not pushed', async () => { + platform.state.buildStatuses[ACTOR_A.id] = 'ABORTED'; + outputJobLog.mockImplementationOnce(async () => { + process.emit('SIGINT', 'SIGINT'); + }); + + await run({ flags_all: true, flags_json: true }); + + expect(commandProcess.exitCode).toBe(CommandExitCodes.BuildAborted); + expect(pushedActorIds()).toEqual([ACTOR_A.id]); + expect(stderr()).toContain('Interrupted. Not pushed: actors/b, tools/c'); + expect(process.listenerCount('SIGINT')).toBe(0); + const summary = stdoutJson(); + expect(summary).toMatchObject({ ok: false, exitCode: CommandExitCodes.BuildAborted }); + expect(summary.results).toEqual([ + expect.objectContaining({ dir: 'actors/a', ok: false, exitCode: CommandExitCodes.BuildAborted }), + ]); + }); }); describe('--json', () => { From e70478fe9c9d8d2f44e2c2e9fe9e44c2377e92dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Mon, 26 Oct 2026 15:09:50 +0100 Subject: [PATCH 21/44] fix: report a declined Git-source switch as skipped in the push results --- src/commands/actors/push.ts | 20 ++++++++++++---- test/local/commands/push-select.test.ts | 31 +++++++++++++++++++++++-- 2 files changed, 45 insertions(+), 6 deletions(-) diff --git a/src/commands/actors/push.ts b/src/commands/actors/push.ts index 855d4d48b..b6c21850c 100644 --- a/src/commands/actors/push.ts +++ b/src/commands/actors/push.ts @@ -76,10 +76,14 @@ 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. */ +/** + * One entry of a push from a root without an Actor of its own: the single push result, or what stopped it. + * `skipped` marks an Actor left alone on purpose (a declined Git-source switch); it carries no exit code. + */ interface SelectedPushResult extends Partial { dir: string; ok: boolean; + skipped?: true; } interface PushSelectionResult { @@ -382,7 +386,9 @@ export class ActorsPushCommand extends ApifyCommand { const exitCode = typeof process.exitCode === 'number' && process.exitCode !== 0 ? process.exitCode : undefined; firstFailureExitCode ??= exitCode; - const entry: SelectedPushResult = { dir, ...(result ?? { ok: false }) }; + // A push that ends with neither a result nor an exit code was declined at the Git-source question. + const entry: SelectedPushResult = + !result && exitCode === undefined ? { dir, ok: false, skipped: true } : { dir, ...(result ?? { ok: false }) }; if (exitCode !== undefined && entry.exitCode === undefined) { entry.exitCode = exitCode; } @@ -400,8 +406,14 @@ export class ActorsPushCommand extends ApifyCommand { 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); + const summary: PushSelectionResult = { + ok: results.every((entry) => entry.ok || entry.skipped), + operation: 'push', + results, + }; + const exitCode = + firstFailureExitCode ?? + (results.every((entry) => entry.skipped || entry.exitCode !== undefined) ? 0 : undefined); if (exitCode !== undefined) { summary.exitCode = exitCode; } diff --git a/test/local/commands/push-select.test.ts b/test/local/commands/push-select.test.ts index 5bc06eda8..b06aa1ff9 100644 --- a/test/local/commands/push-select.test.ts +++ b/test/local/commands/push-select.test.ts @@ -22,6 +22,12 @@ vitest.mock('../../../src/lib/hooks/user-confirmations/useMultiSelectFromList.js useMultiSelectFromList: multiSelect, })); +// Answers the Git-source question; a test sets the answer with `mockResolvedValueOnce`. +const yesNoConfirm = vitest.hoisted(() => vitest.fn()); +vitest.mock('../../../src/lib/hooks/user-confirmations/useYesNoConfirm.js', () => ({ + useYesNoConfirm: yesNoConfirm, +})); + interface PlatformActor { id: string; name: string; @@ -33,11 +39,12 @@ const ACTOR_B = { id: 'actorIdBBBBBBBBB', name: 'actor-b', modifiedAt: new Date( 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, the build wait, and every version write with its Actor. +// build ends with, which Actors build from Git, the build wait, and every version write with its Actor. const platform = vitest.hoisted(() => { const state = { actors: [] as { id: string; name: string; modifiedAt: Date }[], buildStatuses: {} as Record, + gitSourcedActorIds: [] as string[], }; const buildFor = (actorId: string) => ({ @@ -64,7 +71,10 @@ const platform = vitest.hoisted(() => { taggedBuilds: { latest: { buildId: `build-${actor.id}` }, beta: { buildId: `build-${actor.id}` } }, }, version: (version: string) => ({ - get: async () => ({ versionNumber: version, sourceType: 'SOURCE_FILES' }), + get: async () => + state.gitSourcedActorIds.includes(actor!.id) + ? { versionNumber: version, sourceType: 'GIT_REPO', gitRepoUrl: 'https://github.com/org/repo' } + : { versionNumber: version, sourceType: 'SOURCE_FILES' }, update: async (modifier: unknown) => versionUpdate(actor!.id, version, modifier), }), build: async () => buildFor(actor!.id), @@ -135,10 +145,12 @@ beforeEach(async () => { stdinState.isTTY = false; platform.state.actors = [ACTOR_A, ACTOR_B, ACTOR_C]; platform.state.buildStatuses = {}; + platform.state.gitSourcedActorIds = []; versionUpdate.mockClear(); outputJobLog.mockClear(); getLoggedClientOrThrow.mockClear(); multiSelect.mockReset(); + yesNoConfirm.mockReset(); cwdCache.clear(); await beforeAllCalls(); @@ -365,6 +377,21 @@ describe('apify push in a repository with several Actors', () => { expect(summary.results[2]).toMatchObject({ dir: 'tools/c', ok: false, exitCode: CommandExitCodes.BuildTimedOut }); }); + it('marks a declined Git-source switch as skipped and does not fail the run', async () => { + platform.state.gitSourcedActorIds = [ACTOR_A.id]; + yesNoConfirm.mockResolvedValueOnce(false); + + await run({ flags_all: true, flags_json: true }); + + expect(commandProcess.exitCode).toBeFalsy(); + expect(yesNoConfirm).toHaveBeenCalledTimes(1); + expect(pushedActorIds()).toEqual([ACTOR_B.id, ACTOR_C.id]); + const summary = stdoutJson(); + expect(summary).toMatchObject({ ok: true, exitCode: 0 }); + expect(summary.results[0]).toEqual({ dir: 'actors/a', ok: false, skipped: true }); + expect(summary.results[1]).toMatchObject({ dir: 'actors/b', ok: true, exitCode: 0 }); + }); + it('stops after the interrupted Actor and names the ones not pushed', async () => { platform.state.buildStatuses[ACTOR_A.id] = 'ABORTED'; outputJobLog.mockImplementationOnce(async () => { From 695af7fbc75daaf1412cfe89cca11b175b414df7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Mon, 26 Oct 2026 15:10:09 +0100 Subject: [PATCH 22/44] feat: push the --actor-dir directories in path order --- docs/reference.md | 2 +- src/commands/actors/push.ts | 4 ++-- test/local/commands/push-select.test.ts | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/reference.md b/docs/reference.md index 64c475535..8e627d6ac 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -871,7 +871,7 @@ 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. + several Actors; they are pushed in path order. --all When the directory has no Actor of its own, push every Actor found in the directories below it. diff --git a/src/commands/actors/push.ts b/src/commands/actors/push.ts index b6c21850c..32fa1251b 100644 --- a/src/commands/actors/push.ts +++ b/src/commands/actors/push.ts @@ -279,7 +279,7 @@ export class ActorsPushCommand extends ApifyCommand { }), '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.', + '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; they are pushed in path order.', required: false, multiple: true, exclusive: ['all'], @@ -443,7 +443,7 @@ export class ActorsPushCommand extends ApifyCommand { return; } - return dirs; + return dirs.sort(); } if (this.flags.all) { diff --git a/test/local/commands/push-select.test.ts b/test/local/commands/push-select.test.ts index b06aa1ff9..de1daddea 100644 --- a/test/local/commands/push-select.test.ts +++ b/test/local/commands/push-select.test.ts @@ -322,11 +322,11 @@ describe('apify push in a repository with several Actors', () => { expect(logMessages.log.join('\n')).toContain('Apify push result: SUCCEEDED'); }); - it('pushes the --actor-dir directories, normalized and without duplicates', async () => { + it('pushes the --actor-dir directories in path order, 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]); + expect(pushedActorIds()).toEqual([ACTOR_A.id, ACTOR_C.id]); }); it('passes --version and --build-tag to every version payload', async () => { From ea97585722bd2349738f96e4a4f35556b825abec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Mon, 26 Oct 2026 15:10:09 +0100 Subject: [PATCH 23/44] feat: record the Actor selection in push telemetry --- src/commands/actors/push.ts | 4 ++++ src/lib/hooks/telemetry/trackEvent.ts | 6 ++++++ 2 files changed, 10 insertions(+) diff --git a/src/commands/actors/push.ts b/src/commands/actors/push.ts index 32fa1251b..febca58ca 100644 --- a/src/commands/actors/push.ts +++ b/src/commands/actors/push.ts @@ -404,6 +404,10 @@ export class ActorsPushCommand extends ApifyCommand { } process.exitCode = firstFailureExitCode; + this.telemetryData.pushSelection = { + selected: selected.length, + failed: results.filter((entry) => !entry.ok && !entry.skipped).length, + }; if (this.flags.json) { const summary: PushSelectionResult = { diff --git a/src/lib/hooks/telemetry/trackEvent.ts b/src/lib/hooks/telemetry/trackEvent.ts index 3ea2d6eea..204ccab12 100644 --- a/src/lib/hooks/telemetry/trackEvent.ts +++ b/src/lib/hooks/telemetry/trackEvent.ts @@ -43,6 +43,12 @@ interface CliCommandEvent { wasCreated?: boolean; }; + /** A push from a directory without an Actor of its own: how many Actors were chosen and how many failed. */ + pushSelection?: { + selected: number; + failed: number; + }; + // init command actorWrapper?: string; From 4b8a2073a94c98588a921f90eadf482d5dd9e279 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Mon, 26 Oct 2026 15:10:10 +0100 Subject: [PATCH 24/44] test: cover --all against the platform and the --all with --actor-dir rejection --- test/api/commands/push.test.ts | 46 +++++++++++++++++++++++++-- test/e2e/commands/push-select.test.ts | 8 +++++ 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/test/api/commands/push.test.ts b/test/api/commands/push.test.ts index 348b18af5..857f7c46f 100644 --- a/test/api/commands/push.test.ts +++ b/test/api/commands/push.test.ts @@ -1,5 +1,6 @@ import { existsSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'; -import { mkdir, writeFile } from 'node:fs/promises'; +import { cp, mkdir, rm, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; import type { ActorCollectionCreateOptions } from 'apify-client'; @@ -45,7 +46,7 @@ const { forceNewCwd, } = useTempPath(ACTOR_NAME, { create: true, remove: true, cwd: true, cwdParent: true }); -const { lastErrorMessage } = useConsoleSpy(); +const { lastErrorMessage, logMessages } = useConsoleSpy(); const { CreateCommand } = await import('../../../src/commands/create.js'); const { ActorsPushCommand } = await import('../../../src/commands/actors/push.js'); @@ -619,4 +620,45 @@ describe('[api] apify push', () => { }, TEST_TIMEOUT, ); + + it( + 'should push every Actor below a root without an Actor of its own with --all', + async () => { + const rootName = useUniqueId('cli-push-all'); + const root = join(tmpPath, '..', rootName); + const actorNames = [useUniqueId('cli-push-first'), useUniqueId('cli-push-second')]; + + try { + // Two copies of the created Actor, each with its own name, below a root that is not an Actor. + for (const [index, dir] of ['actors/first', 'actors/second'].entries()) { + await cp(tmpPath, join(root, dir), { recursive: true }); + const actorJson = JSON.parse(readFileSync(join(root, dir, LOCAL_CONFIG_PATH), 'utf8')); + actorJson.name = actorNames[index]; + writeFileSync(join(root, dir, LOCAL_CONFIG_PATH), JSON.stringify(actorJson, null, '\t'), { flag: 'w' }); + } + + forceNewCwd(rootName); + logMessages.log.length = 0; + + await testRunCommand(ActorsPushCommand, { flags_all: true, flags_json: true, flags_force: true }); + + const userInfo = await getLocalUserInfo(); + for (const name of actorNames) { + actorsForCleanup.add(`${userInfo.username}/${name}`); + } + + const summary = JSON.parse(logMessages.log.at(-1)!); + + expect(summary).toMatchObject({ ok: true, operation: 'push', exitCode: 0 }); + expect(summary.results.map((entry: { dir: string }) => entry.dir)).toEqual(['actors/first', 'actors/second']); + expect(summary.results.map((entry: { build: { status: string } }) => entry.build.status)).toEqual([ + 'SUCCEEDED', + 'SUCCEEDED', + ]); + } finally { + await rm(root, { recursive: true, force: true }); + } + }, + TEST_TIMEOUT, + ); }); diff --git a/test/e2e/commands/push-select.test.ts b/test/e2e/commands/push-select.test.ts index a02cfcd46..1372906aa 100644 --- a/test/e2e/commands/push-select.test.ts +++ b/test/e2e/commands/push-select.test.ts @@ -45,6 +45,14 @@ describe('[e2e] apify push in a repository with several Actors', () => { expect(result.stdout).toBe(''); }); + it('rejects --all together with --actor-dir', async () => { + const result = await runCli('apify', ['push', '--all', '--actor-dir', 'actors/first'], { cwd: root }); + + expect(result.exitCode, `stdout: ${result.stdout}\nstderr: ${result.stderr}`).not.toBe(0); + expect(result.stderr).toContain('cannot also be provided when using'); + 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 }); From e11dfb7bdb3c43209e938349a1d30d107ce51447 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Mon, 26 Oct 2026 15:10:21 +0100 Subject: [PATCH 25/44] test: pin the empty stdout of a rejected actorId argument under --json --- test/local/commands/push-select.test.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/test/local/commands/push-select.test.ts b/test/local/commands/push-select.test.ts index de1daddea..2f13f553a 100644 --- a/test/local/commands/push-select.test.ts +++ b/test/local/commands/push-select.test.ts @@ -297,6 +297,13 @@ describe('apify push in a repository with several Actors', () => { expect(getLoggedClientOrThrow).not.toHaveBeenCalled(); }); + it('prints nothing on stdout when the actorId argument is rejected under --json', async () => { + await run({ args_actorId: ACTOR_A.id, flags_all: true, flags_json: true }); + + expect(commandProcess.exitCode).toBe(CommandExitCodes.InvalidInput); + expect(logMessages.log).toEqual([]); + }); + it('pushes to the actorId argument when exactly one Actor is selected', async () => { await run({ args_actorId: ACTOR_C.id, flags_actorDir: ['actors/a'] }); From e0938801dd26207f264eaa1e5944285686f9f7db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Mon, 26 Oct 2026 16:10:44 +0100 Subject: [PATCH 26/44] fix: end the process on a signal outside a build wait in a multi-Actor push The loop-level listener kept a signal from terminating the CLI anywhere in the selection, so a Ctrl+C during an upload let the current push run on. Outside a build wait the listener now names the Actors not pushed (and, with --json, prints the results so far), removes itself and raises the signal again, so Node's default action ends the process as it does for a single Actor. During a build wait the build is still aborted and the loop stops after it. --- src/commands/actors/push.ts | 47 ++++++++++++++---- src/lib/hooks/useAbortJobOnSignal.ts | 25 +++++++++- src/lib/hooks/useSignalHandler.ts | 8 ++++ test/local/commands/push-select.test.ts | 64 ++++++++++++++++++++++--- 4 files changed, 128 insertions(+), 16 deletions(-) diff --git a/src/commands/actors/push.ts b/src/commands/actors/push.ts index febca58ca..4e7625066 100644 --- a/src/commands/actors/push.ts +++ b/src/commands/actors/push.ts @@ -1,8 +1,9 @@ -import { existsSync, readFileSync, statSync, unlinkSync } from 'node:fs'; +import { existsSync, readFileSync, statSync, unlinkSync, writeSync } from 'node:fs'; import { basename, isAbsolute, join, resolve } from 'node:path'; import process from 'node:process'; import type { Actor, ActorCollectionCreateOptions, ActorDefaultRunOptions } from 'apify-client'; +import chalk from 'chalk'; import { isCI } from 'ci-info'; import open from 'open'; @@ -28,11 +29,11 @@ import { 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 { isAbortingJobOnSignal, 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 { useSignalHandler } from '../../lib/hooks/useSignalHandler.js'; +import { reraiseSignal, useSignalHandler } from '../../lib/hooks/useSignalHandler.js'; import { useStdin } from '../../lib/hooks/useStdin.js'; import { error, info, run, simpleLog, warning } from '../../lib/outputs.js'; import { transformEnvToEnvVars } from '../../lib/secrets.js'; @@ -95,6 +96,24 @@ interface PushSelectionResult { const SELECTION_HINT = 'Choose what to push: --all, or --actor-dir for each Actor.'; +const interruptedMessage = (notPushed: string[]) => `Interrupted. Not pushed: ${notPushed.join(', ')}`; + +/** + * Says which Actors a signal left out, right before that signal ends the process. The writes are synchronous because + * output still queued when the process ends is lost; a failed write must not keep the process alive. + */ +function reportInterruptBeforeExit(notPushed: string[], summary: PushSelectionResult | undefined) { + try { + writeSync(2, `${chalk.yellow.bold('Warning:')} ${interruptedMessage(notPushed)}\n`); + + if (summary) { + writeSync(1, `${JSON.stringify(summary, null, 2)}\n`); + } + } catch { + // The process ends either way. + } +} + interface PushOutcome { resultLabel: string; exitCode?: number; @@ -358,14 +377,26 @@ export class ActorsPushCommand extends ApifyCommand { const results: SelectedPushResult[] = []; let firstFailureExitCode: number | undefined; - // Each push aborts its own build on Ctrl+C and returns; without this, the loop would then push the next Actor. + // A signal during a build wait aborts that build and the push returns; the loop then stops instead of pushing + // the next Actor. A signal anywhere else ends the process, as it does for a single Actor, once the Actors left + // out are named. let interrupted = false; - using _interruptWatcher = useSignalHandler({ + using interruptWatcher = useSignalHandler({ signals: INTERRUPT_SIGNALS, once: false, - cleanTerminalLine: false, - handler: () => { + handler: (signal) => { interrupted = true; + + if (isAbortingJobOnSignal()) { + return; + } + + reportInterruptBeforeExit( + selected.slice(results.length), + this.flags.json ? { ok: false, operation: 'push', results } : undefined, + ); + interruptWatcher[Symbol.dispose](); + reraiseSignal(signal); }, }); @@ -397,7 +428,7 @@ export class ActorsPushCommand extends ApifyCommand { if (interrupted) { const notPushed = selected.slice(index + 1); if (notPushed.length) { - warning({ message: `Interrupted. Not pushed: ${notPushed.join(', ')}` }); + warning({ message: interruptedMessage(notPushed) }); } break; } diff --git a/src/lib/hooks/useAbortJobOnSignal.ts b/src/lib/hooks/useAbortJobOnSignal.ts index ff02872ce..b211ab4ec 100644 --- a/src/lib/hooks/useAbortJobOnSignal.ts +++ b/src/lib/hooks/useAbortJobOnSignal.ts @@ -27,6 +27,14 @@ export type UseAbortJobOnSignalInput = { } ); +// How many handlers from `useAbortJobOnSignal` are registered right now. +let registeredHandlers = 0; + +/** Whether an interrupt signal received now aborts a job on the platform through a {@link useAbortJobOnSignal} handler. */ +export function isAbortingJobOnSignal(): boolean { + return registeredHandlers > 0; +} + /** * Registers a signal handler that aborts the given build or run on the Apify * platform, and returns a `Disposable` that removes it. Pair with the `using` @@ -61,7 +69,7 @@ export function useAbortJobOnSignal(input: UseAbortJobOnSignalInput): Disposable let abortAttempt = 0; - return useSignalHandler({ + const signalHandler = useSignalHandler({ signals: INTERRUPT_SIGNALS, once: false, handler: async (signal) => { @@ -118,4 +126,19 @@ export function useAbortJobOnSignal(input: UseAbortJobOnSignalInput): Disposable } }, }); + + registeredHandlers += 1; + let disposed = false; + + return { + [Symbol.dispose]() { + if (disposed) { + return; + } + + disposed = true; + registeredHandlers -= 1; + signalHandler[Symbol.dispose](); + }, + }; } diff --git a/src/lib/hooks/useSignalHandler.ts b/src/lib/hooks/useSignalHandler.ts index 7aedb7e24..59c205be2 100644 --- a/src/lib/hooks/useSignalHandler.ts +++ b/src/lib/hooks/useSignalHandler.ts @@ -151,3 +151,11 @@ export function useSignalHandler({ }, }; } + +/** + * Sends `signal` to the process again, so that with the caller's own handler disposed the process ends as it does when + * nothing handles the signal (Node's default action). + */ +export function reraiseSignal(signal: NodeJS.Signals): void { + process.kill(process.pid, signal); +} diff --git a/test/local/commands/push-select.test.ts b/test/local/commands/push-select.test.ts index 2f13f553a..8567d4833 100644 --- a/test/local/commands/push-select.test.ts +++ b/test/local/commands/push-select.test.ts @@ -28,6 +28,13 @@ vitest.mock('../../../src/lib/hooks/user-confirmations/useYesNoConfirm.js', () = useYesNoConfirm: yesNoConfirm, })); +// The lines the command writes synchronously right before a signal ends the process. +const writeSync = vitest.hoisted(() => vitest.fn()); +vitest.mock('node:fs', async (importOriginal) => ({ + ...(await importOriginal()), + writeSync, +})); + interface PlatformActor { id: string; name: string; @@ -57,6 +64,7 @@ const platform = vitest.hoisted(() => { const versionUpdate = vitest.fn(async (_actorId: string, _version: string, _modifier: unknown) => ({})); // Stands in for the build wait; a test can emit a signal from here to interrupt the push. const outputJobLog = vitest.fn(async () => {}); + const buildAbort = vitest.fn(async (_buildId: string) => ({})); const getLoggedClientOrThrow = vitest.fn(async () => ({ baseUrl: 'https://api.apify.com/v2', @@ -80,14 +88,17 @@ const platform = vitest.hoisted(() => { build: async () => buildFor(actor!.id), }; }, - build: (buildId: string) => ({ get: async () => buildFor(buildId.replace('build-', '')) }), + build: (buildId: string) => ({ + get: async () => buildFor(buildId.replace('build-', '')), + abort: async () => buildAbort(buildId), + }), log: () => ({ get: async () => 'npm ERR! boom\n' }), })); - return { state, versionUpdate, outputJobLog, getLoggedClientOrThrow }; + return { state, versionUpdate, outputJobLog, buildAbort, getLoggedClientOrThrow }; }); -const { versionUpdate, outputJobLog, getLoggedClientOrThrow } = platform; +const { versionUpdate, outputJobLog, buildAbort, 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) => ({ @@ -97,10 +108,15 @@ vitest.mock('../../../src/lib/utils.js', async (importOriginal) => ({ 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]() {} }), -})); +// The real build-abort handler, kept off stdout so the --json document stays the only output there. +vitest.mock('../../../src/lib/hooks/useAbortJobOnSignal.js', async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + useAbortJobOnSignal: (input: Parameters[0]) => + original.useAbortJobOnSignal({ ...input, silent: true }), + }; +}); const { beforeAllCalls, afterAllCalls, joinPath, forceNewCwd } = useTempPath('push-select-root', { create: true, @@ -148,6 +164,8 @@ beforeEach(async () => { platform.state.gitSourcedActorIds = []; versionUpdate.mockClear(); outputJobLog.mockClear(); + buildAbort.mockClear(); + writeSync.mockClear(); getLoggedClientOrThrow.mockClear(); multiSelect.mockReset(); yesNoConfirm.mockReset(); @@ -405,8 +423,12 @@ describe('apify push in a repository with several Actors', () => { process.emit('SIGINT', 'SIGINT'); }); + const kill = vitest.spyOn(process, 'kill'); + await run({ flags_all: true, flags_json: true }); + expect(buildAbort).toHaveBeenCalledWith(`build-${ACTOR_A.id}`); + expect(kill).not.toHaveBeenCalled(); expect(commandProcess.exitCode).toBe(CommandExitCodes.BuildAborted); expect(pushedActorIds()).toEqual([ACTOR_A.id]); expect(stderr()).toContain('Interrupted. Not pushed: actors/b, tools/c'); @@ -417,6 +439,34 @@ describe('apify push in a repository with several Actors', () => { expect.objectContaining({ dir: 'actors/a', ok: false, exitCode: CommandExitCodes.BuildAborted }), ]); }); + it('ends the process on a signal outside a build wait, after naming the Actors not pushed', async () => { + // What the process would do with the signal: no listener may be left, so Node's default action ends it. + const listenersAtKill: number[] = []; + const kill = vitest.spyOn(process, 'kill').mockImplementation(() => { + listenersAtKill.push(process.listenerCount('SIGTERM')); + return true; + }); + // The signal arrives while Actor A's files are uploaded, before its build starts. + versionUpdate.mockImplementationOnce(async () => { + process.emit('SIGTERM', 'SIGTERM'); + return {}; + }); + + await run({ flags_all: true, flags_json: true }); + + expect(kill).toHaveBeenCalledExactlyOnceWith(process.pid, 'SIGTERM'); + expect(listenersAtKill).toEqual([0]); + expect(buildAbort).not.toHaveBeenCalled(); + expect(writeSync).toHaveBeenCalledWith( + 2, + expect.stringContaining('Interrupted. Not pushed: actors/a, actors/b, tools/c'), + ); + const [, document] = writeSync.mock.calls.find(([fd]) => fd === 1)!; + expect(JSON.parse(document as string)).toEqual({ ok: false, operation: 'push', results: [] }); + // With the process gone, Actor A's push never finishes and no other Actor is written. + expect(pushedActorIds()).toEqual([ACTOR_A.id]); + expect(process.listenerCount('SIGTERM')).toBe(0); + }); }); describe('--json', () => { From 824615776ee60dd50e1ee29739d142be371cfdcf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Mon, 26 Oct 2026 16:10:45 +0100 Subject: [PATCH 27/44] fix: stop a multi-Actor push after Ctrl+C at a prompt Ctrl+C at the Git-source question rejects the prompt, which ends a single push. In the loop the error was caught and the next Actor was pushed; the loop now stops and names the Actors not pushed. --- src/commands/actors/push.ts | 2 ++ test/local/commands/push-select.test.ts | 14 ++++++++++++++ 2 files changed, 16 insertions(+) diff --git a/src/commands/actors/push.ts b/src/commands/actors/push.ts index 4e7625066..c47b03a85 100644 --- a/src/commands/actors/push.ts +++ b/src/commands/actors/push.ts @@ -412,6 +412,8 @@ export class ActorsPushCommand extends ApifyCommand { } catch (err) { error({ message: (err as Error).message }); process.exitCode ||= 1; + // Ctrl+C at a prompt (the Git-source question) ends a single push; here it stops the loop too. + interrupted ||= (err as Error).name === 'ExitPromptError'; } const exitCode = typeof process.exitCode === 'number' && process.exitCode !== 0 ? process.exitCode : undefined; diff --git a/test/local/commands/push-select.test.ts b/test/local/commands/push-select.test.ts index 8567d4833..ce1c4f3a6 100644 --- a/test/local/commands/push-select.test.ts +++ b/test/local/commands/push-select.test.ts @@ -467,6 +467,20 @@ describe('apify push in a repository with several Actors', () => { expect(pushedActorIds()).toEqual([ACTOR_A.id]); expect(process.listenerCount('SIGTERM')).toBe(0); }); + + it('stops after Ctrl+C at the Git-source question and names the ones not pushed', async () => { + platform.state.gitSourcedActorIds = [ACTOR_A.id]; + yesNoConfirm.mockRejectedValueOnce( + Object.assign(new Error('User force closed the prompt with SIGINT'), { name: 'ExitPromptError' }), + ); + + await run({ flags_all: true }); + + expect(commandProcess.exitCode).toBe(1); + expect(pushedActorIds()).toEqual([]); + expect(stderr()).toContain('User force closed the prompt with SIGINT'); + expect(stderr()).toContain('Interrupted. Not pushed: actors/b, tools/c'); + }); }); describe('--json', () => { From 50fb1b8462035599b0e096dc14f68a44c73067c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Tue, 27 Oct 2026 15:08:38 +0100 Subject: [PATCH 28/44] docs: explain why the Docker context is judged by its path as written --- src/lib/docker-context.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/lib/docker-context.ts b/src/lib/docker-context.ts index 313ec90a4..e0db40895 100644 --- a/src/lib/docker-context.ts +++ b/src/lib/docker-context.ts @@ -65,6 +65,9 @@ export function resolveDockerContext(actorDir: string, dockerContextDir: unknown } const realContextRoot = realpathSync(contextRoot); + // Inside or outside the Actor folder is judged by the path as written, not by where a symlink on it leads. Push + // follows a symlink and uploads its files under the link's own name: with `"dockerContextDir": "../sym"` and `sym` + // linking to a folder outside, the platform receives `sym` as a normal folder inside the Actor and builds from it. const contextPathInActor = relative(realActorDir, contextRoot); if (contextPathInActor === '' || !isOutside(contextPathInActor)) { return { kind: 'within', contextRoot: realContextRoot }; From 1af9abf48b82cc617127316af7ff8c59ee583b8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Tue, 27 Oct 2026 15:08:39 +0100 Subject: [PATCH 29/44] test: pin the Docker context classification through symlinks --- test/local/lib/docker-context.test.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/test/local/lib/docker-context.test.ts b/test/local/lib/docker-context.test.ts index e5826da47..6e4a7c72f 100644 --- a/test/local/lib/docker-context.test.ts +++ b/test/local/lib/docker-context.test.ts @@ -45,6 +45,7 @@ describe('resolveDockerContext', () => { writeFileSync(joinPath('Dockerfile'), 'FROM apify/actor-node:20\n'); writeFileSync(joinPath(ACTOR_PATH, 'Dockerfile'), 'FROM apify/actor-node:20\n'); symlinkSync(ACTOR_PATH, joinPath('linked-actor')); + symlinkSync('../../shared', joinPath(ACTOR_PATH, 'sym')); execFileSync('git', ['init', '--quiet'], { cwd: tmpPath, stdio: 'ignore' }); root = realpathSync(tmpPath); }); @@ -110,6 +111,17 @@ describe('resolveDockerContext', () => { }); }); + it('judges the context by its path as written, as push uploads a symlink under its own name', () => { + // `sym` inside the Actor folder links to the shared folder outside it; push uploads it as a folder inside. + expect(resolveDockerContext(actorDir(), '../sym')).toMatchObject({ kind: 'within' }); + // This path leaves the Actor folder through a link that leads back in; the upload has no such link. + expect(resolveDockerContext(actorDir(), '../../../linked-actor/src')).toMatchObject({ + kind: 'outside', + sourceRoot: root, + actorPath: ACTOR_PATH, + }); + }); + it('rejects a context outside the Git repository', () => { expect(resolveDockerContext(actorDir(), '../../../..')).toEqual({ kind: 'invalid', From 944b6ae3be3acb4581dedf7ff3d1832c5d2f35e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Tue, 27 Oct 2026 15:37:22 +0100 Subject: [PATCH 30/44] fix: return the Docker context path as the upload lays it out --- src/lib/docker-context.ts | 24 +++++++++++++++--------- test/local/lib/docker-context.test.ts | 10 +++++++--- 2 files changed, 22 insertions(+), 12 deletions(-) diff --git a/src/lib/docker-context.ts b/src/lib/docker-context.ts index e0db40895..1555cc48d 100644 --- a/src/lib/docker-context.ts +++ b/src/lib/docker-context.ts @@ -15,10 +15,15 @@ export type DockerContextResolution = /** `dockerContextDir` is absent: undefined, null, or an empty string. */ | { kind: 'none' } /** The context is the Actor folder itself or a directory inside it, so a push of the folder carries it. */ - | { kind: 'within'; contextRoot: string } + | { + kind: 'within'; + /** As the upload lays it out: the path as written, from the Actor folder's real path. */ + contextRoot: string; + } /** The context lies beyond the Actor folder (a monorepo Actor): above it, or elsewhere in the Git repository. */ | { kind: 'outside'; + /** As the upload lays it out: the path as written, from the Actor folder's real path. */ contextRoot: string; /** The Git repository's root, else the context. */ sourceRoot: string; @@ -32,8 +37,8 @@ export type DockerContextResolution = * `dockerContextDir` is relative to `.actor/`, as on the platform. An absent field is `none`, with no path work. A * context at or below the Actor folder is `within`: an ordinary push, which the platform builds as it is. A context * above or beside the Actor folder is `outside`: a monorepo Actor, with the source root a push would have to carry. - * A context that cannot be used is `invalid`, with the message to show. Paths come back real, as the platform sees - * them. + * A context that cannot be used is `invalid`, with the message to show. The context path comes back as the upload + * lays it out, the source root as a real path. */ export function resolveDockerContext(actorDir: string, dockerContextDir: unknown): DockerContextResolution { if (dockerContextDir === undefined || dockerContextDir === null || dockerContextDir === '') return { kind: 'none' }; @@ -65,12 +70,13 @@ export function resolveDockerContext(actorDir: string, dockerContextDir: unknown } const realContextRoot = realpathSync(contextRoot); - // Inside or outside the Actor folder is judged by the path as written, not by where a symlink on it leads. Push - // follows a symlink and uploads its files under the link's own name: with `"dockerContextDir": "../sym"` and `sym` - // linking to a folder outside, the platform receives `sym` as a normal folder inside the Actor and builds from it. + // Inside or outside the Actor folder is judged, and returned, by the path as written, not by where a symlink on it + // leads. Push follows a symlink and uploads its files under the link's own name: with `"dockerContextDir": "../sym"` + // and `sym` linking to a folder outside, the platform receives `sym` as a normal folder inside the Actor and builds + // from it. const contextPathInActor = relative(realActorDir, contextRoot); if (contextPathInActor === '' || !isOutside(contextPathInActor)) { - return { kind: 'within', contextRoot: realContextRoot }; + return { kind: 'within', contextRoot }; } // The platform's clone holds nothing above the repository root. @@ -87,7 +93,7 @@ export function resolveDockerContext(actorDir: string, dockerContextDir: unknown } return { kind: 'outside', - contextRoot: realContextRoot, + contextRoot, sourceRoot, actorPath: toPosix(relative(sourceRoot, realActorDir)), }; @@ -100,7 +106,7 @@ export function resolveDockerContext(actorDir: string, dockerContextDir: unknown message: `"dockerContextDir" in .actor/actor.json points to ${realContextRoot}, which does not contain the Actor's folder.`, }; } - return { kind: 'outside', contextRoot: realContextRoot, sourceRoot: realContextRoot, actorPath: toPosix(actorPath) }; + return { kind: 'outside', contextRoot, sourceRoot: realContextRoot, actorPath: toPosix(actorPath) }; } function isOutside(relativePath: string): boolean { diff --git a/test/local/lib/docker-context.test.ts b/test/local/lib/docker-context.test.ts index 6e4a7c72f..4e92bbde7 100644 --- a/test/local/lib/docker-context.test.ts +++ b/test/local/lib/docker-context.test.ts @@ -60,7 +60,7 @@ describe('resolveDockerContext', () => { expect(resolveDockerContext(actorDir(), '')).toEqual({ kind: 'none' }); }); - it('reports a context at or below the Actor folder as within, with its real path', () => { + it('reports a context at or below the Actor folder as within, with its path as the upload lays it out', () => { const actorRoot = join(root, ACTOR_PATH); expect(resolveDockerContext(actorDir(), '..')).toEqual({ kind: 'within', contextRoot: actorRoot }); expect(resolveDockerContext(actorDir(), '.')).toEqual({ kind: 'within', contextRoot: join(actorRoot, '.actor') }); @@ -113,10 +113,14 @@ describe('resolveDockerContext', () => { it('judges the context by its path as written, as push uploads a symlink under its own name', () => { // `sym` inside the Actor folder links to the shared folder outside it; push uploads it as a folder inside. - expect(resolveDockerContext(actorDir(), '../sym')).toMatchObject({ kind: 'within' }); + expect(resolveDockerContext(actorDir(), '../sym')).toEqual({ + kind: 'within', + contextRoot: join(root, ACTOR_PATH, 'sym'), + }); // This path leaves the Actor folder through a link that leads back in; the upload has no such link. - expect(resolveDockerContext(actorDir(), '../../../linked-actor/src')).toMatchObject({ + expect(resolveDockerContext(actorDir(), '../../../linked-actor/src')).toEqual({ kind: 'outside', + contextRoot: join(root, 'linked-actor', 'src'), sourceRoot: root, actorPath: ACTOR_PATH, }); From ddd1c35debdec919f851921180ce015abe66593e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Tue, 27 Oct 2026 16:16:52 +0100 Subject: [PATCH 31/44] test: expect the Console build URL without the hash in push selection tests --- test/local/commands/push-select.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/local/commands/push-select.test.ts b/test/local/commands/push-select.test.ts index ce1c4f3a6..14b864898 100644 --- a/test/local/commands/push-select.test.ts +++ b/test/local/commands/push-select.test.ts @@ -501,7 +501,7 @@ describe('apify push in a repository with several Actors', () => { id: `build-${ACTOR_A.id}`, number: '0.0.1', status: 'SUCCEEDED', - url: expect.stringContaining('#/builds/0.0.1'), + url: expect.stringContaining('/builds/0.0.1'), }, exitCode: 0, }); @@ -535,7 +535,7 @@ describe('apify push in a repository with several Actors', () => { id: `build-${ACTOR_A.id}`, number: '0.0.1', status: 'SUCCEEDED', - url: expect.stringContaining('#/builds/0.0.1'), + url: expect.stringContaining('/builds/0.0.1'), }, exitCode: 0, }); From 39dc8cb0373141995a607cbd8def8634a4692070 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Tue, 27 Oct 2026 16:23:58 +0100 Subject: [PATCH 32/44] refactor: use @inquirer/checkbox for the Actor multi-select --- package.json | 1 + pnpm-lock.yaml | 42 ++++++++ .../useMultiSelectFromList.ts | 9 +- src/lib/utils/inquirer/checkboxImpl.ts | 98 ------------------- 4 files changed, 49 insertions(+), 101 deletions(-) delete mode 100644 src/lib/utils/inquirer/checkboxImpl.ts diff --git a/package.json b/package.json index f4445f9d0..d102a267c 100644 --- a/package.json +++ b/package.json @@ -73,6 +73,7 @@ "@apify/json_schemas": "^1.0.1", "@apify/utilities": "^3.0.1", "@crawlee/memory-storage": "^3.18.1", + "@inquirer/checkbox": "^5.2.7", "@inquirer/core": "^12.0.3", "@inquirer/input": "^5.1.6", "@inquirer/password": "^5.2.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 73bab1edd..e18db0023 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -190,6 +190,9 @@ importers: '@crawlee/memory-storage': specifier: ^3.18.1 version: 3.18.2 + '@inquirer/checkbox': + specifier: ^5.2.7 + version: 5.2.7(@types/node@24.19.2) '@inquirer/core': specifier: ^12.0.3 version: 12.0.4(@types/node@24.19.2) @@ -2426,6 +2429,15 @@ packages: resolution: {integrity: sha512-0Ywt3S8tqM6TWUcsRlBFQ1x+X3/o8kktX0ZiKAnByYb9GFDknnOhse1PVtfiD2BXPI8ar+BWx9PV9R0lQIcygw==} engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + '@inquirer/checkbox@5.2.7': + resolution: {integrity: sha512-/+QmGE/9bvE/EobaZDnzYe+T+QWoJrnmxwBQNtoi2OVSUR9d1znYsNyEoUrOZvFSxDYttrhzRIpXpaLkN+iIsg==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + '@inquirer/core@12.0.4': resolution: {integrity: sha512-M5fB67y7jPObI45vhD82RskWwCSm0scOvenV/I6K8tIi2gfr9DXqQqJ+iw+Wi6SgapikdOiJibYzz1gW47fsDg==} engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} @@ -2435,6 +2447,15 @@ packages: '@types/node': optional: true + '@inquirer/core@12.0.5': + resolution: {integrity: sha512-c0ZSnO9oVk/IlEGoZK3u8b3IZQeu9I6jUkXJSQnQEVRXsjKvtAq3AGqFL2LgfJhlzMZne08pZHWRWasIcI3Rew==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + '@inquirer/figures@2.0.9': resolution: {integrity: sha512-EAWgUTGQ/Umgga51dE3B2PUHbufuXarDfg86uVgoSgNHNNQnyFKcOrQLWVqYMghuSyHh8+2HUH0Js9cTC1WAdg==} engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} @@ -12700,6 +12721,15 @@ snapshots: '@inquirer/ansi@2.0.9': {} + '@inquirer/checkbox@5.2.7(@types/node@24.19.2)': + dependencies: + '@inquirer/ansi': 2.0.9 + '@inquirer/core': 12.0.5(@types/node@24.19.2) + '@inquirer/figures': 2.0.9 + '@inquirer/type': 4.2.0(@types/node@24.19.2) + optionalDependencies: + '@types/node': 24.19.2 + '@inquirer/core@12.0.4(@types/node@24.19.2)': dependencies: '@inquirer/ansi': 2.0.9 @@ -12712,6 +12742,18 @@ snapshots: optionalDependencies: '@types/node': 24.19.2 + '@inquirer/core@12.0.5(@types/node@24.19.2)': + dependencies: + '@inquirer/ansi': 2.0.9 + '@inquirer/figures': 2.0.9 + '@inquirer/type': 4.2.0(@types/node@24.19.2) + cli-width: 4.1.0 + fast-wrap-ansi: 0.2.2 + mute-stream: 3.0.0 + signal-exit: 4.1.0 + optionalDependencies: + '@types/node': 24.19.2 + '@inquirer/figures@2.0.9': {} '@inquirer/input@5.1.7(@types/node@24.19.2)': diff --git a/src/lib/hooks/user-confirmations/useMultiSelectFromList.ts b/src/lib/hooks/user-confirmations/useMultiSelectFromList.ts index ade74e567..d234947e5 100644 --- a/src/lib/hooks/user-confirmations/useMultiSelectFromList.ts +++ b/src/lib/hooks/user-confirmations/useMultiSelectFromList.ts @@ -1,16 +1,19 @@ -import { type CheckboxChoice, checkboxImpl } from '../../utils/inquirer/checkboxImpl.js'; +import checkbox from '@inquirer/checkbox'; + import { promptContext, type StdinCheckWrapperInput, stdinCheckWrapper } from './_stdinCheckWrapper.js'; +export type MultiSelectChoicesType = Parameters>[0]['choices']; + interface UseMultiSelectFromListInput { message: string; - choices: CheckboxChoice[]; + choices: MultiSelectChoicesType; pageSize?: number; loop?: boolean; } export const useMultiSelectFromList = stdinCheckWrapper( async ({ message, choices, pageSize, loop }: UseMultiSelectFromListInput) => { - const result = await checkboxImpl({ message, choices, pageSize, loop }, promptContext); + const result = await checkbox({ message, choices, pageSize, loop }, promptContext); return result; }, diff --git a/src/lib/utils/inquirer/checkboxImpl.ts b/src/lib/utils/inquirer/checkboxImpl.ts deleted file mode 100644 index 47d677792..000000000 --- a/src/lib/utils/inquirer/checkboxImpl.ts +++ /dev/null @@ -1,98 +0,0 @@ -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, rl) => { - 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); - } - } else if (isSpaceKey(key)) { - setChecked(checked.map((value, index) => (index === active ? !value : value))); - } else if (key.name === 'a') { - const allChecked = checked.every(Boolean); - setChecked(checked.map(() => !allChecked)); - } else if (key.name === 'i') { - setChecked(checked.map((value) => !value)); - } - - // Readline still buffers every key; drop it so the cursor placement does not drift, as confirmImpl does. - rl.clearLine(0); - }); - - 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}`; -}); From f7d846d1c941710fd3973dd1773638053288b3c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Thu, 29 Oct 2026 09:58:03 +0100 Subject: [PATCH 33/44] Update src/commands/actors/push.ts Co-authored-by: Edyta <142720610+szaganek@users.noreply.github.com> --- 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 5d99ac9eb..bc989611e 100644 --- a/src/commands/actors/push.ts +++ b/src/commands/actors/push.ts @@ -287,7 +287,7 @@ export class ActorsPushCommand extends ApifyCommand { required: false, }), all: Flags.boolean({ - description: 'When the directory has no Actor of its own, push every Actor found in the directories below it.', + description: 'Push every Actor found in the subdirectories. Works only from a directory without an Actor config of its own.', default: false, required: false, exclusive: ['actor-dir'], From 86c4d0bb26ad138ae492d3027a8da53477c19ae0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Thu, 29 Oct 2026 09:58:40 +0100 Subject: [PATCH 34/44] Update src/commands/actors/push.ts Co-authored-by: Edyta <142720610+szaganek@users.noreply.github.com> --- 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 bc989611e..d676f2175 100644 --- a/src/commands/actors/push.ts +++ b/src/commands/actors/push.ts @@ -294,7 +294,7 @@ export class ActorsPushCommand extends ApifyCommand { }), '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; they are pushed in path order.', + 'Push the Actor in this subdirectory. The path is relative to the directory you push from. To push several Actors, repeat the flag. Actors are pushed in path order. Works only from a directory without an Actor config of its own.', required: false, multiple: true, exclusive: ['all'], From 0765904c4365ca17a941bf7590d7bc9835a6181e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Fri, 30 Oct 2026 09:50:01 +0100 Subject: [PATCH 35/44] style: wrap the --all flag description to satisfy the formatter --- src/commands/actors/push.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/commands/actors/push.ts b/src/commands/actors/push.ts index d676f2175..4008c4f43 100644 --- a/src/commands/actors/push.ts +++ b/src/commands/actors/push.ts @@ -287,7 +287,8 @@ export class ActorsPushCommand extends ApifyCommand { required: false, }), all: Flags.boolean({ - description: 'Push every Actor found in the subdirectories. Works only from a directory without an Actor config of its own.', + description: + 'Push every Actor found in the subdirectories. Works only from a directory without an Actor config of its own.', default: false, required: false, exclusive: ['actor-dir'], From 68675e1fe86d8ee4b94b083ae21ba507f3031409 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Fri, 30 Oct 2026 09:50:59 +0100 Subject: [PATCH 36/44] docs: regenerate the command reference --- docs/reference.md | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/docs/reference.md b/docs/reference.md index 9f78f4917..e36a8ccf2 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -873,13 +873,15 @@ 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; they are pushed in path order. - --all When the directory has - no Actor of its own, push every Actor found in the - directories below it. + --actor-dir= Push the Actor in this + subdirectory. The path is relative to the directory + you push from. To push several Actors, repeat the + flag. Actors are pushed in path order. Works only + from a directory without an Actor config of its + own. + --all Push every Actor found + in the subdirectories. Works only from a directory + without an Actor config of its own. --allow-missing-secrets Allow the command to continue even when secret values are not found in the local secrets storage. From 8dff92ad249cff8155f0611590c6307e715a0cea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Fri, 30 Oct 2026 14:37:54 +0100 Subject: [PATCH 37/44] refactor: drop --actor-dir from apify push in favor of --dir One Actor could be pushed three ways (--dir, --actor-dir, or both), which confuses users and agents. --all and the interactive prompt stay. Without a terminal and without --all, the listing now points to --dir and shows paths that work as its value from where the command ran. --- docs/reference.md | 8 +-- src/commands/actors/push.ts | 59 +++++------------ test/e2e/commands/push-select.test.ts | 25 ++----- test/local/commands/push-select.test.ts | 88 ++++++++----------------- 4 files changed, 47 insertions(+), 133 deletions(-) diff --git a/docs/reference.md b/docs/reference.md index e36a8ccf2..20e5c6c48 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -862,7 +862,7 @@ DESCRIPTION Use --force to override newer remote versions. USAGE - $ apify actors push [actorId] [--actor-dir | --all] + $ apify actors push [actorId] [--all] [--allow-missing-secrets] [--apply-env-vars-to-build] [-b ] [--dir ] [-f] [--json] [--open] [--profile ] [-v ] [-w ] @@ -873,12 +873,6 @@ ARGUMENTS modify the Actor with the name specified in '.actor/actor.json' file. FLAGS - --actor-dir= Push the Actor in this - subdirectory. The path is relative to the directory - you push from. To push several Actors, repeat the - flag. Actors are pushed in path order. Works only - from a directory without an Actor config of its - own. --all Push every Actor found in the subdirectories. Works only from a directory without an Actor config of its own. diff --git a/src/commands/actors/push.ts b/src/commands/actors/push.ts index a8a36f043..ae54a482c 100644 --- a/src/commands/actors/push.ts +++ b/src/commands/actors/push.ts @@ -1,5 +1,5 @@ -import { existsSync, readFileSync, statSync, unlinkSync, writeSync } from 'node:fs'; -import { basename, isAbsolute, join, resolve } from 'node:path'; +import { readFileSync, statSync, unlinkSync, writeSync } from 'node:fs'; +import { basename, join, resolve } from 'node:path'; import process from 'node:process'; import type { Actor, ActorCollectionCreateOptions, ActorDefaultRunOptions, Build } from 'apify-client'; @@ -91,7 +91,7 @@ interface PushSelectionResult { exitCode?: number; } -const SELECTION_HINT = 'Choose what to push: --all, or --actor-dir for each Actor.'; +const SELECTION_HINT = 'Choose what to push: --all, or run apify push --dir for each Actor.'; const interruptedMessage = (notPushed: string[]) => `Interrupted. Not pushed: ${notPushed.join(', ')}`; @@ -245,8 +245,8 @@ export class ActorsPushCommand extends ApifyCommand { 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', + description: 'From the root of a repository with several Actors, deploy all of them.', + command: 'apify push --all', }, ]; @@ -292,14 +292,6 @@ export class ActorsPushCommand extends ApifyCommand { 'Push every Actor found in the subdirectories. Works only from a directory without an Actor config of its own.', default: false, required: false, - exclusive: ['actor-dir'], - }), - 'actor-dir': Flags.string({ - description: - 'Push the Actor in this subdirectory. The path is relative to the directory you push from. To push several Actors, repeat the flag. Actors are pushed in path order. Works only from a directory without an Actor config of its own.', - 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.', @@ -329,12 +321,12 @@ export class ActorsPushCommand extends ApifyCommand { 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. - // The selection flags have no meaning in an Actor directory, so they fail there instead of being ignored. + // --all has no meaning in an Actor directory, so it fails there instead of being ignored. if (hasOwnConfigFile(filePathsToPush)) { - if (this.flags.all || this.flags.actorDir?.length) { + if (this.flags.all) { error({ message: - 'This directory is an Actor itself, so --all and --actor-dir do not apply. Run the command without them, or from a directory above the Actors.', + "This directory is an Actor itself, so --all doesn't apply. Run the command without it, or from a directory above the Actors.", }); process.exitCode = CommandExitCodes.InvalidInput; return; @@ -342,8 +334,7 @@ export class ActorsPushCommand extends ApifyCommand { } else { const actorDirs = await findActorDirs(cwd); - // An explicit --actor-dir may name an Actor discovery does not reach (a symlink, an ignored folder). - if (actorDirs.length || this.flags.actorDir?.length) { + if (actorDirs.length) { await this.pushSelected(cwd, actorDirs); return; } @@ -366,7 +357,7 @@ export class ActorsPushCommand extends ApifyCommand { 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.`, + message: `The actorId argument applies to a single Actor, but ${selected.length} Actors are selected. Run apify push --dir for one Actor, or drop the argument.`, }); process.exitCode = CommandExitCodes.InvalidInput; return; @@ -456,31 +447,8 @@ export class ActorsPushCommand extends ApifyCommand { } } - /** Resolves the Actor directories to push from the flags or a prompt; `undefined` when the command stops here. */ + /** Resolves the Actor directories to push from --all or a prompt; `undefined` when the command stops here. */ private async selectActorDirs(root: string, actorDirs: FoundActorDir[]): Promise { - 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.sort(); - } - if (this.flags.all) { return actorDirs.map((actor) => actor.dir); } @@ -489,10 +457,13 @@ export class ActorsPushCommand extends ApifyCommand { const { isTTY } = await useStdin(); if (isCI || !isTTY) { + // Paths relative to where the command ran, so each one works as the --dir value. + const dirForFlag = (dir: string) => toPosixRelative(process.cwd(), join(root, dir)); + simpleLog({ message: [ 'This directory has no Actor of its own, but these directories below it do:', - ...actorDirs.map((actor) => ` ${label(actor)}`), + ...actorDirs.map((actor) => ` ${label({ ...actor, dir: dirForFlag(actor.dir) })}`), ].join('\n'), }); error({ message: SELECTION_HINT }); diff --git a/test/e2e/commands/push-select.test.ts b/test/e2e/commands/push-select.test.ts index 1372906aa..6477d7b02 100644 --- a/test/e2e/commands/push-select.test.ts +++ b/test/e2e/commands/push-select.test.ts @@ -31,33 +31,18 @@ describe('[e2e] apify push in a repository with several Actors', () => { 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).toContain('Choose what to push: --all, or run apify push --dir for each Actor.'); expect(result.stderr).not.toContain('login'); 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 }); + it('lists the paths to pass to --dir from where the command ran', async () => { + const result = await runCli('apify', ['push', '--dir', 'actors', '--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).toContain(' actors/first (first-actor)'); + expect(result.stderr).toContain(' actors/second (second-actor)'); expect(result.stderr).not.toContain('login'); expect(result.stdout).toBe(''); }); - - it('rejects --all together with --actor-dir', async () => { - const result = await runCli('apify', ['push', '--all', '--actor-dir', 'actors/first'], { cwd: root }); - - expect(result.exitCode, `stdout: ${result.stdout}\nstderr: ${result.stderr}`).not.toBe(0); - expect(result.stderr).toContain('cannot also be provided when using'); - 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 index 14b864898..420eaca02 100644 --- a/test/local/commands/push-select.test.ts +++ b/test/local/commands/push-select.test.ts @@ -1,4 +1,4 @@ -import { mkdir, rm, symlink, writeFile } from 'node:fs/promises'; +import { mkdir, writeFile } from 'node:fs/promises'; import { dirname } from 'node:path'; import { testRunCommand } from '../../../src/lib/command-framework/apify-command.js'; @@ -200,11 +200,23 @@ describe('apify push in a repository with several Actors', () => { 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).toContain('Choose what to push: --all, or run apify push --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('lists the paths to pass to --dir from where the command ran when --dir points elsewhere', async () => { + await run({ flags_dir: 'actors' }); + + expect(commandProcess.exitCode).toBe(CommandExitCodes.NoFilesToPush); + const output = stderr(); + expect(output).toContain(' actors/a (actor-a)'); + expect(output).toContain(' actors/b (actor-b)'); + expect(output).not.toContain('tools/c'); + expect(output).toContain('run apify push --dir for each Actor.'); + expect(getLoggedClientOrThrow).not.toHaveBeenCalled(); + }); + it('exits 4 with a single Actor below too, instead of guessing', async () => { await writeActor('single/one', ACTOR_A.name); forceNewCwd('single'); @@ -254,55 +266,7 @@ describe('apify push in a repository with several Actors', () => { await run({ flags_all: true }); expect(commandProcess.exitCode).toBe(CommandExitCodes.InvalidInput); - expect(stderr()).toContain('This directory is an Actor itself, so --all and --actor-dir do not apply.'); - expect(getLoggedClientOrThrow).not.toHaveBeenCalled(); - expect(versionUpdate).not.toHaveBeenCalled(); - }); - - it('rejects --actor-dir in a directory that is an Actor itself', async () => { - forceNewCwd('actors/a'); - - await run({ flags_actorDir: ['../b'] }); - - expect(commandProcess.exitCode).toBe(CommandExitCodes.InvalidInput); - expect(stderr()).toContain('This directory is an Actor itself, so --all and --actor-dir do not apply.'); - expect(getLoggedClientOrThrow).not.toHaveBeenCalled(); - expect(versionUpdate).not.toHaveBeenCalled(); - }); - - it('pushes an --actor-dir that discovery does not reach, such as a symlinked Actor', async () => { - await mkdir(joinPath('solo'), { recursive: true }); - await symlink(joinPath('actors', 'b'), joinPath('solo', 'linked'), 'junction'); - forceNewCwd('solo'); - - await run({ flags_actorDir: ['linked'] }); - - expect(commandProcess.exitCode).toBeFalsy(); - expect(pushedActorIds()).toEqual([ACTOR_B.id]); - }); - - 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 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(stderr()).toContain("This directory is an Actor itself, so --all doesn't apply."); expect(getLoggedClientOrThrow).not.toHaveBeenCalled(); expect(versionUpdate).not.toHaveBeenCalled(); }); @@ -312,6 +276,7 @@ describe('apify push in a repository with several Actors', () => { expect(commandProcess.exitCode).toBe(CommandExitCodes.InvalidInput); expect(stderr()).toContain('actorId argument applies to a single Actor'); + expect(stderr()).toContain('Run apify push --dir for one Actor, or drop the argument.'); expect(getLoggedClientOrThrow).not.toHaveBeenCalled(); }); @@ -323,7 +288,10 @@ describe('apify push in a repository with several Actors', () => { }); it('pushes to the actorId argument when exactly one Actor is selected', async () => { - await run({ args_actorId: ACTOR_C.id, flags_actorDir: ['actors/a'] }); + stdinState.isTTY = true; + multiSelect.mockResolvedValueOnce(['actors/a']); + + await run({ args_actorId: ACTOR_C.id }); expect(commandProcess.exitCode).toBeFalsy(); expect(pushedActorIds()).toEqual([ACTOR_C.id]); @@ -347,13 +315,6 @@ describe('apify push in a repository with several Actors', () => { expect(logMessages.log.join('\n')).toContain('Apify push result: SUCCEEDED'); }); - it('pushes the --actor-dir directories in path order, normalized and without duplicates', async () => { - await run({ flags_actorDir: ['./tools/c/', 'actors/a', 'tools/c'] }); - - expect(commandProcess.exitCode).toBeFalsy(); - expect(pushedActorIds()).toEqual([ACTOR_A.id, ACTOR_C.id]); - }); - it('passes --version and --build-tag to every version payload', async () => { await run({ flags_all: true, flags_version: '1.5', flags_buildTag: 'beta' }); @@ -507,8 +468,11 @@ describe('apify push in a repository with several Actors', () => { }); }); - it('wraps even a single --actor-dir push in the results document', async () => { - await run({ flags_actorDir: ['actors/b'], flags_json: true }); + it('wraps even a single selected push in the results document', async () => { + stdinState.isTTY = true; + multiSelect.mockResolvedValueOnce(['actors/b']); + + await run({ flags_json: true }); const summary = stdoutJson(); expect(summary.results).toEqual([expect.objectContaining({ dir: 'actors/b', ok: true })]); @@ -574,7 +538,7 @@ describe('apify push in a repository with several Actors', () => { expect(stdoutJson()).toEqual({ ok: true, operation: 'push', results: [], exitCode: 0 }); }); - it('is skipped when a flag already chose', async () => { + it('is skipped with --all', async () => { await run({ flags_all: true }); expect(multiSelect).not.toHaveBeenCalled(); From 0170178d8aeb924d6280bd08819171072f36d96d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Fri, 30 Oct 2026 14:38:04 +0100 Subject: [PATCH 38/44] refactor: remove repeatable flag support that only --actor-dir used Restores the command framework to its master state, including the inline invalid-choice error and the short-form = handling, which were reshaped only for repeated values. --- src/lib/command-framework/apify-command.ts | 54 +++-------- src/lib/command-framework/flags.ts | 16 +--- .../command-framework/multiple-flag.test.ts | 91 ------------------- 3 files changed, 15 insertions(+), 146 deletions(-) delete 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 aaa505f61..e22cc444a 100644 --- a/src/lib/command-framework/apify-command.ts +++ b/src/lib/command-framework/apify-command.ts @@ -49,20 +49,9 @@ 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< @@ -560,34 +549,7 @@ export abstract class ApifyCommand - new CommandError({ - code: CommandErrorCode.APIFY_INVALID_CHOICE, - command: this.ctor, - metadata: { - flag: baseFlagName, - choices: choices.map((choice) => chalk.white.bold(choice)).join(', '), - }, - }); - - // -i='{"foo":"bar"}' - if (Array.isArray(rawFlag) && usedShortFormOfTheFlag) { - rawFlag = rawFlag.map((value) => (typeof value === 'string' && value.startsWith('=') ? value.slice(1) : value)); - } - - // A flag that opted into `multiple` keeps every value; otherwise --a 1 --a 2 is not allowed - if (Array.isArray(rawFlag) && builderData.multiple) { - const { choices } = builderData; - - if (choices && rawFlag.some((value) => !choices.includes(value as never))) { - throw invalidChoiceError(choices); - } - - this.flags[camelCasedName] = rawFlag; - - continue; - } - + // If you provide --a 1 --a 2, it's not allowed if (Array.isArray(rawFlag)) { if (rawFlag.length > 1) { throw new CommandError({ @@ -602,6 +564,11 @@ export abstract class ApifyCommand chalk.white.bold(choice)).join(', '), + }, + }); } // Re-validate required (we don't have the flag and it's either required or passed in) diff --git a/src/lib/command-framework/flags.ts b/src/lib/command-framework/flags.ts index 234a2aef4..b84ea0c82 100644 --- a/src/lib/command-framework/flags.ts +++ b/src/lib/command-framework/flags.ts @@ -24,11 +24,6 @@ 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 { @@ -47,14 +42,12 @@ 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 @@ -84,13 +77,7 @@ export function YesFlag(description = 'Answer "yes" to all prompts.') { function stringFlag>( options: T & { choices?: Choices }, -): TaggedFlagBuilder< - 'string', - Choices, - T['default'] extends string ? true : T['required'], - T['default'], - T['multiple'] extends true ? true : false -> { +): TaggedFlagBuilder<'string', Choices, T['default'] extends string ? true : T['required'], T['default']> { return { flagTag: 'string', builder: (objectName) => { @@ -128,7 +115,6 @@ 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({ char: 'd', description: 'repeatable', multiple: true }), - kind: Flags.string({ description: 'repeatable with choices', multiple: true, choices: ['x', 'y'] }), - 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('strips the = of the short form from every value', async () => { - await runWith(['-d=actors/a', '-d=actors/b']); - - expect(receivedFlags.actorDir).toEqual(['actors/a', 'actors/b']); - }); - - test('accepts repeated values that are all among the choices', async () => { - await runWith(['--kind', 'x', '--kind', 'y']); - - expect(receivedFlags.kind).toEqual(['x', 'y']); - }); - - test('rejects a repeated flag when one value is not among the choices', async () => { - await expect(runWith(['--kind', 'x', '--kind', 'z'])).rejects.toMatchObject({ - code: CommandErrorCode.APIFY_INVALID_CHOICE, - }); - }); - - 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 2eb1d9cad39a4bc93501c8acb049c9d359c91b01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Fri, 30 Oct 2026 14:41:38 +0100 Subject: [PATCH 39/44] style: keep the getActorLocalFilePaths doc comment on the function The exported ignore list had been inserted between the comment and the function it describes. --- src/lib/utils.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 35ea63a64..4a2565a3c 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -400,12 +400,12 @@ const parseActorIgnore = async (cwd: string): Promise => { return { excludeFilter, forceIncludePatterns }; }; +export const ACTOR_FILES_HARDCODED_IGNORE = ['.git/**', 'apify_storage', 'node_modules', 'storage', 'crawlee_storage']; + /** * 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(); From c27e02659cedcb70fcfd8b55399ec98f61c4b00c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Fri, 30 Oct 2026 15:09:34 +0100 Subject: [PATCH 40/44] refactor: move the gitignore check of apify push into getGitignoreMatcher getActorLocalFilePaths now asks the shared helper whether a path is ignored. No behavior change: the same git ls-files call, and the same fallback when git fails. --- src/lib/utils.ts | 86 +++++++++++++++++++++++++----------------------- 1 file changed, 44 insertions(+), 42 deletions(-) diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 4a2565a3c..34f47d97e 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -289,12 +289,12 @@ export const createSourceFiles = async (paths: string[], cwd: string) => { }; /** - * Fallback for when git is unavailable: find all .gitignore files and build a filter + * Fallback for when git is unavailable: find all .gitignore files and build a matcher * using the `ignore` package, scoped to each file's directory. * Also walks ancestor directories to pick up parent .gitignore files (e.g. monorepo root), * stopping at the first .git boundary found. */ -const getGitignoreFallbackFilter = async (cwd: string): Promise<(paths: string[]) => string[]> => { +const getGitignoreFallbackMatcher = async (cwd: string): Promise<(path: string) => boolean> => { const gitignoreFiles = await glob('**/.gitignore', { dot: true, cwd, @@ -338,28 +338,50 @@ const getGitignoreFallbackFilter = async (cwd: string): Promise<(paths: string[] } if (filters.length === 0) { - return (paths) => paths; - } - - return (paths) => - paths.filter((filePath) => { - for (const { dir, ig, ancestorPrefix } of filters) { - let relativePath: string | null; - if (!dir) { - relativePath = ancestorPrefix ? `${ancestorPrefix}/${filePath}` : filePath; - } else if (filePath.startsWith(`${dir}/`)) { - relativePath = filePath.slice(dir.length + 1); - } else { - relativePath = null; - } - if (relativePath !== null && ig.ignores(relativePath)) { - return false; - } + return () => false; + } + + return (filePath) => + filters.some(({ dir, ig, ancestorPrefix }) => { + let relativePath: string | null; + if (!dir) { + relativePath = ancestorPrefix ? `${ancestorPrefix}/${filePath}` : filePath; + } else if (filePath.startsWith(`${dir}/`)) { + relativePath = filePath.slice(dir.length + 1); + } else { + relativePath = null; } - return true; + return relativePath !== null && ig.ignores(relativePath); }); }; +/** + * Returns a function that says whether .gitignore rules exclude a path relative to `cwd`. A directory path needs a + * trailing slash. Asks `git ls-files`, which handles ancestor and nested .gitignore files, .git/info/exclude, and the + * global gitignore config. When git fails (not installed, not a repository), reads the .gitignore files itself. + * `getActorLocalFilePaths` and the Actor discovery of `apify push` both use it, so they agree on what's ignored. + */ +export const getGitignoreMatcher = async (cwd: string): Promise<(path: string) => boolean> => { + try { + const gitIgnored = execSync('git ls-files --others --ignored --exclude-standard --directory', { + cwd, + encoding: 'utf-8', + stdio: ['ignore', 'pipe', 'ignore'], + }) + .split('\n') + .filter(Boolean); + + if (gitIgnored.length === 0) { + return () => false; + } + + const ig = makeIg().add(gitIgnored); + return (path) => ig.ignores(path); + } catch { + return getGitignoreFallbackMatcher(cwd); + } +}; + interface ActorIgnoreResult { /** Filter that removes paths matching non-negated .actorignore patterns */ excludeFilter: ((paths: string[]) => string[]) | null; @@ -414,27 +436,7 @@ export const getActorLocalFilePaths = async (cwd?: string) => { // Parse .actorignore early to get both exclude filter and force-include patterns const { excludeFilter: actorignoreFilter, forceIncludePatterns } = await parseActorIgnore(resolvedCwd); - let gitIgnoreFilter: ((paths: string[]) => string[]) | null = null; - - // Use git ls-files to get gitignored paths — this correctly handles ancestor .gitignore files, - // nested .gitignore files, .git/info/exclude, and global gitignore config - try { - const gitIgnored = execSync('git ls-files --others --ignored --exclude-standard --directory', { - cwd: resolvedCwd, - encoding: 'utf-8', - stdio: ['ignore', 'pipe', 'ignore'], - }) - .split('\n') - .filter(Boolean); - - if (gitIgnored.length > 0) { - const ig = makeIg().add(gitIgnored); - gitIgnoreFilter = (paths) => paths.filter((p) => !ig.ignores(p)); - } - } catch { - // git is unavailable or directory is not a git repo — fall back to parsing .gitignore files - gitIgnoreFilter = await getGitignoreFallbackFilter(resolvedCwd); - } + const isGitignored = await getGitignoreMatcher(resolvedCwd); const allFiles = await glob(['*', '**/**'], { ignore: hardcodedIgnore, @@ -443,7 +445,7 @@ export const getActorLocalFilePaths = async (cwd?: string) => { cwd: resolvedCwd, }); - let paths = gitIgnoreFilter ? gitIgnoreFilter(allFiles) : allFiles; + let paths = allFiles.filter((filePath) => !isGitignored(filePath)); if (actorignoreFilter) { paths = actorignoreFilter(paths); From 6cf42bf49e7e4b07e1d681df861bcc50744db972 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Fri, 30 Oct 2026 15:09:35 +0100 Subject: [PATCH 41/44] feat: skip Actors in gitignored directories with apify push --all findActorDirs marks each Actor whose directory .gitignore ignores, using the same check as the upload. --all skips those Actors and names each one on stderr with the apify push --dir command that pushes it. When every Actor is ignored, --all exits 4. The picker and the non-interactive list still show them, labeled [ignored by .gitignore], and a picked one is pushed. Under --json, skipped Actors aren't entries in the results. --- src/commands/actors/push.ts | 37 +++++++++--- src/lib/find-actor-dirs.ts | 17 +++++- test/local/commands/push-select.test.ts | 79 +++++++++++++++++++++++++ test/local/lib/find-actor-dirs.test.ts | 67 +++++++++++++++++++-- 4 files changed, 185 insertions(+), 15 deletions(-) diff --git a/src/commands/actors/push.ts b/src/commands/actors/push.ts index ae54a482c..01f782f43 100644 --- a/src/commands/actors/push.ts +++ b/src/commands/actors/push.ts @@ -93,6 +93,9 @@ interface PushSelectionResult { const SELECTION_HINT = 'Choose what to push: --all, or run apify push --dir for each Actor.'; +/** Marks an Actor in a directory .gitignore ignores. --all skips it; picking it or naming it with --dir pushes it. */ +const IGNORED_TAG = '[ignored by .gitignore]'; + const interruptedMessage = (notPushed: string[]) => `Interrupted. Not pushed: ${notPushed.join(', ')}`; /** @@ -289,7 +292,7 @@ export class ActorsPushCommand extends ApifyCommand { }), all: Flags.boolean({ description: - 'Push every Actor found in the subdirectories. Works only from a directory without an Actor config of its own.', + 'Push every Actor found in the subdirectories, except the ones ignored by .gitignore. Works only from a directory without an Actor config of its own.', default: false, required: false, }), @@ -447,23 +450,43 @@ export class ActorsPushCommand extends ApifyCommand { } } - /** Resolves the Actor directories to push from --all or a prompt; `undefined` when the command stops here. */ + /** + * Resolves the Actor directories to push from --all or a prompt; `undefined` when the command stops here. + * An Actor in an ignored directory is never pushed implicitly: --all skips it, the prompt and the list mark it. + */ private async selectActorDirs(root: string, actorDirs: FoundActorDir[]): Promise { + // Paths relative to where the command ran, so each one works as the --dir value. + const dirForFlag = (dir: string) => toPosixRelative(process.cwd(), join(root, dir)); + const describe = ({ dir, name }: FoundActorDir) => (name ? `${dir} (${name})` : dir); + const label = (actor: FoundActorDir) => (actor.ignored ? `${describe(actor)} ${IGNORED_TAG}` : describe(actor)); + if (this.flags.all) { - return actorDirs.map((actor) => actor.dir); + for (const actor of actorDirs.filter(({ ignored }) => ignored)) { + const dir = dirForFlag(actor.dir); + info({ + message: `Skipping ${describe({ ...actor, dir })} because it's ignored by .gitignore. Run apify push --dir ${dir} to push it.`, + }); + } + + const dirs = actorDirs.filter(({ ignored }) => !ignored).map((actor) => actor.dir); + + if (!dirs.length) { + error({ message: 'Every Actor below this directory is ignored by .gitignore, so --all has nothing to push.' }); + process.exitCode = CommandExitCodes.NoFilesToPush; + return; + } + + return dirs; } - const label = ({ dir, name }: FoundActorDir) => (name ? `${dir} (${name})` : dir); const { isTTY } = await useStdin(); if (isCI || !isTTY) { - // Paths relative to where the command ran, so each one works as the --dir value. - const dirForFlag = (dir: string) => toPosixRelative(process.cwd(), join(root, dir)); - simpleLog({ message: [ 'This directory has no Actor of its own, but these directories below it do:', ...actorDirs.map((actor) => ` ${label({ ...actor, dir: dirForFlag(actor.dir) })}`), + ...(actorDirs.some(({ ignored }) => ignored) ? ['--all skips the Actors ignored by .gitignore.'] : []), ].join('\n'), }); error({ message: SELECTION_HINT }); diff --git a/src/lib/find-actor-dirs.ts b/src/lib/find-actor-dirs.ts index 7511b0522..2f1ca48df 100644 --- a/src/lib/find-actor-dirs.ts +++ b/src/lib/find-actor-dirs.ts @@ -3,7 +3,7 @@ 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'; +import { ACTOR_FILES_HARDCODED_IGNORE, getGitignoreMatcher, getJsonFileContent } from './utils.js'; import { cliDebugPrint } from './utils/cliDebugPrint.js'; const SKIPPED_DIRECTORY_NAMES = new Set(ACTOR_FILES_HARDCODED_IGNORE.map((pattern) => pattern.replace(/\/\*\*$/, ''))); @@ -13,6 +13,8 @@ export interface FoundActorDir { dir: string; /** The `name` from its `.actor/actor.json`, when the file could be read. */ name: string | undefined; + /** Whether .gitignore rules exclude the directory, judged from the root the same way the upload judges files. */ + ignored: boolean; } /** Turns a directory below `root` into the `/`-separated relative path used to address it. */ @@ -34,9 +36,10 @@ function readActorName(dir: string): string | undefined { * Finds every directory below `root` with a `.actor/actor.json`, sorted by path. Skips the directory names * `getActorLocalFilePaths` always ignores (at any depth, not only at the root) and does not descend below a * found Actor. A directory that cannot be read is skipped; when that is the root, nothing is found. + * An Actor in a directory .gitignore ignores is still found, marked `ignored`. */ export async function findActorDirs(root: string): Promise { - const found: FoundActorDir[] = []; + const found: Omit[] = []; const walk = async (dir: string) => { let entries; @@ -66,5 +69,13 @@ export async function findActorDirs(root: string): Promise { await walk(root); - return found.sort((a, b) => (a.dir < b.dir ? -1 : a.dir > b.dir ? 1 : 0)); + if (!found.length) { + return []; + } + + const isGitignored = await getGitignoreMatcher(root); + + return found + .map((actor) => ({ ...actor, ignored: isGitignored(`${actor.dir}/`) })) + .sort((a, b) => (a.dir < b.dir ? -1 : a.dir > b.dir ? 1 : 0)); } diff --git a/test/local/commands/push-select.test.ts b/test/local/commands/push-select.test.ts index 420eaca02..d94714251 100644 --- a/test/local/commands/push-select.test.ts +++ b/test/local/commands/push-select.test.ts @@ -1,3 +1,4 @@ +import { execSync } from 'node:child_process'; import { mkdir, writeFile } from 'node:fs/promises'; import { dirname } from 'node:path'; @@ -200,6 +201,7 @@ describe('apify push in a repository with several Actors', () => { expect(output).toContain('actors/b (actor-b)'); expect(output).toContain('tools/c (actor-c)'); expect(output).not.toContain('node_modules'); + expect(output).not.toContain('ignored by .gitignore'); expect(output).toContain('Choose what to push: --all, or run apify push --dir for each Actor.'); expect(output.indexOf('actors/a')).toBeLessThan(output.indexOf('actors/b')); expect(output.indexOf('actors/b')).toBeLessThan(output.indexOf('tools/c')); @@ -545,4 +547,81 @@ describe('apify push in a repository with several Actors', () => { expect(pushedActorIds()).toHaveLength(3); }); }); + + describe('Actors in directories .gitignore ignores', () => { + // The root becomes a Git repository whose .gitignore ignores tools/, the directory of Actor C. + beforeEach(async () => { + execSync('git init', { cwd: joinPath(), stdio: 'ignore' }); + await write('.gitignore', 'tools/\n'); + }); + + it('skips them with --all, names them on stderr, and pushes the rest', async () => { + await run({ flags_all: true }); + + expect(commandProcess.exitCode).toBeFalsy(); + expect(pushedActorIds()).toEqual([ACTOR_A.id, ACTOR_B.id]); + expect(stderr()).toContain( + "Skipping tools/c (actor-c) because it's ignored by .gitignore. Run apify push --dir tools/c to push it.", + ); + }); + + it('pushes nothing and exits 4 when --all finds only ignored Actors', async () => { + await write('.gitignore', 'actors/\ntools/\n'); + + await run({ flags_all: true, flags_json: true }); + + expect(commandProcess.exitCode).toBe(CommandExitCodes.NoFilesToPush); + expect(getLoggedClientOrThrow).not.toHaveBeenCalled(); + expect(logMessages.log).toEqual([]); + const output = stderr(); + expect(output).toContain('Run apify push --dir actors/a to push it.'); + expect(output).toContain('Run apify push --dir tools/c to push it.'); + expect(output).toContain( + 'Every Actor below this directory is ignored by .gitignore, so --all has nothing to push.', + ); + }); + + it('leaves them out of the --json results and keeps stdout to the document', async () => { + await run({ flags_all: true, flags_json: true }); + + expect(commandProcess.exitCode).toBeFalsy(); + const summary = stdoutJson(); + expect(summary).toMatchObject({ ok: true, operation: 'push', exitCode: 0 }); + expect(summary.results.map((entry: { dir: string }) => entry.dir)).toEqual(['actors/a', 'actors/b']); + expect(stderr()).toContain('Skipping tools/c (actor-c)'); + }); + + it('labels them in the prompt, unchecked like the rest, and pushes one when picked', async () => { + stdinState.isTTY = true; + multiSelect.mockResolvedValueOnce(['tools/c']); + + await run(); + + expect(multiSelect.mock.calls[0][0].choices).toEqual([ + { name: 'actors/a (actor-a)', value: 'actors/a' }, + { name: 'actors/b (actor-b)', value: 'actors/b' }, + { name: 'tools/c (actor-c) [ignored by .gitignore]', value: 'tools/c' }, + ]); + expect(commandProcess.exitCode).toBeFalsy(); + expect(pushedActorIds()).toEqual([ACTOR_C.id]); + }); + + it('labels them in the list and says --all skips them when it cannot ask', async () => { + await run(); + + expect(commandProcess.exitCode).toBe(CommandExitCodes.NoFilesToPush); + expect(getLoggedClientOrThrow).not.toHaveBeenCalled(); + const output = stderr(); + expect(output).toContain(' actors/a (actor-a)\n'); + expect(output).toContain(' tools/c (actor-c) [ignored by .gitignore]'); + expect(output).toContain('--all skips the Actors ignored by .gitignore.'); + }); + + it('pushes one named with --dir', async () => { + await run({ flags_dir: 'tools/c' }); + + expect(commandProcess.exitCode).toBeFalsy(); + expect(pushedActorIds()).toEqual([ACTOR_C.id]); + }); + }); }); diff --git a/test/local/lib/find-actor-dirs.test.ts b/test/local/lib/find-actor-dirs.test.ts index 7d37f991c..510af78c3 100644 --- a/test/local/lib/find-actor-dirs.test.ts +++ b/test/local/lib/find-actor-dirs.test.ts @@ -1,10 +1,28 @@ +import { execSync } from 'node:child_process'; import { chmod, mkdir, writeFile } from 'node:fs/promises'; import { dirname, join } from 'node:path'; import process from 'node:process'; import { findActorDirs, toPosixRelative } from '../../../src/lib/find-actor-dirs.js'; +import { getActorLocalFilePaths } from '../../../src/lib/utils.js'; import { useTempPath } from '../../__setup__/hooks/useTempPath.js'; +// vi.mock is hoisted before imports, so the gitignore matcher gets the mocked version. With `gitAvailable` off, every +// git call fails as it does without git, which sends the matcher to its own .gitignore parsing. +const gitState = vi.hoisted(() => ({ gitAvailable: true })); +vi.mock('node:child_process', async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + execSync: ((...args: Parameters) => { + if (!gitState.gitAvailable) { + throw new Error('not a git repository'); + } + return original.execSync(...args); + }) as typeof original.execSync, + }; +}); + const { beforeAllCalls, afterAllCalls, joinPath, tmpPath } = useTempPath('find-actor-dirs', { create: true, remove: true, @@ -22,6 +40,7 @@ async function writeActor(dir: string, name: string | undefined = dir.split('/') } beforeEach(async () => { + gitState.gitAvailable = true; await beforeAllCalls(); }); @@ -37,9 +56,9 @@ describe('findActorDirs', () => { 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' }, + { dir: 'actors/alpha', name: 'alpha', ignored: false }, + { dir: 'actors/zeta', name: 'zeta', ignored: false }, + { dir: 'tools/beta', name: 'tool-beta', ignored: false }, ]); }); @@ -71,8 +90,8 @@ describe('findActorDirs', () => { await write('nameless/.actor/actor.json', '{}'); await expect(findActorDirs(tmpPath)).resolves.toEqual([ - { dir: 'broken', name: undefined }, - { dir: 'nameless', name: undefined }, + { dir: 'broken', name: undefined, ignored: false }, + { dir: 'nameless', name: undefined, ignored: false }, ]); }); @@ -113,4 +132,42 @@ describe('findActorDirs', () => { expect(found.dir).not.toContain('\\'); expect(toPosixRelative(tmpPath, joinPath('group', 'sub', 'actor'))).toBe('group/sub/actor'); }); + + describe('Actors in directories .gitignore ignores', () => { + // The root .gitignore ignores a parent directory, and a nested .gitignore ignores an Actor directory itself. + // An ignored file inside an Actor leaves the Actor alone. + async function writeRepository() { + await write('.gitignore', 'archive/\n*.log\n'); + await write('actors/.gitignore', 'gamma\n'); + await writeActor('actors/beta'); + await writeActor('actors/gamma'); + await writeActor('archive/old/alpha'); + await write('actors/beta/debug.log'); + } + + const expected = [ + { dir: 'actors/beta', name: 'beta', ignored: false }, + { dir: 'actors/gamma', name: 'gamma', ignored: true }, + { dir: 'archive/old/alpha', name: 'alpha', ignored: true }, + ]; + + it('finds them and marks them ignored, as the upload leaves them out', async () => { + execSync('git init', { cwd: tmpPath, stdio: 'ignore' }); + await writeRepository(); + + await expect(findActorDirs(tmpPath)).resolves.toEqual(expected); + + const uploaded = await getActorLocalFilePaths(tmpPath); + expect(uploaded).toContain('actors/beta/.actor/actor.json'); + expect(uploaded).not.toContain('actors/gamma/.actor/actor.json'); + expect(uploaded).not.toContain('archive/old/alpha/.actor/actor.json'); + }); + + it('marks them the same way when git is unavailable', async () => { + gitState.gitAvailable = false; + await writeRepository(); + + await expect(findActorDirs(tmpPath)).resolves.toEqual(expected); + }); + }); }); From f1f2874ee63a7dd17718b7aa7ad7ac9c8fe7304c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Fri, 30 Oct 2026 15:09:36 +0100 Subject: [PATCH 42/44] docs: regenerate the command reference --- docs/reference.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/reference.md b/docs/reference.md index 20e5c6c48..420618be4 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -874,8 +874,9 @@ ARGUMENTS FLAGS --all Push every Actor found - in the subdirectories. Works only from a directory - without an Actor config of its own. + in the subdirectories, except the ones ignored by + .gitignore. Works only from a directory without an + Actor config of its own. --allow-missing-secrets Allow the command to continue even when secret values are not found in the local secrets storage. From 857bfef6d065f1c744d83871f031fa1dbb82436f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Fri, 30 Oct 2026 15:19:49 +0100 Subject: [PATCH 43/44] fix: don't crash Actor discovery without git when an Actor has its own .gitignore Without git, the fallback matcher turned an Actor directory into an empty path for the Actor's own .gitignore, and the ignore package throws on it. A .gitignore can't ignore its own directory, so an empty path now isn't ignored. The discovery tests also cover a rule only git reads, and the doc comments no longer claim that discovery and the upload always agree. --- src/lib/find-actor-dirs.ts | 2 +- src/lib/utils.ts | 6 ++-- test/local/commands/push-select.test.ts | 45 +++++++++++++++++++++++++ test/local/lib/find-actor-dirs.test.ts | 24 ++++++++----- 4 files changed, 65 insertions(+), 12 deletions(-) diff --git a/src/lib/find-actor-dirs.ts b/src/lib/find-actor-dirs.ts index 2f1ca48df..d945210ea 100644 --- a/src/lib/find-actor-dirs.ts +++ b/src/lib/find-actor-dirs.ts @@ -13,7 +13,7 @@ export interface FoundActorDir { dir: string; /** The `name` from its `.actor/actor.json`, when the file could be read. */ name: string | undefined; - /** Whether .gitignore rules exclude the directory, judged from the root the same way the upload judges files. */ + /** Whether .gitignore rules exclude the directory, judged from the root with the matcher the upload uses. */ ignored: boolean; } diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 34f47d97e..2c84119e6 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -351,7 +351,8 @@ const getGitignoreFallbackMatcher = async (cwd: string): Promise<(path: string) } else { relativePath = null; } - return relativePath !== null && ig.ignores(relativePath); + // An empty path is the directory of this .gitignore, which it can't ignore. `ignore` throws on it. + return !!relativePath && ig.ignores(relativePath); }); }; @@ -359,7 +360,8 @@ const getGitignoreFallbackMatcher = async (cwd: string): Promise<(path: string) * Returns a function that says whether .gitignore rules exclude a path relative to `cwd`. A directory path needs a * trailing slash. Asks `git ls-files`, which handles ancestor and nested .gitignore files, .git/info/exclude, and the * global gitignore config. When git fails (not installed, not a repository), reads the .gitignore files itself. - * `getActorLocalFilePaths` and the Actor discovery of `apify push` both use it, so they agree on what's ignored. + * `getActorLocalFilePaths` and the Actor discovery of `apify push` both use it. Discovery asks from the root it + * searches, the upload from the Actor directory. */ export const getGitignoreMatcher = async (cwd: string): Promise<(path: string) => boolean> => { try { diff --git a/test/local/commands/push-select.test.ts b/test/local/commands/push-select.test.ts index d94714251..6fa17c6c6 100644 --- a/test/local/commands/push-select.test.ts +++ b/test/local/commands/push-select.test.ts @@ -29,6 +29,22 @@ vitest.mock('../../../src/lib/hooks/user-confirmations/useYesNoConfirm.js', () = useYesNoConfirm: yesNoConfirm, })); +// With `gitAvailable` off, every git call fails as it does without git, which sends the gitignore matcher to its own +// .gitignore parsing. +const gitState = vitest.hoisted(() => ({ gitAvailable: true })); +vitest.mock('node:child_process', async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + execSync: ((...args: Parameters) => { + if (!gitState.gitAvailable && args[0].startsWith('git ')) { + throw new Error('not a git repository'); + } + return original.execSync(...args); + }) as typeof original.execSync, + }; +}); + // The lines the command writes synchronously right before a signal ends the process. const writeSync = vitest.hoisted(() => vitest.fn()); vitest.mock('node:fs', async (importOriginal) => ({ @@ -160,6 +176,7 @@ let originalExitCode: typeof process.exitCode; beforeEach(async () => { originalExitCode = commandProcess.exitCode; stdinState.isTTY = false; + gitState.gitAvailable = true; platform.state.actors = [ACTOR_A, ACTOR_B, ACTOR_C]; platform.state.buildStatuses = {}; platform.state.gitSourcedActorIds = []; @@ -624,4 +641,32 @@ describe('apify push in a repository with several Actors', () => { expect(pushedActorIds()).toEqual([ACTOR_C.id]); }); }); + + describe('without git, in a folder of Actors that each have a .gitignore', () => { + // What apify create leaves in a plain folder: the folder isn't a repository, and every Actor has a .gitignore. + beforeEach(async () => { + gitState.gitAvailable = false; + for (const dir of ['actors/a', 'actors/b', 'tools/c']) { + await write(`${dir}/.gitignore`, 'node_modules\nstorage\n'); + } + }); + + it('lists every Actor without the ignored label when it cannot ask', async () => { + await run(); + + expect(commandProcess.exitCode).toBe(CommandExitCodes.NoFilesToPush); + const output = stderr(); + expect(output).toContain(' actors/a (actor-a)\n'); + expect(output).toContain(' actors/b (actor-b)\n'); + expect(output).toContain(' tools/c (actor-c)'); + expect(output).not.toContain('ignored by .gitignore'); + }); + + it('pushes every Actor with --all', async () => { + await run({ flags_all: true }); + + expect(commandProcess.exitCode).toBeFalsy(); + expect(pushedActorIds()).toEqual([ACTOR_A.id, ACTOR_B.id, ACTOR_C.id]); + }); + }); }); diff --git a/test/local/lib/find-actor-dirs.test.ts b/test/local/lib/find-actor-dirs.test.ts index 510af78c3..961801fd1 100644 --- a/test/local/lib/find-actor-dirs.test.ts +++ b/test/local/lib/find-actor-dirs.test.ts @@ -135,39 +135,45 @@ describe('findActorDirs', () => { describe('Actors in directories .gitignore ignores', () => { // The root .gitignore ignores a parent directory, and a nested .gitignore ignores an Actor directory itself. - // An ignored file inside an Actor leaves the Actor alone. + // .git/info/exclude ignores actors/delta, which only git reads. An Actor's own .gitignore, as every template + // ships, and an ignored file inside an Actor leave the Actor alone. async function writeRepository() { + execSync('git init', { cwd: tmpPath, stdio: 'ignore' }); + await write('.git/info/exclude', 'actors/delta/\n'); await write('.gitignore', 'archive/\n*.log\n'); await write('actors/.gitignore', 'gamma\n'); await writeActor('actors/beta'); + await write('actors/beta/.gitignore', 'node_modules\nstorage\n'); + await write('actors/beta/debug.log'); + await writeActor('actors/delta'); await writeActor('actors/gamma'); await writeActor('archive/old/alpha'); - await write('actors/beta/debug.log'); } - const expected = [ + const expected = ({ deltaIgnored }: { deltaIgnored: boolean }) => [ { dir: 'actors/beta', name: 'beta', ignored: false }, + { dir: 'actors/delta', name: 'delta', ignored: deltaIgnored }, { dir: 'actors/gamma', name: 'gamma', ignored: true }, { dir: 'archive/old/alpha', name: 'alpha', ignored: true }, ]; - it('finds them and marks them ignored, as the upload leaves them out', async () => { - execSync('git init', { cwd: tmpPath, stdio: 'ignore' }); + it('asks git and marks them ignored, as the upload leaves them out', async () => { await writeRepository(); - await expect(findActorDirs(tmpPath)).resolves.toEqual(expected); + await expect(findActorDirs(tmpPath)).resolves.toEqual(expected({ deltaIgnored: true })); const uploaded = await getActorLocalFilePaths(tmpPath); expect(uploaded).toContain('actors/beta/.actor/actor.json'); + expect(uploaded).not.toContain('actors/delta/.actor/actor.json'); expect(uploaded).not.toContain('actors/gamma/.actor/actor.json'); expect(uploaded).not.toContain('archive/old/alpha/.actor/actor.json'); }); - it('marks them the same way when git is unavailable', async () => { - gitState.gitAvailable = false; + it('reads the .gitignore files itself when git is unavailable', async () => { await writeRepository(); + gitState.gitAvailable = false; - await expect(findActorDirs(tmpPath)).resolves.toEqual(expected); + await expect(findActorDirs(tmpPath)).resolves.toEqual(expected({ deltaIgnored: false })); }); }); }); From 06d5afbdf737c5dd50731b69bbaaa5435d103b9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Hanu=C5=A1?= Date: Fri, 30 Oct 2026 15:19:58 +0100 Subject: [PATCH 44/44] fix: quote a path with spaces in the apify push --dir advice of --all The skip note for an ignored Actor prints a command to copy. A path with a space or another character a shell splits or expands is now quoted. --- src/commands/actors/push.ts | 5 ++++- test/local/commands/push-select.test.ts | 13 +++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/commands/actors/push.ts b/src/commands/actors/push.ts index 01f782f43..b4f5f3080 100644 --- a/src/commands/actors/push.ts +++ b/src/commands/actors/push.ts @@ -96,6 +96,9 @@ const SELECTION_HINT = 'Choose what to push: --all, or run apify push --dir (/^[\w./-]+$/.test(path) ? path : `"${path}"`); + const interruptedMessage = (notPushed: string[]) => `Interrupted. Not pushed: ${notPushed.join(', ')}`; /** @@ -464,7 +467,7 @@ export class ActorsPushCommand extends ApifyCommand { for (const actor of actorDirs.filter(({ ignored }) => ignored)) { const dir = dirForFlag(actor.dir); info({ - message: `Skipping ${describe({ ...actor, dir })} because it's ignored by .gitignore. Run apify push --dir ${dir} to push it.`, + message: `Skipping ${describe({ ...actor, dir })} because it's ignored by .gitignore. Run apify push --dir ${quoteForShell(dir)} to push it.`, }); } diff --git a/test/local/commands/push-select.test.ts b/test/local/commands/push-select.test.ts index 6fa17c6c6..e4e65be8c 100644 --- a/test/local/commands/push-select.test.ts +++ b/test/local/commands/push-select.test.ts @@ -640,6 +640,19 @@ describe('apify push in a repository with several Actors', () => { expect(commandProcess.exitCode).toBeFalsy(); expect(pushedActorIds()).toEqual([ACTOR_C.id]); }); + + it('quotes a path with a space in the --dir advice', async () => { + await write('.gitignore', 'tools/\nold actors/\n'); + await writeActor('old actors/d', 'actor-d'); + + await run({ flags_all: true }); + + expect(commandProcess.exitCode).toBeFalsy(); + expect(pushedActorIds()).toEqual([ACTOR_A.id, ACTOR_B.id]); + const output = stderr(); + expect(output).toContain('Run apify push --dir "old actors/d" to push it.'); + expect(output).toContain('Run apify push --dir tools/c to push it.'); + }); }); describe('without git, in a folder of Actors that each have a .gitignore', () => {