Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/render-tasks-json-progress.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@shopify/cli-kit': minor
---

Run `renderTasks` without Ink in JSON mode, report task retries and failures, and support retries in `renderSingleTask`.
18 changes: 18 additions & 0 deletions docs/cli/json-output.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,21 @@ Events are separate from finite results. Progress events can drive spinners or s
running, but they aren't fields in the final JSON result. Errors continue through the standard CLI error path;
don't encode failures as successful result shapes merely to support `--json`.

### Task progress events

`renderTasks` uses one `operation` ID for the whole task list, including subtasks. It emits `started` for the first
task that runs, `updated` for subsequent tasks, and `completed` after the whole list succeeds. Skipped tasks emit no
progress events. Empty lists and lists where every task is skipped emit no events.

`renderTasks` accepts a `retry` count on each task, and `renderSingleTask` accepts it in its options. It is the number
of additional attempts after a failure and defaults to zero. Both emit `retrying` before each repeated task attempt,
using the same operation ID. Once retries are exhausted, they emit one `failed` event and throw the original error.
Only successful operations emit `completed`. Failure events identify the task through `message`; error details
continue through the standard CLI error path.

Cancellation does not trigger retries or a `failed` event in `renderSingleTask` when its `onAbort` callback runs.
An interrupted operation can still end without a terminal progress event, so consumers must also handle process exit.

## Preserve compatibility

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

- In the command event context, `renderTasks` and `renderSingleTask` run without Ink and emit JSON progress events
when JSON mode is enabled. This also applies when `SHOPIFY_FLAG_JSON=1` enables JSON mode for a plugin command that
doesn't declare a `--json` flag.
- Oclif `init` hooks run before the command's error handling. A hook that renders a warning and calls `process.exit(1)`
bypasses the JSON fatal error path and can leave stdout empty. Put command validation in the command lifecycle and
throw an `AbortError` so CLI Kit can encode the failure.
Expand Down
63 changes: 6 additions & 57 deletions packages/cli-kit/src/private/node/ui/components/Tasks.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,11 @@ import {isUnitTest} from '../../../../public/node/context/local.js'
import {AbortSignal} from '../../../../public/node/abort.js'
import useAbortSignal from '../hooks/use-abort-signal.js'
import {useExitOnCtrlC} from '../hooks/use-exit-on-ctrl-c.js'
import {TokenizedString} from '../../../../public/node/output.js'
import {runTasks, Task} from '../tasks.js'

import React, {useRef, useState} from 'react'
import React, {useState} from 'react'

export interface Task<TContext = unknown> {
title: string | TokenizedString

task: (ctx: TContext, task: Task<TContext>) => Promise<void | Task<TContext>[]>
retry?: number
retryCount?: number
errors?: Error[]
skip?: (ctx: TContext) => boolean
}
export type {Task} from '../tasks.js'

