diff --git a/docs/reference.md b/docs/reference.md index 09af9be11..1087aef3a 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 ] + [-i | -f ] [--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. + -i, --input= Saved task input as a JSON + string. + -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, + 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,41 @@ 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 ] [-i | -f ] [--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. + -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, + 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..1d7c332b8 --- /dev/null +++ b/src/commands/task/create.ts @@ -0,0 +1,141 @@ +import process from 'node:process'; + +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 { getInputOverride } from '../../lib/commands/resolve-input.js'; +import { error, success } from '../../lib/outputs.js'; +import { getLoggedClientOrThrow, printJsonToStdout } from '../../lib/utils.js'; + +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({ + 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'], + }), + 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 cwd = process.cwd(); + + const actorCtx = await resolveActorContext({ providedActorNameOrId: actorIdOrName, client }); + if (!actorCtx.valid) { + error({ + message: `${actorCtx.reason}. Please specify a valid Actor ID or name.`, + stdout: true, + }); + process.exitCode ||= 1; + return; + } + + 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 + ? { + ...(memory != null ? { memoryMbytes: memory } : {}), + ...(timeout != null ? { timeoutSecs: timeout } : {}), + ...(build ? { build } : {}), + } + : undefined; + + try { + 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, + }); + } 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 new file mode 100644 index 000000000..4e5968ace --- /dev/null +++ b/src/commands/task/info.ts @@ -0,0 +1,117 @@ +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 { 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(); + + const { task, userFriendlyId } = await resolveTaskId(apifyClient, taskId); + + 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..6709ac07a --- /dev/null +++ b/src/commands/task/ls.ts @@ -0,0 +1,89 @@ +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 { 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 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.username}/${task.name}`, + '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..e2c6314bd --- /dev/null +++ b/src/commands/task/rm.ts @@ -0,0 +1,84 @@ +import process from 'node:process'; + +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(); + + const resolved = await resolveTaskId(apifyClient, taskId); + + 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, + }); + process.exitCode ||= 1; + } + } +} 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..eeab45c6d --- /dev/null +++ b/src/commands/task/update.ts @@ -0,0 +1,131 @@ +import process from 'node:process'; + +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 { 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'; + +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({ + 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'], + }), + 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(); + const cwd = process.cwd(); + + const resolved = await resolveTaskId(client, taskId); + + 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 || hasInputUpdate || hasOptionUpdate; + + if (!hasAnyUpdate) { + error({ + message: + 'Provide at least one of --name, --title, --description, --input/--input-file, --memory, --timeout, or --build.', + stdout: true, + }); + process.exitCode ||= 1; + 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 } : {}), + ...(hasInputUpdate ? { input: parsedInput } : {}), + ...(nextOptions ? { options: nextOptions } : {}), + }); + + if (json) { + printJsonToStdout(updated); + return; + } + + success({ + message: `Task ${chalk.yellow(updated.name)} (${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..49fc0bd59 --- /dev/null +++ b/test/e2e/commands/task/lifecycle.test.ts @@ -0,0 +1,103 @@ +import { randomBytes } from 'node:crypto'; +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'; + +describe('[e2e][api] task namespace', () => { + let authEnv: Record; + let client: ApifyClient; + 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; + 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)); + }, 60_000); + + afterAll(async () => { + if (taskId && client) { + try { + await client.task(taskId).delete(); + } catch { + // Do nothing + } + } + }); + + it('creates a task', async () => { + 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( + '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).toContain('was deleted'); + + const gone = await client.task(taskId).get(); + expect(gone).toBeUndefined(); + taskId = ''; + }); +}); 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(); + }); +});