@sketch-hq/sketch-assistant-cli
Version:
Sketch Assistants Node command-line utility.
406 lines (382 loc) • 15.8 kB
JavaScript
;
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();