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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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}`; -});