diff --git a/packages/alfa-cli/bin/alfa.ts b/packages/alfa-cli/bin/alfa.ts index e4ed12a5d3..76f4cd7b61 100644 --- a/packages/alfa-cli/bin/alfa.ts +++ b/packages/alfa-cli/bin/alfa.ts @@ -1,14 +1,62 @@ #!/usr/bin/env node -import * as command from "@oclif/command"; -import * as errors from "@oclif/errors"; - -async function alfa() { - try { - await command.run(); - } catch (err) { - errors.handle(err); - } -} - -alfa(); +/// + +import * as path from "path"; +import * as process from "process"; +import * as tty from "tty"; + +import { Command, Flag } from "@siteimprove/alfa-command"; +import { None } from "@siteimprove/alfa-option"; +import { Err } from "@siteimprove/alfa-result"; + +import * as pkg from "../package.json"; + +import audit from "./alfa/command/audit"; +import scrape from "./alfa/command/scrape"; + +const { + argv: [node, bin], + platform, + arch, + version, +} = process; + +const application = Command.withSubcommands( + path.basename(bin), + `${pkg.name}/${pkg.version} ${platform}-${arch} node-${version}`, + `The tool for all your accessibility needs on the command line.`, + { + help: Flag.help("Display the help information."), + version: Flag.version("Output the current version."), + }, + (self) => ({ + audit: audit(self), + scrape: scrape(self), + }), + None +); + +application + .run(process.argv.slice(2)) + .catch((err: Error) => Err.of(`${err.message}`)) + .then((result) => { + let stream: tty.WriteStream; + let output: string; + + if (result.isOk()) { + stream = process.stdout; + output = result.get(); + } else { + stream = process.stderr; + output = result.getErr(); + } + + output = output.trimRight(); + + if (output.length > 0) { + stream.write(output + "\n"); + } + + process.exit(result.isOk() ? 0 : 1); + }); diff --git a/packages/alfa-cli/bin/alfa/command/audit.ts b/packages/alfa-cli/bin/alfa/command/audit.ts new file mode 100644 index 0000000000..b62114dcaf --- /dev/null +++ b/packages/alfa-cli/bin/alfa/command/audit.ts @@ -0,0 +1,22 @@ +import { Command } from "@siteimprove/alfa-command"; +import { Option } from "@siteimprove/alfa-option"; + +import { Arguments } from "./audit/arguments"; +import { Flags } from "./audit/flags"; + +/** + * @internal + */ +export default (parent: Command) => + Command.withArguments( + "audit", + parent.version, + "Perform an accessibility audit of a page.", + Flags, + Arguments, + Option.of(parent), + () => async (...args) => { + const { run } = await import("./audit/run"); + return run(...args); + } + ); diff --git a/packages/alfa-cli/bin/alfa/command/audit/arguments.ts b/packages/alfa-cli/bin/alfa/command/audit/arguments.ts new file mode 100644 index 0000000000..79d9b04e15 --- /dev/null +++ b/packages/alfa-cli/bin/alfa/command/audit/arguments.ts @@ -0,0 +1,11 @@ +import { Argument } from "@siteimprove/alfa-command"; + +export const Arguments = { + url: Argument.string( + "url", + `The URL of the page to audit. Both remote and local protocols are + supported so the URL can either be an address of a remote page or a path to + a local file. If no URL is provided, an already serialised page will be read + from stdin.` + ).optional(), +}; diff --git a/packages/alfa-cli/bin/alfa/command/audit/flags.ts b/packages/alfa-cli/bin/alfa/command/audit/flags.ts new file mode 100644 index 0000000000..cdc683ed37 --- /dev/null +++ b/packages/alfa-cli/bin/alfa/command/audit/flags.ts @@ -0,0 +1,40 @@ +import { Flag } from "@siteimprove/alfa-command"; + +import * as scrape from "../scrape/flags"; + +export const Flags = { + help: Flag.help("Display the help information."), + + interactive: Flag.boolean( + "interactive", + "Whether or not to run an interactive audit." + ) + .alias("i") + .default(false), + + format: Flag.string("format", "The reporting format to use.") + .type("format or package") + .alias("f") + .default("earl"), + + output: Flag.string( + "output", + `The path to write results to. If no path is provided, results are written + to stdout.` + ) + .type("path") + .alias("o") + .optional(), + + outcomes: Flag.string( + "outcome", + `The type of outcome to include in the results. If not provided, all types + of outcomes are included. This flag can be repeated to include multiple + types of outcomes.` + ) + .choices("passed", "failed", "inapplicable", "cantTell") + .repeatable() + .optional(), + + ...scrape.Flags, +}; diff --git a/packages/alfa-cli/bin/alfa/command/audit/run.ts b/packages/alfa-cli/bin/alfa/command/audit/run.ts new file mode 100644 index 0000000000..a2cb2e913a --- /dev/null +++ b/packages/alfa-cli/bin/alfa/command/audit/run.ts @@ -0,0 +1,97 @@ +/// + +import * as fs from "fs"; + +import { Audit, Outcome } from "@siteimprove/alfa-act"; +import { Command } from "@siteimprove/alfa-command"; +import { Node } from "@siteimprove/alfa-dom"; +import { Formatter } from "@siteimprove/alfa-formatter"; +import { Iterable } from "@siteimprove/alfa-iterable"; +import { None } from "@siteimprove/alfa-option"; +import { Ok } from "@siteimprove/alfa-result"; +import { Rules, Question } from "@siteimprove/alfa-rules"; +import { Page } from "@siteimprove/alfa-web"; + +import { Oracle } from "../../oracle"; + +import type { Arguments } from "./arguments"; +import type { Flags } from "./flags"; + +import * as scrape from "../scrape/run"; + +type Input = Page; +type Target = Node | Iterable; + +export const run: Command.Runner = async ({ + flags, + args: { url: target }, +}) => { + const formatter = Formatter.load(flags.format); + + if (formatter.isErr()) { + return formatter; + } + + let json: string; + + if (target.isNone()) { + json = fs.readFileSync(0, "utf-8"); + } else { + const result = await scrape.run({ + flags: { + ...flags, + output: None, + }, + args: { + url: target.get(), + }, + }); + + if (result.isErr()) { + return result; + } + + json = result.get(); + } + + const page = Page.from(JSON.parse(json)); + + const audit = Rules.reduce( + (audit, rule) => audit.add(rule), + Audit.of( + page, + flags.interactive ? Oracle(page) : undefined + ) + ); + + let outcomes = await audit.evaluate(); + + if (flags.outcomes.isSome()) { + const filter = new Set(flags.outcomes.get()); + + outcomes = Iterable.filter(outcomes, (outcome) => { + if (Outcome.isPassed(outcome)) { + return filter.has("passed"); + } + + if (Outcome.isFailed(outcome)) { + return filter.has("failed"); + } + + if (Outcome.isInapplicable(outcome)) { + return filter.has("inapplicable"); + } + + return filter.has("cantTell"); + }); + } + + const output = formatter.get()(page, outcomes); + + if (flags.output.isNone()) { + return Ok.of(output); + } else { + fs.writeFileSync(flags.output.get() + "\n", output); + return Ok.of(""); + } +}; diff --git a/packages/alfa-cli/bin/alfa/command/scrape.ts b/packages/alfa-cli/bin/alfa/command/scrape.ts new file mode 100644 index 0000000000..f2f76c836e --- /dev/null +++ b/packages/alfa-cli/bin/alfa/command/scrape.ts @@ -0,0 +1,22 @@ +import { Command } from "@siteimprove/alfa-command"; +import { Option } from "@siteimprove/alfa-option"; + +import { Arguments } from "./scrape/arguments"; +import { Flags } from "./scrape/flags"; + +/** + * @internal + */ +export default (parent: Command) => + Command.withArguments( + "scrape", + parent.version, + "Scrape a page and output it in a serialisable format.", + Flags, + Arguments, + Option.of(parent), + () => async (...args) => { + const { run } = await import("./scrape/run"); + return run(...args); + } + ); diff --git a/packages/alfa-cli/bin/alfa/command/scrape/arguments.ts b/packages/alfa-cli/bin/alfa/command/scrape/arguments.ts new file mode 100644 index 0000000000..03061db0de --- /dev/null +++ b/packages/alfa-cli/bin/alfa/command/scrape/arguments.ts @@ -0,0 +1,10 @@ +import { Argument } from "@siteimprove/alfa-command"; + +export const Arguments = { + url: Argument.string( + "url", + `The URL of the page to scrape. Both remote and local protocols are + supported so the URL can either be an address of a remote page or a path to + a local file.` + ), +}; diff --git a/packages/alfa-cli/bin/alfa/command/scrape/flags.ts b/packages/alfa-cli/bin/alfa/command/scrape/flags.ts new file mode 100644 index 0000000000..ff9fcb955e --- /dev/null +++ b/packages/alfa-cli/bin/alfa/command/scrape/flags.ts @@ -0,0 +1,156 @@ +import { Flag } from "@siteimprove/alfa-command"; + +export const Flags = { + help: Flag.help("Display the help information."), + + output: Flag.string( + "output", + `The path to write the page to. If no path is provided, the page is written + to stdout.` + ) + .type("path") + .alias("o") + .optional(), + + timeout: Flag.integer( + "timeout", + "The maximum time to wait for the page to load." + ) + .type("milliseconds") + .default(10000), + + width: Flag.integer("width", "The width of the browser viewport.") + .type("pixels") + .alias("w") + .default(1280), + + height: Flag.integer("height", "The height of the browser viewport.") + .type("pixels") + .alias("h") + .default(720), + + orientation: Flag.string( + "orientation", + "The orientation of the browser viewport." + ) + .choices("landscape", "portrait") + .default("landscape"), + + resolution: Flag.integer( + "resolution", + "The pixel density of the browser as a device pixel ratio." + ) + .type("ratio") + .default(1), + + scripting: Flag.boolean( + "scripting", + "Whether or not scripts, such as JavaScript, are evaluated." + ).default(true), + + username: Flag.string( + "username", + "The username to use for HTTP Basic authentication." + ) + .alias("u") + .optional(), + + password: Flag.string( + "password", + "The password to use for HTTP Basic authentication." + ) + .alias("p") + .optional(), + + headers: Flag.string( + "header", + `An additional header to set as a name:value pair. This flag can be repeated + to set multiple headers.` + ) + .type("name:value") + .repeatable() + .default([]), + + headersPath: Flag.string( + "headers", + `A path to a JSON file containing additional headers to set. The file must + contain a list of { name: string, value: string } objects.` + ) + .type("path") + .optional(), + + cookies: Flag.string( + "cookie", + `An additional cookie to set as a name=value pair. This flag can be repeated + to set multiple cookies.` + ) + .type("name=value") + .repeatable() + .default([]), + + cookiesPath: Flag.string( + "cookies", + `A path to a JSON file containing additional cookies to set. The file must + contain a list of { name: string, value: string } objects.` + ) + .type("path") + .optional(), + + awaitState: Flag.string( + "await-state", + "The state to await before considering the page loaded." + ) + .choices("ready", "loaded", "idle") + .default("loaded"), + + awaitDuration: Flag.integer( + "await-duration", + "The duration to wait before considering the page loaded." + ) + .type("milliseconds") + .optional(), + + awaitSelector: Flag.string( + "await-selector", + `A CSS selector matching an element that must be present before considering + the page loaded.` + ) + .type("selector") + .optional(), + + awaitXPath: Flag.string( + "await-xpath", + `An XPath expression evaluating to an element that must be present before + considering the page loaded.` + ) + .type("expression") + .optional(), + + screenshot: Flag.string( + "screenshot", + "The path to write a screenshot to. If not provided, no screenshot is taken." + ) + .type("path") + .optional(), + + screenshotType: Flag.string( + "screenshot-type", + "The file type of the screenshot" + ) + .choices("png", "jpeg") + .default("png"), + + screenshotBackground: Flag.boolean( + "screenshot-background", + `Whether or not the screenshot should include a default white background. + Only applies to PNG screenshots.` + ).default(true), + + screenshotQuality: Flag.integer( + "screenshot-quality", + "The quality of the screenshot. Only applies to JPEG screenshots." + ) + .type("0-100") + .filter((value) => value >= 0 && value <= 100) + .default(100), +}; diff --git a/packages/alfa-cli/bin/alfa/command/scrape/run.ts b/packages/alfa-cli/bin/alfa/command/scrape/run.ts new file mode 100644 index 0000000000..652d4b0379 --- /dev/null +++ b/packages/alfa-cli/bin/alfa/command/scrape/run.ts @@ -0,0 +1,168 @@ +/// + +import * as fs from "fs"; +import * as path from "path"; +import * as url from "url"; + +import type { Command } from "@siteimprove/alfa-command"; +import { Device, Display, Scripting, Viewport } from "@siteimprove/alfa-device"; +import { Header, Cookie } from "@siteimprove/alfa-http"; +import { Ok, Err } from "@siteimprove/alfa-result"; +import { + Awaiter, + Credentials, + Scraper, + Screenshot, +} from "@siteimprove/alfa-scraper"; +import { Timeout } from "@siteimprove/alfa-time"; + +import type { Arguments } from "./arguments"; +import type { Flags } from "./flags"; + +export const run: Command.Runner = async ({ + flags, + args: { url: target }, +}) => { + const scraper = await Scraper.of(); + + let awaiter: Awaiter | undefined; + + for (const state of flags.awaitState) { + switch (state) { + case "ready": + awaiter = Awaiter.ready(); + break; + case "loaded": + awaiter = Awaiter.loaded(); + break; + case "idle": + awaiter = Awaiter.idle(); + } + } + + for (const duration of flags.awaitDuration) { + awaiter = Awaiter.duration(duration); + } + + for (const selector of flags.awaitSelector) { + awaiter = Awaiter.selector(selector); + } + + for (const xpath of flags.awaitXPath) { + awaiter = Awaiter.xpath(xpath); + } + + const orientation = + flags.orientation === "portrait" + ? Viewport.Orientation.Portrait + : Viewport.Orientation.Landscape; + + const device = Device.of( + Device.Type.Screen, + Viewport.of(flags.width, flags.height, orientation), + Display.of(flags.resolution), + Scripting.of(flags.scripting) + ); + + const credentials = + flags.username.isNone() || flags.password.isNone() + ? undefined + : Credentials.of(flags.username.get(), flags.password.get()); + + let screenshot: Screenshot | undefined; + + for (const path of flags.screenshot) { + switch (flags.screenshotType) { + case "png": + screenshot = Screenshot.of( + path, + Screenshot.Type.PNG.of(flags.screenshotBackground) + ); + break; + + case "jpeg": + screenshot = Screenshot.of( + path, + Screenshot.Type.JPEG.of(flags.screenshotQuality) + ); + } + } + + const headers = [...flags.headers].map((header) => { + const index = header.indexOf(":"); + + if (index === -1) { + return Header.of(header, ""); + } + + return Header.of(header.substring(0, index), header.substring(index + 1)); + }); + + for (const path of flags.headersPath) { + try { + const parsed = Array.from( + JSON.parse(fs.readFileSync(path, "utf-8")) + ); + + headers.push( + ...parsed.map((header) => Header.of(header.name, header.value)) + ); + } catch (err) { + return Err.of(err.message); + } + } + + const cookies = [...flags.cookies].map((cookie) => { + const index = cookie.indexOf("="); + + if (index === -1) { + return Cookie.of(cookie, ""); + } + + return Cookie.of(cookie.substring(0, index), cookie.substring(index + 1)); + }); + + for (const path of flags.cookiesPath) { + try { + const parsed = Array.from( + JSON.parse(fs.readFileSync(path, "utf-8")) + ); + + cookies.push( + ...parsed.map((cookie) => Cookie.of(cookie.name, cookie.value)) + ); + } catch (err) { + return Err.of(err.message); + } + } + + const timeout = Timeout.of(flags.timeout); + + const result = await scraper.scrape( + new URL(target, url.pathToFileURL(process.cwd() + path.sep)), + { + timeout, + awaiter, + device, + credentials, + screenshot, + headers, + cookies, + } + ); + + await scraper.close(); + + if (result.isErr()) { + return result; + } + + const output = JSON.stringify(result.get()); + + if (flags.output.isNone()) { + return Ok.of(output); + } else { + fs.writeFileSync(flags.output.get() + "\n", output); + return Ok.of(""); + } +}; diff --git a/packages/alfa-cli/bin/alfa/commands/audit.ts b/packages/alfa-cli/bin/alfa/commands/audit.ts deleted file mode 100644 index bab8cb866d..0000000000 --- a/packages/alfa-cli/bin/alfa/commands/audit.ts +++ /dev/null @@ -1,143 +0,0 @@ -/// - -import * as fs from "fs"; -import * as temp from "tempy"; - -import { Command, flags } from "@oclif/command"; -import { error } from "@oclif/errors"; - -import * as parser from "@oclif/parser"; - -import { Outcome } from "@siteimprove/alfa-act"; -import { Node } from "@siteimprove/alfa-dom"; -import { Formatter } from "@siteimprove/alfa-formatter"; -import { Iterable } from "@siteimprove/alfa-iterable"; -import { Rules, Question } from "@siteimprove/alfa-rules"; -import { Page } from "@siteimprove/alfa-web"; - -import * as act from "@siteimprove/alfa-act"; - -import Scrape from "./scrape"; - -import { Oracle } from "../../../src/oracle"; - -type Input = Page; -type Target = Node | Iterable; - -export default class Audit extends Command { - public static description = "Perform an accessibility audit of a page"; - - public static flags = { - ...Scrape.flags, - - help: flags.help({ - description: "Display this command help", - }), - - interactive: flags.boolean({ - char: "i", - default: false, - allowNo: true, - description: "Whether or not to run an interactive audit", - }), - - format: flags.string({ - char: "f", - default: "earl", - helpValue: "format or package", - description: "The reporting format to use", - }), - - output: flags.string({ - char: "o", - helpValue: "path", - description: - "The path to write results to. If no path is provided, results are written to stdout", - }), - - outcomes: flags.string({ - options: ["passed", "failed", "inapplicable", "cantTell"], - multiple: true, - description: - "The outcomes to include in the results. If not provided, all outcomes are included", - }), - }; - - public static args = [ - { - name: "url", - description: - "The URL of the page to audit. If no URL is provided, an already serialised page will be read from stdin", - }, - ]; - - public async run() { - const { args, flags } = this.parse(Audit); - - await Audit.runWithFlags(flags, args.url); - } - - public static async runWithFlags(flags: Flags, target?: string) { - const formatter = Formatter.load(flags.format); - - if (formatter.isErr()) { - error(formatter.getErr(), { exit: 1 }); - } - - let json: string; - - if (target === undefined) { - json = fs.readFileSync(0, "utf-8"); - } else { - const output = temp.file({ extension: "json" }); - - await Scrape.runWithFlags({ ...flags, output }, target); - - json = fs.readFileSync(output, "utf-8"); - - fs.unlinkSync(output); - } - - const page = Page.from(JSON.parse(json)); - - const audit = Rules.reduce( - (audit, rule) => audit.add(rule), - act.Audit.of( - page, - flags.interactive ? Oracle(page) : undefined - ) - ); - - let outcomes = await audit.evaluate(); - - if (flags.outcomes !== undefined) { - const filter = new Set(flags.outcomes); - - outcomes = Iterable.filter(outcomes, (outcome) => { - if (Outcome.isPassed(outcome)) { - return filter.has("passed"); - } - - if (Outcome.isFailed(outcome)) { - return filter.has("failed"); - } - - if (Outcome.isInapplicable(outcome)) { - return filter.has("inapplicable"); - } - - return filter.has("cantTell"); - }); - } - - const output = formatter.get()(page, outcomes) + "\n"; - - if (flags.output === undefined) { - process.stdout.write(output); - } else { - fs.writeFileSync(flags.output, output); - } - } -} - -export type Flags = typeof Audit extends parser.Input ? F : never; diff --git a/packages/alfa-cli/bin/alfa/commands/scrape.ts b/packages/alfa-cli/bin/alfa/commands/scrape.ts deleted file mode 100644 index 2a10e261ca..0000000000 --- a/packages/alfa-cli/bin/alfa/commands/scrape.ts +++ /dev/null @@ -1,302 +0,0 @@ -/// - -import * as fs from "fs"; -import * as path from "path"; -import * as url from "url"; - -import { Command, flags } from "@oclif/command"; -import { error } from "@oclif/errors"; - -import * as parser from "@oclif/parser"; - -import { Device, Display, Scripting, Viewport } from "@siteimprove/alfa-device"; -import { Header, Cookie } from "@siteimprove/alfa-http"; -import { - Awaiter, - Credentials, - Scraper, - Screenshot, -} from "@siteimprove/alfa-scraper"; -import { Sequence } from "@siteimprove/alfa-sequence"; -import { Timeout } from "@siteimprove/alfa-time"; - -export default class Scrape extends Command { - public static description = - "Scrape a page and output it in a serialisable format"; - - public static flags = { - help: flags.help({ - description: "Display this command help", - }), - - output: flags.string({ - char: "o", - helpValue: "path", - description: - "The path to write the page to. If no path is provided, the page is written to stdout", - }), - - timeout: flags.integer({ - default: 10000, - helpValue: "milliseconds", - description: "The maximum time to wait for the page to load", - }), - - width: flags.integer({ - char: "w", - default: Viewport.standard().width, - helpValue: "pixels", - description: "The width of the browser viewport", - }), - - height: flags.integer({ - char: "h", - default: Viewport.standard().height, - helpValue: "pixels", - description: "The height of the browser viewport", - }), - - orientation: flags.enum({ - options: ["landscape", "portrait"], - default: Viewport.standard().orientation, - description: "The orientation of the browser viewport", - }), - - resolution: flags.integer({ - default: 1, - description: "The pixel density of the browser", - }), - - scripting: flags.boolean({ - default: true, - allowNo: true, - description: "Whether or not scripts, such as JavaScript, are evaluated", - }), - - username: flags.string({ - char: "u", - dependsOn: ["password"], - description: "The username to use for HTTP Basic authentication", - }), - - password: flags.string({ - char: "p", - dependsOn: ["username"], - description: "The password to use for HTTP Basic authentication", - }), - - headers: flags.string({ - helpValue: "name:value or path", - multiple: true, - description: `Additional headers to set, either as name:value pairs or a path to a JSON file`, - }), - - cookies: flags.string({ - helpValue: "name=value or path", - multiple: true, - description: `Additional cookies to set, either as name=value pairs or a path to a JSON file`, - }), - - "await-state": flags.enum({ - options: ["ready", "loaded", "idle"], - exclusive: ["await-duration", "await-selector", "await-xpath"], - description: "The state to await before considering the page loaded", - }), - - "await-duration": flags.integer({ - exclusive: ["await-state", "await-selector", "await-xpath"], - helpValue: "milliseconds", - description: "The duration to wait before considering the page loaded", - }), - - "await-selector": flags.string({ - exclusive: ["await-state", "await-duration", "await-xpath"], - helpValue: "selector", - description: - "A CSS selector matching an element that must be present before considering the page loaded", - }), - - "await-xpath": flags.string({ - exclusive: ["await-state", "await-duration", "await-selector"], - helpValue: "expression", - description: - "An XPath expression evaluating to an element that must be present before considering the page loaded", - }), - - screenshot: flags.string({ - helpValue: "path", - description: - "The path to write a screenshot to. If not provided, no screenshot is taken", - }), - - "screenshot-type": flags.enum({ - options: ["png", "jpeg"], - default: "png", - description: "The file type of the screenshot", - }), - - "screenshot-background": flags.boolean({ - default: true, - allowNo: true, - description: - "Whether or not the screenshot should include a default white background. Only applies to PNG screenshots", - }), - - "screenshot-quality": flags.integer({ - default: 100, - helpValue: "0-100", - description: - "The quality of the screenshot. Only applies to JPEG screenshots", - }), - }; - - public static args = [ - { - name: "url", - required: true, - description: "The URL of the page to scrape", - }, - ]; - - public async run() { - const { args, flags } = this.parse(Scrape); - - await Scrape.runWithFlags(flags, args.url); - } - - public static async runWithFlags(flags: Flags, target: string) { - const scraper = await Scraper.of(); - - let awaiter: Awaiter | undefined; - - switch (flags["await-state"]) { - case "ready": - awaiter = Awaiter.ready(); - break; - case "loaded": - awaiter = Awaiter.loaded(); - break; - case "idle": - awaiter = Awaiter.idle(); - } - - if (flags["await-duration"] !== undefined) { - awaiter = Awaiter.duration(flags["await-duration"]); - } - - if (flags["await-selector"] !== undefined) { - awaiter = Awaiter.selector(flags["await-selector"]); - } - - if (flags["await-xpath"] !== undefined) { - awaiter = Awaiter.xpath(flags["await-xpath"]); - } - - const orientation = - flags.orientation === "portrait" - ? Viewport.Orientation.Portrait - : Viewport.Orientation.Landscape; - - const device = Device.of( - Device.Type.Screen, - Viewport.of(flags.width, flags.height, orientation), - Display.of(flags.resolution), - Scripting.of(flags.scripting) - ); - - const credentials = - flags.username === undefined || flags.password === undefined - ? undefined - : Credentials.of(flags.username, flags.password); - - let screenshot: Screenshot | undefined; - - if (flags.screenshot !== undefined) { - switch (flags["screenshot-type"]) { - case "png": - screenshot = Screenshot.of( - flags.screenshot, - Screenshot.Type.PNG.of(flags["screenshot-background"]) - ); - break; - - case "jpeg": - screenshot = Screenshot.of( - flags.screenshot, - Screenshot.Type.JPEG.of(flags["screenshot-quality"]) - ); - } - } - - const headers = Sequence.from(flags.headers ?? []).flatMap((header) => { - const index = header.indexOf(":"); - - if (index === -1) { - try { - const headers = Sequence.from( - JSON.parse(fs.readFileSync(header, "utf-8")) - ); - - return headers.map((header) => Header.of(header.name, header.value)); - } catch (err) { - error(err.message, { exit: 1 }); - } - } - - return Sequence.of( - Header.of(header.substring(0, index), header.substring(index + 1)) - ); - }); - - const cookies = Sequence.from(flags.cookies ?? []).flatMap((cookie) => { - const index = cookie.indexOf("="); - - if (index === -1) { - try { - const cookies = Sequence.from( - JSON.parse(fs.readFileSync(cookie, "utf-8")) - ); - - return cookies.map((cookie) => Cookie.of(cookie.name, cookie.value)); - } catch (err) { - error(err.message, { exit: 1 }); - } - } - - return Sequence.of( - Cookie.of(cookie.substring(0, index), cookie.substring(index + 1)) - ); - }); - - const timeout = Timeout.of(flags.timeout); - - const result = await scraper.scrape( - new URL(target, url.pathToFileURL(process.cwd() + path.sep)), - { - timeout, - awaiter, - device, - credentials, - screenshot, - headers, - cookies, - } - ); - - await scraper.close(); - - if (result.isErr()) { - error(result.getErr(), { exit: 1 }); - } - - const output = JSON.stringify(result.get()) + "\n"; - - if (flags.output === undefined) { - process.stdout.write(output); - } else { - fs.writeFileSync(flags.output, output); - } - } -} - -export type Flags = typeof Scrape extends parser.Input ? F : never; diff --git a/packages/alfa-cli/src/oracle.ts b/packages/alfa-cli/bin/alfa/oracle.ts similarity index 93% rename from packages/alfa-cli/src/oracle.ts rename to packages/alfa-cli/bin/alfa/oracle.ts index e38005db1f..aa6e053ad6 100644 --- a/packages/alfa-cli/src/oracle.ts +++ b/packages/alfa-cli/bin/alfa/oracle.ts @@ -10,6 +10,12 @@ import { Page } from "@siteimprove/alfa-web"; import * as act from "@siteimprove/alfa-act"; import * as xpath from "@siteimprove/alfa-xpath"; +/** + * @internal + * + * @todo Make this stuff external to the CLI so that it simply requires a package + * responsible for doing the interview. + */ export const Oracle = (page: Page): act.Oracle => { const answers = Cache.empty>>(); diff --git a/packages/alfa-cli/package.json b/packages/alfa-cli/package.json index 9a69637ecf..99fc5c6762 100644 --- a/packages/alfa-cli/package.json +++ b/packages/alfa-cli/package.json @@ -11,29 +11,17 @@ "directory": "packages/alfa-cli" }, "bugs": "https://github.com/siteimprove/alfa/issues", - "main": "src/index.js", - "types": "src/index.d.ts", "bin": { "alfa": "bin/alfa.js" }, "files": [ - "src/**/*.js", - "src/**/*.d.ts", "bin/**/*.js", "bin/**/*.d.ts" ], - "oclif": { - "commands": "./bin/alfa/commands", - "bin": "alfa" - }, "dependencies": { - "@oclif/command": "^1.5.19", - "@oclif/config": "^1.13.3", - "@oclif/errors": "^1.2.2", - "@oclif/parser": "^3.8.5", - "@oclif/plugin-help": "^2.2.1", "@siteimprove/alfa-act": "^0.2.0", "@siteimprove/alfa-cache": "^0.2.0", + "@siteimprove/alfa-command": "^0.2.0", "@siteimprove/alfa-device": "^0.2.0", "@siteimprove/alfa-dom": "^0.2.0", "@siteimprove/alfa-formatter": "^0.2.0", @@ -43,15 +31,14 @@ "@siteimprove/alfa-http": "^0.2.0", "@siteimprove/alfa-iterable": "^0.2.0", "@siteimprove/alfa-option": "^0.2.0", + "@siteimprove/alfa-result": "^0.2.0", "@siteimprove/alfa-rules": "^0.2.0", "@siteimprove/alfa-scraper": "^0.2.0", - "@siteimprove/alfa-sequence": "^0.2.0", "@siteimprove/alfa-time": "^0.2.0", "@siteimprove/alfa-web": "^0.2.0", "@siteimprove/alfa-xpath": "^0.2.0", "@types/node": "^14.0.12", - "enquirer": "^2.3.0", - "tempy": "^0.5.0" + "enquirer": "^2.3.0" }, "devDependencies": { "@siteimprove/alfa-test": "^0.2.0" diff --git a/packages/alfa-cli/src/index.ts b/packages/alfa-cli/src/index.ts deleted file mode 100644 index 281f234a37..0000000000 --- a/packages/alfa-cli/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./oracle"; diff --git a/packages/alfa-cli/tsconfig.json b/packages/alfa-cli/tsconfig.json index d3b46d5e4b..3c58e64109 100644 --- a/packages/alfa-cli/tsconfig.json +++ b/packages/alfa-cli/tsconfig.json @@ -1,12 +1,21 @@ { "$schema": "http://json.schemastore.org/tsconfig", "extends": "../tsconfig.json", + "compilerOptions": { + "resolveJsonModule": true + }, "files": [ + "package.json", "bin/alfa.ts", - "bin/alfa/commands/audit.ts", - "bin/alfa/commands/scrape.ts", - "src/index.ts", - "src/oracle.ts" + "bin/alfa/command/audit.ts", + "bin/alfa/command/audit/arguments.ts", + "bin/alfa/command/audit/flags.ts", + "bin/alfa/command/audit/run.ts", + "bin/alfa/command/scrape.ts", + "bin/alfa/command/scrape/arguments.ts", + "bin/alfa/command/scrape/flags.ts", + "bin/alfa/command/scrape/run.ts", + "bin/alfa/oracle.ts" ], "references": [ { @@ -15,6 +24,9 @@ { "path": "../alfa-cache" }, + { + "path": "../alfa-command" + }, { "path": "../alfa-device" }, @@ -30,18 +42,39 @@ { "path": "../alfa-formatter-json" }, + { + "path": "../alfa-functor" + }, { "path": "../alfa-future" }, + { + "path": "../alfa-highlight" + }, { "path": "../alfa-http" }, { "path": "../alfa-iterable" }, + { + "path": "../alfa-json" + }, + { + "path": "../alfa-mapper" + }, { "path": "../alfa-option" }, + { + "path": "../alfa-parser" + }, + { + "path": "../alfa-predicate" + }, + { + "path": "../alfa-result" + }, { "path": "../alfa-rules" }, diff --git a/packages/alfa-command/package.json b/packages/alfa-command/package.json new file mode 100644 index 0000000000..c2294a4e40 --- /dev/null +++ b/packages/alfa-command/package.json @@ -0,0 +1,38 @@ +{ + "$schema": "http://json.schemastore.org/package", + "name": "@siteimprove/alfa-command", + "homepage": "https://siteimprove.com", + "version": "0.2.0", + "license": "MIT", + "description": "Functionality for building robust command line interfaces", + "repository": { + "type": "git", + "url": "https://github.com/siteimprove/alfa.git", + "directory": "packages/alfa-command" + }, + "bugs": "https://github.com/siteimprove/alfa/issues", + "main": "src/index.js", + "types": "src/index.d.ts", + "files": [ + "src/**/*.js", + "src/**/*.d.ts" + ], + "dependencies": { + "@siteimprove/alfa-functor": "^0.2.0", + "@siteimprove/alfa-highlight": "^0.2.0", + "@siteimprove/alfa-json": "^0.2.0", + "@siteimprove/alfa-mapper": "^0.2.0", + "@siteimprove/alfa-option": "^0.2.0", + "@siteimprove/alfa-parser": "^0.2.0", + "@siteimprove/alfa-predicate": "^0.2.0", + "@siteimprove/alfa-result": "^0.2.0", + "@siteimprove/alfa-thunk": "^0.2.0" + }, + "devDependencies": { + "@siteimprove/alfa-test": "^0.2.0" + }, + "publishConfig": { + "access": "public", + "registry": "https://npm.pkg.github.com/" + } +} diff --git a/packages/alfa-command/src/argument.ts b/packages/alfa-command/src/argument.ts new file mode 100644 index 0000000000..8c6754b4ff --- /dev/null +++ b/packages/alfa-command/src/argument.ts @@ -0,0 +1,229 @@ +import { Functor } from "@siteimprove/alfa-functor"; +import { Serializable } from "@siteimprove/alfa-json"; +import { Mapper } from "@siteimprove/alfa-mapper"; +import { Option, None } from "@siteimprove/alfa-option"; +import { Parser } from "@siteimprove/alfa-parser"; +import { Predicate } from "@siteimprove/alfa-predicate"; +import { Ok, Err } from "@siteimprove/alfa-result"; +import { Thunk } from "@siteimprove/alfa-thunk"; + +import * as json from "@siteimprove/alfa-json"; +import * as parser from "@siteimprove/alfa-parser"; + +export class Argument implements Functor, Serializable { + public static of( + name: string, + description: string, + parse: Argument.Parser + ): Argument { + const options: Argument.Options = { + default: None, + optional: false, + repeatable: false, + }; + + return new Argument( + name, + description.replace(/\s+/g, " ").trim(), + options, + parse + ); + } + + private readonly _name: string; + private readonly _description: string; + private readonly _options: Argument.Options; + private readonly _parse: Argument.Parser; + + private constructor( + name: string, + description: string, + options: Argument.Options, + parse: Argument.Parser + ) { + this._name = name; + this._description = description; + this._options = options; + this._parse = parse; + } + + public get name(): string { + return this._name; + } + + public get description(): string { + return this._description; + } + + public get options(): Argument.Options { + return this._options; + } + + public get parse(): Argument.Parser { + return this._parse; + } + + public map(mapper: Mapper): Argument { + return new Argument( + this._name, + this._description, + this._options, + Parser.map(this._parse, mapper) + ); + } + + public filter( + predicate: Predicate, + ifError: Thunk = () => "Incorrect value" + ): Argument { + return new Argument( + this._name, + this._description, + this._options, + Parser.filter(this._parse, predicate, ifError) + ); + } + + public optional(): Argument> { + return new Argument( + this._name, + this._description, + { + ...this._options, + optional: true, + }, + Parser.option(this._parse) + ); + } + + public repeatable(): Argument> { + return new Argument( + this._name, + this._description, + { + ...this._options, + repeatable: true, + }, + Parser.oneOrMore(this._parse) + ); + } + + public default(value: T, label: string = `${value}`): Argument { + label = label.replace(/\s+/g, " ").trim(); + + return new Argument( + this._name, + this._description, + { + ...this._options, + optional: true, + default: label === "" ? None : Option.of(label), + }, + this._parse + ); + } + + public choices(...choices: Array): Argument { + return this.filter(Predicate.equals(...choices)); + } + + public toJSON(): Argument.JSON { + return { + name: this._name, + description: this._description, + options: { + ...this._options, + default: this._options.default.map(Serializable.toJSON).getOr(null), + }, + }; + } +} + +export namespace Argument { + export interface JSON { + [key: string]: json.JSON; + name: string; + description: string; + options: { + [key: string]: json.JSON; + optional: boolean; + repeatable: boolean; + default: json.JSON | null; + }; + } + + export type Parser = parser.Parser, T, string>; + + export interface Options { + default: Option; + optional: boolean; + repeatable: boolean; + } + + export function string(name: string, description: string): Argument { + return Argument.of(name, description, (argv) => { + const [value] = argv; + + if (value === undefined) { + return Err.of("Missing value"); + } + + return Ok.of([argv.slice(1), value] as const); + }); + } + + export function number(name: string, description: string): Argument { + return Argument.of(name, description, (argv) => { + const [value] = argv; + + if (value === undefined) { + return Err.of("Missing value"); + } + + const number = Number(value); + + if (!Number.isFinite(number)) { + return Err.of(`${value} is not a number`); + } + + return Ok.of([argv.slice(1), number] as const); + }); + } + + export function integer(name: string, description: string): Argument { + return Argument.of(name, description, (argv) => { + const [value] = argv; + + if (value === undefined) { + return Err.of("Missing value"); + } + + const number = Number(value); + + if (!Number.isInteger(number)) { + return Err.of(`${value} is not an integer`); + } + + return Ok.of([argv.slice(1), number] as const); + }); + } + + export function boolean( + name: string, + description: string + ): Argument { + return Argument.of(name, description, (argv) => { + const [value] = argv; + + if (value === undefined) { + return Err.of("Missing value"); + } + + if (value !== "true" && value !== "false") { + return Err.of(`Incorrect value, expected one of "true", "false"`); + } + + return Ok.of([argv.slice(1), value === "true"] as const); + }); + } +} diff --git a/packages/alfa-command/src/command.ts b/packages/alfa-command/src/command.ts new file mode 100644 index 0000000000..13ba6cad2c --- /dev/null +++ b/packages/alfa-command/src/command.ts @@ -0,0 +1,456 @@ +import { Marker } from "@siteimprove/alfa-highlight"; +import { Serializable } from "@siteimprove/alfa-json"; +import { Mapper } from "@siteimprove/alfa-mapper"; +import { Option, None } from "@siteimprove/alfa-option"; +import { Result, Ok, Err } from "@siteimprove/alfa-result"; + +import * as json from "@siteimprove/alfa-json"; + +import { Argument } from "./argument"; +import { Flag } from "./flag"; +import { Text } from "./text"; + +const { values, entries } = Object; + +export class Command< + F extends Command.Flags = {}, + A extends Command.Arguments = {}, + S extends Command.Subcommands = {} +> implements Serializable { + public static withArguments< + F extends Command.Flags, + A extends Command.Arguments + >( + name: string, + version: string, + description: string, + flags: F, + args: A, + parent: Option = None, + run?: (command: Command) => Command.Runner + ): Command { + return new Command( + name, + version, + description, + flags, + args, + () => ({}), + parent, + run + ); + } + + public static withSubcommands< + F extends Command.Flags, + S extends Command.Subcommands + >( + name: string, + version: string, + description: string, + flags: F, + subcommands: Mapper, + parent: Option = None, + run?: (command: Command) => Command.Runner + ): Command { + return new Command( + name, + version, + description, + flags, + {}, + subcommands, + parent, + run + ); + } + + private readonly _name: string; + private readonly _version: string; + private readonly _description: string; + private readonly _flags: F; + private readonly _arguments: A; + private readonly _subcommands: S; + private readonly _parent: Option; + private readonly _run: Command.Runner; + + private constructor( + name: string, + version: string, + description: string, + flags: F, + args: A, + subcommands: Mapper, + parent: Option, + run?: (command: Command) => Command.Runner + ) { + this._name = name; + this._version = version; + this._description = description; + this._flags = flags; + this._arguments = args; + this._subcommands = subcommands((this as unknown) as Command); + this._parent = parent; + this._run = run?.(this) ?? (async () => Ok.of(this._help())); + } + + public get name(): string { + return this._name; + } + + public get version(): string { + return this._version; + } + + public get description(): string { + return this._description; + } + + public get flags(): F { + return this._flags; + } + + public get arguments(): A { + return this._arguments; + } + + public get subcommands(): S { + return this._subcommands; + } + + public async run(input: Array | Command.Input): Command.Output { + if (Array.isArray(input)) { + let argv = input; + + input = {} as Command.Input; + + const flags = this._parseFlags(argv); + + if (flags.isErr()) { + return flags; + } + + [argv, input.flags] = flags.get(); + + for (const name in this._flags) { + const value = input.flags[name]; + + if (Option.isOption(value) && value.isSome()) { + switch (value.get()) { + case Flag.Help: + return Ok.of(this._help()); + case Flag.Version: + return Ok.of(this._version); + } + } + } + + if (argv[0] === "--") { + argv = argv.slice(1); + } + + for (const command of values(this._subcommands)) { + if (command.name === argv[0]) { + return command.run(argv.slice(1)); + } + } + + const args = this._parseArguments(argv); + + if (args.isErr()) { + return args; + } + + [argv, input.args] = args.get(); + + if (argv.length !== 0) { + const [argument] = argv; + + return Err.of( + `Unknown ${argument[0] === "-" ? "flag" : "argument"}: ${argument}` + ); + } + } + + return this._run(input); + } + + public toJSON(): Command.JSON { + return { + name: this._name, + description: this._description, + flags: values(this._flags).map((flag) => flag.toJSON()), + arguments: values(this._arguments).map((argument) => argument.toJSON()), + subcommands: values(this._subcommands).map((command) => command.toJSON()), + }; + } + + private _parseFlags( + argv: Array + ): Result, Command.Flags.Values], string> { + const flags = entries(this._flags); + + const sets: Record> = {}; + + while (argv.length > 0) { + const [argument] = argv; + + if (argument[0] !== "-") { + break; + } + + const match = flags.find(([, flag]) => flag.matches(argument)); + + if (match === undefined) { + return Err.of(`Unknown flag: ${argument}`); + } + + const [name, flag] = match; + + const parse = name in sets ? sets[name].parse : flag.parse; + + const value = parse(argv); + + if (value.isOk()) { + [argv, sets[name]] = value.get(); + } else { + return Err.of(`${argument}: ${value.getErr()}`); + } + } + + const values: Record = {}; + + for (const [name, flag] of flags) { + if (name in sets) { + values[name] = sets[name].value; + } else { + const result = flag.parse([]); + + if (result.isErr()) { + return Err.of(`--${flag.name}: ${result.getErr()}`); + } + + const [, { value }] = result.get(); + + values[name] = value; + } + } + + return Ok.of([argv, values as Command.Flags.Values] as const); + } + + private _parseArguments( + argv: Array + ): Result, Command.Arguments.Values], string> { + const values: Record = {}; + + for (const [name, argument] of entries(this._arguments)) { + const result = argument.parse(argv); + + if (result.isOk()) { + [argv, values[name]] = result.get(); + } else { + return Err.of(`${argument.name}: ${result.getErr()}`); + } + } + + return Ok.of([argv, values as Command.Arguments.Values] as const); + } + + private _invocation(): string { + const invocation = this._name; + + for (const parent of this._parent) { + return parent._invocation() + " " + invocation; + } + + return invocation; + } + + private _help(): string { + return [ + this._description, + this._helpVersion(), + this._helpUsage(), + ...this._helpArguments(), + ...this._helpCommands(), + ...this._helpFlags(), + ].join("\n\n"); + } + + private _helpUsage(): string { + return ` +${Marker.bold("Usage:")} + ${Marker.bold("$")} ${this._invocation()} [flags] ${[ + ...values(this._arguments), + ] + .map((argument) => + argument.options.optional + ? `[<${argument.name}>]` + : `<${argument.name}>` + ) + .join(" ")} + `.trim(); + } + + private _helpVersion(): string { + return ` +${Marker.bold("Version:")} + ${this._version} + `.trim(); + } + + private _helpArguments(): Option { + const args = values(this._arguments); + + if (args.length === 0) { + return None; + } + + return Option.of( + ` +${Marker.bold("Arguments:")} +${args + .map((argument) => { + const { options } = argument; + + let help = " "; + + help += Marker.bold(`${argument.name}`); + + if (!options.optional) { + help += " " + Marker.dim("(required)"); + } + + for (const value of options.default) { + help += " " + Marker.dim(`[default: ${value}]`); + } + + help += "\n"; + help += Text.indent(Text.wrap(argument.description, 76), 4); + + return help; + }) + .join("\n\n")} + `.trim() + ); + } + + private _helpCommands(): Option { + const commands = values(this._subcommands); + + if (commands.length === 0) { + return None; + } + + return Option.of( + ` +${Marker.bold("Commands:")} +${[...values(this._subcommands)] + .map( + (command) => + ` ${Marker.bold(command.name)}\n${Text.indent( + Text.wrap(command.description, 76), + 4 + )}` + ) + .join("\n\n")} + `.trim() + ); + } + + private _helpFlags(): Option { + const flags = values(this._flags); + + if (flags.length === 0) { + return None; + } + + return Option.of( + ` +${Marker.bold("Flags:")} +${[...values(this._flags)] + .map((flag) => { + const { options } = flag; + + let help = " "; + + if (options.aliases.length > 0) { + help += + options.aliases + .map((alias) => + Marker.bold(alias.length === 1 ? `-${alias}` : `--${alias}`) + ) + .join(", ") + ", "; + } + + help += Marker.bold(`--${options.negatable ? "[no-]" : ""}${flag.name}`); + + for (const type of options.type) { + help += ` <${Marker.underline(type)}>`; + } + + if (!options.optional) { + help += " " + Marker.dim("(required)"); + } + + for (const value of options.default) { + help += " " + Marker.dim(`[default: ${value}]`); + } + + help += "\n"; + help += Text.indent(Text.wrap(flag.description, 76), 4); + + return help; + }) + .join("\n\n")} + `.trim() + ); + } +} + +export namespace Command { + export interface JSON { + [key: string]: json.JSON; + name: string; + description: string; + flags: Array; + arguments: Array; + subcommands: Array; + } + + export interface Flags { + [name: string]: Flag; + } + + export namespace Flags { + export type Values = { + [N in keyof F]: F[N] extends Flag ? T : never; + }; + } + + export interface Arguments { + [name: string]: Argument; + } + + export namespace Arguments { + export type Values = { + [N in keyof A]: A[N] extends Argument ? T : never; + }; + } + + export interface Subcommands { + [name: string]: Command; + } + + export interface Input { + flags: Flags.Values; + args: Arguments.Values; + } + + export type Output = Promise>; + + export type Runner = ( + input: Input + ) => Output; +} diff --git a/packages/alfa-command/src/flag.ts b/packages/alfa-command/src/flag.ts new file mode 100644 index 0000000000..45982376ad --- /dev/null +++ b/packages/alfa-command/src/flag.ts @@ -0,0 +1,544 @@ +import { Functor } from "@siteimprove/alfa-functor"; +import { Serializable } from "@siteimprove/alfa-json"; +import { Mapper } from "@siteimprove/alfa-mapper"; +import { Option, None } from "@siteimprove/alfa-option"; +import { Parser } from "@siteimprove/alfa-parser"; +import { Predicate } from "@siteimprove/alfa-predicate"; +import { Result, Err } from "@siteimprove/alfa-result"; +import { Thunk } from "@siteimprove/alfa-thunk"; + +import * as json from "@siteimprove/alfa-json"; +import * as parser from "@siteimprove/alfa-parser"; + +export class Flag implements Functor, Serializable { + public static of( + name: string, + description: string, + parse: Flag.Parser]> + ): Flag { + const options: Flag.Options = { + type: None, + aliases: [], + optional: false, + repeatable: false, + negatable: false, + default: None, + }; + + return new Flag( + name, + description.replace(/\s+/g, " ").trim(), + options, + parse + ); + } + + private readonly _name: string; + private readonly _description: string; + private readonly _options: Flag.Options; + private readonly _parse: Flag.Parser]>; + + private constructor( + name: string, + description: string, + options: Flag.Options, + parse: Flag.Parser]> + ) { + this._name = name; + this._description = description; + this._options = options; + this._parse = parse; + } + + public get name(): string { + return this._name; + } + + public get description(): string { + return this._description; + } + + public get options(): Flag.Options { + return this._options; + } + + public get parse(): Flag.Parser { + return (argv) => this._parse(argv, (name) => this.matches(name)); + } + + public matches(name: string): boolean { + name = name.length === 2 ? name.replace(/^-/, "") : name.replace(/^--/, ""); + + if (this._options.negatable) { + name = name.replace(/^no-/, ""); + } + + return ( + this._name === name || + this._options.aliases.some((alias) => alias === name) + ); + } + + public map(mapper: Mapper): Flag { + return new Flag( + this._name, + this._description, + this._options, + Parser.map(this._parse, (set) => set.map(mapper)) + ); + } + + public filter( + predicate: Predicate, + ifError: Thunk = () => "Incorrect value" + ): Flag { + const filter = (previous: Flag.Set): Flag.Parser => (argv) => + previous + .parse(argv) + .flatMap(([argv, set]) => + predicate(set.value) + ? Result.of([ + argv, + Flag.Set.of(set.value, (argv) => filter(set)(argv)), + ]) + : Err.of(ifError()) + ); + + const parse: Flag.Parser]> = (argv, matches) => + this._parse(argv, matches).flatMap(([argv, set]) => + predicate(set.value) + ? Result.of([ + argv, + Flag.Set.of(set.value, (argv) => + filter(set as Flag.Set)(argv) + ), + ]) + : Err.of(ifError()) + ); + + return new Flag(this._name, this._description, this._options, parse); + } + + public type(type: string): Flag { + return new Flag( + this._name, + this._description, + { + ...this._options, + type: Option.of(type), + }, + this._parse + ); + } + + public alias(alias: string): Flag { + return new Flag( + this._name, + this._description, + { + ...this._options, + aliases: [...this._options.aliases, alias], + }, + this._parse + ); + } + + public default(value: T, label: string = `${value}`): Flag { + label = label.replace(/\s+/g, " ").trim(); + + const options = { + ...this._options, + optional: true, + default: label === "" ? None : Option.of(label), + }; + + const missing = ( + previous: Flag.Set + ): Flag.Parser]> => (argv, matches) => { + const [name] = argv; + + if (name === undefined || !matches(name)) { + return Result.of([ + argv, + Flag.Set.of(previous.value, (argv) => + missing(previous)(argv, matches) + ), + ]); + } + + return previous + .parse(argv) + .map(([argv, set]) => [ + argv, + Flag.Set.of(set.value, (argv) => missing(set)(argv, matches)), + ]); + }; + + const parse: Flag.Parser]> = (argv, matches) => { + const [name] = argv; + + if (name === undefined || !matches(name)) { + return Result.of([ + argv, + Flag.Set.of(value, (argv) => parse(argv, matches)), + ]); + } + + return this._parse(argv, matches).map(([argv, set]) => [ + argv, + Flag.Set.of(set.value, (argv) => missing(set)(argv, matches)), + ]); + }; + + return new Flag(this._name, this._description, options, parse); + } + + public optional(): Flag> { + const options = { ...this._options, optional: true }; + + const missing = ( + previous: Flag.Set> + ): Flag.Parser, [Predicate]> => (argv, matches) => { + const [name] = argv; + + if (name === undefined || !matches(name)) { + return Result.of([ + argv, + Flag.Set.of(previous.value, (argv) => + missing(previous)(argv, matches) + ), + ]); + } + + return previous + .parse(argv) + .map(([argv, set]) => [ + argv, + Flag.Set.of(set.value, (argv) => missing(set)(argv, matches)), + ]); + }; + + const parse: Flag.Parser, [Predicate]> = ( + argv, + matches + ) => { + const [name] = argv; + + if (name === undefined || !matches(name)) { + return Result.of([ + argv, + Flag.Set.of(Option.empty(), (argv) => parse(argv, matches)), + ]); + } + + return this._parse(argv, matches).map(([argv, set]) => [ + argv, + Flag.Set.of(Option.of(set.value), (argv) => + missing(set.map(Option.of))(argv, matches) + ), + ]); + }; + + return new Flag(this._name, this._description, options, parse); + } + + public repeatable(): Flag> { + const options = { ...this._options, repeatable: true }; + + const repeat = (previous: Flag.Set>): Flag.Parser> => ( + argv + ) => + previous + .parse(argv) + .map(([argv, set]) => [ + argv, + Flag.Set.of([...previous.value, ...set.value], (argv) => + repeat(set)(argv) + ), + ]); + + const parse: Flag.Parser, [Predicate]> = (argv, matches) => + this._parse(argv, matches).map(([argv, set]) => [ + argv, + Flag.Set.of([set.value], (argv) => + repeat(set.map((value) => [value]))(argv) + ), + ]); + + return new Flag(this._name, this._description, options, parse); + } + + public negatable(mapper: Mapper): Flag { + const options = { ...this._options, negatable: true }; + + const negate = ( + previous: Flag.Set + ): Flag.Parser]> => (argv, matches) => { + const [name] = argv; + + const isNegated = name !== undefined && name.startsWith("--no-"); + + if (isNegated) { + argv = [name.replace("--no-", "--"), ...argv.slice(1)]; + } + + return previous + .parse(argv) + .map(([argv, set]) => [ + argv, + Flag.Set.of(isNegated ? mapper(set.value) : set.value, (argv) => + negate(set)(argv, matches) + ), + ]); + }; + + const parse: Flag.Parser]> = (argv, matches) => { + const [name] = argv; + + const isNegated = name !== undefined && name.startsWith("--no-"); + + if (isNegated) { + argv = [name.replace("--no-", "--"), ...argv.slice(1)]; + } + + return this._parse(argv, matches).map(([argv, set]) => [ + argv, + Flag.Set.of(isNegated ? mapper(set.value) : set.value, (argv) => + negate(set)(argv, matches) + ), + ]); + }; + + return new Flag(this._name, this._description, options, parse); + } + + public choices(...choices: Array): Flag { + return this.filter( + Predicate.equals(...choices), + () => + `Incorrect value, expected one of ${choices + .map((choice) => `"${choice}"`) + .join(", ")}` + ).type(choices.join("|")); + } + + public toJSON(): Flag.JSON { + return { + name: this._name, + description: this._description, + options: { + ...this._options, + type: this._options.type.getOr(null), + default: this._options.default.map(Serializable.toJSON).getOr(null), + }, + }; + } +} + +export namespace Flag { + export interface JSON { + [key: string]: json.JSON; + name: string; + description: string; + options: { + [key: string]: json.JSON; + type: string | null; + aliases: Array; + default: json.JSON | null; + optional: boolean; + repeatable: boolean; + }; + } + + export type Parser = []> = parser.Parser< + Array, + Set, + string, + A + >; + + export interface Options { + readonly type: Option; + readonly aliases: Array; + readonly default: Option; + readonly optional: boolean; + readonly repeatable: boolean; + readonly negatable: boolean; + } + + /** + * The `Set` class, from the concept of "flag sets", acts as a container + * for parsed flag values. As flags can be specified multiple times, this + * class allows us to encapsulate the current value of a given flag and a + * parser to parse another instance of the flag value and determine how to + * combine the two. + */ + export class Set implements Functor { + public static of(value: T, parse: Flag.Parser) { + return new Set(value, parse); + } + + private readonly _value: T; + private readonly _parse: Flag.Parser; + + private constructor(value: T, parse: Flag.Parser) { + this._value = value; + this._parse = parse; + } + + public get value(): T { + return this._value; + } + + public get parse(): Flag.Parser { + return this._parse; + } + + public map(mapper: Mapper): Set { + return new Set(mapper(this._value), (argv) => + this._parse(argv).map(([argv, set]) => [argv, set.map(mapper)]) + ); + } + } + + export function string(name: string, description: string): Flag { + const parse: Flag.Parser]> = (argv, matches) => { + const [name, value] = argv; + + if (name === undefined || !matches(name)) { + return Err.of("Missing flag"); + } + + if (value === undefined) { + return Err.of("Missing value"); + } + + return Result.of([ + argv.slice(2), + Flag.Set.of(value, (argv) => parse(argv, matches)), + ]); + }; + + return Flag.of(name, description, parse).type("string"); + } + + export function number(name: string, description: string): Flag { + const parse: Flag.Parser]> = (argv, matches) => { + const [name, value] = argv; + + if (name === undefined || !matches(name)) { + return Err.of("Missing flag"); + } + + if (value === undefined) { + return Err.of("Missing value"); + } + + const number = Number(value); + + if (!Number.isFinite(number)) { + return Err.of(`${value} is not a number`); + } + + return Result.of([ + argv.slice(2), + Flag.Set.of(number, (argv) => parse(argv, matches)), + ]); + }; + + return Flag.of(name, description, parse).type("number"); + } + + export function integer(name: string, description: string): Flag { + const parse: Flag.Parser]> = (argv, matches) => { + const [name, value] = argv; + + if (name === undefined || !matches(name)) { + return Err.of("Missing flag"); + } + + if (value === undefined) { + return Err.of("Missing value"); + } + + const number = Number(value); + + if (!Number.isInteger(number)) { + return Err.of(`${value} is not an integer`); + } + + return Result.of([ + argv.slice(2), + Flag.Set.of(number, (argv) => parse(argv, matches)), + ]); + }; + + return Flag.of(name, description, parse).type("integer"); + } + + export function boolean(name: string, description: string): Flag { + const parse: Flag.Parser]> = ( + argv, + matches + ) => { + const [name, value] = argv; + + if (name === undefined || !matches(name)) { + return Err.of("Missing flag"); + } + + if (value === undefined) { + return Result.of([ + argv.slice(1), + Flag.Set.of(true, (argv) => parse(argv, matches)), + ]); + } + + if (value !== "true" && value !== "false") { + return Err.of(`Incorrect value, expected one of "true", "false"`); + } + + return Result.of([ + argv.slice(2), + Flag.Set.of(value === "true", (argv) => parse(argv, matches)), + ]); + }; + + return Flag.of(name, description, parse) + .type("boolean") + .negatable((value) => !value); + } + + export function empty(name: string, description: string): Flag { + const parse: Flag.Parser]> = (argv, matches) => { + const [name] = argv; + + if (name === undefined || !matches(name)) { + return Err.of("Missing flag"); + } + + return Result.of([ + argv.slice(1), + Flag.Set.of(undefined, (argv) => parse(argv, matches)), + ]); + }; + + return Flag.of(name, description, parse); + } + + export const Help = Symbol("--help"); + + export function help(description: string): Flag> { + return empty("help", description) + .map(() => Help) + .optional(); + } + + export const Version = Symbol("--version"); + + export function version(description: string): Flag> { + return empty("version", description) + .map(() => Version) + .optional(); + } +} diff --git a/packages/alfa-command/src/index.ts b/packages/alfa-command/src/index.ts new file mode 100644 index 0000000000..d1fff0bbbd --- /dev/null +++ b/packages/alfa-command/src/index.ts @@ -0,0 +1,4 @@ +export * from "./argument"; +export * from "./command"; +export * from "./flag"; +export * from "./text"; diff --git a/packages/alfa-command/src/text.ts b/packages/alfa-command/src/text.ts new file mode 100644 index 0000000000..b31c01219b --- /dev/null +++ b/packages/alfa-command/src/text.ts @@ -0,0 +1,30 @@ +/** + * @internal + */ +export namespace Text { + export function indent(text: string, indent: string | number = " "): string { + return text.replace( + /^/gm, + typeof indent === "string" ? indent : " ".repeat(indent) + ); + } + + export function wrap(text: string, length: number = 80): string { + if (text.length > length) { + let target = length; + + while (target > 0 && !/\s/.test(text[target])) { + target--; + } + + if (target > 0) { + const left = text.substring(0, target); + const right = text.substring(target + 1); + + return left + "\n" + wrap(right, length); + } + } + + return text; + } +} diff --git a/packages/alfa-command/test/flag.spec.ts b/packages/alfa-command/test/flag.spec.ts new file mode 100644 index 0000000000..48a355b76f --- /dev/null +++ b/packages/alfa-command/test/flag.spec.ts @@ -0,0 +1,103 @@ +import { test } from "@siteimprove/alfa-test"; + +import { Flag } from "../src/flag"; + +test(".default() constructs a flag with a default value", (t) => { + const flag = Flag.string("foo", "").default("hello"); + + // When parsed without arguments, the value should be equal to the default + // value. + let [, { value }] = flag.parse([]).get(); + t.equal(value, "hello"); + + // When parsed with valid arguments, the default value should not be applied. + [, { value }] = flag.parse(["--foo", "world"]).get(); + t.equal(value, "world"); +}); + +test(".optional() constructs an optional flag", (t) => { + const flag = Flag.string("foo", "").optional(); + + // When parsed without arguments, the value should be `None` as the flag is + // optional. + let [argv, set] = flag.parse([]).get(); + t.equal(set.value.isNone(), true); + + // When parsed with valid arguments, the value should be wrapped in `Option`. + [argv, set] = flag.parse(["--foo", "hello"]).get(); + t.deepEqual(set.value.get(), "hello"); +}); + +test(".repeatable() constructs a repeatable flag", (t) => { + const flag = Flag.string("foo", "").repeatable(); + + let [argv, set] = flag.parse(["--foo", "hello", "--foo", "world"]).get(); + t.deepEqual(set.value, ["hello"]); + + // When parsing the flag the second time, the second value should be combined + // with the first value. + [argv, set] = set.parse(argv).get(); + t.deepEqual(set.value, ["hello", "world"]); + + // When parsing the flag the third time, an error should be returned as there + // are no more arguments left. + t.equal(set.parse(argv).getErr(), "Missing flag"); +}); + +test(".repeatable().optional() constructs a repeatable, optional flag", (t) => { + const flag = Flag.string("foo", "").repeatable().optional(); + + // When parsed without arguments, the value should be `None` as the flag is + // optional. + let [argv, set] = flag.parse([]).get(); + t.equal(set.value.isNone(), true); + + // When parsed with valid arguments, the value be wrapped in `Option`. + [argv, set] = flag.parse(["--foo", "hello", "--foo", "world"]).get(); + t.deepEqual(set.value.get(), ["hello"]); + + // When parsing the flag the second time, the second value should be combined + // with the first value and wrapped in `Option`. + [argv, set] = set.parse(argv).get(); + t.deepEqual(set.value.get(), ["hello", "world"]); + + // When parsing the flag the third time, the value should remain the same as + // there are no more arguments left. As the flag is optional, this does not + // cause an error. + [argv, set] = set.parse(argv).get(); + t.deepEqual(set.value.get(), ["hello", "world"]); +}); + +test(".repeatable().default() constructs a repeatable flag with a default value", (t) => { + const flag = Flag.string("foo", "").repeatable().default(["default"]); + + // When parsed without arguments, the value should be equal to the default + // value. + let [argv, set] = flag.parse([]).get(); + t.deepEqual(set.value, ["default"]); + + // When parsed with valid arguments, the default value should not be applied. + [argv, set] = flag.parse(["--foo", "hello", "--foo", "world"]).get(); + t.deepEqual(set.value, ["hello"]); + + // When parsing the flag the second time, the second value should be combined + // with the first value. + [argv, set] = set.parse(argv).get(); + t.deepEqual(set.value, ["hello", "world"]); + + // When parsing the flag the third time, the value should remain the same as + // there are no more arguments left. As the flag has a default value, this + // does not cause an error. + [argv, set] = set.parse(argv).get(); + t.deepEqual(set.value, ["hello", "world"]); +}); + +test(".negatable() constructs a negatable flag", (t) => { + const flag = Flag.string("foo", "").negatable((arg) => arg.toUpperCase()); + + let [, { value }] = flag.parse(["--foo", "hello"]).get(); + t.equal(value, "hello"); + + [, { value }] = flag.parse(["--no-foo", "hello"]).get(); + t.equal(value, "HELLO"); +}); diff --git a/packages/alfa-command/test/text.spec.ts b/packages/alfa-command/test/text.spec.ts new file mode 100644 index 0000000000..1f7e5a0a4c --- /dev/null +++ b/packages/alfa-command/test/text.spec.ts @@ -0,0 +1,15 @@ +import { test } from "@siteimprove/alfa-test"; + +import { Text } from "../src/text"; + +test(".indent() indents all lines of text", (t) => { + const text = `hello world\nhow are you?`; + + t.equal(Text.indent(text, 2), ` hello world\n how are you?`); +}); + +test(".wrap() wraps all lines of text", (t) => { + const text = `hello world\n\nhow are you?`; + + t.equal(Text.wrap(text, 7), `hello\nworld\n\nhow are\nyou?`); +}); diff --git a/packages/alfa-command/tsconfig.json b/packages/alfa-command/tsconfig.json new file mode 100644 index 0000000000..3e1368742e --- /dev/null +++ b/packages/alfa-command/tsconfig.json @@ -0,0 +1,45 @@ +{ + "$schema": "http://json.schemastore.org/tsconfig", + "extends": "../tsconfig.json", + "files": [ + "src/argument.ts", + "src/command.ts", + "src/flag.ts", + "src/index.ts", + "src/text.ts", + "test/flag.spec.ts", + "test/text.spec.ts" + ], + "references": [ + { + "path": "../alfa-functor" + }, + { + "path": "../alfa-highlight" + }, + { + "path": "../alfa-json" + }, + { + "path": "../alfa-mapper" + }, + { + "path": "../alfa-option" + }, + { + "path": "../alfa-parser" + }, + { + "path": "../alfa-predicate" + }, + { + "path": "../alfa-result" + }, + { + "path": "../alfa-test" + }, + { + "path": "../alfa-thunk" + } + ] +} diff --git a/packages/tsconfig.json b/packages/tsconfig.json index ecb0827718..8296e29e75 100644 --- a/packages/tsconfig.json +++ b/packages/tsconfig.json @@ -19,6 +19,7 @@ { "path": "alfa-cli" }, { "path": "alfa-clone" }, { "path": "alfa-collection" }, + { "path": "alfa-command" }, { "path": "alfa-comparable" }, { "path": "alfa-compatibility" }, { "path": "alfa-continuation" }, diff --git a/yarn.lock b/yarn.lock index 9043590d66..181132c7fb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1180,77 +1180,6 @@ resolved "https://registry.yarnpkg.com/@nodelib/fs.stat/-/fs.stat-1.1.3.tgz#2b5a3ab3f918cca48a8c754c08168e3f03eba61b" integrity sha512-shAmDyaQC4H92APFoIaVDHCx5bStIocgvbwQyxPRrbUY20V1EYTbSDchWbuwlMG3V17cprZhA6+78JfB+3DTPw== -"@oclif/command@^1.5.13", "@oclif/command@^1.5.19": - version "1.5.20" - resolved "https://registry.yarnpkg.com/@oclif/command/-/command-1.5.20.tgz#bb0693586d7d66a457c49b719e394c02ff0169a7" - integrity sha512-lzst5RU/STfoutJJv4TLE/cm1WtW3xy6Aqvqy3r1lPsGdNifgbEq4dCOYyc/ZEuhV/IStQLDFTnAlqTdolkz1Q== - dependencies: - "@oclif/config" "^1" - "@oclif/errors" "^1.2.2" - "@oclif/parser" "^3.8.3" - "@oclif/plugin-help" "^2" - debug "^4.1.1" - semver "^5.6.0" - -"@oclif/config@^1", "@oclif/config@^1.13.3": - version "1.15.1" - resolved "https://registry.yarnpkg.com/@oclif/config/-/config-1.15.1.tgz#39950c70811ab82d75bb3cdb33679ed0a4c21c57" - integrity sha512-GdyHpEZuWlfU8GSaZoiywtfVBsPcfYn1KuSLT1JTfvZGpPG6vShcGr24YZ3HG2jXUFlIuAqDcYlTzOrqOdTPNQ== - dependencies: - "@oclif/errors" "^1.0.0" - "@oclif/parser" "^3.8.0" - debug "^4.1.1" - tslib "^1.9.3" - -"@oclif/errors@^1.0.0", "@oclif/errors@^1.2.2": - version "1.2.2" - resolved "https://registry.yarnpkg.com/@oclif/errors/-/errors-1.2.2.tgz#9d8f269b15f13d70aa93316fed7bebc24688edc2" - integrity sha512-Eq8BFuJUQcbAPVofDxwdE0bL14inIiwt5EaKRVY9ZDIG11jwdXZqiQEECJx0VfnLyUZdYfRd/znDI/MytdJoKg== - dependencies: - clean-stack "^1.3.0" - fs-extra "^7.0.0" - indent-string "^3.2.0" - strip-ansi "^5.0.0" - wrap-ansi "^4.0.0" - -"@oclif/linewrap@^1.0.0": - version "1.0.0" - resolved "https://registry.yarnpkg.com/@oclif/linewrap/-/linewrap-1.0.0.tgz#aedcb64b479d4db7be24196384897b5000901d91" - integrity sha512-Ups2dShK52xXa8w6iBWLgcjPJWjais6KPJQq3gQ/88AY6BXoTX+MIGFPrWQO1KLMiQfoTpcLnUwloN4brrVUHw== - -"@oclif/parser@^3.8.0", "@oclif/parser@^3.8.3": - version "3.8.4" - resolved "https://registry.yarnpkg.com/@oclif/parser/-/parser-3.8.4.tgz#1a90fc770a42792e574fb896325618aebbe8c9e4" - integrity sha512-cyP1at3l42kQHZtqDS3KfTeyMvxITGwXwH1qk9ktBYvqgMp5h4vHT+cOD74ld3RqJUOZY/+Zi9lb4Tbza3BtuA== - dependencies: - "@oclif/linewrap" "^1.0.0" - chalk "^2.4.2" - tslib "^1.9.3" - -"@oclif/parser@^3.8.5": - version "3.8.5" - resolved "https://registry.yarnpkg.com/@oclif/parser/-/parser-3.8.5.tgz#c5161766a1efca7343e1f25d769efbefe09f639b" - integrity sha512-yojzeEfmSxjjkAvMRj0KzspXlMjCfBzNRPkWw8ZwOSoNWoJn+OCS/m/S+yfV6BvAM4u2lTzX9Y5rCbrFIgkJLg== - dependencies: - "@oclif/errors" "^1.2.2" - "@oclif/linewrap" "^1.0.0" - chalk "^2.4.2" - tslib "^1.9.3" - -"@oclif/plugin-help@^2", "@oclif/plugin-help@^2.2.1": - version "2.2.3" - resolved "https://registry.yarnpkg.com/@oclif/plugin-help/-/plugin-help-2.2.3.tgz#b993041e92047f0e1762668aab04d6738ac06767" - integrity sha512-bGHUdo5e7DjPJ0vTeRBMIrfqTRDBfyR5w0MP41u0n3r7YG5p14lvMmiCXxi6WDaP2Hw5nqx3PnkAIntCKZZN7g== - dependencies: - "@oclif/command" "^1.5.13" - chalk "^2.4.1" - indent-string "^4.0.0" - lodash.template "^4.4.0" - string-width "^3.0.0" - strip-ansi "^5.0.0" - widest-line "^2.0.1" - wrap-ansi "^4.0.0" - "@octokit/auth-token@^2.4.0": version "2.4.0" resolved "https://registry.yarnpkg.com/@octokit/auth-token/-/auth-token-2.4.0.tgz#b64178975218b99e4dfe948253f0673cbbb59d9f" @@ -2544,7 +2473,7 @@ chai@^4.2.0: pathval "^1.1.0" type-detect "^4.0.5" -chalk@2.4.2, chalk@^2.0.0, chalk@^2.0.1, chalk@^2.3.1, chalk@^2.4.0, chalk@^2.4.1, chalk@^2.4.2: +chalk@2.4.2, chalk@^2.0.0, chalk@^2.0.1, chalk@^2.3.1, chalk@^2.4.0, chalk@^2.4.2: version "2.4.2" resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424" integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ== @@ -2666,11 +2595,6 @@ class-utils@^0.3.5: isobject "^3.0.0" static-extend "^0.1.1" -clean-stack@^1.3.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/clean-stack/-/clean-stack-1.3.0.tgz#9e821501ae979986c46b1d66d2d432db2fd4ae31" - integrity sha1-noIVAa6XmYbEax1m0tQy2y/UrjE= - cli-cursor@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/cli-cursor/-/cli-cursor-1.0.2.tgz#64da3f7d56a54412e59794bd62dc35295e8f2987" @@ -3064,11 +2988,6 @@ cross-spawn@^7.0.0: shebang-command "^2.0.0" which "^2.0.1" -crypto-random-string@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/crypto-random-string/-/crypto-random-string-2.0.0.tgz#ef2a7a966ec11083388369baa02ebead229b30d5" - integrity sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA== - css-select@~1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/css-select/-/css-select-1.2.0.tgz#2b3a110539c5355f1cd8d314623e870b121ec858" @@ -4147,15 +4066,6 @@ fs-extra@^0.30.0: path-is-absolute "^1.0.0" rimraf "^2.2.8" -fs-extra@^7.0.0: - version "7.0.1" - resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-7.0.1.tgz#4f189c44aa123b895f722804f55ea23eadc348e9" - integrity sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw== - dependencies: - graceful-fs "^4.1.2" - jsonfile "^4.0.0" - universalify "^0.1.0" - fs-extra@^8.0.1, fs-extra@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-8.1.0.tgz#49d43c45a88cd9677668cb7be1b46efdb8d2e1c0" @@ -4731,16 +4641,11 @@ indent-string@^2.1.0: dependencies: repeating "^2.0.0" -indent-string@^3.0.0, indent-string@^3.2.0: +indent-string@^3.0.0: version "3.2.0" resolved "https://registry.yarnpkg.com/indent-string/-/indent-string-3.2.0.tgz#4a5fd6d27cc332f37e5419a504dbb837105c9289" integrity sha1-Sl/W0nzDMvN+VBmlBNu4NxBckok= -indent-string@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/indent-string/-/indent-string-4.0.0.tgz#624f8f4497d619b2d9768531d58f4122854d7251" - integrity sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg== - infer-owner@^1.0.3, infer-owner@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/infer-owner/-/infer-owner-1.0.4.tgz#c4cefcaa8e51051c2a40ba2ce8a3d27295af9467" @@ -6082,7 +5987,7 @@ lodash.sortby@^4.7.0: resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438" integrity sha1-7dFMgk4sycHgsKG0K7UhBRakJDg= -lodash.template@^4.0.2, lodash.template@^4.4.0, lodash.template@^4.5.0: +lodash.template@^4.0.2, lodash.template@^4.5.0: version "4.5.0" resolved "https://registry.yarnpkg.com/lodash.template/-/lodash.template-4.5.0.tgz#f976195cf3f347d0d5f52483569fe8031ccce8ab" integrity sha512-84vYFxIkmidUiFxidA/KjjH9pAycqW+h980j7Fuz5qxRtO9pgB7MDFTdys1N7A5mcucRiDyEq4fusljItR1T/A== @@ -8438,7 +8343,7 @@ string-width@^1.0.1: is-fullwidth-code-point "^1.0.0" strip-ansi "^3.0.0" -"string-width@^1.0.2 || 2", string-width@^2.1.0, string-width@^2.1.1: +"string-width@^1.0.2 || 2", string-width@^2.1.0: version "2.1.1" resolved "https://registry.yarnpkg.com/string-width/-/string-width-2.1.1.tgz#ab93f27a8dc13d28cac815c462143a6d9012ae9e" integrity sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw== @@ -8682,11 +8587,6 @@ temp-dir@^1.0.0: resolved "https://registry.yarnpkg.com/temp-dir/-/temp-dir-1.0.0.tgz#0a7c0ea26d3a39afa7e0ebea9c1fc0bc4daa011d" integrity sha1-CnwOom06Oa+n4OvqnB/AvE2qAR0= -temp-dir@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/temp-dir/-/temp-dir-2.0.0.tgz#bde92b05bdfeb1516e804c9c00ad45177f31321e" - integrity sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg== - temp-write@^3.4.0: version "3.4.0" resolved "https://registry.yarnpkg.com/temp-write/-/temp-write-3.4.0.tgz#8cff630fb7e9da05f047c74ce4ce4d685457d492" @@ -8699,16 +8599,6 @@ temp-write@^3.4.0: temp-dir "^1.0.0" uuid "^3.0.1" -tempy@^0.5.0: - version "0.5.0" - resolved "https://registry.yarnpkg.com/tempy/-/tempy-0.5.0.tgz#2785c89df39fcc4d1714fc554813225e1581d70b" - integrity sha512-VEY96x7gbIRfsxqsafy2l5yVxxp3PhwAGoWMyC2D2Zt5DmEv+2tGiPOrquNRpf21hhGnKLVEsuqleqiZmKG/qw== - dependencies: - is-stream "^2.0.0" - temp-dir "^2.0.0" - type-fest "^0.12.0" - unique-string "^2.0.0" - test-exclude@^5.2.3: version "5.2.3" resolved "https://registry.yarnpkg.com/test-exclude/-/test-exclude-5.2.3.tgz#c3d3e1e311eb7ee405e092dac10aefd09091eac0" @@ -8883,7 +8773,7 @@ ts-jest@^24.0.0: semver "^5.5" yargs-parser "10.x" -tslib@^1.9.0, tslib@^1.9.3: +tslib@^1.9.0: version "1.11.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.11.1.tgz#eb15d128827fbee2841549e171f45ed338ac7e35" integrity sha512-aZW88SY8kQbU7gpV19lN24LtXh/yD4ZZg6qieAJDDg+YBsJcSmLGK9QpnUjAKVG/xefmvJGd1WUmfpT/g6AJGA== @@ -8917,11 +8807,6 @@ type-fest@^0.11.0: resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.11.0.tgz#97abf0872310fed88a5c466b25681576145e33f1" integrity sha512-OdjXJxnCN1AvyLSzeKIgXTXxV+99ZuXl3Hpo9XpJAv9MBcHrrJOQ5kV7ypXOuQie+AmWG25hLbiKdwYTifzcfQ== -type-fest@^0.12.0: - version "0.12.0" - resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.12.0.tgz#f57a27ab81c68d136a51fd71467eff94157fa1ee" - integrity sha512-53RyidyjvkGpnWPMF9bQgFtWp+Sl8O2Rp13VavmJgfAP9WWG6q6TkrKU8iyJdnwnfgHI6k2hTlgqH4aSdjoTbg== - type-fest@^0.3.0: version "0.3.1" resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.3.1.tgz#63d00d204e059474fe5e1b7c011112bbd1dc29e1" @@ -9007,13 +8892,6 @@ unique-slug@^2.0.0: dependencies: imurmurhash "^0.1.4" -unique-string@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/unique-string/-/unique-string-2.0.0.tgz#39c6451f81afb2749de2b233e3f7c5e8843bd89d" - integrity sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg== - dependencies: - crypto-random-string "^2.0.0" - universal-user-agent@^4.0.0: version "4.0.1" resolved "https://registry.yarnpkg.com/universal-user-agent/-/universal-user-agent-4.0.1.tgz#fd8d6cb773a679a709e967ef8288a31fcc03e557" @@ -9272,13 +9150,6 @@ wide-align@1.1.3, wide-align@^1.1.0: dependencies: string-width "^1.0.2 || 2" -widest-line@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/widest-line/-/widest-line-2.0.1.tgz#7438764730ec7ef4381ce4df82fb98a53142a3fc" - integrity sha512-Ba5m9/Fa4Xt9eb2ELXt77JxVDV8w7qQrH0zS/TWSJdLyAwQjWoOzpzj5lwVftDz6n/EOu3tNACS84v509qwnJA== - dependencies: - string-width "^2.1.1" - windows-release@^3.1.0: version "3.3.0" resolved "https://registry.yarnpkg.com/windows-release/-/windows-release-3.3.0.tgz#dce167e9f8be733f21c849ebd4d03fe66b29b9f0" @@ -9296,15 +9167,6 @@ wordwrap@^1.0.0: resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb" integrity sha1-J1hIEIkUVqQXHI0CJkQa3pDLyus= -wrap-ansi@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-4.0.0.tgz#b3570d7c70156159a2d42be5cc942e957f7b1131" - integrity sha512-uMTsj9rDb0/7kk1PbcbCcwvHUxp60fGDB/NNXpVa0Q+ic/e7y5+BwTxKfQ33VYgDppSwi/FBzpetYzo8s6tfbg== - dependencies: - ansi-styles "^3.2.0" - string-width "^2.1.1" - strip-ansi "^4.0.0" - wrap-ansi@^5.0.0, wrap-ansi@^5.1.0: version "5.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-5.1.0.tgz#1fd1f67235d5b6d0fee781056001bfb694c03b09"