interface TasksProps<TContext> {
tasks: Task<TContext>[]
Expand All @@ -33,30 +25,6 @@ enum TasksState {
Failure = 'failure',
}

async function runTask<TContext>(task: Task<TContext>, ctx: TContext) {
task.retryCount = 0
task.errors = []
const retry = task.retry && task.retry > 0 ? task.retry + 1 : 1

for (let retries = 1; retries <= retry; retries++) {
try {
if (task.skip?.(ctx)) {
return
}
// eslint-disable-next-line no-await-in-loop
return await task.task(ctx, task)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) {
if (retries === retry) {
throw error
} else {
task.errors.push(error)
task.retryCount = retries
}
}
}
}

const noop = () => {}

function Tasks<TContext>({
Expand All @@ -69,30 +37,11 @@ function Tasks<TContext>({
}: React.PropsWithChildren<TasksProps<TContext>>) {
const [currentTask, setCurrentTask] = useState<Task<TContext>>(tasks[0]!)
const [state, setState] = useState<TasksState>(TasksState.Loading)
const ctx = useRef<TContext>({} as TContext)

const runTasks = async () => {
for (const task of tasks) {
setCurrentTask(task)

// eslint-disable-next-line no-await-in-loop
const subTasks = await runTask(task, ctx.current)

// subtasks
if (Array.isArray(subTasks) && subTasks.length > 0 && subTasks.every((task) => 'task' in task)) {
for (const subTask of subTasks) {
setCurrentTask(subTask)
// eslint-disable-next-line no-await-in-loop
await runTask(subTask, ctx.current)
}
}
}
}

useAsyncAndUnmount(runTasks, {
onFulfilled: () => {
useAsyncAndUnmount(() => runTasks(tasks, setCurrentTask), {
onFulfilled: (context) => {
setState(TasksState.Success)
onComplete(ctx.current)
onComplete(context)
},
onRejected: () => {
setState(TasksState.Failure)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,22 +1,22 @@
import {useComplete} from '../../ui.js'
import {useEffect, useState} from 'react'

interface Options {
onFulfilled?: () => unknown
interface Options<T> {
onFulfilled?: (result: T) => unknown
onRejected?: (error: Error) => void
}

export default function useAsyncAndUnmount(
asyncFunction: () => Promise<unknown>,
{onFulfilled = () => {}, onRejected = () => {}}: Options = {},
export default function useAsyncAndUnmount<T>(
asyncFunction: () => Promise<T>,
{onFulfilled = () => {}, onRejected = () => {}}: Options<T> = {},
) {
const complete = useComplete()
const [result, setResult] = useState<{error?: Error} | null>(null)

useEffect(() => {
asyncFunction()
.then(() => {
onFulfilled()
.then((result) => {
onFulfilled(result)
setResult({})
})
.catch((error) => {
Expand Down
96 changes: 96 additions & 0 deletions packages/cli-kit/src/private/node/ui/tasks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import {emitCommandEvent} from '../../../public/node/command-events.js'
import {randomUUID} from '../../../public/node/crypto.js'
import {TokenizedString, unstyled} from '../../../public/node/output.js'

export interface Task<TContext = unknown> {
title: string | TokenizedString
task: (ctx: TContext, task: Task<TContext>) => Promise<void | Task<TContext>[]>
retry?: number
retryCount?: number
errors?: Error[]
skip?: (ctx: TContext) => boolean
}

export async function runTasks<TContext>(tasks: Task<TContext>[], onTask: (task: Task<TContext>) => void = () => {}) {
const context = {} as TContext
const operation = randomUUID()
let currentTask: Task<TContext> | undefined

const execute = async (task: Task<TContext>) => {
try {
onTask(task)
return await runTask(task, context, (status) => {
emitCommandEvent(
{
type: 'progress',
operation,
status: status === 'started' && currentTask ? 'updated' : status,
message: taskMessage(task),
},
{alreadyRendered: true},
)
currentTask = task
})
} catch (error) {
emitCommandEvent(
{type: 'progress', operation, status: 'failed', message: taskMessage(task)},
{alreadyRendered: true},
)
throw error
}
}

for (const task of tasks) {
// eslint-disable-next-line no-await-in-loop
const subTasks = await execute(task)

if (Array.isArray(subTasks) && subTasks.length > 0 && subTasks.every((task) => 'task' in task)) {
for (const subTask of subTasks) {
// eslint-disable-next-line no-await-in-loop
await execute(subTask)
}
}
}

// A task list can grow while it runs, so only the entire list marks the operation complete.
if (currentTask) {
emitCommandEvent(
{type: 'progress', operation, status: 'completed', message: taskMessage(currentTask), current: 1, total: 1},
{alreadyRendered: true},
)
}
Comment thread
gonzaloriestra marked this conversation as resolved.

return context
}

async function runTask<TContext>(
task: Task<TContext>,
context: TContext,
onProgress: (status: 'started' | 'retrying') => void,
) {
task.retryCount = 0
task.errors = []
const maxAttempts = task.retry && task.retry > 0 ? task.retry + 1 : 1
let started = false

for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
if (task.skip?.(context)) return

onProgress(started ? 'retrying' : 'started')
started = true

// eslint-disable-next-line no-await-in-loop
return await task.task(context, task)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) {
if (attempt === maxAttempts) throw error
task.errors.push(error)
task.retryCount = attempt
}
}
}

function taskMessage<TContext>(task: Task<TContext>) {
return unstyled(typeof task.title === 'string' ? task.title : task.title.value)
}
11 changes: 7 additions & 4 deletions packages/cli-kit/src/public/common/command-events.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,14 @@ describe('commandEventSchema', () => {
expect(commandEventSchema.parse(event)).toEqual(event)
})

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

expect(commandEventSchema.parse(event)).toEqual(event)
})
expect(commandEventSchema.parse(event)).toEqual(event)
},
)

test.each([{operation: 'upload'}, {status: 'started'}, {operation: 'upload', status: 'unknown'}])(
'rejects incomplete or invalid progress metadata: %j',
Expand Down
2 changes: 1 addition & 1 deletion packages/cli-kit/src/public/common/command-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export const commandProgressEventSchema = z
.object({
type: z.literal('progress'),
timestamp: z.string().datetime({offset: true}),
status: z.enum(['started', 'updated', 'completed']),
status: z.enum(['started', 'updated', 'retrying', 'completed', 'failed']),
operation: z.string(),
message: z.string().optional(),
current: z.number().nonnegative().optional(),
Expand Down
20 changes: 19 additions & 1 deletion packages/cli-kit/src/public/node/command-events.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,11 @@ describe('commandEventOutputSchema', () => {
additionalProperties: false,
},
CommandProgressEvent: {
properties: {current: {type: 'number', minimum: 0}, total: {type: 'number', minimum: 0}},
properties: {
status: {enum: ['started', 'updated', 'retrying', 'completed', 'failed']},
current: {type: 'number', minimum: 0},
total: {type: 'number', minimum: 0},
},
required: ['type', 'timestamp', 'status', 'operation'],
additionalProperties: false,
},
Expand Down Expand Up @@ -131,6 +135,20 @@ describe('renderCommandEvent', () => {
})

describe('renderCommandEventAsJson', () => {
test.each(['retrying', 'failed'] as const)('renders %s progress as JSON', (status) => {
const event: CommandEvent = {
type: 'progress',
timestamp: '2026-08-26T12:00:00.000Z',
operation: 'upload',
status,
message: 'Uploading files',
}

renderCommandEventAsJson(event)

expect(JSON.parse(outputMock.info())).toEqual(event)
})

test.each([
{type: 'diagnostic', level: 'unknown', message: 'Invalid level'},
{type: 'progress', operation: 'upload', status: 'started', current: -1},
Expand Down
Loading
Loading