Skip to content

feat(task): add create, ls, info, update, and rm commands - #1315

Open
Ayush7614 wants to merge 4 commits into
apify:masterfrom
Ayush7614:feat/task-management-commands
Open

feat(task): add create, ls, info, update, and rm commands#1315
Ayush7614 wants to merge 4 commits into
apify:masterfrom
Ayush7614:feat/task-management-commands

Conversation

@Ayush7614

@Ayush7614 Ayush7614 commented Jul 30, 2026

Copy link
Copy Markdown

Summary

  • Expands the apify task namespace beyond task run with full manage commands: create, ls, info, update, and rm.
  • Adds a shared resolveTaskId / tryToGetTask helper (also used by task run) so tasks resolve consistently by ID, username/name, or bare name.
  • Regenerates CLI reference docs and adds an e2e lifecycle suite covering create → ls → info → update → rm.

This fills a real product gap: tasks are first-class on the platform, but the CLI previously only supported running them. No open issue was claimed for this work.

Test plan

  • pnpm run lint
  • pnpm run format
  • pnpm run build
  • pnpm run test:local (375+ tests passed)
  • apify task help lists the new subcommands
  • pnpm run test:e2e / API token suite for test/e2e/commands/task/lifecycle.test.ts (requires TEST_USER_TOKEN)

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.

@l2ysho l2ysho left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @Ayush7614 thx for contribution. I seems good enough, I left some comments about topic I found important.

Comment thread src/commands/task/info.ts Outdated
Comment on lines +46 to +52
let resolved;
try {
resolved = await resolveTaskId(apifyClient, taskId);
} catch (err) {
error({ message: (err as Error).message, stdout: true });
return;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

important: catching resolveTaskId's throw turns exit 1 into exit 0.

task run (src/commands/task/run.ts:43) lets the same error propagate, so ApifyCommand._run catches it at src/lib/command-framework/apify-command.ts:386-391, prints Error: <msg> and sets process.exitCode ||= 1. Here we catch the identical error, print the identical message, and return — nothing sets the exit code (grep process.exitCode src/commands/task/ → zero hits).

So apify task info missing exits 0 while apify task run missing exits 1, for the same failure in the same namespace. And because of stdout: true, apify task info missing --json | jq gets non-JSON on stdout plus a success status.

The catch buys nothing the framework doesn't already do — dropping it is a net line reduction. (CommandExitCodes.NotFound = 250 at src/lib/consts.ts:94 if you'd rather be explicit.)

Same pattern in task/rm.ts:52-58 and task/update.ts:85-91.

