UNPKG

@shopify/cli-kit

Version:

A set of utilities, interfaces, and models that are common across all the platform features

506 lines (504 loc) 23.3 kB
/* eslint-disable @typescript-eslint/no-non-null-assertion */ /* eslint-disable tsdoc/syntax */ import { AbortError, AbortSilentError } from './error.js'; import { outputContent, outputDebug, outputToken } from './output.js'; import { terminalSupportsPrompting } from './system.js'; import { AbortController } from './abort.js'; import { runWithTimer } from './metadata.js'; import { ConcurrentOutput } from '../../private/node/ui/components/ConcurrentOutput.js'; import { handleCtrlC, render, renderOnce } from '../../private/node/ui.js'; import { alert } from '../../private/node/ui/alert.js'; import { FatalError } from '../../private/node/ui/components/FatalError.js'; import { Table } from '../../private/node/ui/components/Table/Table.js'; import { tokenItemToString, } from '../../private/node/ui/components/TokenizedText.js'; import { DangerousConfirmationPrompt, } from '../../private/node/ui/components/DangerousConfirmationPrompt.js'; import { SelectPrompt } from '../../private/node/ui/components/SelectPrompt.js'; import { Tasks } from '../../private/node/ui/components/Tasks.js'; import { TextPrompt } from '../../private/node/ui/components/TextPrompt.js'; import { AutocompletePrompt } from '../../private/node/ui/components/AutocompletePrompt.js'; import { SingleTask } from '../../private/node/ui/components/SingleTask.js'; import React from 'react'; const defaultUIDebugOptions = { skipTTYCheck: false, }; /** * Renders output from concurrent processes to the terminal with {@link ConcurrentOutput}. * @example * 00:00:00 │ backend │ first backend message * 00:00:00 │ backend │ second backend message * 00:00:00 │ backend │ third backend message * 00:00:00 │ frontend │ first frontend message * 00:00:00 │ frontend │ second frontend message * 00:00:00 │ frontend │ third frontend message * */ export async function renderConcurrent({ renderOptions, ...props }) { const abortSignal = props.abortSignal ?? new AbortController().signal; return render(React.createElement(ConcurrentOutput, { ...props, abortSignal: abortSignal }), renderOptions); } /** * Renders an information banner to the console. * @example Basic * ╭─ info ───────────────────────────────────────────────────╮ * │ │ * │ CLI update available. │ * │ │ * │ Run `npm run shopify upgrade`. │ * │ │ * ╰──────────────────────────────────────────────────────────╯ * * @example Complete * ╭─ info ───────────────────────────────────────────────────╮ * │ │ * │ my-app initialized and ready to build. │ * │ │ * │ Next steps │ * │ • Run `cd verification-app` │ * │ • To preview your project, run `npm app dev` │ * │ • To add extensions, run `npm generate extension` │ * │ │ * │ Reference │ * │ • Run `npm shopify help` │ * │ • Dev docs [1] │ * │ │ * │ Custom section │ * │ • Item 1 [2] │ * │ • Item 2 │ * │ • Item 3 [3] │ * │ │ * ╰──────────────────────────────────────────────────────────╯ * [1] https://shopify.dev * [2] https://www.google.com/search?q=jh56t9l34kpo35tw8s28hn7s * 9s2xvzla01d8cn6j7yq&rlz=1C1GCEU_enUS832US832&oq=jh56t9l34kpo * 35tw8s28hn7s9s2xvzla01d8cn6j7yq&aqs=chrome.0.35i39l2j0l4j46j * 69i60.2711j0j7&sourceid=chrome&ie=UTF-8 * [3] https://shopify.com * */ export function renderInfo(options) { return alert({ ...options, type: 'info' }); } /** * Renders a success banner to the console. * @example Basic * ╭─ success ────────────────────────────────────────────────╮ * │ │ * │ CLI updated. │ * │ │ * │ You are now running version 3.47. │ * │ │ * ╰──────────────────────────────────────────────────────────╯ * * @example Complete * ╭─ success ────────────────────────────────────────────────╮ * │ │ * │ Deployment successful. │ * │ │ * │ Your extensions have been uploaded to your Shopify │ * │ Partners Dashboard. │ * │ │ * │ Next steps │ * │ • See your deployment and set it live [1] │ * │ │ * ╰──────────────────────────────────────────────────────────╯ * [1] https://partners.shopify.com/1797046/apps/4523695/deploy * ments * */ export function renderSuccess(options) { return alert({ ...options, type: 'success' }); } /** * Renders a warning banner to the console. * @example Basic * ╭─ warning ────────────────────────────────────────────────╮ * │ │ * │ You have reached your limit of checkout extensions for │ * │ this app. │ * │ │ * │ You can free up space for a new one by deleting an │ * │ existing one. │ * │ │ * ╰──────────────────────────────────────────────────────────╯ * * @example Complete * ╭─ warning ────────────────────────────────────────────────╮ * │ │ * │ Required access scope update. │ * │ │ * │ The deadline for re-selecting your app scopes is May │ * │ 1, 2022. │ * │ │ * │ Reference │ * │ • Dev docs [1] │ * │ │ * ╰──────────────────────────────────────────────────────────╯ * [1] https://shopify.dev/app/scopes * */ export function renderWarning(options) { return alert({ ...options, type: 'warning' }); } /** * Renders an error banner to the console. * @example * ╭─ error ──────────────────────────────────────────────────╮ * │ │ * │ Version couldn't be released. │ * │ │ * │ This version needs to be submitted for review and │ * │ approved by Shopify before it can be released. │ * │ │ * ╰──────────────────────────────────────────────────────────╯ * */ export function renderError(options) { return alert({ ...options, type: 'error' }); } /** * Renders a Fatal error to the console inside a banner. * @example Basic * ╭─ error ──────────────────────────────────────────────────╮ * │ │ * │ Something went wrong. │ * │ │ * │ To investigate the issue, examine this stack trace: │ * │ at _compile (internal/modules/cjs/loader.js:1137) │ * │ at js (internal/modules/cjs/loader.js:1157) │ * │ at load (internal/modules/cjs/loader.js:985) │ * │ at _load (internal/modules/cjs/loader.js:878) │ * │ │ * ╰──────────────────────────────────────────────────────────╯ * * @example Complete * ╭─ error ──────────────────────────────────────────────────╮ * │ │ * │ No Organization found │ * │ │ * │ Next steps │ * │ • Have you created a Shopify Partners organization │ * │ [1]? │ * │ • Have you confirmed your accounts from the emails │ * │ you received? │ * │ • Need to connect to a different App or │ * │ organization? Run the command again with `--reset` │ * │ │ * │ amortizable-marketplace-ext │ * │ • Some other error │ * │ Validation errors │ * │ • Missing expected key(s). │ * │ │ * │ amortizable-marketplace-ext-2 │ * │ • Something was not found │ * │ │ * ╰──────────────────────────────────────────────────────────╯ * [1] https://partners.shopify.com/signup * */ // eslint-disable-next-line max-params export function renderFatalError(error, { renderOptions } = {}) { return renderOnce(React.createElement(FatalError, { error: error }), { logLevel: 'error', renderOptions }); } /** * Renders a select prompt to the console. * @example * ? Associate your project with the org Castile Ventures? * * ┃ Add * ┃ • new-ext * ┃ * ┃ Remove * ┃ • integrated-demand-ext * ┃ • order-discount * * Automations * > fifth * sixth * * Merchant Admin * eighth * ninth * * Other * first * second * third (limit reached) * fourth * seventh * tenth * * Press ↑↓ arrows to select, enter to confirm. * */ // eslint-disable-next-line max-params export async function renderSelectPrompt({ renderOptions, isConfirmationPrompt, ...props }, uiDebugOptions = defaultUIDebugOptions) { throwInNonTTY({ message: props.message, stdin: renderOptions?.stdin }, uiDebugOptions); return runWithTimer('cmd_all_timing_prompts_ms')(async () => { let selectedValue; await render(React.createElement(SelectPrompt, { ...props, onSubmit: (value) => { selectedValue = value; } }), { ...renderOptions, exitOnCtrlC: false, }); return selectedValue; }); } /** * Renders a confirmation prompt to the console. * @example * ? Delete the following themes from the store? * * ┃ Info message title * ┃ * ┃ Info message body * ┃ * ┃ • first theme (#1) * ┃ • second theme (#2) * * > (y) Yes, confirm changes * (n) Cancel * * Press ↑↓ arrows to select, enter or a shortcut to * confirm. * */ export async function renderConfirmationPrompt({ message, infoTable, confirmationMessage = 'Yes, confirm', cancellationMessage = 'No, cancel', renderOptions, defaultValue = true, abortSignal, infoMessage, }) { const choices = [ { label: confirmationMessage, value: true, key: 'y', }, { label: cancellationMessage, value: false, key: 'n', }, ]; return renderSelectPrompt({ choices, message, infoTable, renderOptions, defaultValue, isConfirmationPrompt: true, abortSignal, infoMessage, }); } /** * Renders an autocomplete prompt to the console. * @example * ? Select a template: Type to search... * * ┃ Info message title * ┃ * ┃ Info message body * * > first * second * third * fourth * fifth * sixth * seventh * eighth * ninth * tenth * eleventh * twelfth * thirteenth * fourteenth * fifteenth * sixteenth * seventeenth * eighteenth * nineteenth (disabled) * twentieth * twenty-first * twenty-second * twenty-third * twenty-fourth * twenty-fifth * * Press ↑↓ arrows to select, enter to confirm. * */ // eslint-disable-next-line max-params export async function renderAutocompletePrompt({ renderOptions, ...props }, uiDebugOptions = defaultUIDebugOptions) { throwInNonTTY({ message: props.message, stdin: renderOptions?.stdin }, uiDebugOptions); const newProps = { search(term) { const lowerTerm = term.toLowerCase(); return Promise.resolve({ data: props.choices.filter((item) => { return item.label.toLowerCase().includes(lowerTerm) || item.group?.toLowerCase().includes(lowerTerm); }), }); }, ...props, }; return runWithTimer('cmd_all_timing_prompts_ms')(async () => { let selectedValue; await render(React.createElement(AutocompletePrompt, { ...newProps, onSubmit: (value) => { selectedValue = value; } }), { ...renderOptions, exitOnCtrlC: false, }); if (selectedValue === undefined) { throw new Error('Prompt was interrupted before a selection was made. This can happen if the process received a signal, was terminated, or the prompt was aborted.'); } return selectedValue; }); } /** * Renders a table to the console. * @example * ID Name email * ── ────────── ───────────── * 1 John Doe jon@doe.com * 2 Jane Doe jane@doe.com * 3 John Smith jon@smith.com */ export function renderTable({ renderOptions, ...props }) { return renderOnce(React.createElement(Table, { ...props }), { renderOptions }); } /** * Runs async tasks and displays their progress to the console. * @example * ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ * Installing dependencies ... */ // eslint-disable-next-line max-params export async function renderTasks(tasks, { renderOptions, noProgressBar } = {}) { // eslint-disable-next-line max-params return new Promise((resolve, reject) => { render(React.createElement(Tasks, { tasks: tasks, onComplete: resolve, noProgressBar: noProgressBar }), { ...renderOptions, exitOnCtrlC: false, }) .then(() => { }) .catch(reject); }); } /** * Awaits a single task and displays a loading bar while it's in progress. The task's result is returned. * @param options - Configuration object * @param options.title - The initial title to display with the loading bar * @param options.task - The async task to execute. Receives an updateStatus callback to change the displayed title. * @param options.renderOptions - Optional render configuration * @returns The result of the task * @example * ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ * Loading app ... */ export async function renderSingleTask({ title, task, onAbort, renderOptions, }) { // eslint-disable-next-line max-params return new Promise((resolve, reject) => { render(React.createElement(SingleTask, { title: title, task: task, onComplete: resolve, onAbort: onAbort }), { ...renderOptions, exitOnCtrlC: false, }).catch(reject); }); } /** * Renders a text prompt to the console. * @example * ? App project name (can be changed later): * > expansive commerce app * ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ * */ // eslint-disable-next-line max-params export async function renderTextPrompt({ renderOptions, ...props }, uiDebugOptions = defaultUIDebugOptions) { throwInNonTTY({ message: props.message, stdin: renderOptions?.stdin }, uiDebugOptions); return runWithTimer('cmd_all_timing_prompts_ms')(async () => { let enteredText = ''; await render(React.createElement(TextPrompt, { ...props, onSubmit: (value) => { enteredText = value; } }), { ...renderOptions, exitOnCtrlC: false, }); return enteredText; }); } /** * Renders a dangerous confirmation prompt to the console, forcing the user to * type a confirmation string to proceed. * @example * ? Release a new version of nightly-app-2023-06-19? * * ┃ Includes: * ┃ + web-px (new) * ┃ + sub-ui-ext * ┃ + theme-app-ext * ┃ + paymentify (from Partner Dashboard) * ┃ * ┃ Removes: * ┃ - prod-discount-fun * ┃ * ┃ This can permanently delete app user data. * * Type nightly-app-2023-06-19 to confirm, or press Escape * to cancel. * > █ * ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ * */ // eslint-disable-next-line max-params export async function renderDangerousConfirmationPrompt({ renderOptions, ...props }, uiDebugOptions = defaultUIDebugOptions) { throwInNonTTY({ message: props.message, stdin: renderOptions?.stdin }, uiDebugOptions); return runWithTimer('cmd_all_timing_prompts_ms')(async () => { let confirmed; await render(React.createElement(DangerousConfirmationPrompt, { ...props, onSubmit: (value) => { confirmed = value; } }), { ...renderOptions, exitOnCtrlC: false, }); return confirmed; }); } /** Waits for any key to be pressed except Ctrl+C which will terminate the process. */ // eslint-disable-next-line max-params export const keypress = async (stdin = process.stdin, uiDebugOptions = defaultUIDebugOptions) => { throwInNonTTY({ message: 'Press any key' }, uiDebugOptions); return runWithTimer('cmd_all_timing_prompts_ms')(() => { // eslint-disable-next-line max-params return new Promise((resolve, reject) => { const handler = (buffer) => { stdin.setRawMode(false); const bytes = Array.from(buffer); if (bytes.length && bytes[0] === 3) { outputDebug('Canceled keypress, User pressed CTRL+C'); reject(new AbortSilentError()); } stdin.unref(); process.nextTick(resolve); }; stdin.setRawMode(true); stdin.once('data', handler); // We want to indicate that we're still using stdin, so that the process // doesn't exit early. stdin.ref(); }); }); }; export function isTTY({ stdin = undefined, uiDebugOptions = defaultUIDebugOptions } = {}) { return Boolean(uiDebugOptions.skipTTYCheck || stdin || terminalSupportsPrompting()); } // eslint-disable-next-line max-params function throwInNonTTY({ message, stdin = undefined }, uiDebugOptions) { if (isTTY({ stdin, uiDebugOptions })) return; const promptText = tokenItemToString(message); const errorMessage = `Failed to prompt: ${outputContent `${outputToken.cyan(promptText)}`.value} This usually happens when running a command non-interactively, for example in a CI environment, or when piping to or from another process.`; throw new AbortError(errorMessage, 'To resolve this, specify the option in the command, or run the command in an interactive environment such as your local terminal.'); } export { render, handleCtrlC }; //# sourceMappingURL=ui.js.map