From 3536a63b55dfb747d9a55d8f2f082128ca774f43 Mon Sep 17 00:00:00 2001 From: Ayush7614 Date: Thu, 30 Jul 2026 19:45:47 +0530 Subject: [PATCH 1/2] feat(task): add create, ls, info, update, and rm commands Expand the task namespace beyond run so users can manage saved Actor tasks from the CLI instead of only Console, with shared resolve helpers and e2e coverage. --- docs/reference.md | 138 ++++++++++++++++++++- scripts/generate-cli-docs.ts | 5 + src/commands/task/_index.ts | 16 ++- src/commands/task/create.ts | 150 +++++++++++++++++++++++ src/commands/task/info.ts | 125 +++++++++++++++++++ src/commands/task/ls.ts | 90 ++++++++++++++ src/commands/task/rm.ts | 87 +++++++++++++ src/commands/task/run.ts | 47 +------ src/commands/task/update.ts | 145 ++++++++++++++++++++++ src/lib/commands/resolve-task.ts | 73 +++++++++++ test/e2e/commands/task/lifecycle.test.ts | 126 +++++++++++++++++++ 11 files changed, 953 insertions(+), 49 deletions(-) create mode 100644 src/commands/task/create.ts create mode 100644 src/commands/task/info.ts create mode 100644 src/commands/task/ls.ts create mode 100644 src/commands/task/rm.ts create mode 100644 src/commands/task/update.ts create mode 100644 src/lib/commands/resolve-task.ts create mode 100644 test/e2e/commands/task/lifecycle.test.ts diff --git a/docs/reference.md b/docs/reference.md index 09af9be11..6cbaef739 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -1613,12 +1613,106 @@ These commands help you manage scheduled and configured Actor runs. Use them to ```sh DESCRIPTION - Run saved Apify tasks (named Actor configurations). Only 'task run' is - available; create and manage tasks in Apify Console. + Manage and run saved Apify tasks (named Actor configurations with input and + options). SUBCOMMANDS - task run Executes predefined Actor task remotely using local - key-value store for input. + task create Creates a new Actor task with optional saved input and + run options. + task info Prints information about a specific Actor task. + task ls Lists Actor tasks on your account. + task rm Permanently removes an Actor task from your account. + task run Executes predefined Actor task remotely using local + key-value store for input. + task update Updates an existing Actor task (title, description, + input, or run options). +``` + +##### `apify task create` + +```sh +DESCRIPTION + Creates a new Actor task with optional saved input and run options. + Provide input via --input (inline JSON) or --input-file. + +USAGE + $ apify task create --actor + [--build ] [--description ] + [--input | --input-file ] [--json] + [--memory ] [--timeout ] [--title ] + +ARGUMENTS + taskName Name for the new Task (unique under your account). + +FLAGS + --actor= Actor ID or name the task should + run (e.g. "apify/hello-world" or "my-actor"). + --build= Actor build tag or number to use + for the task (e.g. "latest" or "1.2.3"). + --description= Optional description for the + task. + --input= Saved task input as a JSON + string. + --input-file= Path to a JSON file with saved + task input. + --json Format the command output as + JSON. + --memory= Memory limit for the task run, + in megabytes. + --timeout= Timeout for the task run, in + seconds. Use 0 for no timeout. + --title= Optional human-readable title + for the task. +``` + +##### `apify task info` + +```sh +DESCRIPTION + Prints information about a specific Actor task. + +USAGE + $ apify task info [--json] + +ARGUMENTS + taskId Name or ID of the Task (e.g. "my-task" or "username/my-task"). + +FLAGS + --json Format the command output as JSON. +``` + +##### `apify task ls` + +```sh +DESCRIPTION + Lists Actor tasks on your account. + +USAGE + $ apify task ls [--desc] [--json] [--limit ] + [--offset ] + +FLAGS + --desc Sort tasks in descending order. + --json Format the command output as JSON. + --limit= Number of tasks that will be listed. + --offset= Number of tasks that will be skipped. +``` + +##### `apify task rm` + +```sh +DESCRIPTION + Permanently removes an Actor task from your account. + +USAGE + $ apify task rm [-y] + +ARGUMENTS + taskId Name or ID of the Task to delete. + +FLAGS + -y, --yes Automatic yes to prompts; assume "yes" as answer to all + prompts. ``` ##### `apify task run` @@ -1645,6 +1739,42 @@ FLAGS -t, --timeout= Timeout for the Task run in seconds. Zero value means there is no timeout. ``` + +##### `apify task update` + +```sh +DESCRIPTION + Updates an existing Actor task (title, description, input, or run options). + Only the flags you pass are changed; omitted fields keep their current values. + +USAGE + $ apify task update [--build ] + [--description ] + [--input | --input-file ] [--json] + [--memory ] [--name ] [--timeout ] + [--title ] + +ARGUMENTS + taskId Name or ID of the Task to update. + +FLAGS + --build= Actor build tag or number to use + for the task. + --description= New description for the task. + --input= Replace saved task input with + this JSON string. + --input-file= Replace saved task input with + JSON from this file. + --json Format the command output as + JSON. + --memory= Memory limit for the task run, + in megabytes. + --name= New unique name for the task. + --timeout= Timeout for the task run, in + seconds. Use 0 for no timeout. + --title= New human-readable title for the + task. +``` diff --git a/scripts/generate-cli-docs.ts b/scripts/generate-cli-docs.ts index a45eb8555..71442e845 100644 --- a/scripts/generate-cli-docs.ts +++ b/scripts/generate-cli-docs.ts @@ -113,7 +113,12 @@ const categories: Record = { 'task': [ // { command: Commands.task }, + { command: Commands.taskCreate }, + { command: Commands.taskInfo }, + { command: Commands.taskLs }, + { command: Commands.taskRm }, { command: Commands.taskRun }, + { command: Commands.taskUpdate }, ], 'mcp': [ // diff --git a/src/commands/task/_index.ts b/src/commands/task/_index.ts index f44286d6e..714a1b25b 100644 --- a/src/commands/task/_index.ts +++ b/src/commands/task/_index.ts @@ -1,16 +1,28 @@ import { ApifyCommand } from '../../lib/command-framework/apify-command.js'; +import { TaskCreateCommand } from './create.js'; +import { TaskInfoCommand } from './info.js'; +import { TaskLsCommand } from './ls.js'; +import { TaskRmCommand } from './rm.js'; import { TaskRunCommand } from './run.js'; +import { TaskUpdateCommand } from './update.js'; export class TasksIndexCommand extends ApifyCommand { static override name = 'task' as const; - static override description = `Run saved Apify tasks (named Actor configurations). Only 'task run' is available; create and manage tasks in Apify Console.`; + static override description = 'Manage and run saved Apify tasks (named Actor configurations with input and options).'; static override group = 'Apify Console'; static override docsUrl = 'https://docs.apify.com/cli/docs/reference#apify-task'; - static override subcommands = [TaskRunCommand]; + static override subcommands = [ + TaskCreateCommand, + TaskInfoCommand, + TaskLsCommand, + TaskRmCommand, + TaskRunCommand, + TaskUpdateCommand, + ]; async run() { this.printHelp(); diff --git a/src/commands/task/create.ts b/src/commands/task/create.ts new file mode 100644 index 000000000..5a52d8ba8 --- /dev/null +++ b/src/commands/task/create.ts @@ -0,0 +1,150 @@ +import { readFileSync } from 'node:fs'; + +import type { Dictionary } from 'apify-client'; +import chalk from 'chalk'; + +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 { resolveActorContext } from '../../lib/commands/resolve-actor-context.js'; +import { tryToGetTask } from '../../lib/commands/resolve-task.js'; +import { error, success } from '../../lib/outputs.js'; +import { getLoggedClientOrThrow, printJsonToStdout } from '../../lib/utils.js'; + +function parseJsonInput(raw: string, sourceLabel: string): Dictionary | Dictionary[] { + try { + return JSON.parse(raw) as Dictionary | Dictionary[]; + } catch { + throw new Error(`Failed to parse ${sourceLabel} as JSON.`); + } +} + +export class TaskCreateCommand extends ApifyCommand { + static override name = 'create' as const; + + static override description = + 'Creates a new Actor task with optional saved input and run options.\n' + + 'Provide input via --input (inline JSON) or --input-file.'; + + static override examples = [ + { + description: 'Create a task for an Actor with a name.', + command: 'apify task create my-task --actor apify/hello-world', + }, + { + description: 'Create a task with saved JSON input and custom memory.', + command: + 'apify task create my-task --actor my-username/my-actor --input \'{"url":"https://example.com"}\' --memory 2048', + }, + { + description: 'Create a task using input from a file.', + command: 'apify task create my-task --actor my-actor --input-file ./input.json --title "Nightly scrape"', + }, + ]; + + static override docsUrl = 'https://docs.apify.com/cli/docs/reference#apify-task-create'; + + static override args = { + taskName: Args.string({ + required: true, + description: 'Name for the new Task (unique under your account).', + }), + }; + + static override flags = { + actor: Flags.string({ + description: 'Actor ID or name the task should run (e.g. "apify/hello-world" or "my-actor").', + required: true, + }), + title: Flags.string({ + description: 'Optional human-readable title for the task.', + }), + description: Flags.string({ + description: 'Optional description for the task.', + }), + input: Flags.string({ + description: 'Saved task input as a JSON string.', + exclusive: ['input-file'], + }), + 'input-file': Flags.string({ + description: 'Path to a JSON file with saved task input.', + exclusive: ['input'], + }), + memory: Flags.integer({ + description: 'Memory limit for the task run, in megabytes.', + }), + timeout: Flags.integer({ + description: 'Timeout for the task run, in seconds. Use 0 for no timeout.', + }), + build: Flags.string({ + description: 'Actor build tag or number to use for the task (e.g. "latest" or "1.2.3").', + }), + }; + + static override enableJsonFlag = true; + + async run() { + const { taskName } = this.args; + const { actor: actorIdOrName, title, description, input, inputFile, memory, timeout, build, json } = this.flags; + + const client = await getLoggedClientOrThrow(); + + const existing = await tryToGetTask(client, taskName); + if (existing) { + error({ + message: `A Task with name or ID "${taskName}" already exists (${existing.userFriendlyId}).`, + stdout: true, + }); + return; + } + + const actorCtx = await resolveActorContext({ providedActorNameOrId: actorIdOrName, client }); + if (!actorCtx.valid) { + error({ + message: `${actorCtx.reason}. Please specify a valid Actor ID or name.`, + stdout: true, + }); + return; + } + + let parsedInput: Dictionary | Dictionary[] | undefined; + try { + if (input) { + parsedInput = parseJsonInput(input, '--input'); + } else if (inputFile) { + parsedInput = parseJsonInput(readFileSync(inputFile, 'utf8'), `--input-file ${inputFile}`); + } + } catch (err) { + error({ message: (err as Error).message, stdout: true }); + return; + } + + const options = + memory != null || timeout != null || build + ? { + ...(memory != null ? { memoryMbytes: memory } : {}), + ...(timeout != null ? { timeoutSecs: timeout } : {}), + ...(build ? { build } : {}), + } + : undefined; + + const newTask = await client.tasks().create({ + actId: actorCtx.id, + name: taskName, + ...(title ? { title } : {}), + ...(description ? { description } : {}), + ...(parsedInput !== undefined ? { input: parsedInput } : {}), + ...(options ? { options } : {}), + }); + + if (json) { + printJsonToStdout(newTask); + return; + } + + success({ + message: `Task with ID ${chalk.yellow(newTask.id)} (called ${chalk.yellow(newTask.name)}) was created for Actor ${chalk.yellow(actorCtx.userFriendlyId)}.`, + stdout: true, + }); + } +} diff --git a/src/commands/task/info.ts b/src/commands/task/info.ts new file mode 100644 index 000000000..528dd5d5f --- /dev/null +++ b/src/commands/task/info.ts @@ -0,0 +1,125 @@ +import chalk from 'chalk'; + +import { ApifyCommand } from '../../lib/command-framework/apify-command.js'; +import { Args } from '../../lib/command-framework/args.js'; +import { resolveTaskId } from '../../lib/commands/resolve-task.js'; +import { CompactMode, ResponsiveTable } from '../../lib/commands/responsive-table.js'; +import { error, simpleLog } from '../../lib/outputs.js'; +import { getLoggedClientOrThrow, printJsonToStdout, TimestampFormatter } from '../../lib/utils.js'; + +const consoleLikeTable = new ResponsiveTable({ + allColumns: ['Row1', 'Row2'], + mandatoryColumns: ['Row1', 'Row2'], +}); + +export class TaskInfoCommand extends ApifyCommand { + static override name = 'info' as const; + + static override description = 'Prints information about a specific Actor task.'; + + static override examples = [ + { + description: 'Show task metadata and run options.', + command: 'apify task info my-task', + }, + { + description: 'Show task details as JSON, including saved input.', + command: 'apify task info my-username/my-task --json', + }, + ]; + + static override docsUrl = 'https://docs.apify.com/cli/docs/reference#apify-task-info'; + + static override args = { + taskId: Args.string({ + required: true, + description: 'Name or ID of the Task (e.g. "my-task" or "username/my-task").', + }), + }; + + static override enableJsonFlag = true; + + async run() { + const { taskId } = this.args; + const apifyClient = await getLoggedClientOrThrow(); + + let resolved; + try { + resolved = await resolveTaskId(apifyClient, taskId); + } catch (err) { + error({ message: (err as Error).message, stdout: true }); + return; + } + + const { task, userFriendlyId } = resolved; + + const [user, actor, input] = await Promise.all([ + apifyClient + .user(task.userId) + .get() + .then((u) => u!), + apifyClient.actor(task.actId).get(), + apifyClient + .task(task.id) + .getInput() + .catch(() => undefined), + ]); + + if (this.flags.json) { + printJsonToStdout({ + ...task, + input: input ?? task.input ?? null, + user, + actor: actor || null, + }); + return; + } + + const memory = task.options?.memoryMbytes; + const timeout = task.options?.timeoutSecs; + const build = task.options?.build; + + const optionsParts = [ + memory != null ? `${chalk.bold(memory)} ${chalk.gray('MB')}` : chalk.gray('default memory'), + timeout != null + ? `${chalk.bold(timeout)} ${chalk.gray(this.pluralString(timeout, 'second', 'seconds'))}` + : chalk.gray('default timeout'), + build ? `${chalk.gray('build')} ${chalk.bold(build)}` : chalk.gray('default build'), + ]; + + const row1 = [ + `Task ID: ${chalk.bgGray(task.id)}`, + `Name: ${chalk.bgGray(task.name)}`, + `Title: ${task.title ? chalk.bold(task.title) : chalk.italic(chalk.gray('None'))}`, + `Created: ${chalk.bold(TimestampFormatter.display(task.createdAt))}`, + `Modified: ${chalk.bold(TimestampFormatter.display(task.modifiedAt))}`, + ].join('\n'); + + const row2 = [ + `Actor: ${actor ? chalk.blue(actor.title || `${actor.username}/${actor.name}`) : chalk.gray(task.actId)}`, + `Total runs: ${chalk.cyan(task.stats?.totalRuns ?? 0)}`, + `Options: ${optionsParts.join(' / ')}`, + ].join('\n'); + + consoleLikeTable.pushRow({ + Row1: row1, + Row2: row2, + }); + + const rendered = consoleLikeTable.render(CompactMode.NoLines); + const rows = rendered.split('\n').map((row) => row.trim()); + rows.shift(); + + const description = task.description?.trim() ? ['', chalk.bold('Description'), task.description.trim()] : []; + + const message = [ + `${chalk.bold(task.title || task.name)}`, + `${chalk.gray(userFriendlyId)} ${chalk.gray('Owned by')} ${chalk.blue(user.username)}`, + '', + rows.join('\n'), + ...description, + ].join('\n'); + + simpleLog({ message, stdout: true }); + } +} diff --git a/src/commands/task/ls.ts b/src/commands/task/ls.ts new file mode 100644 index 000000000..d9d034c71 --- /dev/null +++ b/src/commands/task/ls.ts @@ -0,0 +1,90 @@ +import chalk from 'chalk'; + +import { ApifyCommand } from '../../lib/command-framework/apify-command.js'; +import { Flags } from '../../lib/command-framework/flags.js'; +import { CompactMode, ResponsiveTable } from '../../lib/commands/responsive-table.js'; +import { info, simpleLog } from '../../lib/outputs.js'; +import { getLocalUserInfo, getLoggedClientOrThrow, printJsonToStdout, TimestampFormatter } from '../../lib/utils.js'; + +const table = new ResponsiveTable({ + allColumns: ['Task ID', 'Name', 'Actor ID', 'Runs', 'Created', 'Modified'], + mandatoryColumns: ['Task ID', 'Name', 'Runs'], + columnAlignments: { + Runs: 'right', + }, +}); + +export class TaskLsCommand extends ApifyCommand { + static override name = 'ls' as const; + + static override description = 'Lists Actor tasks on your account.'; + + static override examples = [ + { + description: 'List your tasks (most recently modified first).', + command: 'apify task ls --desc', + }, + { + description: 'List the next page of 50 tasks.', + command: 'apify task ls --limit 50 --offset 50', + }, + ]; + + static override docsUrl = 'https://docs.apify.com/cli/docs/reference#apify-task-ls'; + + static override flags = { + offset: Flags.integer({ + description: 'Number of tasks that will be skipped.', + default: 0, + }), + limit: Flags.integer({ + description: 'Number of tasks that will be listed.', + default: 20, + }), + desc: Flags.boolean({ + description: 'Sort tasks in descending order.', + default: false, + }), + }; + + static override enableJsonFlag = true; + + async run() { + const { desc, offset, limit, json } = this.flags; + + const client = await getLoggedClientOrThrow(); + const user = await getLocalUserInfo(); + + const rawTaskList = await client.tasks().list({ desc, offset, limit }); + + if (json) { + printJsonToStdout(rawTaskList); + return; + } + + if (rawTaskList.count === 0) { + info({ + message: "You don't have any Tasks on your account", + stdout: true, + }); + + return; + } + + for (const task of rawTaskList.items) { + table.pushRow({ + 'Task ID': task.id, + Name: task.name ? `${user.username ?? task.username}/${task.name}` : chalk.italic('Unnamed'), + 'Actor ID': chalk.gray(task.actId), + Runs: chalk.cyan(`${task.stats?.totalRuns ?? 0}`), + Created: TimestampFormatter.display(task.createdAt), + Modified: TimestampFormatter.display(task.modifiedAt), + }); + } + + simpleLog({ + message: table.render(CompactMode.WebLikeCompact), + stdout: true, + }); + } +} diff --git a/src/commands/task/rm.ts b/src/commands/task/rm.ts new file mode 100644 index 000000000..b66eb47a4 --- /dev/null +++ b/src/commands/task/rm.ts @@ -0,0 +1,87 @@ +import type { ApifyApiError } from 'apify-client'; +import chalk from 'chalk'; + +import { ApifyCommand } from '../../lib/command-framework/apify-command.js'; +import { Args } from '../../lib/command-framework/args.js'; +import { YesFlag } from '../../lib/command-framework/flags.js'; +import { resolveTaskId } from '../../lib/commands/resolve-task.js'; +import { useYesNoConfirm } from '../../lib/hooks/user-confirmations/useYesNoConfirm.js'; +import { error, info, success } from '../../lib/outputs.js'; +import { getLoggedClientOrThrow } from '../../lib/utils.js'; + +export class TaskRmCommand extends ApifyCommand { + static override name = 'rm' as const; + + static override description = 'Permanently removes an Actor task from your account.'; + + static override interactive = true; + + static override interactiveNote = + 'Prompts for confirmation before deleting. Cannot be bypassed; deletion is irreversible.'; + + static override examples = [ + { + description: 'Delete a task by name (prompts for confirmation).', + command: 'apify task rm my-task', + }, + { + description: 'Delete a task without prompting.', + command: 'apify task rm my-username/my-task --yes', + }, + ]; + + static override docsUrl = 'https://docs.apify.com/cli/docs/reference#apify-task-rm'; + + static override args = { + taskId: Args.string({ + required: true, + description: 'Name or ID of the Task to delete.', + }), + }; + + static override flags = { + ...YesFlag(), + }; + + async run() { + const { taskId } = this.args; + const { yes } = this.flags; + + const apifyClient = await getLoggedClientOrThrow(); + + let resolved; + try { + resolved = await resolveTaskId(apifyClient, taskId); + } catch (err) { + error({ message: (err as Error).message, stdout: true }); + return; + } + + const confirmed = await useYesNoConfirm({ + message: `Are you sure you want to delete Task "${resolved.userFriendlyId}"?`, + providedConfirmFromStdin: yes || undefined, + }); + + if (!confirmed) { + info({ + message: `Deletion of Task "${resolved.userFriendlyId}" was canceled.`, + }); + return; + } + + try { + await resolved.taskClient.delete(); + + success({ + message: `Task with ID ${chalk.yellow(resolved.id)} (called ${chalk.yellow(resolved.task.name)}) was deleted.`, + stdout: true, + }); + } catch (err) { + const casted = err as ApifyApiError; + error({ + message: `Failed to delete Task "${resolved.userFriendlyId}".\n ${casted.message || casted}`, + stdout: true, + }); + } + } +} diff --git a/src/commands/task/run.ts b/src/commands/task/run.ts index e4e9add60..e4404fb0d 100644 --- a/src/commands/task/run.ts +++ b/src/commands/task/run.ts @@ -1,10 +1,11 @@ -import type { ActorRun, ApifyClient, TaskStartOptions } from 'apify-client'; +import type { ActorRun, TaskStartOptions } from 'apify-client'; import { ApifyCommand } from '../../lib/command-framework/apify-command.js'; import { Args } from '../../lib/command-framework/args.js'; +import { resolveTaskId } from '../../lib/commands/resolve-task.js'; import { runActorOrTaskOnCloud, SharedRunOnCloudFlags } from '../../lib/commands/run-on-cloud.js'; import { finalizeRun } from '../../lib/commands/run-result.js'; -import { getLocalUserInfo, getLoggedClientOrThrow } from '../../lib/utils.js'; +import { getLoggedClientOrThrow } from '../../lib/utils.js'; export class TaskRunCommand extends ApifyCommand { static override name = 'run' as const; @@ -39,10 +40,7 @@ export class TaskRunCommand extends ApifyCommand { async run() { const apifyClient = await getLoggedClientOrThrow(); - const userInfo = await getLocalUserInfo(); - const usernameOrId = userInfo.username || (userInfo.id as string); - - const { id: taskId, userFriendlyId, title } = await this.resolveTaskId(apifyClient, usernameOrId); + const { id: taskId, userFriendlyId, title } = await resolveTaskId(apifyClient, this.args.taskId); const runOpts: TaskStartOptions = { waitForFinish: 2, // NOTE: We need to wait some time to Apify open stream and we can create connection @@ -81,41 +79,4 @@ export class TaskRunCommand extends ApifyCommand { await finalizeRun({ apifyClient, run, operation: 'task-run', json: this.flags.json }); } - - private async resolveTaskId(client: ApifyClient, usernameOrId: string) { - const { taskId } = this.args; - - // Full ID - if (taskId?.includes('/')) { - const task = await client.task(taskId).get(); - if (!task) { - throw new Error(`Cannot find Task with ID '${taskId}' in your account.`); - } - - return { - id: task.id, - userFriendlyId: `${usernameOrId}/${task.name}`, - title: task.title, - task, - }; - } - - // Try fetching task directly by name - if (taskId) { - const task = await client.task(`${usernameOrId}/${taskId.toLowerCase()}`).get(); - - if (!task) { - throw new Error(`Cannot find Task with name '${taskId}' in your account.`); - } - - return { - id: task.id, - userFriendlyId: `${usernameOrId}/${task.name}`, - title: task.title, - task, - }; - } - - throw new Error('Please provide a valid Task ID or name.'); - } } diff --git a/src/commands/task/update.ts b/src/commands/task/update.ts new file mode 100644 index 000000000..7e7f2f14f --- /dev/null +++ b/src/commands/task/update.ts @@ -0,0 +1,145 @@ +import { readFileSync } from 'node:fs'; + +import type { Dictionary } from 'apify-client'; +import chalk from 'chalk'; + +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 { resolveTaskId } from '../../lib/commands/resolve-task.js'; +import { error, success } from '../../lib/outputs.js'; +import { getLoggedClientOrThrow, printJsonToStdout } from '../../lib/utils.js'; + +function parseJsonInput(raw: string, sourceLabel: string): Dictionary | Dictionary[] { + try { + return JSON.parse(raw) as Dictionary | Dictionary[]; + } catch { + throw new Error(`Failed to parse ${sourceLabel} as JSON.`); + } +} + +export class TaskUpdateCommand extends ApifyCommand { + static override name = 'update' as const; + + static override description = + 'Updates an existing Actor task (title, description, input, or run options).\n' + + 'Only the flags you pass are changed; omitted fields keep their current values.'; + + static override examples = [ + { + description: 'Rename the task title.', + command: 'apify task update my-task --title "Updated title"', + }, + { + description: 'Replace saved input from a file and bump memory.', + command: 'apify task update my-task --input-file ./input.json --memory 4096', + }, + ]; + + static override docsUrl = 'https://docs.apify.com/cli/docs/reference#apify-task-update'; + + static override args = { + taskId: Args.string({ + required: true, + description: 'Name or ID of the Task to update.', + }), + }; + + static override flags = { + name: Flags.string({ + description: 'New unique name for the task.', + }), + title: Flags.string({ + description: 'New human-readable title for the task.', + }), + description: Flags.string({ + description: 'New description for the task.', + }), + input: Flags.string({ + description: 'Replace saved task input with this JSON string.', + exclusive: ['input-file'], + }), + 'input-file': Flags.string({ + description: 'Replace saved task input with JSON from this file.', + exclusive: ['input'], + }), + memory: Flags.integer({ + description: 'Memory limit for the task run, in megabytes.', + }), + timeout: Flags.integer({ + description: 'Timeout for the task run, in seconds. Use 0 for no timeout.', + }), + build: Flags.string({ + description: 'Actor build tag or number to use for the task.', + }), + }; + + static override enableJsonFlag = true; + + async run() { + const { taskId } = this.args; + const { name, title, description, input, inputFile, memory, timeout, build, json } = this.flags; + + const client = await getLoggedClientOrThrow(); + + let resolved; + try { + resolved = await resolveTaskId(client, taskId); + } catch (err) { + error({ message: (err as Error).message, stdout: true }); + return; + } + + let parsedInput: Dictionary | Dictionary[] | undefined; + try { + if (input) { + parsedInput = parseJsonInput(input, '--input'); + } else if (inputFile) { + parsedInput = parseJsonInput(readFileSync(inputFile, 'utf8'), `--input-file ${inputFile}`); + } + } catch (err) { + error({ message: (err as Error).message, stdout: true }); + return; + } + + const hasOptionUpdate = memory != null || timeout != null || build != null; + const hasAnyUpdate = + name != null || title != null || description != null || parsedInput !== undefined || hasOptionUpdate; + + if (!hasAnyUpdate) { + error({ + message: + 'Provide at least one of --name, --title, --description, --input/--input-file, --memory, --timeout, or --build.', + stdout: true, + }); + return; + } + + const nextOptions = hasOptionUpdate + ? { + ...resolved.task.options, + ...(memory != null ? { memoryMbytes: memory } : {}), + ...(timeout != null ? { timeoutSecs: timeout } : {}), + ...(build != null ? { build } : {}), + } + : undefined; + + const updated = await resolved.taskClient.update({ + ...(name != null ? { name } : {}), + ...(title != null ? { title } : {}), + ...(description != null ? { description } : {}), + ...(parsedInput !== undefined ? { input: parsedInput } : {}), + ...(nextOptions ? { options: nextOptions } : {}), + }); + + if (json) { + printJsonToStdout(updated); + return; + } + + success({ + message: `Task ${chalk.yellow(resolved.userFriendlyId)} (${chalk.gray(updated.id)}) was updated.`, + stdout: true, + }); + } +} diff --git a/src/lib/commands/resolve-task.ts b/src/lib/commands/resolve-task.ts new file mode 100644 index 000000000..699c8e2be --- /dev/null +++ b/src/lib/commands/resolve-task.ts @@ -0,0 +1,73 @@ +import type { ApifyClient, Task, TaskClient } from 'apify-client'; + +import { getLocalUserInfo } from '../utils.js'; + +export interface ResolvedTask { + id: string; + userFriendlyId: string; + title?: string; + task: Task; + taskClient: TaskClient; +} + +/** + * Resolves a Task by ID, `username/name`, or bare name under the logged-in account. + */ +export async function resolveTaskId(client: ApifyClient, taskIdOrName: string | undefined): Promise { + const userInfo = await getLocalUserInfo(); + const usernameOrId = userInfo.username || (userInfo.id as string); + + if (!taskIdOrName) { + throw new Error('Please provide a valid Task ID or name.'); + } + + if (taskIdOrName.includes('/')) { + const task = await client.task(taskIdOrName).get(); + if (!task) { + throw new Error(`Cannot find Task with ID '${taskIdOrName}' in your account.`); + } + + return { + id: task.id, + userFriendlyId: `${task.username ?? usernameOrId}/${task.name}`, + title: task.title, + task, + taskClient: client.task(task.id), + }; + } + + const byId = await client.task(taskIdOrName).get(); + if (byId) { + return { + id: byId.id, + userFriendlyId: `${byId.username ?? usernameOrId}/${byId.name}`, + title: byId.title, + task: byId, + taskClient: client.task(byId.id), + }; + } + + const byName = await client.task(`${usernameOrId}/${taskIdOrName.toLowerCase()}`).get(); + if (!byName) { + throw new Error(`Cannot find Task with name '${taskIdOrName}' in your account.`); + } + + return { + id: byName.id, + userFriendlyId: `${byName.username ?? usernameOrId}/${byName.name}`, + title: byName.title, + task: byName, + taskClient: client.task(byName.id), + }; +} + +/** + * Like {@link resolveTaskId}, but returns `null` instead of throwing when the Task is missing. + */ +export async function tryToGetTask(client: ApifyClient, taskIdOrName: string): Promise { + try { + return await resolveTaskId(client, taskIdOrName); + } catch { + return null; + } +} diff --git a/test/e2e/commands/task/lifecycle.test.ts b/test/e2e/commands/task/lifecycle.test.ts new file mode 100644 index 000000000..6e3bab6ba --- /dev/null +++ b/test/e2e/commands/task/lifecycle.test.ts @@ -0,0 +1,126 @@ +import { randomBytes } from 'node:crypto'; +import { writeFileSync } from 'node:fs'; +import { join } from 'node:path'; + +import { ApifyClient } from 'apify-client'; + +import { getApifyClientOptions } from '../../../../src/lib/utils.js'; +import { runCli } from '../../__helpers__/run-cli.js'; +import { createTestActor, removeTestActor, type TestActor } from '../../__helpers__/test-actor.js'; + +describe('[e2e][api] task namespace', () => { + let actor: TestActor; + let authEnv: Record; + let client: ApifyClient; + let actorFullName: string; + let taskId: string; + const taskName = `e2e-task-${randomBytes(6).toString('hex')}`; + const renamedTitle = 'E2E updated task title'; + + beforeAll(async () => { + const token = process.env.TEST_USER_TOKEN; + if (!token) throw new Error('TEST_USER_TOKEN env var is required for task tests'); + + const authPath = `e2e-task-${randomBytes(6).toString('hex')}`; + authEnv = { __APIFY_INTERNAL_TEST_AUTH_PATH__: authPath }; + + const loginResult = await runCli('apify', ['login', '--token', token], { env: authEnv }); + if (loginResult.exitCode !== 0) { + throw new Error(`Failed to login:\n${loginResult.stderr}`); + } + + client = new ApifyClient(await getApifyClientOptions(token)); + const me = await client.user('me').get(); + + actor = await createTestActor('e2e-task'); + actorFullName = `${me.username}/${actor.name}`; + + const pushResult = await runCli('apify', ['push'], { + cwd: actor.dir, + env: authEnv, + }); + + if (pushResult.exitCode !== 0) { + throw new Error(`Push failed:\n${pushResult.stderr}\n${pushResult.stdout}`); + } + }, 300_000); + + afterAll(async () => { + if (taskId && client) { + try { + await client.task(taskId).delete(); + } catch { + // Do nothing + } + } + + if (actorFullName && client) { + try { + await client.actor(actorFullName).delete(); + } catch { + // Do nothing + } + } + + if (actor) await removeTestActor(actor); + }); + + it('creates a task', async () => { + const inputPath = join(actor.dir, 'task-input.json'); + writeFileSync(inputPath, JSON.stringify({ hello: 'world' })); + + const result = await runCli( + 'apify', + ['task', 'create', taskName, '--actor', actorFullName, '--input-file', inputPath, '--memory', '1024', '--json'], + { env: authEnv }, + ); + + expect(result.exitCode, `stderr: ${result.stderr}`).toBe(0); + const task = JSON.parse(result.stdout); + taskId = task.id; + expect(task.name).toBe(taskName); + expect(task.options?.memoryMbytes).toBe(1024); + }); + + it('lists tasks including the new one', async () => { + const result = await runCli('apify', ['task', 'ls', '--json', '--desc'], { env: authEnv }); + expect(result.exitCode, `stderr: ${result.stderr}`).toBe(0); + const list = JSON.parse(result.stdout); + expect(list).toHaveProperty('items'); + expect(list.items.some((t: { id: string }) => t.id === taskId)).toBe(true); + }); + + it('shows task info', async () => { + const jsonResult = await runCli('apify', ['task', 'info', taskName, '--json'], { env: authEnv }); + expect(jsonResult.exitCode, `stderr: ${jsonResult.stderr}`).toBe(0); + const info = JSON.parse(jsonResult.stdout); + expect(info.id).toBe(taskId); + expect(info.input).toMatchObject({ hello: 'world' }); + + const textResult = await runCli('apify', ['task', 'info', taskId], { env: authEnv }); + expect(textResult.exitCode, `stderr: ${textResult.stderr}`).toBe(0); + expect(textResult.stdout).toContain(taskId); + }); + + it('updates a task', async () => { + const result = await runCli( + 'apify', + ['task', 'update', taskName, '--title', renamedTitle, '--input', '{"hello":"updated"}', '--json'], + { env: authEnv }, + ); + expect(result.exitCode, `stderr: ${result.stderr}`).toBe(0); + const updated = JSON.parse(result.stdout); + expect(updated.title).toBe(renamedTitle); + expect(updated.input).toMatchObject({ hello: 'updated' }); + }); + + it('deletes a task', async () => { + const result = await runCli('apify', ['task', 'rm', taskName, '--yes'], { env: authEnv }); + expect(result.exitCode, `stderr: ${result.stderr}`).toBe(0); + expect(result.stdout + result.stderr).toMatch(/deleted/i); + + const gone = await client.task(taskId).get(); + expect(gone).toBeUndefined(); + taskId = ''; + }); +}); From 53b641a3b594b50380786eeedf9b0792bb045b63 Mon Sep 17 00:00:00 2001 From: Ayush7614 Date: Fri, 31 Jul 2026 01:35:56 +0530 Subject: [PATCH 2/2] fix(task): address review on exit codes, input, and tests Propagate resolveTaskId failures, set non-zero exit on delete/create errors, reuse getInputOverride with -i/-f shortcuts, drop redundant create preflight, add local coverage, and simplify the e2e suite. --- docs/reference.md | 21 +- src/commands/task/create.ts | 83 ++++---- src/commands/task/info.ts | 12 +- src/commands/task/ls.ts | 5 +- src/commands/task/rm.ts | 11 +- src/commands/task/update.ts | 44 ++-- test/e2e/commands/task/lifecycle.test.ts | 37 +--- test/local/commands/task/commands.test.ts | 237 ++++++++++++++++++++++ test/local/lib/resolve-task.test.ts | 104 ++++++++++ 9 files changed, 418 insertions(+), 136 deletions(-) create mode 100644 test/local/commands/task/commands.test.ts create mode 100644 test/local/lib/resolve-task.test.ts diff --git a/docs/reference.md b/docs/reference.md index 6cbaef739..1087aef3a 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -1638,8 +1638,8 @@ DESCRIPTION USAGE $ apify task create --actor [--build ] [--description ] - [--input | --input-file ] [--json] - [--memory ] [--timeout ] [--title ] + [-i | -f ] [--json] [--memory ] + [--timeout ] [--title ] ARGUMENTS taskName Name for the new Task (unique under your account). @@ -1651,10 +1651,10 @@ FLAGS for the task (e.g. "latest" or "1.2.3"). --description= Optional description for the task. - --input= Saved task input as a JSON + -i, --input= Saved task input as a JSON string. - --input-file= Path to a JSON file with saved - task input. + -f, --input-file= Path to a JSON file with + saved task input. --json Format the command output as JSON. --memory= Memory limit for the task run, @@ -1749,8 +1749,7 @@ DESCRIPTION USAGE $ apify task update [--build ] - [--description ] - [--input | --input-file ] [--json] + [--description ] [-i | -f ] [--json] [--memory ] [--name ] [--timeout ] [--title ] @@ -1761,10 +1760,10 @@ FLAGS --build= Actor build tag or number to use for the task. --description= New description for the task. - --input= Replace saved task input with - this JSON string. - --input-file= Replace saved task input with - JSON from this file. + -i, --input= Replace saved task input + with this JSON string. + -f, --input-file= Replace saved task input + with JSON from this file. --json Format the command output as JSON. --memory= Memory limit for the task run, diff --git a/src/commands/task/create.ts b/src/commands/task/create.ts index 5a52d8ba8..1d7c332b8 100644 --- a/src/commands/task/create.ts +++ b/src/commands/task/create.ts @@ -1,24 +1,16 @@ -import { readFileSync } from 'node:fs'; +import process from 'node:process'; -import type { Dictionary } from 'apify-client'; +import type { ApifyApiError, Dictionary } from 'apify-client'; import chalk from 'chalk'; 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 { resolveActorContext } from '../../lib/commands/resolve-actor-context.js'; -import { tryToGetTask } from '../../lib/commands/resolve-task.js'; +import { getInputOverride } from '../../lib/commands/resolve-input.js'; import { error, success } from '../../lib/outputs.js'; import { getLoggedClientOrThrow, printJsonToStdout } from '../../lib/utils.js'; -function parseJsonInput(raw: string, sourceLabel: string): Dictionary | Dictionary[] { - try { - return JSON.parse(raw) as Dictionary | Dictionary[]; - } catch { - throw new Error(`Failed to parse ${sourceLabel} as JSON.`); - } -} - export class TaskCreateCommand extends ApifyCommand { static override name = 'create' as const; @@ -63,10 +55,12 @@ export class TaskCreateCommand extends ApifyCommand { description: 'Optional description for the task.', }), input: Flags.string({ + char: 'i', description: 'Saved task input as a JSON string.', exclusive: ['input-file'], }), 'input-file': Flags.string({ + char: 'f', description: 'Path to a JSON file with saved task input.', exclusive: ['input'], }), @@ -88,15 +82,7 @@ export class TaskCreateCommand extends ApifyCommand { const { actor: actorIdOrName, title, description, input, inputFile, memory, timeout, build, json } = this.flags; const client = await getLoggedClientOrThrow(); - - const existing = await tryToGetTask(client, taskName); - if (existing) { - error({ - message: `A Task with name or ID "${taskName}" already exists (${existing.userFriendlyId}).`, - stdout: true, - }); - return; - } + const cwd = process.cwd(); const actorCtx = await resolveActorContext({ providedActorNameOrId: actorIdOrName, client }); if (!actorCtx.valid) { @@ -104,21 +90,17 @@ export class TaskCreateCommand extends ApifyCommand { message: `${actorCtx.reason}. Please specify a valid Actor ID or name.`, stdout: true, }); + process.exitCode ||= 1; return; } - let parsedInput: Dictionary | Dictionary[] | undefined; - try { - if (input) { - parsedInput = parseJsonInput(input, '--input'); - } else if (inputFile) { - parsedInput = parseJsonInput(readFileSync(inputFile, 'utf8'), `--input-file ${inputFile}`); - } - } catch (err) { - error({ message: (err as Error).message, stdout: true }); + const inputOverride = await getInputOverride(cwd, input, inputFile); + if (inputOverride === false) { return; } + const parsedInput = inputOverride?.input as Dictionary | undefined; + const options = memory != null || timeout != null || build ? { @@ -128,23 +110,32 @@ export class TaskCreateCommand extends ApifyCommand { } : undefined; - const newTask = await client.tasks().create({ - actId: actorCtx.id, - name: taskName, - ...(title ? { title } : {}), - ...(description ? { description } : {}), - ...(parsedInput !== undefined ? { input: parsedInput } : {}), - ...(options ? { options } : {}), - }); - - if (json) { - printJsonToStdout(newTask); - return; - } + try { + const newTask = await client.tasks().create({ + actId: actorCtx.id, + name: taskName, + ...(title ? { title } : {}), + ...(description ? { description } : {}), + ...(parsedInput !== undefined ? { input: parsedInput } : {}), + ...(options ? { options } : {}), + }); - success({ - message: `Task with ID ${chalk.yellow(newTask.id)} (called ${chalk.yellow(newTask.name)}) was created for Actor ${chalk.yellow(actorCtx.userFriendlyId)}.`, - stdout: true, - }); + if (json) { + printJsonToStdout(newTask); + return; + } + + success({ + message: `Task with ID ${chalk.yellow(newTask.id)} (called ${chalk.yellow(newTask.name)}) was created for Actor ${chalk.yellow(actorCtx.userFriendlyId)}.`, + stdout: true, + }); + } catch (err) { + const casted = err as ApifyApiError; + error({ + message: `Failed to create Task "${taskName}".\n ${casted.message || casted}`, + stdout: true, + }); + process.exitCode ||= 1; + } } } diff --git a/src/commands/task/info.ts b/src/commands/task/info.ts index 528dd5d5f..4e5968ace 100644 --- a/src/commands/task/info.ts +++ b/src/commands/task/info.ts @@ -4,7 +4,7 @@ import { ApifyCommand } from '../../lib/command-framework/apify-command.js'; import { Args } from '../../lib/command-framework/args.js'; import { resolveTaskId } from '../../lib/commands/resolve-task.js'; import { CompactMode, ResponsiveTable } from '../../lib/commands/responsive-table.js'; -import { error, simpleLog } from '../../lib/outputs.js'; +import { simpleLog } from '../../lib/outputs.js'; import { getLoggedClientOrThrow, printJsonToStdout, TimestampFormatter } from '../../lib/utils.js'; const consoleLikeTable = new ResponsiveTable({ @@ -43,15 +43,7 @@ export class TaskInfoCommand extends ApifyCommand { const { taskId } = this.args; const apifyClient = await getLoggedClientOrThrow(); - let resolved; - try { - resolved = await resolveTaskId(apifyClient, taskId); - } catch (err) { - error({ message: (err as Error).message, stdout: true }); - return; - } - - const { task, userFriendlyId } = resolved; + const { task, userFriendlyId } = await resolveTaskId(apifyClient, taskId); const [user, actor, input] = await Promise.all([ apifyClient diff --git a/src/commands/task/ls.ts b/src/commands/task/ls.ts index d9d034c71..6709ac07a 100644 --- a/src/commands/task/ls.ts +++ b/src/commands/task/ls.ts @@ -4,7 +4,7 @@ import { ApifyCommand } from '../../lib/command-framework/apify-command.js'; import { Flags } from '../../lib/command-framework/flags.js'; import { CompactMode, ResponsiveTable } from '../../lib/commands/responsive-table.js'; import { info, simpleLog } from '../../lib/outputs.js'; -import { getLocalUserInfo, getLoggedClientOrThrow, printJsonToStdout, TimestampFormatter } from '../../lib/utils.js'; +import { getLoggedClientOrThrow, printJsonToStdout, TimestampFormatter } from '../../lib/utils.js'; const table = new ResponsiveTable({ allColumns: ['Task ID', 'Name', 'Actor ID', 'Runs', 'Created', 'Modified'], @@ -53,7 +53,6 @@ export class TaskLsCommand extends ApifyCommand { const { desc, offset, limit, json } = this.flags; const client = await getLoggedClientOrThrow(); - const user = await getLocalUserInfo(); const rawTaskList = await client.tasks().list({ desc, offset, limit }); @@ -74,7 +73,7 @@ export class TaskLsCommand extends ApifyCommand { for (const task of rawTaskList.items) { table.pushRow({ 'Task ID': task.id, - Name: task.name ? `${user.username ?? task.username}/${task.name}` : chalk.italic('Unnamed'), + Name: `${task.username}/${task.name}`, 'Actor ID': chalk.gray(task.actId), Runs: chalk.cyan(`${task.stats?.totalRuns ?? 0}`), Created: TimestampFormatter.display(task.createdAt), diff --git a/src/commands/task/rm.ts b/src/commands/task/rm.ts index b66eb47a4..e2c6314bd 100644 --- a/src/commands/task/rm.ts +++ b/src/commands/task/rm.ts @@ -1,3 +1,5 @@ +import process from 'node:process'; + import type { ApifyApiError } from 'apify-client'; import chalk from 'chalk'; @@ -49,13 +51,7 @@ export class TaskRmCommand extends ApifyCommand { const apifyClient = await getLoggedClientOrThrow(); - let resolved; - try { - resolved = await resolveTaskId(apifyClient, taskId); - } catch (err) { - error({ message: (err as Error).message, stdout: true }); - return; - } + const resolved = await resolveTaskId(apifyClient, taskId); const confirmed = await useYesNoConfirm({ message: `Are you sure you want to delete Task "${resolved.userFriendlyId}"?`, @@ -82,6 +78,7 @@ export class TaskRmCommand extends ApifyCommand { message: `Failed to delete Task "${resolved.userFriendlyId}".\n ${casted.message || casted}`, stdout: true, }); + process.exitCode ||= 1; } } } diff --git a/src/commands/task/update.ts b/src/commands/task/update.ts index 7e7f2f14f..eeab45c6d 100644 --- a/src/commands/task/update.ts +++ b/src/commands/task/update.ts @@ -1,4 +1,4 @@ -import { readFileSync } from 'node:fs'; +import process from 'node:process'; import type { Dictionary } from 'apify-client'; import chalk from 'chalk'; @@ -6,18 +6,11 @@ import chalk from 'chalk'; 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 { getInputOverride } from '../../lib/commands/resolve-input.js'; import { resolveTaskId } from '../../lib/commands/resolve-task.js'; import { error, success } from '../../lib/outputs.js'; import { getLoggedClientOrThrow, printJsonToStdout } from '../../lib/utils.js'; -function parseJsonInput(raw: string, sourceLabel: string): Dictionary | Dictionary[] { - try { - return JSON.parse(raw) as Dictionary | Dictionary[]; - } catch { - throw new Error(`Failed to parse ${sourceLabel} as JSON.`); - } -} - export class TaskUpdateCommand extends ApifyCommand { static override name = 'update' as const; @@ -56,10 +49,12 @@ export class TaskUpdateCommand extends ApifyCommand { description: 'New description for the task.', }), input: Flags.string({ + char: 'i', description: 'Replace saved task input with this JSON string.', exclusive: ['input-file'], }), 'input-file': Flags.string({ + char: 'f', description: 'Replace saved task input with JSON from this file.', exclusive: ['input'], }), @@ -81,30 +76,20 @@ export class TaskUpdateCommand extends ApifyCommand { const { name, title, description, input, inputFile, memory, timeout, build, json } = this.flags; const client = await getLoggedClientOrThrow(); + const cwd = process.cwd(); - let resolved; - try { - resolved = await resolveTaskId(client, taskId); - } catch (err) { - error({ message: (err as Error).message, stdout: true }); - return; - } + const resolved = await resolveTaskId(client, taskId); - let parsedInput: Dictionary | Dictionary[] | undefined; - try { - if (input) { - parsedInput = parseJsonInput(input, '--input'); - } else if (inputFile) { - parsedInput = parseJsonInput(readFileSync(inputFile, 'utf8'), `--input-file ${inputFile}`); - } - } catch (err) { - error({ message: (err as Error).message, stdout: true }); + const inputOverride = await getInputOverride(cwd, input, inputFile); + if (inputOverride === false) { return; } + const parsedInput = inputOverride?.input as Dictionary | undefined; + const hasInputUpdate = input != null || inputFile != null; + const hasOptionUpdate = memory != null || timeout != null || build != null; - const hasAnyUpdate = - name != null || title != null || description != null || parsedInput !== undefined || hasOptionUpdate; + const hasAnyUpdate = name != null || title != null || description != null || hasInputUpdate || hasOptionUpdate; if (!hasAnyUpdate) { error({ @@ -112,6 +97,7 @@ export class TaskUpdateCommand extends ApifyCommand { 'Provide at least one of --name, --title, --description, --input/--input-file, --memory, --timeout, or --build.', stdout: true, }); + process.exitCode ||= 1; return; } @@ -128,7 +114,7 @@ export class TaskUpdateCommand extends ApifyCommand { ...(name != null ? { name } : {}), ...(title != null ? { title } : {}), ...(description != null ? { description } : {}), - ...(parsedInput !== undefined ? { input: parsedInput } : {}), + ...(hasInputUpdate ? { input: parsedInput } : {}), ...(nextOptions ? { options: nextOptions } : {}), }); @@ -138,7 +124,7 @@ export class TaskUpdateCommand extends ApifyCommand { } success({ - message: `Task ${chalk.yellow(resolved.userFriendlyId)} (${chalk.gray(updated.id)}) was updated.`, + message: `Task ${chalk.yellow(updated.name)} (${chalk.gray(updated.id)}) was updated.`, stdout: true, }); } diff --git a/test/e2e/commands/task/lifecycle.test.ts b/test/e2e/commands/task/lifecycle.test.ts index 6e3bab6ba..49fc0bd59 100644 --- a/test/e2e/commands/task/lifecycle.test.ts +++ b/test/e2e/commands/task/lifecycle.test.ts @@ -1,21 +1,20 @@ import { randomBytes } from 'node:crypto'; -import { writeFileSync } from 'node:fs'; +import { mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { ApifyClient } from 'apify-client'; import { getApifyClientOptions } from '../../../../src/lib/utils.js'; import { runCli } from '../../__helpers__/run-cli.js'; -import { createTestActor, removeTestActor, type TestActor } from '../../__helpers__/test-actor.js'; describe('[e2e][api] task namespace', () => { - let actor: TestActor; let authEnv: Record; let client: ApifyClient; - let actorFullName: string; let taskId: string; const taskName = `e2e-task-${randomBytes(6).toString('hex')}`; const renamedTitle = 'E2E updated task title'; + const actorFullName = 'apify/hello-world'; beforeAll(async () => { const token = process.env.TEST_USER_TOKEN; @@ -30,20 +29,7 @@ describe('[e2e][api] task namespace', () => { } client = new ApifyClient(await getApifyClientOptions(token)); - const me = await client.user('me').get(); - - actor = await createTestActor('e2e-task'); - actorFullName = `${me.username}/${actor.name}`; - - const pushResult = await runCli('apify', ['push'], { - cwd: actor.dir, - env: authEnv, - }); - - if (pushResult.exitCode !== 0) { - throw new Error(`Push failed:\n${pushResult.stderr}\n${pushResult.stdout}`); - } - }, 300_000); + }, 60_000); afterAll(async () => { if (taskId && client) { @@ -53,20 +39,11 @@ describe('[e2e][api] task namespace', () => { // Do nothing } } - - if (actorFullName && client) { - try { - await client.actor(actorFullName).delete(); - } catch { - // Do nothing - } - } - - if (actor) await removeTestActor(actor); }); it('creates a task', async () => { - const inputPath = join(actor.dir, 'task-input.json'); + const inputDir = mkdtempSync(join(tmpdir(), 'apify-cli-task-')); + const inputPath = join(inputDir, 'task-input.json'); writeFileSync(inputPath, JSON.stringify({ hello: 'world' })); const result = await runCli( @@ -117,7 +94,7 @@ describe('[e2e][api] task namespace', () => { it('deletes a task', async () => { const result = await runCli('apify', ['task', 'rm', taskName, '--yes'], { env: authEnv }); expect(result.exitCode, `stderr: ${result.stderr}`).toBe(0); - expect(result.stdout + result.stderr).toMatch(/deleted/i); + expect(result.stdout).toContain('was deleted'); const gone = await client.task(taskId).get(); expect(gone).toBeUndefined(); diff --git a/test/local/commands/task/commands.test.ts b/test/local/commands/task/commands.test.ts new file mode 100644 index 000000000..a90eb01b2 --- /dev/null +++ b/test/local/commands/task/commands.test.ts @@ -0,0 +1,237 @@ +import { writeFileSync } from 'node:fs'; +import process from 'node:process'; + +import type { ApifyClient } from 'apify-client'; + +import { TaskCreateCommand } from '../../../../src/commands/task/create.js'; +import { TaskInfoCommand } from '../../../../src/commands/task/info.js'; +import { TaskLsCommand } from '../../../../src/commands/task/ls.js'; +import { TaskUpdateCommand } from '../../../../src/commands/task/update.js'; +import { testRunCommand } from '../../../../src/lib/command-framework/apify-command.js'; +import { useAuthSetup } from '../../../__setup__/hooks/useAuthSetup.js'; +import { useConsoleSpy } from '../../../__setup__/hooks/useConsoleSpy.js'; +import { useTempPath } from '../../../__setup__/hooks/useTempPath.js'; + +useAuthSetup({ perTest: true }); + +const { lastErrorMessage, lastLogMessage, logMessages } = useConsoleSpy(); + +const { beforeAllCalls, afterAllCalls, joinPath } = useTempPath('task-commands', { + create: true, + remove: true, + cwd: true, + cwdParent: false, +}); + +const sampleTask = { + id: 'task-id-1', + name: 'my-task', + username: 'alice', + title: 'My Task', + userId: 'user-1', + actId: 'act-1', + createdAt: new Date('2024-01-01T00:00:00.000Z'), + modifiedAt: new Date('2024-01-02T00:00:00.000Z'), + description: 'A task', + options: { memoryMbytes: 1024, timeoutSecs: 60, build: 'latest' }, + stats: { totalRuns: 3 }, + input: { hello: 'world' }, +}; + +let mockClient: ApifyClient; +let createCalls: unknown[] = []; +let updateCalls: unknown[] = []; + +vitest.mock('../../../../src/lib/utils.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getLoggedClientOrThrow: async () => mockClient, + getLocalUserInfo: async () => ({ username: 'alice', id: 'user-1' }), + }; +}); + +vitest.mock('../../../../src/lib/commands/resolve-actor-context.js', () => ({ + resolveActorContext: async () => ({ + valid: true, + id: 'act-1', + userFriendlyId: 'apify/hello-world', + }), +})); + +beforeAll(async () => { + await beforeAllCalls(); +}); + +afterAll(async () => { + await afterAllCalls(); +}); + +beforeEach(() => { + createCalls = []; + updateCalls = []; + process.exitCode = undefined; + + mockClient = { + tasks: () => ({ + create: async (payload: unknown) => { + createCalls.push(payload); + return { ...sampleTask, ...(payload as object) }; + }, + list: async () => ({ + total: 1, + count: 1, + offset: 0, + limit: 20, + desc: false, + items: [sampleTask], + }), + }), + task: (idOrName: string) => ({ + get: async () => { + if (idOrName === 'missing-task') return undefined; + if (idOrName === 'alice/missing-task') return undefined; + if (idOrName === 'alice/my-task' || idOrName === 'task-id-1' || idOrName === 'my-task') { + return sampleTask; + } + return undefined; + }, + getInput: async () => sampleTask.input, + update: async (payload: unknown) => { + updateCalls.push(payload); + return { ...sampleTask, ...(payload as object), name: (payload as { name?: string }).name ?? sampleTask.name }; + }, + delete: async () => undefined, + }), + user: () => ({ + get: async () => ({ username: 'alice', id: 'user-1' }), + }), + actor: () => ({ + get: async () => ({ + id: 'act-1', + name: 'hello-world', + username: 'apify', + title: 'Hello World', + }), + }), + } as unknown as ApifyClient; +}); + +afterEach(() => { + process.exitCode = undefined; +}); + +describe('apify task commands (local)', () => { + it('rejects --input and --input-file together', async () => { + await expect( + testRunCommand(TaskCreateCommand, { + args_taskName: 'new-task', + flags_actor: 'apify/hello-world', + flags_input: '{}', + flags_inputFile: './input.json', + }), + ).rejects.toThrow(/APIFY_FLAG_IS_EXCLUSIVE_WITH_ANOTHER_FLAG/); + }); + + it('rejects malformed --input JSON', async () => { + await testRunCommand(TaskCreateCommand, { + args_taskName: 'new-task', + flags_actor: 'apify/hello-world', + flags_input: '{not-json', + }); + + expect(lastErrorMessage()).toMatch(/Cannot parse JSON input/i); + expect(process.exitCode).toBeTruthy(); + }); + + it('rejects malformed --input-file JSON', async () => { + const inputPath = joinPath('bad-input.json'); + writeFileSync(inputPath, '{bad'); + + await testRunCommand(TaskCreateCommand, { + args_taskName: 'new-task', + flags_actor: 'apify/hello-world', + flags_inputFile: inputPath, + }); + + expect(lastErrorMessage()).toMatch(/Cannot read input file|Cannot parse JSON/i); + expect(process.exitCode).toBeTruthy(); + }); + + it('prints human-readable create output', async () => { + await testRunCommand(TaskCreateCommand, { + args_taskName: 'new-task', + flags_actor: 'apify/hello-world', + flags_input: '{"url":"https://example.com"}', + }); + + expect(createCalls).toHaveLength(1); + expect(lastLogMessage()).toMatch(/was created/i); + }); + + it('reports API create failures with a non-zero exit code', async () => { + mockClient = { + ...mockClient, + tasks: () => ({ + create: async () => { + throw Object.assign(new Error('Task name is not unique'), { + type: 'actor-task-name-not-unique', + }); + }, + }), + } as unknown as ApifyClient; + + await testRunCommand(TaskCreateCommand, { + args_taskName: 'new-task', + flags_actor: 'apify/hello-world', + }); + + expect(logMessages.log.join('\n')).toMatch(/Failed to create Task/i); + expect(process.exitCode).toBeTruthy(); + }); + + it('requires at least one update flag', async () => { + await testRunCommand(TaskUpdateCommand, { + args_taskId: 'my-task', + }); + + expect(logMessages.log.join('\n')).toMatch(/Provide at least one of/i); + expect(process.exitCode).toBeTruthy(); + }); + + it('prints human-readable update output using the updated name', async () => { + await testRunCommand(TaskUpdateCommand, { + args_taskId: 'my-task', + flags_title: 'Renamed', + }); + + expect(updateCalls).toHaveLength(1); + expect(lastLogMessage()).toMatch(/my-task/); + expect(lastLogMessage()).toMatch(/was updated/i); + }); + + it('prints human-readable ls output', async () => { + await testRunCommand(TaskLsCommand, {}); + const output = logMessages.log.join('\n'); + expect(output).toContain('alice/my-task'); + expect(output).toContain('task-id-1'); + }); + + it('prints human-readable info output', async () => { + await testRunCommand(TaskInfoCommand, { + args_taskId: 'my-task', + }); + const output = logMessages.log.join('\n'); + expect(output).toContain('task-id-1'); + expect(output).toContain('my-task'); + }); + + it('propagates resolve failures as non-zero exit for info', async () => { + await testRunCommand(TaskInfoCommand, { + args_taskId: 'missing-task', + }); + + expect(lastErrorMessage()).toMatch(/Cannot find Task/i); + expect(process.exitCode).toBeTruthy(); + }); +}); diff --git a/test/local/lib/resolve-task.test.ts b/test/local/lib/resolve-task.test.ts new file mode 100644 index 000000000..14a9a4d47 --- /dev/null +++ b/test/local/lib/resolve-task.test.ts @@ -0,0 +1,104 @@ +import type { ApifyClient } from 'apify-client'; + +import { resolveTaskId, tryToGetTask } from '../../../src/lib/commands/resolve-task.js'; + +const sampleTask = { + id: 'task-id-1', + name: 'my-task', + username: 'alice', + title: 'My Task', + userId: 'user-1', + actId: 'act-1', + createdAt: new Date(), + modifiedAt: new Date(), +}; + +function fakeClient(handlers: { byKey: Record }): ApifyClient { + return { + task: (idOrName: string) => ({ + get: async () => { + if (Object.prototype.hasOwnProperty.call(handlers.byKey, idOrName)) { + return handlers.byKey[idOrName] ?? undefined; + } + return undefined; + }, + }), + } as unknown as ApifyClient; +} + +vitest.mock('../../../src/lib/utils.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getLocalUserInfo: async () => ({ username: 'alice', id: 'user-1' }), + }; +}); + +describe('resolveTaskId', () => { + it('resolves username/name', async () => { + const client = fakeClient({ + byKey: { + 'alice/my-task': sampleTask, + }, + }); + + const resolved = await resolveTaskId(client, 'alice/my-task'); + expect(resolved.id).toBe('task-id-1'); + expect(resolved.userFriendlyId).toBe('alice/my-task'); + expect(resolved.title).toBe('My Task'); + }); + + it('resolves a bare ID', async () => { + const client = fakeClient({ + byKey: { + 'task-id-1': sampleTask, + }, + }); + + const resolved = await resolveTaskId(client, 'task-id-1'); + expect(resolved.id).toBe('task-id-1'); + expect(resolved.userFriendlyId).toBe('alice/my-task'); + }); + + it('resolves a bare name', async () => { + const client = fakeClient({ + byKey: { + 'task-id-1': null, + 'alice/my-task': sampleTask, + }, + }); + + const resolved = await resolveTaskId(client, 'my-task'); + expect(resolved.id).toBe('task-id-1'); + expect(resolved.userFriendlyId).toBe('alice/my-task'); + }); + + it('lowercases the bare name', async () => { + const lookups: string[] = []; + const client = { + task: (idOrName: string) => ({ + get: async () => { + lookups.push(idOrName); + if (idOrName === 'alice/my-task') return sampleTask; + return undefined; + }, + }), + } as unknown as ApifyClient; + + await resolveTaskId(client, 'My-Task'); + expect(lookups).toContain('alice/my-task'); + expect(lookups).not.toContain('alice/My-Task'); + }); + + it('throws when the task does not exist', async () => { + const client = fakeClient({ byKey: {} }); + await expect(resolveTaskId(client, 'missing')).rejects.toThrow(/Cannot find Task with name 'missing'/); + }); +}); + +describe('tryToGetTask', () => { + it('returns null instead of throwing', async () => { + const client = fakeClient({ byKey: {} }); + await expect(tryToGetTask(client, 'missing')).resolves.toBeNull(); + }); +});