UNPKG

@sketch-hq/sketch-assistant-cli

Version:

Sketch Assistants Node command-line utility.

406 lines (382 loc) 15.8 kB
"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; Object.defineProperty(o, k2, { enumerable: true, get: function() { return m[k]; } }); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); __setModuleDefault(result, mod); return result; }; var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } return new (P || (P = Promise))(function (resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); }); }; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); const sketch_assistant_types_1 = require("@sketch-hq/sketch-assistant-types"); const sketch_assistant_utils_1 = require("@sketch-hq/sketch-assistant-utils"); const sketch_file_1 = require("@sketch-hq/sketch-file"); const chalk_1 = __importDefault(require("chalk")); const cp = __importStar(require("child_process")); const conf_1 = __importDefault(require("conf")); const crypto_1 = __importDefault(require("crypto")); const fs = __importStar(require("fs")); const globby_1 = __importDefault(require("globby")); const meow_1 = __importDefault(require("meow")); const ora_1 = __importDefault(require("ora")); const os_1 = __importDefault(require("os")); const os_locale_1 = __importDefault(require("os-locale")); const path_1 = require("path"); const util_1 = require("util"); const helpText = ` Usage Run a Sketch file's Assistants. sketch-assistants "./path/to/file.sketch" Or run multiple files: sketch-assistants "./path/to/file-1.sketch" "./path/to/file-2.sketch" Or use globs to run all Sketch files that match a pattern: sketch-assistants "./**/*.sketch" Flags --json Switch from human-readable output to JSON. The JSON output includes the full result set from the Assistants as well as profiling data about how quickly individual rules executed. Example: sketch-assistants --json "./path/to/file.sketch" --clear-cache When Assistants are installed before a run, they are cached in a temporary folder to make future runs faster. Pass this flag to delete the cache folder. --workspace Optionally supply and overwrite the Assistant workspace configuration within the Sketch file(s) with your own. This can be useful for running Assistants against a file that haven't yet been setup with Assistants in the Sketch app. Example: sketch-assistants --workspace=./workspace.json "./path/to/file.sketch" The data shape of the workspace itself is essentially a package.json, with the dependencies section indicating the active Assistants. The workspace JSON example below activates two Assistants: { "dependencies": { "@sketch-hq/sketch-tidy-assistant": "latest", "@sketch-hq/sketch-naming-conventions-assistant": "latest" } } --assistant Optionally supply a custom Assistant to use on the files. This is an Assistant defined entirely in JSON. Assistants to extend, as well as a custom configuration of object can be supplied. sketch-assistants --assistant=./assistant.json "./path/to/file.sketch" Example Assistant definition in JSON: { "name": "max-3", "dependencies": { "@sketch-hq/sketch-core-assistant": "latest" }, "assistant": { "extends": ["@sketch-hq/sketch-core-assistant"], "config": { "rules": { "@sketch-hq/sketch-core-assistant/groups-max-layers": { "active": true, "maxLayers": 3, "skipClasses": [] } } } } } `; const cli = meow_1.default(helpText, { flags: { json: { type: 'boolean', default: false, }, clearCache: { type: 'boolean', default: false, }, workspace: { type: 'string', default: '', }, assistant: { type: 'string', default: '', }, profile: { type: 'boolean', default: false, }, }, }); const settings = new conf_1.default(); const exec = util_1.promisify(cp.exec); const exists = util_1.promisify(fs.exists); const readFile = util_1.promisify(fs.readFile); const writeFile = util_1.promisify(fs.writeFile); /** * Return the path to a usable temporary directory that remains consistent between cli runs. */ const getTmpDirPath = () => { const dir = settings.has('dir') ? settings.get('dir') : fs.realpathSync(os_1.default.tmpdir()); settings.set('dir', dir); return `${dir}/sketch-assistants`; }; /** * Exit the process with a reason and a non-zero exit code. */ const failWithReason = (reason) => { console.error(chalk_1.default.red(`Failed: ${reason}`)); process.exit(1); }; /** * Format a spinner progress message. */ const spinnerMessage = (filename, message) => `${chalk_1.default.gray(`${filename}:`)} ${message}`; /** * Require Assistants for a given workspace. That is, resolve their names to * their actual package export values. */ const requireAssistants = (dir, workspace) => Object.keys(workspace.dependencies || {}).reduce((assistantGroup, pkgName) => (Object.assign({ [pkgName]: require(`${dir}/node_modules/${pkgName}`) }, assistantGroup)), {}); /** * Install a set of Assistants to a temporary working directory. */ const installWorkspace = (dir, workspace) => __awaiter(void 0, void 0, void 0, function* () { yield exec(`rm -rf ${dir}`); yield exec(`mkdir -p ${dir}`); yield exec('npm init --yes', { cwd: dir }); const pkg = Object.assign(Object.assign({}, JSON.parse(yield readFile(`${dir}/package.json`, { encoding: 'utf8' }))), workspace); yield writeFile(`${dir}/package.json`, JSON.stringify(pkg, null, 2), { encoding: 'utf8', }); yield exec(`npm install --no-scripts --prefix=${dir}`); }); /** * Create a custom Assistant based on a WorkspaceWithAssistant value. */ const makeAssistant = (dir, workspace) => __awaiter(void 0, void 0, void 0, function* () { const assistants = yield requireAssistants(dir, workspace); const assistantName = `custom/${workspace.name}`; return { [assistantName]: [ ...workspace.assistant.extends.map((assistantName) => assistants[assistantName]), () => __awaiter(void 0, void 0, void 0, function* () { return ({ name: assistantName, rules: [], config: workspace.assistant.config ? workspace.assistant.config : { rules: {} }, }); }), ], }; }); /** * Format results as a human readable string. */ const formatResults = (cliResults) => { let formatted = ''; const append = (indent = 0, s = '') => { formatted += `${Array(indent).fill(' ').join('')}${s}\n`; return formatted; }; const severityColors = { [sketch_assistant_types_1.ViolationSeverity.info]: chalk_1.default.blue, [sketch_assistant_types_1.ViolationSeverity.warn]: chalk_1.default.yellow, [sketch_assistant_types_1.ViolationSeverity.error]: chalk_1.default.red, }; append(); for (const cliResult of cliResults) { append(0, `File: ${chalk_1.default.grey(cliResult.filepath)}\n`); if (cliResult.code === 'error') { append(2, chalk_1.default.red(cliResult.message)); continue; } for (const [name, res] of Object.entries(cliResult.output.assistants)) { if (res.code === 'error') { append(2, `${name}: ${chalk_1.default.red(res.error.message)}`); continue; } append(2, `${chalk_1.default.inverse(` ${name} `)}\n`); append(4, `Grade: ${res.result.grade === 'pass' ? chalk_1.default.green('pass') : res.result.grade === 'fail' ? chalk_1.default.red('fail') : chalk_1.default.gray('unknown')}`); append(4, `Violations: ${res.result.violations.length}`); append(4, `Rule errors: ${res.result.ruleErrors.length}\n`); for (const violation of res.result.violations) { const rule = res.result.metadata.rules[violation.ruleName]; append(4, `${severityColors[violation.severity](rule.title)}`); append(6, `Detail: ${chalk_1.default.grey(violation.message)}`); append(6, `Severity: ${chalk_1.default.grey(sketch_assistant_types_1.ViolationSeverity[violation.severity])}`); append(6, `Rule: ${chalk_1.default.grey(violation.ruleName)}`); for (const object of violation.objects) { const objects = []; if (object.name) objects.push(`Name : ${object.name}`); if (object.pointer) objects.push(`Pointer : ${object.pointer}`); if (object.id) objects.push(`Id : ${object.id}`); if (object.class) objects.push(`Class : ${object.id}`); append(6, `Object: ${chalk_1.default.grey(objects.join(' | '))}`); } append(); } for (const error of res.result.ruleErrors) { const rule = res.result.metadata.rules[error.ruleName]; append(4, `${chalk_1.default.red(rule.title)}`); append(6, `Error: ${chalk_1.default.red(error.message)}`); append(); } } } return formatted; }; /** * Return the Assistant workspace to use for the current file run. */ const getWorkspace = (file) => __awaiter(void 0, void 0, void 0, function* () { var _a; if (cli.flags.workspace) { return JSON.parse(yield readFile(path_1.resolve(cli.flags.workspace), { encoding: 'utf8' })); } if (cli.flags.assistant) { return JSON.parse(yield readFile(path_1.resolve(cli.flags.assistant), { encoding: 'utf8' })); } if (!((_a = file.contents.workspace) === null || _a === void 0 ? void 0 : _a.assistants)) { throw Error('No Assistant workspace found for run'); } return file.contents.workspace.assistants; }); /** * Run Assistants on a Sketch file. */ const runFile = (filepath, tmpDir) => __awaiter(void 0, void 0, void 0, function* () { const filename = path_1.basename(filepath); const spinner = ora_1.default({ stream: cli.flags.json || cli.flags.profile ? fs.createWriteStream('/dev/null') : process.stdout, }); spinner.start(spinnerMessage(filename, '')); spinner.start(spinnerMessage(filename, 'Processing file…')); try { const cancelToken = { cancelled: false }; const timeBudgets = { totalMs: Infinity, minRuleTimeoutMs: 0, maxRuleTimeoutMs: Infinity, }; let file = yield sketch_file_1.fromFile(filepath); const workspace = yield getWorkspace(file); let { ignore = { pages: [], assistants: {} } } = workspace; ignore = sketch_assistant_utils_1.prunePages(ignore, file); file = sketch_assistant_utils_1.filterPages(file, ignore.pages); const processedFile = yield sketch_assistant_utils_1.process(file, cancelToken); const env = { runtime: sketch_assistant_types_1.AssistantRuntime.Node, locale: yield os_locale_1.default(), }; const workspaceHash = crypto_1.default .createHash('md5') .update(JSON.stringify(workspace.dependencies || {})) .digest('hex'); const dir = path_1.resolve(`${tmpDir}`, workspaceHash); spinner.start(spinnerMessage(filename, 'Installing workspace…')); if (!(yield exists(dir))) { yield installWorkspace(dir, workspace); } const assistants = cli.flags.assistant ? yield makeAssistant(dir, workspace) : yield requireAssistants(dir, workspace); spinner.start(spinnerMessage(filename, 'Running Assistants…')); const output = yield sketch_assistant_utils_1.runMultipleAssistants({ ignore, assistants, processedFile, getImageMetadata: sketch_assistant_utils_1.getImageMetadata, cancelToken, env, timeBudgets, }); spinner.succeed(spinnerMessage(filename, 'Ready')); return output; } catch (err) { spinner.fail(spinnerMessage(filename, err.message)); throw err; } }); /** * Main entrypoint function for the CLI. */ const main = () => __awaiter(void 0, void 0, void 0, function* () { const tmpDir = getTmpDirPath(); if (cli.flags.clearCache) { yield exec(`rm -rf ${tmpDir}`); console.log(`Cache directory deleted: ${tmpDir}`); return; } if (cli.input.length === 0) { failWithReason('No input supplied to cli'); } const paths = cli.input .map((value) => globby_1.default.sync(value)) .flat() .map((value) => path_1.resolve(value)) .filter((value) => fs.existsSync(value)); if (paths.length === 0) { failWithReason("Couldn't find the input Sketch file(s) on disk"); } const results = []; for (const filepath of paths) { try { const output = yield runFile(filepath, tmpDir); results.push({ filepath, code: 'success', output: { ignore: output.ignore, assistants: output.assistants }, profile: sketch_assistant_utils_1.makeProfile(output), }); } catch (err) { results.push({ filepath, code: 'error', message: `${err.message}`, }); } } if (cli.flags.json) { console.log(JSON.stringify(results, null, 2)); } else { console.log(formatResults(results)); } // TODO: Need think about what failure means? Does it mean no Assistants ran, // or some Assistants ran but didn't pass? Or something else? Maybe needs to // be customisable ... const success = results.findIndex((result) => result.code === 'error') === -1; process.exit(success ? 0 : 1); }); main();