diff --git a/.circleci/config.yml b/.circleci/config.yml index 2905654d1a78..849cf28105a5 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -410,6 +410,20 @@ jobs: command: | cd code yarn lint + script-unit-tests: + executor: sb_node_14_browsers + steps: + - git-shallow-clone/checkout_advanced: + clone_options: '--depth 1 --verbose' + - attach_workspace: + at: . + - run: + name: Test + command: | + cd scripts + yarn test --coverage --runInBand --ci + - store_test_results: + path: scripts/junit.xml unit-tests: executor: sb_node_14_browsers steps: @@ -462,6 +476,9 @@ workflows: - unit-tests: requires: - build + - script-unit-tests: + requires: + - build - coverage: requires: - unit-tests diff --git a/.gitignore b/.gitignore index 26cd055c6108..4ae2a283700c 100644 --- a/.gitignore +++ b/.gitignore @@ -16,4 +16,5 @@ junit.xml !/**/.yarn/sdks !/**/.yarn/versions /**/.pnp.* -/yarn.lock \ No newline at end of file +/yarn.lock +./examples/ \ No newline at end of file diff --git a/code/lib/blocks/package.json b/code/lib/blocks/package.json index 43f7ce771d0a..8772767ba338 100644 --- a/code/lib/blocks/package.json +++ b/code/lib/blocks/package.json @@ -46,7 +46,7 @@ "@storybook/client-logger": "7.0.0-alpha.17", "@storybook/components": "7.0.0-alpha.17", "@storybook/core-events": "7.0.0-alpha.17", - "@storybook/csf": "0.0.2--canary.7c6c115.0", + "@storybook/csf": "0.0.2--canary.0899bb7.0", "@storybook/docs-tools": "7.0.0-alpha.17", "@storybook/preview-web": "7.0.0-alpha.17", "@storybook/store": "7.0.0-alpha.17", diff --git a/code/lib/cli/src/generate.ts b/code/lib/cli/src/generate.ts index d94e4517c432..bdfe9c12a883 100644 --- a/code/lib/cli/src/generate.ts +++ b/code/lib/cli/src/generate.ts @@ -150,8 +150,9 @@ program .command('link ') .description('Pull down a repro from a URL (or a local directory), link it, and run storybook') .option('--local', 'Link a local directory already in your file system') - .action((target, { local }) => - link({ target, local }).catch((e) => { + .option('--no-start', 'Start the storybook', true) + .action((target, { local, start }) => + link({ target, local, start }).catch((e) => { logger.error(e); process.exit(1); }) diff --git a/code/lib/cli/src/link.ts b/code/lib/cli/src/link.ts index 9ceff0afbb7b..ce3e419dcf98 100644 --- a/code/lib/cli/src/link.ts +++ b/code/lib/cli/src/link.ts @@ -7,9 +7,10 @@ import { exec } from './repro-generators/scripts'; interface LinkOptions { target: string; local?: boolean; + start: boolean; } -export const link = async ({ target, local }: LinkOptions) => { +export const link = async ({ target, local, start }: LinkOptions) => { const storybookDir = process.cwd(); try { const packageJson = JSON.parse(fse.readFileSync('package.json', 'utf8')); @@ -58,6 +59,8 @@ export const link = async ({ target, local }: LinkOptions) => { ); await exec(`yarn add -D webpack-hot-middleware`, { cwd: reproDir }); - logger.info(`Running ${reproName} storybook`); - await exec(`yarn run storybook`, { cwd: reproDir }); + if (start) { + logger.info(`Running ${reproName} storybook`); + await exec(`yarn run storybook`, { cwd: reproDir }); + } }; diff --git a/code/lib/cli/src/repro-generators/scripts.ts b/code/lib/cli/src/repro-generators/scripts.ts index 5791c30c9a4f..35ac70ad6454 100644 --- a/code/lib/cli/src/repro-generators/scripts.ts +++ b/code/lib/cli/src/repro-generators/scripts.ts @@ -51,11 +51,19 @@ export interface Options extends Parameters { export const exec = async ( command: string, options: ExecOptions = {}, - { startMessage, errorMessage }: { startMessage?: string; errorMessage?: string } = {} + { + startMessage, + errorMessage, + dryRun, + }: { startMessage?: string; errorMessage?: string; dryRun?: boolean } = {} ) => { - if (startMessage) { - logger.info(startMessage); + if (startMessage) logger.info(startMessage); + + if (dryRun) { + logger.info(`\n> ${command}\n`); + return undefined; } + logger.debug(command); return new Promise((resolve, reject) => { const defaultOptions: ExecOptions = { diff --git a/code/package.json b/code/package.json index 54ea9ceed446..9e78de564412 100644 --- a/code/package.json +++ b/code/package.json @@ -62,6 +62,7 @@ "clean:dist": "del **/dist", "coverage": "codecov", "danger": "danger", + "example": "ts-node ../scripts/example.ts", "generate-repros": "zx ../scripts/repros-generator/index.mjs", "github-release": "github-release-from-changelog", "linear-export": "ts-node --project=../scripts/tsconfig.json ../scripts/linear-export.ts", diff --git a/code/yarn.lock b/code/yarn.lock index e41c5bdd9df7..940aa44590bf 100644 --- a/code/yarn.lock +++ b/code/yarn.lock @@ -7522,7 +7522,7 @@ __metadata: "@storybook/client-logger": 7.0.0-alpha.17 "@storybook/components": 7.0.0-alpha.17 "@storybook/core-events": 7.0.0-alpha.17 - "@storybook/csf": 0.0.2--canary.7c6c115.0 + "@storybook/csf": 0.0.2--canary.0899bb7.0 "@storybook/docs-tools": 7.0.0-alpha.17 "@storybook/preview-web": 7.0.0-alpha.17 "@storybook/store": 7.0.0-alpha.17 @@ -8076,15 +8076,6 @@ __metadata: languageName: node linkType: hard -"@storybook/csf@npm:0.0.2--canary.7c6c115.0": - version: 0.0.2--canary.7c6c115.0 - resolution: "@storybook/csf@npm:0.0.2--canary.7c6c115.0" - dependencies: - lodash: ^4.17.15 - checksum: 85a179664d18eeca8462c1b6ff36f9b68b856c9f9c5143aa6f19b17e4cc97bc08ed69921a5287a61d8c90f61366ff6a5ab89930d158402e7c04d07a3ffaad8bb - languageName: node - linkType: hard - "@storybook/csf@npm:^0.0.1": version: 0.0.1 resolution: "@storybook/csf@npm:0.0.1" diff --git a/examples/react b/examples/react deleted file mode 160000 index b7ef5bd9f548..000000000000 --- a/examples/react +++ /dev/null @@ -1 +0,0 @@ -Subproject commit b7ef5bd9f548330f7a0c0b0f6b8d285bcd12a6f7 diff --git a/scripts/example.ts b/scripts/example.ts new file mode 100644 index 000000000000..27f83b1d751b --- /dev/null +++ b/scripts/example.ts @@ -0,0 +1,235 @@ +import path from 'path'; +import { remove, pathExists, readJSON, writeJSON } from 'fs-extra'; +import prompts from 'prompts'; + +import { getOptionsOrPrompt } from './utils/options'; +import { executeCLIStep } from './utils/cli-step'; +import { exec } from '../code/lib/cli/src/repro-generators/scripts'; +import type { Parameters } from '../code/lib/cli/src/repro-generators/configs'; +import { getInterpretedFile } from '../code/lib/core-common'; +import { readConfig, writeConfig } from '../code/lib/csf-tools'; +import { babelParse } from '../code/lib/csf-tools/src/babelParse'; + +const frameworks = ['react', 'angular']; +const addons = ['a11y', 'storysource']; +const examplesDir = path.resolve(__dirname, '../examples'); +const codeDir = path.resolve(__dirname, '../code'); + +async function getOptions() { + return getOptionsOrPrompt('yarn example', { + framework: { + description: 'Which framework would you like to use?', + values: frameworks, + required: true as const, + }, + addon: { + description: 'Which extra addons (beyond the CLI defaults) would you like installed?', + values: addons, + multiple: true as const, + }, + includeStories: { + description: "Include Storybook's own stories?", + promptType: (_, { framework }) => framework === 'react', + }, + create: { + description: 'Create the example from scratch (rather than degitting it)?', + }, + forceDelete: { + description: 'Always delete an existing example, even if it has the same configuration?', + promptType: false, + }, + forceReuse: { + description: 'Always reuse an existing example, even if it has a different configuration?', + promptType: false, + }, + link: { + description: 'Link the storybook to the local code?', + inverse: true, + }, + start: { + description: 'Start the example Storybook?', + inverse: true, + }, + build: { + description: 'Build the example Storybook?', + }, + watch: { + description: 'Start building used packages in watch mode as well as the example Storybook?', + }, + dryRun: { + description: "Don't execute commands, just list them (dry run)?", + }, + }); +} + +const steps = { + repro: { + command: 'repro', + description: 'Bootstrapping example', + icon: '👷', + hasArgument: true, + options: { + template: { values: frameworks }, + e2e: {}, + }, + }, + add: { + command: 'add', + description: 'Adding addon', + icon: '+', + hasArgument: true, + options: {}, + }, + link: { + command: 'link', + description: 'Linking packages', + icon: '🔗', + hasArgument: true, + options: { local: {}, start: { inverse: true } }, + }, + build: { + command: 'build', + description: 'Building example', + icon: '🔨', + options: {}, + }, + dev: { + command: 'dev', + description: 'Starting example', + icon: '🖥 ', + options: {}, + }, +}; + +const logger = console; + +export const overrideMainConfig = async ({ + cwd, + mainOverrides, +}: { + cwd: string; + mainOverrides: Parameters['mainOverrides']; +}) => { + logger.info(`📝 Overwriting main.js with the following configuration:`); + const configDir = path.join(cwd, '.storybook'); + const mainConfigPath = getInterpretedFile(path.resolve(configDir, 'main')); + logger.debug(mainOverrides); + const mainConfig = await readConfig(mainConfigPath); + + Object.keys(mainOverrides).forEach((field) => { + // NOTE: using setFieldNode and passing the output of babelParse() + mainConfig.setFieldNode([field], mainOverrides[field]); + }); + + await writeConfig(mainConfig); +}; + +const addPackageScripts = async ({ + cwd, + scripts, +}: { + cwd: string; + scripts: Record; +}) => { + logger.info(`🔢 Adding package resolutions:`); + const packageJsonPath = path.join(cwd, 'package.json'); + const packageJson = await readJSON(packageJsonPath); + packageJson.scripts = { + ...packageJson.scripts, + ...scripts, + }; + await writeJSON(packageJsonPath, packageJson, { spaces: 2 }); +}; + +async function main() { + const optionValues = await getOptions(); + + const { framework, forceDelete, forceReuse, link, dryRun } = optionValues; + const cwd = path.join(examplesDir, framework as string); + + const exists = await pathExists(cwd); + let shouldDelete = exists && !forceReuse; + if (exists && !forceDelete && !forceReuse) { + const relativePath = path.relative(process.cwd(), cwd); + ({ shouldDelete } = await prompts({ + type: 'toggle', + message: `${relativePath} already exists, should delete it and create a new one?`, + name: 'shouldDelete', + initial: false, + active: 'yes', + inactive: 'no', + })); + } + + if (exists && shouldDelete && !dryRun) await remove(cwd); + + if (!exists || shouldDelete) { + await executeCLIStep(steps.repro, { + argument: cwd, + optionValues: { template: framework }, + cwd: examplesDir, + dryRun, + }); + + // TODO -- sb add doesn't actually work properly: + // - installs in `deps` not `devDeps` + // - does a `workspace:^` install (what does that mean?) + // - doesn't add to `main.js` + + // eslint-disable-next-line no-restricted-syntax + for (const addon of optionValues.addon as string[]) { + const addonName = `@storybook/addon-${addon}`; + // eslint-disable-next-line no-await-in-loop + await executeCLIStep(steps.add, { argument: addonName, cwd, dryRun }); + } + + // TODO copy stories + + if (link) { + await executeCLIStep(steps.link, { + argument: cwd, + cwd: codeDir, + dryRun, + optionValues: { local: true, start: false }, + }); + + // TODO -- work out exactly where this should happen + const code = '(c) => ({ ...c, resolve: { ...c.resolve, symlinks: false } })'; + const mainOverrides = { + // @ts-ignore (not sure why TS complains here, it does exist) + webpackFinal: babelParse(code).program.body[0].expression, + }; + await overrideMainConfig({ cwd, mainOverrides } as any); + + await addPackageScripts({ + cwd, + scripts: { + storybook: + 'NODE_OPTIONS="--preserve-symlinks --preserve-symlinks-main" storybook dev -p 6006', + 'build-storybook': + 'NODE_OPTIONS="--preserve-symlinks --preserve-symlinks-main" storybook build', + }, + }); + } + } + + const { start } = optionValues; + if (start) { + await exec( + 'yarn storybook', + { cwd }, + { + dryRun, + startMessage: `⬆️ Starting Storybook`, + errorMessage: `🚨 Starting Storybook failed`, + } + ); + } else { + await executeCLIStep(steps.build, { cwd, dryRun }); + // TODO serve + } + + // TODO start dev +} + +main().catch((err) => console.error(err)); diff --git a/scripts/jest.config.js b/scripts/jest.config.js new file mode 100644 index 000000000000..f053ebf7976e --- /dev/null +++ b/scripts/jest.config.js @@ -0,0 +1 @@ +module.exports = {}; diff --git a/scripts/package.json b/scripts/package.json index 3f0af3afbd6d..791af4510268 100644 --- a/scripts/package.json +++ b/scripts/package.json @@ -6,7 +6,8 @@ "lint": "yarn lint:js && yarn lint:md", "lint:js": "yarn lint:js:cmd . --quiet", "lint:js:cmd": "cross-env NODE_ENV=production eslint --cache --cache-location=../.cache/eslint --ext .js,.jsx,.json,.html,.ts,.tsx,.mjs --report-unused-disable-directives", - "lint:package": "sort-package-json" + "lint:package": "sort-package-json", + "test": "jest --config ./jest.config.js" }, "husky": { "hooks": { @@ -54,6 +55,7 @@ "@compodoc/compodoc": "^1.1.18", "@emotion/babel-plugin": "^11.7.2", "@emotion/jest": "^11.8.0", + "@jest/globals": "^26.6.2", "@linear/sdk": "^1.21.0", "@nicolo-ribaudo/chokidar-2": "^2.1.8", "@nrwl/cli": "12.3.4", @@ -140,6 +142,7 @@ "jest-watch-typeahead": "^0.6.1", "js-yaml": "^3.14.1", "lint-staged": "^10.5.4", + "lodash": "^4.17.21", "mocha-list-tests": "^1.0.5", "node-cleanup": "^2.1.2", "node-fetch": "^2.6.1", @@ -190,5 +193,8 @@ "engines": { "node": ">=10.13.0", "yarn": ">=1.3.2" + }, + "devDependencies": { + "@types/lodash": "^4" } } diff --git a/scripts/utils/cli-step.ts b/scripts/utils/cli-step.ts new file mode 100644 index 000000000000..bff96f6436fe --- /dev/null +++ b/scripts/utils/cli-step.ts @@ -0,0 +1,43 @@ +import { getCommand, OptionSpecifier, OptionValues } from './options'; +import { exec } from '../../code/lib/cli/src/repro-generators/scripts'; + +const cliExecutable = require.resolve('../../code/lib/cli/bin/index.js'); + +export type CLIStep = { + command: string; + description: string; + hasArgument?: boolean; + icon: string; + // It would be kind of great to be able to share these with `lib/cli/src/generate.ts` + options: TOptions; +}; + +export async function executeCLIStep( + cliStep: CLIStep, + options: { + argument?: string; + optionValues?: Partial>; + cwd: string; + dryRun?: boolean; + } +) { + if (cliStep.hasArgument && !options.argument) + throw new Error(`Argument required for ${cliStep.command} command.`); + + const prefix = `node ${cliExecutable} ${cliStep.command}`; + const command = getCommand( + cliStep.hasArgument ? `${prefix} ${options.argument}` : prefix, + cliStep.options, + options.optionValues || {} + ); + + await exec( + command, + { cwd: options.cwd }, + { + startMessage: `${cliStep.icon} ${cliStep.description}`, + errorMessage: `🚨 ${cliStep.description} failed`, + dryRun: options.dryRun, + } + ); +} diff --git a/scripts/utils/options.test.ts b/scripts/utils/options.test.ts new file mode 100644 index 000000000000..c73cb1ac5a74 --- /dev/null +++ b/scripts/utils/options.test.ts @@ -0,0 +1,165 @@ +import { createCommand } from 'commander'; +import { describe, it, expect } from '@jest/globals'; + +import { + getOptions, + areOptionsSatisfied, + getCommand, + OptionValues, + MaybeOptionValues, +} from './options'; + +const allOptions = { + first: { + description: 'first', + }, + second: { + description: 'second', + inverse: true, + }, + third: { + description: 'third', + values: ['one', 'two', 'three'], + required: true as const, + }, + fourth: { + description: 'fourth', + values: ['a', 'b', 'c'], + multiple: true as const, + }, +}; + +// TS "tests" +// deepscan-disable-next-line +function test(mv: MaybeOptionValues, v: OptionValues) { + console.log(mv.first, mv.second, mv.third, mv.fourth); + // @ts-expect-error as it's not allowed + console.log(mv.fifth); + console.log(v.first, v.second, v.third, v.fourth); + // @ts-expect-error as it's not allowed + console.log(v.fifth); +} + +describe('getOptions', () => { + it('deals with boolean options', () => { + expect(getOptions(createCommand(), allOptions, ['command', 'name', '--first'])).toMatchObject({ + first: true, + second: true, + }); + }); + + it('deals with inverse boolean options', () => { + expect( + getOptions(createCommand(), allOptions, ['command', 'name', '--no-second']) + ).toMatchObject({ + first: false, + second: false, + }); + }); + + it('deals with short options', () => { + expect(getOptions(createCommand(), allOptions, ['command', 'name', '-f', '-S'])).toMatchObject({ + first: true, + second: false, + }); + }); + + it('deals with string options', () => { + const r = getOptions(createCommand(), allOptions, ['command', 'name', '--third', 'one']); + expect( + getOptions(createCommand(), allOptions, ['command', 'name', '--third', 'one']) + ).toMatchObject({ + third: 'one', + }); + }); + + it('disallows invalid string options', () => { + expect(() => + getOptions(createCommand(), allOptions, ['command', 'name', '--third', 'random']) + ).toThrow(/Unexpected value/); + }); + + it('deals with multiple string options', () => { + expect( + getOptions(createCommand(), allOptions, ['command', 'name', '--fourth', 'a']) + ).toMatchObject({ + fourth: ['a'], + }); + + expect( + getOptions(createCommand(), allOptions, ['command', 'name', '--fourth', 'a', '--fourth', 'b']) + ).toMatchObject({ + fourth: ['a', 'b'], + }); + }); + + it('disallows invalid multiple string options', () => { + expect(() => + getOptions(createCommand(), allOptions, ['command', 'name', '--fourth', 'random']) + ).toThrow(/Unexpected value/); + }); +}); + +describe('areOptionsSatisfied', () => { + it('checks each required string option has a value', () => { + expect( + areOptionsSatisfied(allOptions, { + first: true, + second: true, + third: undefined, + fourth: ['a', 'c'], + }) + ).toBe(false); + expect( + areOptionsSatisfied(allOptions, { + first: true, + second: true, + third: 'one', + fourth: [], + }) + ).toBe(true); + }); +}); + +describe('getCommand', () => { + const { first, second, third, fourth } = allOptions; + it('works with boolean options', () => { + expect(getCommand('node foo', { first, second }, { first: true, second: true })).toBe( + 'node foo --first' + ); + }); + + it('works with inverse boolean options', () => { + expect(getCommand('node foo', { first, second }, { first: false, second: false })).toBe( + 'node foo --no-second' + ); + }); + + it('works with string options', () => { + expect(getCommand('node foo', { third }, { third: 'one' })).toBe('node foo --third one'); + }); + + it('works with multiple string options', () => { + expect(getCommand('node foo', { fourth }, { fourth: ['a', 'b'] })).toBe( + 'node foo --fourth a --fourth b' + ); + }); + + // This is for convenience + it('works with partial options', () => { + expect(getCommand('node foo', allOptions, { third: 'one' })).toBe( + 'node foo --no-second --third one' + ); + }); + + it('works with combinations string options', () => { + expect( + getCommand('node foo', allOptions, { + first: true, + second: false, + third: 'one', + fourth: ['a', 'b'], + }) + ).toBe('node foo --first --no-second --third one --fourth a --fourth b'); + }); +}); diff --git a/scripts/utils/options.ts b/scripts/utils/options.ts new file mode 100644 index 000000000000..2496a0500515 --- /dev/null +++ b/scripts/utils/options.ts @@ -0,0 +1,274 @@ +/** + * Use commander and prompts to gather a list of options for a script + */ + +import prompts, { Falsy, PrevCaller, PromptType } from 'prompts'; +import type { PromptObject } from 'prompts'; +import program from 'commander'; +import kebabCase from 'lodash/kebabCase'; + +// Option types + +export type OptionId = string; +export type BaseOption = { + description?: string; + /** + * By default the one-char version of the option key will be used as short flag. Override here, + * e.g. `shortFlag: 'c'` + */ + shortFlag?: string; + /** + * What type of prompt to use? (return false to skip, true for default) + */ + promptType?: PromptType | Falsy | PrevCaller; +}; + +export type BooleanOption = BaseOption & { + /** + * Does this option default true? + */ + inverse?: boolean; +}; + +export type StringOption = BaseOption & { + /** + * What values are allowed for this option? + */ + values: string[]; + /** + * Is a value required for this option? + */ + required?: boolean; +}; + +export type StringArrayOption = BaseOption & { + /** + * What values are allowed for this option? + */ + values: string[]; + /** + * This must be set to true + */ + multiple: true; +}; + +type StringArrayOptionMatch = Omit & { multiple: true }; + +export type Option = BooleanOption | StringOption | StringArrayOption; +export type MaybeOptionValue = TOption extends StringArrayOptionMatch + ? string[] + : TOption extends StringOption + ? string | undefined + : TOption extends BooleanOption + ? boolean + : never; + +export type OptionValue = TOption extends { required: true } + ? string + : MaybeOptionValue; + +export type OptionSpecifier = Record; +export type MaybeOptionValues = { + [TKey in keyof TOptions]: MaybeOptionValue; +}; + +export type OptionValues = { + [TKey in keyof TOptions]: OptionValue; +}; + +export function isStringOption(option: Option): option is StringOption { + return 'values' in option && !('multiple' in option); +} + +export function isBooleanOption(option: Option): option is BooleanOption { + return !('values' in option); +} + +export function isStringArrayOption(option: Option): option is StringArrayOption { + return 'values' in option && 'multiple' in option; +} + +function shortFlag(key: OptionId, option: Option) { + const inverse = isBooleanOption(option) && option.inverse; + const defaultShortFlag = inverse ? key.substring(0, 1).toUpperCase() : key.substring(0, 1); + const short = option.shortFlag || defaultShortFlag; + if (short.length !== 1) { + throw new Error( + `Invalid shortFlag for ${key}: '${short}', needs to be a single character (e.g. 's')` + ); + } + return short; +} + +function longFlag(key: OptionId, option: Option) { + const inverse = isBooleanOption(option) && option.inverse; + return inverse ? `no-${kebabCase(key)}` : kebabCase(key); +} + +function optionFlags(key: OptionId, option: Option) { + const base = `-${shortFlag(key, option)}, --${longFlag(key, option)}`; + if (isStringOption(option) || isStringArrayOption(option)) { + return `${base} <${key}>`; + } + return base; +} + +export function getOptions( + command: program.Command, + options: TOptions, + argv: string[] +): MaybeOptionValues { + Object.entries(options) + .reduce((acc, [key, option]) => { + const flags = optionFlags(key, option); + + if (isBooleanOption(option)) return acc.option(flags, option.description, !!option.inverse); + + const checkStringValue = (raw: string) => { + if (!option.values.includes(raw)) + throw new Error(`Unexpected value '${raw}' for option '${key}'`); + return raw; + }; + + if (isStringOption(option)) + return acc.option(flags, option.description, (raw) => checkStringValue(raw)); + + if (isStringArrayOption(option)) { + return acc.option( + flags, + option.description, + (raw, values) => [...values, checkStringValue(raw)], + [] + ); + } + + throw new Error(`Unexpected option type '${key}'`); + }, command) + .parse(argv); + + // Note the code above guarantees the types as they come in, so we cast here. + // Not sure there is an easier way to do this + return command.opts() as MaybeOptionValues; +} + +export function areOptionsSatisfied( + options: TOptions, + values: MaybeOptionValues +) { + return !Object.entries(options) + .filter(([, option]) => isStringOption(option) && option.required) + .find(([key]) => !values[key]); +} + +export async function promptOptions( + options: TOptions, + values: MaybeOptionValues +): Promise> { + const questions = Object.entries(options).map(([key, option]): PromptObject => { + let defaultType: PromptType = 'toggle'; + if (!isBooleanOption(option)) + defaultType = isStringArrayOption(option) ? 'autocompleteMultiselect' : 'select'; + + const passedType = option.promptType; + let type: PromptObject['type'] = defaultType; + // Allow returning `undefined` from `type()` function to fallback to default + if (typeof passedType === 'function') { + type = (...args: Parameters) => { + const chosenType = passedType(...args); + return chosenType === true ? defaultType : chosenType; + }; + } else if (passedType) { + type = passedType; + } + + if (!isBooleanOption(option)) { + const currentValue = values[key]; + return { + type, + message: option.description, + name: key, + // warn: ' ', + // pageSize: Object.keys(tasks).length + Object.keys(groups).length, + choices: option.values.map((value) => ({ + title: value, + value, + selected: + currentValue === value || + (Array.isArray(currentValue) && currentValue.includes?.(value)), + })), + }; + } + return { + type, + message: option.description, + name: key, + initial: option.inverse, + active: 'yes', + inactive: 'no', + }; + }); + + const selection = await prompts(questions); + // Again the structure of the questions guarantees we get responses of the type we need + return selection as OptionValues; +} + +function getFlag( + key: OptionId, + option: TOption, + value?: OptionValue +) { + if (isBooleanOption(option)) { + const toggled = option.inverse ? !value : value; + return toggled ? `--${longFlag(key, option)}` : ''; + } + + if (isStringArrayOption(option)) { + // I'm not sure why TS isn't able to infer that OptionValue is a + // OptionValue (i.e. a string[]), given that it knows + // option is a StringArrayOption + return ((value || []) as OptionValue) + .map((v) => `--${longFlag(key, option)} ${v}`) + .join(' '); + } + + if (isStringOption(option)) { + if (value) { + return `--${longFlag(key, option)} ${value}`; + } + return ''; + } + + throw new Error(`Unknown option type for '${key}'`); +} + +export function getCommand( + prefix: string, + options: TOptions, + values: Partial> +) { + const flags = Object.keys(options) + .map((key) => getFlag(key, options[key], values[key])) + .filter(Boolean); + return `${prefix} ${flags.join(' ')}`; +} + +export async function getOptionsOrPrompt( + commandPrefix: string, + options: TOptions +): Promise> { + const main = program.version('5.0.0'); + const cliValues = getOptions(main as any, options, process.argv); + + if (areOptionsSatisfied(options, cliValues)) { + // areOptionsSatisfied could be a type predicate but I'm not quite sure how to do it + return cliValues as OptionValues; + } + + const finalValues = await promptOptions(options, cliValues); + + const command = getCommand(commandPrefix, options, finalValues); + console.log(`\nTo run this directly next time, use:\n ${command}\n`); + + return finalValues; +} diff --git a/scripts/yarn.lock b/scripts/yarn.lock index 7ebe87564f9f..59a411fc170b 100644 --- a/scripts/yarn.lock +++ b/scripts/yarn.lock @@ -3240,6 +3240,7 @@ __metadata: "@cypress/webpack-preprocessor": ^5.9.1 "@emotion/babel-plugin": ^11.7.2 "@emotion/jest": ^11.8.0 + "@jest/globals": ^26.6.2 "@linear/sdk": ^1.21.0 "@nicolo-ribaudo/chokidar-2": ^2.1.8 "@nrwl/cli": 12.3.4 @@ -3269,6 +3270,7 @@ __metadata: "@types/fs-extra": ^9.0.6 "@types/jest": ^26.0.16 "@types/js-yaml": ^3.12.6 + "@types/lodash": ^4 "@types/node": ^14.14.20 || ^16.0.0 "@types/node-cleanup": ^2.1.1 "@types/node-fetch": ^2.5.7 @@ -3327,6 +3329,7 @@ __metadata: jest-watch-typeahead: ^0.6.1 js-yaml: ^3.14.1 lint-staged: ^10.5.4 + lodash: ^4.17.21 mocha-list-tests: ^1.0.5 node-cleanup: ^2.1.2 node-fetch: ^2.6.1 @@ -3814,6 +3817,13 @@ __metadata: languageName: node linkType: hard +"@types/lodash@npm:^4": + version: 4.14.182 + resolution: "@types/lodash@npm:4.14.182" + checksum: d6bd4789dfb3be631d5e3277e6a1be5becb21440f3364f5d15b982c2e6b6bb1f8048d46fc5bff5ef0f90bebaf4d07c49b2919ba369d07af72d3beb3fea70c61a + languageName: node + linkType: hard + "@types/mdast@npm:^3.0.0": version: 3.0.10 resolution: "@types/mdast@npm:3.0.10"