mirror of
https://github.com/storybookjs/storybook.git
synced 2025-04-01 05:05:25 +08:00
Merge pull request #18781 from storybookjs/tom/sb-568-script-asks-the-maintainer-which
Create a new `yarn example` command that will drive our new dev experience
This commit is contained in:
commit
d3aa364597
@ -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
|
||||
|
3
.gitignore
vendored
3
.gitignore
vendored
@ -16,4 +16,5 @@ junit.xml
|
||||
!/**/.yarn/sdks
|
||||
!/**/.yarn/versions
|
||||
/**/.pnp.*
|
||||
/yarn.lock
|
||||
/yarn.lock
|
||||
./examples/
|
@ -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",
|
||||
|
@ -150,8 +150,9 @@ program
|
||||
.command('link <repo-url-or-directory>')
|
||||
.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);
|
||||
})
|
||||
|
@ -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 });
|
||||
}
|
||||
};
|
||||
|
@ -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 = {
|
||||
|
@ -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",
|
||||
|
@ -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"
|
||||
|
@ -1 +0,0 @@
|
||||
Subproject commit b7ef5bd9f548330f7a0c0b0f6b8d285bcd12a6f7
|
235
scripts/example.ts
Normal file
235
scripts/example.ts
Normal file
@ -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<string, string>;
|
||||
}) => {
|
||||
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 <addon> 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));
|
1
scripts/jest.config.js
Normal file
1
scripts/jest.config.js
Normal file
@ -0,0 +1 @@
|
||||
module.exports = {};
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
43
scripts/utils/cli-step.ts
Normal file
43
scripts/utils/cli-step.ts
Normal file
@ -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<TOptions extends OptionSpecifier> = {
|
||||
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<TOptions extends OptionSpecifier>(
|
||||
cliStep: CLIStep<TOptions>,
|
||||
options: {
|
||||
argument?: string;
|
||||
optionValues?: Partial<OptionValues<TOptions>>;
|
||||
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,
|
||||
}
|
||||
);
|
||||
}
|
165
scripts/utils/options.test.ts
Normal file
165
scripts/utils/options.test.ts
Normal file
@ -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<typeof allOptions>, v: OptionValues<typeof allOptions>) {
|
||||
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');
|
||||
});
|
||||
});
|
274
scripts/utils/options.ts
Normal file
274
scripts/utils/options.ts
Normal file
@ -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<string, PromptType | boolean>;
|
||||
};
|
||||
|
||||
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<StringArrayOption, 'multiple'> & { multiple: true };
|
||||
|
||||
export type Option = BooleanOption | StringOption | StringArrayOption;
|
||||
export type MaybeOptionValue<TOption extends Option> = TOption extends StringArrayOptionMatch
|
||||
? string[]
|
||||
: TOption extends StringOption
|
||||
? string | undefined
|
||||
: TOption extends BooleanOption
|
||||
? boolean
|
||||
: never;
|
||||
|
||||
export type OptionValue<TOption extends Option> = TOption extends { required: true }
|
||||
? string
|
||||
: MaybeOptionValue<TOption>;
|
||||
|
||||
export type OptionSpecifier = Record<OptionId, Option>;
|
||||
export type MaybeOptionValues<TOptions extends OptionSpecifier> = {
|
||||
[TKey in keyof TOptions]: MaybeOptionValue<TOptions[TKey]>;
|
||||
};
|
||||
|
||||
export type OptionValues<TOptions extends OptionSpecifier = OptionSpecifier> = {
|
||||
[TKey in keyof TOptions]: OptionValue<TOptions[TKey]>;
|
||||
};
|
||||
|
||||
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<TOptions extends OptionSpecifier>(
|
||||
command: program.Command,
|
||||
options: TOptions,
|
||||
argv: string[]
|
||||
): MaybeOptionValues<TOptions> {
|
||||
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<TOptions>;
|
||||
}
|
||||
|
||||
export function areOptionsSatisfied<TOptions extends OptionSpecifier>(
|
||||
options: TOptions,
|
||||
values: MaybeOptionValues<TOptions>
|
||||
) {
|
||||
return !Object.entries(options)
|
||||
.filter(([, option]) => isStringOption(option) && option.required)
|
||||
.find(([key]) => !values[key]);
|
||||
}
|
||||
|
||||
export async function promptOptions<TOptions extends OptionSpecifier>(
|
||||
options: TOptions,
|
||||
values: MaybeOptionValues<TOptions>
|
||||
): Promise<OptionValues<TOptions>> {
|
||||
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<typeof passedType>) => {
|
||||
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<TOptions>;
|
||||
}
|
||||
|
||||
function getFlag<TOption extends Option>(
|
||||
key: OptionId,
|
||||
option: TOption,
|
||||
value?: OptionValue<TOption>
|
||||
) {
|
||||
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<TOption> is a
|
||||
// OptionValue<StringArrayOption> (i.e. a string[]), given that it knows
|
||||
// option is a StringArrayOption
|
||||
return ((value || []) as OptionValue<typeof option>)
|
||||
.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<TOptions extends OptionSpecifier>(
|
||||
prefix: string,
|
||||
options: TOptions,
|
||||
values: Partial<OptionValues<TOptions>>
|
||||
) {
|
||||
const flags = Object.keys(options)
|
||||
.map((key) => getFlag(key, options[key], values[key]))
|
||||
.filter(Boolean);
|
||||
return `${prefix} ${flags.join(' ')}`;
|
||||
}
|
||||
|
||||
export async function getOptionsOrPrompt<TOptions extends OptionSpecifier>(
|
||||
commandPrefix: string,
|
||||
options: TOptions
|
||||
): Promise<OptionValues<TOptions>> {
|
||||
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<TOptions>;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
@ -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"
|
||||
|
Loading…
x
Reference in New Issue
Block a user