diff --git a/docs/reference.md b/docs/reference.md index 6ba3390be..1e6b365ad 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -860,10 +860,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 @@ -871,6 +871,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; 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. --allow-missing-secrets Allow the command to continue even when secret values are not found in the local secrets storage. 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/commands/actors/push.ts b/src/commands/actors/push.ts index 23307d8a2..5d99ac9eb 100644 --- a/src/commands/actors/push.ts +++ b/src/commands/actors/push.ts @@ -1,8 +1,10 @@ -import { readFileSync, statSync, unlinkSync } from 'node:fs'; -import { basename, join, resolve } from 'node:path'; +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, Build } from 'apify-client'; +import chalk from 'chalk'; +import { isCI } from 'ci-info'; import open from 'open'; import { fetchManifest } from '@apify/actor-templates'; @@ -14,12 +16,21 @@ import { Args } from '../../lib/command-framework/args.js'; import { Flags } from '../../lib/command-framework/flags.js'; import { isTerminalStatus } from '../../lib/commands/agent-output.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 { useAbortJobOnSignal } from '../../lib/hooks/useAbortJobOnSignal.js'; +import { findActorDirs, type FoundActorDir, toPosixRelative } from '../../lib/find-actor-dirs.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 { 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'; import { @@ -62,6 +73,43 @@ 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. + * `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 { + ok: boolean; + operation: 'push'; + results: SelectedPushResult[]; + exitCode?: number; +} + +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; @@ -150,6 +198,23 @@ 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[]) => + [ + // 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; @@ -178,6 +243,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'; @@ -217,6 +286,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; they are pushed in path order.', + 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, @@ -244,24 +326,197 @@ 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. + // 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); + + // 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; + } + } + + 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; + + // 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({ + signals: INTERRUPT_SIGNALS, + once: false, + 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); + }, + }); + + 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. + 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; + // 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; + firstFailureExitCode ??= exitCode; + + // 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; + } + results.push(entry); + + if (interrupted) { + const notPushed = selected.slice(index + 1); + if (notPushed.length) { + warning({ message: interruptedMessage(notPushed) }); + } + break; + } + } + + 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 = { + 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; + } + 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.actorDir?.length) { + const dirs = [...new Set(this.flags.actorDir.map((actorDir) => toPosixRelative(root, resolve(root, actorDir))))]; + const outside = dirs.find((dir) => isAbsolute(dir) || dir === '..' || dir.startsWith('../')); + + if (outside !== undefined) { + error({ + message: `Directory "${outside}" from --actor-dir is outside the pushed directory. Pass a path below it.`, + }); + process.exitCode = CommandExitCodes.NoFilesToPush; + return; + } + + const missing = dirs.find((dir) => !existsSync(join(root, dir, LOCAL_CONFIG_PATH))); + + if (missing !== undefined) { + error({ message: `Directory "${missing || '.'}" from --actor-dir has no .actor/actor.json.` }); + process.exitCode = CommandExitCodes.NoFilesToPush; + return; + } + + return dirs.sort(); + } + + if (this.flags.all) { + return actorDirs.map((actor) => actor.dir); + } + + 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.', @@ -616,8 +871,7 @@ Skipping push. Use --force to override.`, } if (this.flags.json) { - printJsonToStdout(result); - return; + return result; } const lines = [ @@ -639,5 +893,7 @@ Skipping push. Use --force to override.`, if (this.flags.open) { await open(actorUrl); } + + return result; } } diff --git a/src/lib/command-framework/apify-command.ts b/src/lib/command-framework/apify-command.ts index f6b94f60e..b10e24cf3 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,34 @@ export abstract class ApifyCommand not allowed + const invalidChoiceError = (choices: readonly (string | number)[]) => + new CommandError({ + code: CommandErrorCode.APIFY_INVALID_CHOICE, + command: this.ctor, + metadata: { + flag: baseFlagName, + choices: choices.map((choice) => chalk.white.bold(choice)).join(', '), + }, + }); + + // -i='{"foo":"bar"}' + if (Array.isArray(rawFlag) && usedShortFormOfTheFlag) { + rawFlag = rawFlag.map((value) => (typeof value === 'string' && value.startsWith('=') ? value.slice(1) : value)); + } + + // A flag that opted into `multiple` keeps every value; otherwise --a 1 --a 2 is not allowed + if (Array.isArray(rawFlag) && builderData.multiple) { + const { choices } = builderData; + + if (choices && rawFlag.some((value) => !choices.includes(value as never))) { + throw invalidChoiceError(choices); + } + + this.flags[camelCasedName] = rawFlag; + + continue; + } + if (Array.isArray(rawFlag)) { if (rawFlag.length > 1) { throw new CommandError({ @@ -564,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/src/lib/command-framework/flags.ts b/src/lib/command-framework/flags.ts index b84ea0c82..234a2aef4 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 = 'Answer "yes" to all prompts.') { 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 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 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) => { + 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; + } + + 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/telemetry/trackEvent.ts b/src/lib/hooks/telemetry/trackEvent.ts index b36441e63..da672109a 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; + }; + login?: { method: 'oauth2-device' | 'oauth2-code' | 'console' | 'manual' | 'token'; /** Set when the device code flow could not run and the authorization code flow took over. */ diff --git a/src/lib/hooks/useAbortJobOnSignal.ts b/src/lib/hooks/useAbortJobOnSignal.ts index 8255e5483..e41fbba22 100644 --- a/src/lib/hooks/useAbortJobOnSignal.ts +++ b/src/lib/hooks/useAbortJobOnSignal.ts @@ -29,6 +29,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` @@ -63,7 +71,7 @@ export function useAbortJobOnSignal(input: UseAbortJobOnSignalInput): Disposable let abortAttempt = 0; - return useSignalHandler({ + const signalHandler = useSignalHandler({ signals: INTERRUPT_SIGNALS, once: false, handler: async (signal) => { @@ -120,4 +128,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/src/lib/hooks/user-confirmations/useMultiSelectFromList.ts b/src/lib/hooks/user-confirmations/useMultiSelectFromList.ts new file mode 100644 index 000000000..d234947e5 --- /dev/null +++ b/src/lib/hooks/user-confirmations/useMultiSelectFromList.ts @@ -0,0 +1,23 @@ +import checkbox from '@inquirer/checkbox'; + +import { promptContext, type StdinCheckWrapperInput, stdinCheckWrapper } from './_stdinCheckWrapper.js'; + +export type MultiSelectChoicesType = Parameters>[0]['choices']; + +interface UseMultiSelectFromListInput { + message: string; + choices: MultiSelectChoicesType; + pageSize?: number; + loop?: boolean; +} + +export const useMultiSelectFromList = stdinCheckWrapper( + async ({ message, choices, pageSize, loop }: UseMultiSelectFromListInput) => { + const result = await checkbox({ 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 c5925d5ef..35ea63a64 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -404,10 +404,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/test/api/commands/push.test.ts b/test/api/commands/push.test.ts index 6678d97bc..f8d733963 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'); @@ -630,4 +631,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 new file mode 100644 index 000000000..1372906aa --- /dev/null +++ b/test/e2e/commands/push-select.test.ts @@ -0,0 +1,63 @@ +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 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('rejects --all together with --actor-dir', async () => { + const result = await runCli('apify', ['push', '--all', '--actor-dir', 'actors/first'], { cwd: root }); + + expect(result.exitCode, `stdout: ${result.stdout}\nstderr: ${result.stderr}`).not.toBe(0); + expect(result.stderr).toContain('cannot also be provided when using'); + expect(result.stdout).toBe(''); + }); + + it('exits with code 4 for an --actor-dir that is not an Actor', async () => { + const result = await runCli('apify', ['push', '--actor-dir', 'packages/shared'], { cwd: root }); + + expect(result.exitCode, `stdout: ${result.stdout}\nstderr: ${result.stderr}`).toBe(4); + expect(result.stderr).toContain('Directory "packages/shared" from --actor-dir has no .actor/actor.json.'); + expect(result.stdout).toBe(''); + }); +}); diff --git a/test/local/commands/push-select.test.ts b/test/local/commands/push-select.test.ts new file mode 100644 index 000000000..14b864898 --- /dev/null +++ b/test/local/commands/push-select.test.ts @@ -0,0 +1,584 @@ +import { mkdir, rm, symlink, 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, +})); + +// 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, +})); + +// 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; + 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, 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) => ({ + 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) => ({})); + // 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', + 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 () => + 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), + }; + }, + build: (buildId: string) => ({ + get: async () => buildFor(buildId.replace('build-', '')), + abort: async () => buildAbort(buildId), + }), + log: () => ({ get: async () => 'npm ERR! boom\n' }), + })); + + return { state, versionUpdate, outputJobLog, buildAbort, getLoggedClientOrThrow }; +}); + +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) => ({ + ...(await importOriginal()), + getCurrentUserInfo: vitest.fn(async () => ({ id: 'userId', username: 'user' })), + outputJobLog: platform.outputJobLog, + getLoggedClientOrThrow: platform.getLoggedClientOrThrow, +})); + +// 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, + 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 = {}; + platform.state.gitSourcedActorIds = []; + versionUpdate.mockClear(); + outputJobLog.mockClear(); + buildAbort.mockClear(); + writeSync.mockClear(); + getLoggedClientOrThrow.mockClear(); + multiSelect.mockReset(); + yesNoConfirm.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('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(); + + 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 --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'] }); + + expect(commandProcess.exitCode).toBe(CommandExitCodes.NoFilesToPush); + expect(stderr()).toContain('Directory "packages/shared" from --actor-dir has no .actor/actor.json.'); + expect(getLoggedClientOrThrow).not.toHaveBeenCalled(); + expect(versionUpdate).not.toHaveBeenCalled(); + }); + + it('rejects an --actor-dir outside the root before the login check', async () => { + await writeActor('../push-select-outside', ACTOR_B.name); + + try { + await run({ flags_actorDir: ['actors/a', '../push-select-outside'] }); + } finally { + await rm(joinPath('..', 'push-select-outside'), { recursive: true, force: true }); + } + + expect(commandProcess.exitCode).toBe(CommandExitCodes.NoFilesToPush); + expect(stderr()).toContain( + 'Directory "../push-select-outside" from --actor-dir is outside the pushed directory.', + ); + expect(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('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'] }); + + 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 in path order, normalized and without duplicates', async () => { + await run({ flags_actorDir: ['./tools/c/', 'actors/a', 'tools/c'] }); + + expect(commandProcess.exitCode).toBeFalsy(); + expect(pushedActorIds()).toEqual([ACTOR_A.id, ACTOR_C.id]); + }); + + it('passes --version and --build-tag to every version payload', async () => { + await run({ flags_all: true, flags_version: '1.5', flags_buildTag: 'beta' }); + + 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 }); + }); + + 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 () => { + 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'); + 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 }), + ]); + }); + 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); + }); + + 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', () => { + 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); + }); + }); +}); diff --git a/test/local/lib/command-framework/multiple-flag.test.ts b/test/local/lib/command-framework/multiple-flag.test.ts new file mode 100644 index 000000000..5deb48c54 --- /dev/null +++ b/test/local/lib/command-framework/multiple-flag.test.ts @@ -0,0 +1,91 @@ +/* eslint-disable max-classes-per-file */ +import { parseArgs } from 'node:util'; + +import { + ApifyCommand, + type BuiltApifyCommand as _BuiltApifyCommand, +} from '../../../../src/lib/command-framework/apify-command.js'; +import { CommandErrorCode } from '../../../../src/lib/command-framework/CommandError.js'; +import { Flags } from '../../../../src/lib/command-framework/flags.js'; + +const BuiltApifyCommand = ApifyCommand as typeof _BuiltApifyCommand; + +let receivedFlags: Record = {}; + +class CommandWithRepeatableFlag extends BuiltApifyCommand { + static override name = 'repeatable' as const; + static override description = 'Has a repeatable flag and a plain one.'; + static override flags = { + 'actor-dir': Flags.string({ char: 'd', description: 'repeatable', multiple: true }), + kind: Flags.string({ description: 'repeatable with choices', multiple: true, choices: ['x', 'y'] }), + once: Flags.string({ description: 'plain' }), + }; + + async run() { + receivedFlags = this.flags; + } +} + +async function runWith(args: string[]) { + const instance = new CommandWithRepeatableFlag( + 'apify', + CommandWithRepeatableFlag.name, + CommandWithRepeatableFlag.name, + ); + // eslint-disable-next-line dot-notation + instance['skipTelemetry'] = true; + // eslint-disable-next-line dot-notation + instance['skipNotices'] = true; + // eslint-disable-next-line dot-notation + const parsed = parseArgs({ ...instance['_buildParseArgsOption'](), args }); + // eslint-disable-next-line dot-notation + await instance['_run'](parsed); +} + +describe('Flags.string({ multiple: true })', () => { + beforeEach(() => { + receivedFlags = {}; + }); + + test('keeps every value of a repeated flag, in order', async () => { + await runWith(['--actor-dir', 'actors/b', '--actor-dir', 'actors/a', '--actor-dir=actors/c']); + + expect(receivedFlags.actorDir).toEqual(['actors/b', 'actors/a', 'actors/c']); + }); + + test('wraps a single value in an array', async () => { + await runWith(['--actor-dir', 'actors/a']); + + expect(receivedFlags.actorDir).toEqual(['actors/a']); + }); + + test('leaves the flag undefined when it is not passed', async () => { + await runWith([]); + + expect(receivedFlags.actorDir).toBeUndefined(); + }); + + test('strips the = of the short form from every value', async () => { + await runWith(['-d=actors/a', '-d=actors/b']); + + expect(receivedFlags.actorDir).toEqual(['actors/a', 'actors/b']); + }); + + test('accepts repeated values that are all among the choices', async () => { + await runWith(['--kind', 'x', '--kind', 'y']); + + expect(receivedFlags.kind).toEqual(['x', 'y']); + }); + + test('rejects a repeated flag when one value is not among the choices', async () => { + await expect(runWith(['--kind', 'x', '--kind', 'z'])).rejects.toMatchObject({ + code: CommandErrorCode.APIFY_INVALID_CHOICE, + }); + }); + + test('still rejects a repeated flag that did not opt in', async () => { + await expect(runWith(['--once', 'a', '--once', 'b'])).rejects.toMatchObject({ + code: CommandErrorCode.APIFY_FLAG_PROVIDED_MULTIPLE_TIMES, + }); + }); +}); 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..7d37f991c --- /dev/null +++ b/test/local/lib/find-actor-dirs.test.ts @@ -0,0 +1,116 @@ +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'; + +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([]); + }); + + // 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'); + + 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'); + }); +});