Skip to content

td task update --labels "" always returns HTTP 400 (CLI sends empty body) #326

Description

@moritzketzer

Summary

td task update <ref> --labels "" always fails with HTTP 400. The bug is client-side, not server-side: the CLI's truthy guard at src/commands/task/update.ts:47 treats the empty string as "labels flag absent" and never sets args.labels. With no other field flags passed, the SDK then sends an empty body {}, which the server (correctly) rejects.

This is structurally similar to #316 (now fixed), but unrelated in cause. The server already accepts {"labels": []} on the task update endpoint just fine. Only the CLI mishandles --labels "".

Reproduce (CLI, td 1.62.0)

$ td task add "zzz-labels-test" --labels "deep,quick"
Created: zzz-labels-test
ID: 6gcg5X49F6XJWjV4

$ td task update 6gcg5X49F6XJWjV4 --labels ""
Error: API_ERROR
HTTP 400: Bad Request
$ echo $?
1

$ td task view 6gcg5X49F6XJWjV4 --json | jq '.labels'
[
  "deep",
  "quick"
]

Labels are unchanged after the failed call. Same result against any task with one or more labels.

Reproduce (instrumented wire payload + response body)

Wrapped globalThis.fetch to log both POST request body and the server's response:

// /tmp/td-fetch-trace.mjs
const orig = globalThis.fetch;
globalThis.fetch = async (url, init = {}) => {
    const method = init.method ?? 'GET';
    const isTaskPost = method === 'POST' && url.toString().includes('/api/v1/tasks/');
    if (isTaskPost) {
        process.stderr.write(`[FETCH-TRACE] ${method} ${url}\n`);
        process.stderr.write(`[FETCH-TRACE] req-body=${init.body ?? '<none>'}\n`);
    }
    const res = await orig(url, init);
    if (isTaskPost) {
        const text = await res.clone().text();
        process.stderr.write(`[FETCH-TRACE] resp-status=${res.status}\n`);
        process.stderr.write(`[FETCH-TRACE] resp-body=${text}\n`);
    }
    return res;
};

Run:

$ NODE_OPTIONS="--import file:///tmp/td-fetch-trace.mjs" td task update 6gcgghgpWjGgGWr4 --labels ""

[FETCH-TRACE] POST https://api.todoist.com/api/v1/tasks/6gcgghgpWjGgGWr4
[FETCH-TRACE] req-body={}
[FETCH-TRACE] resp-status=400
[FETCH-TRACE] resp-body={"error":"At least one of supported fields should be set and non-empty","error_code":42,"error_extra":{"event_id":"397d22e72c424083898861299532961f","retry_after":2},"error_tag":"BAD_REQUEST","http_code":400}

Error: API_ERROR
HTTP 400: Bad Request

Two facts captured directly:

  1. The literal wire payload is {} — not {"labels": []}, not {"labels": null}.
  2. The server's response is "At least one of supported fields should be set and non-empty" — same string returned to a hand-rolled curl POST with body {} (probe F below).

The CLI swallows the response body and only surfaces HTTP 400: Bad Request, which is why this took some digging.

Reproduce (raw REST)

Same OAuth token, same task endpoint, four probes:

--- Probe B: empty array accepted ---
POST https://api.todoist.com/api/v1/tasks/6gcg5X49F6XJWjV4
body: {"labels": []}

HTTP 200 — task returned with "labels": []

--- Probe E: empty-string array accepted (server normalizes) ---
body: {"labels": [""]}
HTTP 200 — response shows "labels": []

--- Probe F: empty body rejected ---
body: {}
HTTP 400
{"error":"At least one of supported fields should be set and non-empty","error_code":42,"error_extra":{"event_id":"5081e1d2f0444022a4d4b70797280c3c","retry_after":3},"error_tag":"BAD_REQUEST","http_code":400}

--- Probe G: null rejected ---
body: {"labels": null}
HTTP 400
{"error":"labels must be an array of labels","error_code":42,...}

Probe F's error string matches the CLI failure exactly — confirming the CLI sends {}.

Root cause

src/commands/task/update.ts:47 (and the compiled dist/commands/task/update.js:30):

if (options.labels) args.labels = options.labels.split(',').map((l) => l.trim())

options.labels === "" is falsy in JS, so args.labels is never set. When --labels "" is the only flag, the surrounding args object stays {} and the SDK's updateTask(id, {}) POSTs an empty body.

Note the same file already handles "clear this field" correctly for siblings — src/commands/task/update.ts lines 19–28:

if (options.due === false) {
    args.dueString = null
} else if (options.due) {
    args.dueString = options.due
}

if (options.deadline === false) {
    args.deadlineDate = null
} else if (options.deadline) {
    args.deadlineDate = options.deadline
}

backed by --no-due / --no-deadline declarations in src/commands/task/index.ts:183,185. Labels has no equivalent --no-labels declaration and no clearing path.

Suggested fix

Two reasonable shapes; preference for (a):

(a) Add --no-labels flag (mirrors --no-due / --no-deadline) — cleanest UX, consistent with sibling clear-flags:

// src/commands/task/index.ts (option block around line 192)
.option('--labels <a,b>', 'New labels (replaces existing)')
.option('--no-labels', 'Remove all labels')

// src/commands/task/update.ts (around line 47)
if (options.labels === false) {
    args.labels = []
} else if (options.labels) {
    args.labels = options.labels.split(',').map((l) => l.trim())
}

(b) Minimal patch — change the truthy guard so "" passes through:

if (options.labels !== undefined) args.labels = options.labels.split(',').map((l) => l.trim())

"".split(",").map(trim) is [""], and probe E shows the server normalizes [""] to []. Works, but relies on server-side normalization and is less discoverable than --no-labels.

Workaround (current users)

Direct REST clears labels reliably:

TOKEN=$(security find-generic-password -s "todoist-cli" -w)   # macOS keychain example
curl -sS -X POST "https://api.todoist.com/api/v1/tasks/<task-id>" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"labels": []}'

Versions

  • td 1.62.0 (npm @doist/todoist-cli, dist/index.js)
  • Endpoint: POST https://api.todoist.com/api/v1/tasks/{id}
  • Upstream main src/commands/task/update.ts:47 still has the same guard at the time of filing.
  • macOS, Node 20+

Related

  • #316 (closed, server-side fix shipped) — same shape of bug for --no-favorite on labels, but rooted in the server validator. This task-update bug is the mirror image: it lives in the CLI, and the server was never the problem.

Metadata

Metadata

Labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions