@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
JavaScript
/* 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