Comment thread src/commands/task/update.ts Outdated
Comment on lines +85 to +91
let resolved;
try {
resolved = await resolveTaskId(client, taskId);
} catch (err) {
error({ message: (err as Error).message, stdout: true });
return;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same exit-code downgrade as task/info.ts:46-52 — see that comment. Deleting this try/catch makes update behave like task run.

Comment thread src/commands/task/rm.ts Outdated
Comment on lines +52 to +58
let resolved;
try {
resolved = await resolveTaskId(apifyClient, taskId);
} catch (err) {
error({ message: (err as Error).message, stdout: true });
return;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same exit-code downgrade as task/info.ts:46-52 — see that comment.

Comment thread src/commands/task/rm.ts
Comment on lines +79 to +85
} catch (err) {
const casted = err as ApifyApiError;
error({
message: `Failed to delete Task "${resolved.userFriendlyId}".\n ${casted.message || casted}`,
stdout: true,
});
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

important: 403 or 5xx on delete prints an error message and exits 0, so a script that does apify task rm x --yes && echo gone reports success on a failed deletion.

Either process.exitCode ||= 1 here, or drop the catch and let the framework format the ApifyApiError.

Comment thread src/commands/task/create.ts Outdated
Comment on lines +14 to +20
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.`);
}
}

@l2ysho l2ysho Jul 30, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium — this reimplements getInputOverride and is byte-identical to task/update.ts:13-19.

src/lib/commands/resolve-input.ts already exports getInputOverride(cwd, input, inputFile, opts?), used by task/run.ts, actors/call.ts and actors/start.ts, and it has unit tests (test/local/lib/resolve-input.test.ts). The hand-rolled version loses:

  • bare piped stdinecho '{}' | apify task create x --actor y picks up nothing. getInputOverride handles the no-flags-given case at resolve-input.ts:60-77; there's no equivalent here.
  • the empty-stdin diagnostic for --input - — the framework substitutes piped content before run() sees the flag (apify-command.ts:572-583), so a value that still starts with - means nothing was piped. resolve-input.ts:95 says exactly that; this code reaches JSON.parse('-') and reports Failed to parse --input as JSON. instead.
  • cwd-relative path resolution
  • the "don't pass a .json path to --input" guard
  • treating a non-path --input-file value as inline JSON
  • process.exitCode = CommandExitCodes.InvalidInput on bad input
  • the underlying parser message — Failed to parse --input as JSON. discards err.message, so the user never learns where the JSON broke

Also: neither file imports node:process, and readFileSync(inputFile) resolves against the real cwd — CLAUDE.md requires the explicit import process from 'node:process' for the test cwd mocks to work.

Swapping in getInputOverride is ~5 lines per file and deletes 14 (plus the readFileSync import).

One caveat worth checking: getInputOverride rejects top-level JSON arrays, while apify-client types task input as Dictionary | Dictionary[]. I found no evidence the platform actually accepts array task input (the API reference describes an object), so I believe this is theoretical rather than a real regression.

Edited: the first bullet originally claimed the flags omit stdin: StdinMode.Stringified so --input - is "taken literally". That was wrong — Stringified is the default for Flags.string, and --input - works. Replaced with the two cases that genuinely differ.

Comment thread src/commands/task/update.ts Outdated
}

success({
message: `Task ${chalk.yellow(resolved.userFriendlyId)} (${chalk.gray(updated.id)}) was updated.`,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
message: `Task ${chalk.yellow(resolved.userFriendlyId)} (${chalk.gray(updated.id)}) was updated.`,
message: `Task ${chalk.yellow(updated.name)} (${chalk.gray(updated.id)}) was updated.`,

Comment thread src/commands/task/ls.ts Outdated
for (const task of rawTaskList.items) {
table.pushRow({
'Task ID': task.id,
Name: task.name ? `${user.username ?? task.username}/${task.name}` : chalk.italic('Unnamed'),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Name: task.name ? `${user.username ?? task.username}/${task.name}` : chalk.italic('Unnamed'),
Name: `${task.username}/${task.name}`,

import { runCli } from '../../__helpers__/run-cli.js';
import { createTestActor, removeTestActor, type TestActor } from '../../__helpers__/test-actor.js';

describe('[e2e][api] task namespace', () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you add tests for the parts that aren't covered?

The one new test is [e2e][api], which only runs under test:e2e — not
test:local or test:api, the two suites CI gates PRs on. So none of this
code is currently exercised on a PR run.

Most of it doesn't need a token. src/lib/commands/ siblings unit-test their
helpers with an as unknown as ApifyClient stub (run-on-cloud.test.ts:5-12),
and getLocalUserInfo() is satisfiable with writeAuthFile({ username }) +
__APIFY_INTERNAL_TEST_AUTH_PATH__ (credentials.test.ts:41-51).

test/local/lib/resolve-task.test.ts — stub client, no token:

  • resolves username/name
  • resolves a bare ID
  • resolves a bare name
  • lowercases the bare name (resolve-task.ts:50) — the e2e task name is
    already lowercase, so nothing catches a regression here today
  • throws when the task doesn't exist
  • tryToGetTask returns null instead of throwing

test/local/commands/task/*.test.ts:

  • --input with malformed JSON
  • --input-file with malformed JSON
  • --input and --input-file together (the exclusive pairing — nothing
    in the repo covers exclusive at all)
  • task update with no flags → the "provide at least one of" error
  • task create with a name that already exists
  • the human-readable output of create / update / ls / info
    right now all four are asserted only through --json

Sibling suites pair a failure case with nearly every success case if you want
a shape to follow: builds/lifecycle.test.ts:73,82,116,166,211.

Comment on lines +38 to +46
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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit — this apify push is what forces the 300_000 timeout, and nothing in the suite needs it: a task only needs an actId, the suite never runs the task, and create.ts never touches builds.

client.actors().create({ name }) — or just using apify/hello-world as --actor and dropping the test actor entirely — would cut most of the runtime.

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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
expect(result.stdout + result.stderr).toMatch(/deleted/i);
expect(result.stdout).toContain('was deleted');

Ayush7614 and others added 2 commits July 31, 2026 01:35
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.
@Ayush7614

Ayush7614 commented Jul 30, 2026

Copy link
Copy Markdown
Author

@l2ysho thanks for the thorough review — all of the feedback should be addressed in the latest commits:

  • Dropped the resolveTaskId try/catch in info / update / rm so missing tasks exit non-zero like task run
  • Set process.exitCode on failed create/delete paths
  • Switched to shared getInputOverride, added -i / -f, and removed the redundant create preflight (API errors are caught instead)
  • Applied the ls / update success-message suggestions
  • Added local unit coverage for resolve-task + command edge cases, and simplified the e2e suite to use apify/hello-world (no push)
  • Branch is also updated with master

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants