Skip to content

Commit cafb9e6

Browse files
Merge pull request #8672 from Shopify/gonzalo/json-render-tasks
Add JSON progress and retry support to task runners
2 parents aed3d69 + 58d1f09 commit cafb9e6

10 files changed

Lines changed: 571 additions & 83 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@shopify/cli-kit': minor
3+
---
4+
5+
Run `renderTasks` without Ink in JSON mode, report task retries and failures, and support retries in `renderSingleTask`.

‎docs/cli/json-output.md‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,21 @@ Events are separate from finite results. Progress events can drive spinners or s
8181
running, but they aren't fields in the final JSON result. Errors continue through the standard CLI error path;
8282
don't encode failures as successful result shapes merely to support `--json`.
8383

84+
### Task progress events
85+
86+
`renderTasks` uses one `operation` ID for the whole task list, including subtasks. It emits `started` for the first
87+
task that runs, `updated` for subsequent tasks, and `completed` after the whole list succeeds. Skipped tasks emit no
88+
progress events. Empty lists and lists where every task is skipped emit no events.
89+
90+
`renderTasks` accepts a `retry` count on each task, and `renderSingleTask` accepts it in its options. It is the number
91+
of additional attempts after a failure and defaults to zero. Both emit `retrying` before each repeated task attempt,
92+
using the same operation ID. Once retries are exhausted, they emit one `failed` event and throw the original error.
93+
Only successful operations emit `completed`. Failure events identify the task through `message`; error details
94+
continue through the standard CLI error path.
95+
96+
Cancellation does not trigger retries or a `failed` event in `renderSingleTask` when its `onAbort` callback runs.
97+
An interrupted operation can still end without a terminal progress event, so consumers must also handle process exit.
98+
8499
## Preserve compatibility
85100

86101
Treat the JSON result as a public API. Keep existing keys, omission rules, nullability, collection shapes, and exit
@@ -147,6 +162,9 @@ migration and streaming commands, and remove finite entries as they adopt the co
147162
Plugins must adopt the result contract and control their output before their commands can be used reliably in JSON
148163
mode. Inheriting `--json-schema` or enabling `SHOPIFY_FLAG_JSON=1` doesn't convert all plugin output automatically.
149164

165+
- In the command event context, `renderTasks` and `renderSingleTask` run without Ink and emit JSON progress events
166+
when JSON mode is enabled. This also applies when `SHOPIFY_FLAG_JSON=1` enables JSON mode for a plugin command that
167+
doesn't declare a `--json` flag.
150168
- Oclif `init` hooks run before the command's error handling. A hook that renders a warning and calls `process.exit(1)`
151169
bypasses the JSON fatal error path and can leave stdout empty. Put command validation in the command lifecycle and
152170
throw an `AbortError` so CLI Kit can encode the failure.

‎packages/cli-kit/src/private/node/ui/components/Tasks.tsx‎

Lines changed: 6 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -4,19 +4,11 @@ import {isUnitTest} from '../../../../public/node/context/local.js'
44
import {AbortSignal} from '../../../../public/node/abort.js'
55
import useAbortSignal from '../hooks/use-abort-signal.js'
66
import {useExitOnCtrlC} from '../hooks/use-exit-on-ctrl-c.js'
7-
import {TokenizedString} from '../../../../public/node/output.js'
7+
import {runTasks, Task} from '../tasks.js'
88

9-
import React, {useRef, useState} from 'react'
9+
import React, {useState} from 'react'
1010

11-
export interface Task<TContext = unknown> {
12-
title: string | TokenizedString
13-
14-
task: (ctx: TContext, task: Task<TContext>) => Promise<void | Task<TContext>[]>
15-
retry?: number
16-
retryCount?: number
17-
errors?: Error[]
18-
skip?: (ctx: TContext) => boolean
19-
}
11+
export type {Task} from '../tasks.js'
2012

2113
interface TasksProps<TContext> {
2214
tasks: Task<TContext>[]
@@ -33,30 +25,6 @@ enum TasksState {
3325
Failure = 'failure',
3426
}
3527

36-
async function runTask<TContext>(task: Task<TContext>, ctx: TContext) {
37-
task.retryCount = 0
38-
task.errors = []
39-
const retry = task.retry && task.retry > 0 ? task.retry + 1 : 1
40-
41-
for (let retries = 1; retries <= retry; retries++) {
42-
try {
43-
if (task.skip?.(ctx)) {
44-
return
45-
}
46-
// eslint-disable-next-line no-await-in-loop
47-
return await task.task(ctx, task)
48-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
49-
} catch (error: any) {
50-
if (retries === retry) {
51-
throw error
52-
} else {
53-
task.errors.push(error)
54-
task.retryCount = retries
55-
}
56-
}
57-
}
58-
}
59-
6028
const noop = () => {}
6129

6230
function Tasks<TContext>({
@@ -69,30 +37,11 @@ function Tasks<TContext>({
6937
}: React.PropsWithChildren<TasksProps<TContext>>) {
7038
const [currentTask, setCurrentTask] = useState<Task<TContext>>(tasks[0]!)
7139
const [state, setState] = useState<TasksState>(TasksState.Loading)
72-
const ctx = useRef<TContext>({} as TContext)
73-
74-
const runTasks = async () => {
75-
for (const task of tasks) {
76-
setCurrentTask(task)
77-
78-
// eslint-disable-next-line no-await-in-loop
79-
const subTasks = await runTask(task, ctx.current)
80-
81-
// subtasks
82-
if (Array.isArray(subTasks) && subTasks.length > 0 && subTasks.every((task) => 'task' in task)) {
83-
for (const subTask of subTasks) {
84-
setCurrentTask(subTask)
85-
// eslint-disable-next-line no-await-in-loop
86-
await runTask(subTask, ctx.current)
87-
}
88-
}
89-
}
90-
}
9140

92-
useAsyncAndUnmount(runTasks, {
93-
onFulfilled: () => {
41+
useAsyncAndUnmount(() => runTasks(tasks, setCurrentTask), {
42+
onFulfilled: (context) => {
9443
setState(TasksState.Success)
95-
onComplete(ctx.current)
44+
onComplete(context)
9645
},
9746
onRejected: () => {
9847
setState(TasksState.Failure)

‎packages/cli-kit/src/private/node/ui/hooks/use-async-and-unmount.ts‎

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,22 @@
11
import {useComplete} from '../../ui.js'
22
import {useEffect, useState} from 'react'
33

4-
interface Options {
5-
onFulfilled?: () => unknown
4+
interface Options<T> {
5+
onFulfilled?: (result: T) => unknown
66
onRejected?: (error: Error) => void
77
}
88

9-
export default function useAsyncAndUnmount(
10-
asyncFunction: () => Promise<unknown>,
11-
{onFulfilled = () => {}, onRejected = () => {}}: Options = {},
9+
export default function useAsyncAndUnmount<T>(
10+
asyncFunction: () => Promise<T>,
11+
{onFulfilled = () => {}, onRejected = () => {}}: Options<T> = {},
1212
) {
1313
const complete = useComplete()
1414
const [result, setResult] = useState<{error?: Error} | null>(null)
1515

1616
useEffect(() => {
1717
asyncFunction()
18-
.then(() => {
19-
onFulfilled()
18+
.then((result) => {
19+
onFulfilled(result)
2020
setResult({})
2121
})
2222
.catch((error) => {
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import {emitCommandEvent} from '../../../public/node/command-events.js'
2+
import {randomUUID} from '../../../public/node/crypto.js'
3+
import {TokenizedString, unstyled} from '../../../public/node/output.js'
4+
5+
export interface Task<TContext = unknown> {
6+
title: string | TokenizedString
7+
task: (ctx: TContext, task: Task<TContext>) => Promise<void | Task<TContext>[]>
8+
retry?: number
9+
retryCount?: number
10+
errors?: Error[]
11+
skip?: (ctx: TContext) => boolean
12+
}
13+
14+
export async function runTasks<TContext>(tasks: Task<TContext>[], onTask: (task: Task<TContext>) => void = () => {}) {
15+
const context = {} as TContext
16+
const operation = randomUUID()
17+
let currentTask: Task<TContext> | undefined
18+
19+
const execute = async (task: Task<TContext>) => {
20+
try {
21+
onTask(task)
22+
return await runTask(task, context, (status) => {
23+
emitCommandEvent(
24+
{
25+
type: 'progress',
26+
operation,
27+
status: status === 'started' && currentTask ? 'updated' : status,
28+
message: taskMessage(task),
29+
},
30+
{alreadyRendered: true},
31+
)
32+
currentTask = task
33+
})
34+
} catch (error) {
35+
emitCommandEvent(
36+
{type: 'progress', operation, status: 'failed', message: taskMessage(task)},
37+
{alreadyRendered: true},
38+
)
39+
throw error
40+
}
41+
}
42+
43+
for (const task of tasks) {
44+
// eslint-disable-next-line no-await-in-loop
45+
const subTasks = await execute(task)
46+
47+
if (Array.isArray(subTasks) && subTasks.length > 0 && subTasks.every((task) => 'task' in task)) {
48+
for (const subTask of subTasks) {
49+
// eslint-disable-next-line no-await-in-loop
50+
await execute(subTask)
51+
}
52+
}
53+
}
54+
55+
// A task list can grow while it runs, so only the entire list marks the operation complete.
56+
if (currentTask) {
57+
emitCommandEvent(
58+
{type: 'progress', operation, status: 'completed', message: taskMessage(currentTask), current: 1, total: 1},
59+
{alreadyRendered: true},
60+
)
61+
}
62+
63+
return context
64+
}
65+
66+
async function runTask<TContext>(
67+
task: Task<TContext>,
68+
context: TContext,
69+
onProgress: (status: 'started' | 'retrying') => void,
70+
) {
71+
task.retryCount = 0
72+
task.errors = []
73+
const maxAttempts = task.retry && task.retry > 0 ? task.retry + 1 : 1
74+
let started = false
75+
76+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
77+
try {
78+
if (task.skip?.(context)) return
79+
80+
onProgress(started ? 'retrying' : 'started')
81+
started = true
82+
83+
// eslint-disable-next-line no-await-in-loop
84+
return await task.task(context, task)
85+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
86+
} catch (error: any) {
87+
if (attempt === maxAttempts) throw error
88+
task.errors.push(error)
89+
task.retryCount = attempt
90+
}
91+
}
92+
}
93+
94+
function taskMessage<TContext>(task: Task<TContext>) {
95+
return unstyled(typeof task.title === 'string' ? task.title : task.title.value)
96+
}

‎packages/cli-kit/src/public/common/command-events.test.ts‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,11 +38,14 @@ describe('commandEventSchema', () => {
3838
expect(commandEventSchema.parse(event)).toEqual(event)
3939
})
4040

41-
test.each(['started', 'updated', 'completed'])('accepts %s progress without a message', (status) => {
42-
const event = {type: 'progress', timestamp: '2026-08-26T12:00:00.000Z', operation: 'upload', status}
41+
test.each(['started', 'updated', 'retrying', 'completed', 'failed'])(
42+
'accepts %s progress without a message',
43+
(status) => {
44+
const event = {type: 'progress', timestamp: '2026-08-26T12:00:00.000Z', operation: 'upload', status}
4345

44-
expect(commandEventSchema.parse(event)).toEqual(event)
45-
})
46+
expect(commandEventSchema.parse(event)).toEqual(event)
47+
},
48+
)
4649

4750
test.each([{operation: 'upload'}, {status: 'started'}, {operation: 'upload', status: 'unknown'}])(
4851
'rejects incomplete or invalid progress metadata: %j',

‎packages/cli-kit/src/public/common/command-events.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ export const commandProgressEventSchema = z
1616
.object({
1717
type: z.literal('progress'),
1818
timestamp: z.string().datetime({offset: true}),
19-
status: z.enum(['started', 'updated', 'completed']),
19+
status: z.enum(['started', 'updated', 'retrying', 'completed', 'failed']),
2020
operation: z.string(),
2121
message: z.string().optional(),
2222
current: z.number().nonnegative().optional(),

‎packages/cli-kit/src/public/node/command-events.test.ts‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,11 @@ describe('commandEventOutputSchema', () => {
2929
additionalProperties: false,
3030
},
3131
CommandProgressEvent: {
32-
properties: {current: {type: 'number', minimum: 0}, total: {type: 'number', minimum: 0}},
32+
properties: {
33+
status: {enum: ['started', 'updated', 'retrying', 'completed', 'failed']},
34+
current: {type: 'number', minimum: 0},
35+
total: {type: 'number', minimum: 0},
36+
},
3337
required: ['type', 'timestamp', 'status', 'operation'],
3438
additionalProperties: false,
3539
},
@@ -131,6 +135,20 @@ describe('renderCommandEvent', () => {
131135
})
132136

133137
describe('renderCommandEventAsJson', () => {
138+
test.each(['retrying', 'failed'] as const)('renders %s progress as JSON', (status) => {
139+
const event: CommandEvent = {
140+
type: 'progress',
141+
timestamp: '2026-08-26T12:00:00.000Z',
142+
operation: 'upload',
143+
status,
144+
message: 'Uploading files',
145+
}
146+
147+
renderCommandEventAsJson(event)
148+
149+
expect(JSON.parse(outputMock.info())).toEqual(event)
150+
})
151+
134152
test.each([
135153
{type: 'diagnostic', level: 'unknown', message: 'Invalid level'},
136154
{type: 'progress', operation: 'upload', status: 'started', current: -1},

0 commit comments

Comments
 (0)