@elgato/cli
Version:
Stream Deck CLI tool for building with Stream Deck.
1,056 lines (1,024 loc) • 33.2 kB
JavaScript
/**!
* @author Elgato
* @module elgato/streamdeck
* @license MIT
* @copyright Copyright (c) Corsair Memory Inc.
*/
// src/index.ts
import chalk6 from "chalk";
// src/validation/entry.ts
import chalk from "chalk";
import { EOL } from "node:os";
var ValidationEntry = class {
/**
* Initializes a new instance of the {@link ValidationEntry} class.
* @param level Severity level of the entry.
* @param message Validation message.
* @param details Supporting optional details.
*/
constructor(level, message, details) {
this.level = level;
this.message = message;
this.details = details;
if (message.endsWith(".")) {
this.message = message.slice(0, -1);
}
if (this.details?.location?.column || this.details?.location?.line) {
this.location = `${this.details.location.line}`;
if (this.details.location.column) {
this.location += `:${this.details.location.column}`;
}
}
if (this.details?.location?.key) {
this.message = `${chalk.cyan(this.details.location.key)} ${message}`;
}
}
/**
* Location of the validation entry, represented as a string in the format {line}:{column}.
*/
location = "";
/**
* Converts the entry to a summary string.
* @param padding Optional padding required to align the position of each entry.
* @returns String that represents the entry.
*/
toSummary(padding) {
const position = padding === void 0 || padding === 0 ? "" : `${this.location.padEnd(padding + 2)}`;
const level = ValidationLevel[this.level].padEnd(7);
let message = ` ${chalk.dim(position)}${this.level === 0 /* error */ ? chalk.red(level) : chalk.yellow(level)} ${this.message}`;
if (this.details?.suggestion) {
const prefix = chalk.level > 0 ? chalk.hidden(`${position}${level}`) : " ".repeat(position.length + level.length);
message += `${EOL} ${prefix} ${chalk.dim("\u2514", this.details.suggestion)}`;
}
return message;
}
};
var ValidationLevel = /* @__PURE__ */ ((ValidationLevel2) => {
ValidationLevel2[ValidationLevel2["error"] = 0] = "error";
ValidationLevel2[ValidationLevel2["warning"] = 1] = "warning";
return ValidationLevel2;
})(ValidationLevel || {});
// src/validation/file-result.ts
import chalk2 from "chalk";
// src/common/ordered-array.ts
var OrderedArray = class extends Array {
/**
* Delegates responsible for determining the sort order.
*/
#compareOn;
/**
* Initializes a new instance of the {@link OrderedArray} class.
* @param compareOn Delegates responsible for determining the sort order.
*/
constructor(...compareOn) {
super();
this.#compareOn = compareOn;
}
/**
* "Pushes" the specified {@link value} in a sorted order.
* @param value Value to push.
* @returns New length of the array.
*/
push(value) {
super.splice(this.#sortedIndex(value), 0, value);
return this.length;
}
/**
* Compares {@link a} to {@link b} and returns a numerical representation of the comparison.
* @param a Item A.
* @param b Item B.
* @returns `-1` when {@link a} is less than {@link b}, `1` when {@link a} is greater than {@link b}, otherwise `0`
*/
#compare(a, b) {
for (const compareOn of this.#compareOn) {
const x = compareOn(a);
const y = compareOn(b);
if (x < y) {
return -1;
} else if (x > y) {
return 1;
}
}
return 0;
}
/**
* Gets the sorted index of the specified {@link value} relative to this instance.
* Inspired by {@link https://stackoverflow.com/a/21822316}.
* @param value The value.
* @returns Index.
*/
#sortedIndex(value) {
let low = 0;
let high = this.length;
while (low < high) {
const mid = low + high >>> 1;
const comparison = this.#compare(value, this[mid]);
if (comparison === 0) {
return mid;
} else if (comparison > 0) {
low = mid + 1;
} else {
high = mid;
}
}
return low;
}
};
// src/validation/file-result.ts
var FileValidationResult = class extends OrderedArray {
/**
* Initializes a new instance of the {@link FileValidationResult} class.
* @param path Path that groups the entries together.
*/
constructor(path) {
super(
(x) => x.level,
(x) => x.details?.location?.line ?? Infinity,
(x) => x.details?.location?.column ?? Infinity,
(x) => x.message
);
this.path = path;
}
/**
* Tracks the padding required for the location of a validation entry, i.e. the text before the entry level.
*/
#padding = 0;
/**
* Adds the specified {@link entry} to the collection.
* @param entry Entry to add.
* @returns New length of the validation results.
*/
push(entry) {
this.#padding = Math.max(this.#padding, entry.location.length);
return super.push(entry);
}
/**
* Writes the entry collection to the {@link output}.
* @param output Output to write to.
*/
writeTo(output) {
if (this.length === 0) {
return;
}
output.log(chalk2.underline(this.path));
if (chalk2.level > 0) {
output.log(chalk2.hidden(this.path));
} else {
output.log();
}
this.forEach((entry) => output.log(entry.toSummary(this.#padding)));
output.log();
}
};
// src/validation/result.ts
var ValidationResult = class extends Array {
/**
* Private backing field for {@link ValidationResult.errorCount}.
*/
errorCount = 0;
/**
* Private backing field for {@link ValidationResult.warningCount}.
*/
warningCount = 0;
/**
* Adds a new validation entry to the result.
* @param path Directory or file path the entry is associated with.
* @param entry Validation entry.
*/
add(path, entry) {
if (entry.level === 0 /* error */) {
this.errorCount++;
} else {
this.warningCount++;
}
let fileResult = this.find((c) => c.path === path);
if (fileResult === void 0) {
fileResult = new FileValidationResult(path);
this.push(fileResult);
}
fileResult.push(entry);
}
/**
* Determines whether the result contains errors.
* @returns `true` when the result has errors.
*/
hasErrors() {
return this.errorCount > 0;
}
/**
* Determines whether the result contains warnings.
* @returns `true` when the result has warnings.
*/
hasWarnings() {
return this.warningCount > 0;
}
/**
* Writes the results to the specified {@link output}.
* @param output Output to write to.
*/
writeTo(output) {
if (this.length > 0) {
output.log();
this.forEach((collection) => collection.writeTo(output));
}
if (this.hasErrors() && this.hasWarnings()) {
output.error(
`${pluralize("problem", this.errorCount + this.warningCount)} (${pluralize("error", this.errorCount)}, ${pluralize("warning", this.warningCount)})`
);
return;
}
if (this.hasErrors()) {
output.error(`Failed with ${pluralize("error", this.errorCount)}`);
return;
}
if (this.hasWarnings()) {
output.warn(pluralize("warning", this.warningCount));
return;
}
output.success("Validation successful");
function pluralize(noun, count) {
return `${count} ${count === 1 ? noun : `${noun}s`}`;
}
}
};
// src/validation/rule.ts
var rule = (fn) => fn;
// src/validation/validator.ts
async function validate(path, context, rules) {
const result = new ValidationResult();
const validationContext = new ValidationContext(path, result);
for (const rule2 of rules) {
await rule2.call(validationContext, context);
}
return result;
}
var ValidationContext = class {
/**
* Initializes a new instance of the {@link ValidationContext} class.
* @param path Path to the item being validated.
* @param result Validation results.
*/
constructor(path, result) {
this.path = path;
this.result = result;
}
/**
* Adds a validation error.
* @param path File or directory path the entry is associated with.
* @param message Validation message.
* @param details Optional details.
* @returns This instance for chaining.
*/
addError(path, message, details) {
this.result.add(path, new ValidationEntry(0 /* error */, message, details));
return this;
}
/**
* Adds a validation warning.
* @param path File or directory path the entry is associated with.
* @param message Validation message.
* @param details Optional details.
* @returns This instance for chaining.
*/
addWarning(path, message, details) {
this.result.add(path, new ValidationEntry(1 /* warning */, message, details));
return this;
}
};
// src/validation/plugin/plugin.ts
import { basename, dirname, join, resolve } from "node:path";
// src/json/file-context.ts
import { existsSync, readFileSync } from "node:fs";
// src/json/map.ts
var JsonObjectMap = class {
/**
* Parsed data.
*/
value = {};
/**
* Collection of AST nodes indexed by their instance path (pointer).
*/
nodes = /* @__PURE__ */ new Map();
/**
* Initializes a new instance of the {@link JsonObjectMap} class.
* @param node Source that contains the data.
* @param errors JSON schema errors; used to determine invalid types based on the instance path of an error.
*/
constructor(node, errors) {
if (node?.type === "Object") {
this.value = this.aggregate(node, "", errors);
}
}
/**
* Finds the {@link NodeRef} from its {@link instancePath}.
* @param instancePath Instance path.
* @returns The node associated with the {@link instancePath}.
*/
find(instancePath) {
return this.nodes.get(instancePath);
}
/**
* Aggregates the {@param node} to an object containing the property values and their paths.
* @param node Node to aggregate.
* @param pointer Pointer to the {@param node}.
* @param errors Errors associated with the JSON used to parse the {@param node}.
* @returns Aggregated object.
*/
aggregate(node, pointer, errors) {
const nodeRef = {
location: {
...node.loc?.start,
instancePath: pointer,
key: getPath(pointer)
}
};
this.nodes.set(pointer, nodeRef);
if (errors?.find((e) => e.instancePath === pointer && e.keyword === "type")) {
nodeRef.node = new JsonValueNode(void 0, nodeRef.location);
return nodeRef.node;
}
if (node.type === "Object") {
return node.members.reduce(
(obj, member) => {
obj[member.name.value] = this.aggregate(member.value, `${pointer}/${member.name.value}`, errors);
return obj;
},
{}
);
}
if (node.type === "Array") {
return node.elements.map((item, i) => this.aggregate(item.value, `${pointer}/${i}`, errors));
}
if (node.type === "Boolean" || node.type === "Number" || node.type === "String") {
nodeRef.node = new JsonValueNode(node.value, nodeRef.location);
return nodeRef.node;
}
if (node.type === "Null") {
nodeRef.node = new JsonValueNode(null, nodeRef.location);
return nodeRef.node;
}
throw new Error(
`Encountered unhandled node type '${node.type}' when mapping abstract-syntax tree node to JSON object`
);
}
};
var JsonValueNode = class {
/**
* Initializes a new instance of the {@link JsonValueNode} class.
* @param value Parsed value.
* @param location Location of the element within the JSON it was parsed from.
*/
constructor(value, location) {
this.value = value;
this.location = location;
}
/** @inheritdoc */
toString() {
return this.value?.toString();
}
};
function getPath(pointer) {
const path = pointer.split("/").reduce((path2, segment) => {
if (segment === void 0 || segment === "") {
return path2;
}
if (!isNaN(Number(segment))) {
return `${path2}[${segment}]`;
}
return `${path2}.${segment}`;
}, "");
return path.startsWith(".") ? path.slice(1) : path;
}
// src/json/file-context.ts
var JsonFileContext = class {
/**
* Initializes a new instance of the {@link JsonFileContext} class.
* @param path Path to the file, as defined within the plugin; the file may or may not exist.
* @param schema JSON schema to use when validating the file.
*/
constructor(path, schema) {
this.path = path;
this.schema = schema;
if (existsSync(this.path)) {
const json = readFileSync(this.path, { encoding: "utf-8" });
({ errors: this.errors, map: this._map } = this.schema.validate(json));
}
}
/**
* Collection of JSON schema validation errors.
*/
errors = [];
/**
* Map of the parsed JSON data.
*/
_map = new JsonObjectMap();
/**
* Parsed data with all valid value types set, including the location of which the value was parsed within the JSON.
* @returns Parsed data.
*/
get value() {
return this._map.value;
}
/**
* Finds the node reference for the specified {@link instancePath}.
* @param instancePath Instance path.
* @returns The node associated with the {@link instancePath}.
*/
find(instancePath) {
return this._map.find(instancePath);
}
};
// src/json/schema.ts
import { keywordDefinitions } from "@elgato/schemas";
import { parse } from "@humanwhocodes/momoa";
import Ajv from "ajv";
import _ from "lodash";
// src/common/stdout.ts
import chalk3 from "chalk";
import isInteractive from "is-interactive";
import logSymbols from "log-symbols";
function colorize(value) {
if (typeof value === "string") {
return chalk3.green(`'${value}'`);
}
return chalk3.yellow(value);
}
// src/common/utils.ts
function aggregate(items, conjunction, transform) {
const fn = transform || ((value) => value);
return items.reduce((prev, current, index) => {
const value = fn(current);
if (index === 0) {
return value;
} else if (index === items.length - 1 && index > 0) {
return `${prev}, ${conjunction} ${value}`;
} else {
return `${prev}, ${value}`;
}
}, "");
}
// src/json/schema.ts
var JsonSchema = class {
/**
* Private backing field for {@link JsonSchema.filePathsKeywords}.
*/
_filePathsKeywords = /* @__PURE__ */ new Map();
/**
* Private backing field for {@link JsonSchema.imageDimensionKeywords}.
*/
_imageDimensionKeywords = /* @__PURE__ */ new Map();
/**
* Internal validator.
*/
_validate;
/**
* Collection of custom error messages, indexed by their JSON instance path, defined with the JSON schema using `@errorMessage`.
*/
errorMessages = /* @__PURE__ */ new Map();
/**
* Initializes a new instance of the {@link JsonSchema} class.
* @param schema Schema that defines the JSON structure.
*/
constructor(schema) {
const ajv = new Ajv({
allErrors: true,
messages: false,
strict: false
});
ajv.addKeyword(keywordDefinitions.markdownDescription);
ajv.addKeyword(captureKeyword(keywordDefinitions.errorMessage, this.errorMessages));
ajv.addKeyword(captureKeyword(keywordDefinitions.imageDimensions, this._imageDimensionKeywords));
ajv.addKeyword(captureKeyword(keywordDefinitions.filePath, this._filePathsKeywords));
this._validate = ajv.compile(schema);
}
/**
* Collection of {@link FilePathOptions}, indexed by their JSON instance path, defined with the JSON schema using `@filePath`.
* @returns The collection of {@link FilePathOptions}.
*/
get filePathsKeywords() {
return this._filePathsKeywords;
}
/**
* Collection of {@link ImageDimensions}, indexed by their JSON instance path, defined with the JSON schema using `@imageDimensions`.
* @returns The collection of {@link FilePathOptions}.
*/
get imageDimensionKeywords() {
return this._imageDimensionKeywords;
}
/**
* Validates the {@param json}.
* @param json JSON string to parse.
* @returns Data that could be successfully parsed from the {@param json}, and a collection of errors.
*/
validate(json) {
this._filePathsKeywords.clear();
this._imageDimensionKeywords.clear();
let data;
try {
data = JSON.parse(json);
} catch {
return {
map: new JsonObjectMap(),
errors: [
{
source: {
keyword: "false schema",
instancePath: "",
schemaPath: "",
params: {}
},
message: "Contents must be a valid JSON string",
location: { instancePath: "/", key: void 0 }
}
]
};
}
this._validate(data);
const ast = parse(json, { mode: "json", ranges: false, tokens: false });
const map = new JsonObjectMap(ast.body, this._validate.errors);
return {
map,
errors: this.filter(this._validate.errors).map((source) => ({
location: map.find(source.instancePath)?.location,
message: this.getMessage(source),
source
})) ?? []
};
}
/**
* Filters the errors, removing ignored keywords and duplicates.
* @param errors Errors to filter.
* @returns Filtered errors.
*/
filter(errors) {
if (errors === void 0 || errors === null) {
return [];
}
const ignoredKeywords = ["allOf", "anyOf", "if"];
return _.uniqWith(
errors.filter(({ keyword }) => !ignoredKeywords.includes(keyword)),
(a, b) => a.instancePath === b.instancePath && a.keyword === b.keyword && _.isEqual(a.params, b.params)
);
}
/**
* Parses the error message from the specified {@link ErrorObject}.
* @param error JSON schema error.
* @returns The error message.
*/
getMessage(error) {
const { keyword, message, params, instancePath } = error;
if (keyword === "additionalProperties") {
return params.additionalProperty !== void 0 ? `must not contain property: ${params.additionalProperty}` : "must not contain additional properties";
}
if (keyword === "enum") {
const values = aggregate(params.allowedValues, "or", colorize);
return values !== void 0 ? `must be ${values}` : message || `failed validation for keyword: ${keyword}`;
}
if (keyword === "pattern") {
const errorMessage = this.errorMessages.get(instancePath);
if (errorMessage?.startsWith("String")) {
return errorMessage.substring(7);
}
return errorMessage || `must match pattern ${params.pattern}`;
}
if (keyword === "minimum" || keyword === "maximum") {
return `must be ${getComparison(params.comparison)} ${params.limit}`;
}
if (keyword === "minItems") {
return `must contain at least ${params.limit} item${params.limit === 1 ? "" : "s"}`;
}
if (keyword === "maxItems") {
return `must not contain more than ${params.limit} item${params.limit === 1 ? "" : "s"}`;
}
if (keyword === "required") {
return `must contain property: ${params.missingProperty}`;
}
if (keyword === "type") {
return `must be a${params.type === "object" ? "n" : ""} ${params.type}`;
}
if (keyword === "uniqueItems") {
return "must not contain duplicate items";
}
return message || `failed validation for keyword: ${keyword}`;
}
};
function captureKeyword(def, map) {
const { keyword, schemaType } = def;
return {
keyword,
schemaType,
validate: (schema, data, parentSchema, dataCtx) => {
if (dataCtx?.instancePath !== void 0) {
map.set(dataCtx.instancePath, schema);
}
return true;
}
};
}
function getComparison(comparison) {
switch (comparison) {
case "<":
return "less than";
case "<=":
return "less than or equal to";
case ">":
return "greater than";
case ">=":
return "greater than or equal to";
default:
throw new TypeError(`Expected comparison when validating JSON: ${comparison}`);
}
}
// src/stream-deck.ts
import find from "find-process";
function isPredefinedLayoutLike(value) {
return value.startsWith("$") === true && !value.endsWith(".json");
}
function isValidPluginId(uuid) {
if (uuid === void 0 || uuid === null) {
return false;
}
return /^([a-z0-9-]+)(\.[a-z0-9-]+)+$/.test(uuid);
}
// src/validation/plugin/plugin.ts
var directorySuffix = ".sdPlugin";
async function createContext(path) {
const id = basename(path).replace(/\.sdPlugin$/, "");
const { manifest, layout } = await import("@elgato/schemas/streamdeck/plugins/json");
return {
hasValidId: isValidPluginId(id),
manifest: new ManifestJsonFileContext(join(path, "manifest.json"), manifest, layout),
id
};
}
var ManifestJsonFileContext = class extends JsonFileContext {
/**
* Layout files referenced by the manifest.
*/
layoutFiles = [];
/**
* Initializes a new instance of the {@link ManifestJsonFileContext} class.
* @param path Path to the manifest file.
* @param manifestSchema JSON schema that defines the manifest.
* @param layoutSchema JSON schema that defines a layout.
*/
constructor(path, manifestSchema, layoutSchema) {
super(path, new JsonSchema(manifestSchema));
const compiledLayoutSchema = new JsonSchema(layoutSchema);
this.value.Actions?.forEach((action) => {
if (action.Encoder?.layout !== void 0 && !isPredefinedLayoutLike(action.Encoder?.layout.value)) {
const filePath = resolve(dirname(path), action.Encoder.layout.value);
this.layoutFiles.push({
location: action.Encoder.layout.location,
layout: new JsonFileContext(filePath, compiledLayoutSchema)
});
}
});
}
};
// src/validation/plugin/rules/layout-item-bounds.ts
import chalk4 from "chalk";
var layoutItemsAreWithinBoundsAndNoOverlap = rule(function(plugin) {
plugin.manifest.layoutFiles.forEach(({ layout }) => {
const items = getItemBounds(layout.value);
for (let i = items.length - 1; i >= 0; i--) {
const {
node,
vertices: { x1, x2, y1, y2 }
} = items[i];
if (x1 < 0 || x2 > 200 || y1 < 0 || y2 > 100) {
this.addError(layout.path, "must not be outside of the canvas", {
...node,
suggestion: "Width and height, relative to the x and y, must be within the 200x100 px canvas"
});
}
for (let j = i - 1; j >= 0; j--) {
if (isOverlap(items[i].vertices, items[j].vertices)) {
this.addError(layout.path, `must not overlap ${chalk4.blue(items[j].node.location.key)}`, items[i].node);
}
}
}
});
});
function getItemBounds(layout) {
return layout.items?.reduce((valid, { rect, zOrder }) => {
if (rect?.length === 4) {
valid.push({
node: rect[0],
vertices: {
x1: rect[0].value,
x2: rect[0].value + rect[2].value,
y1: rect[1].value,
y2: rect[1].value + rect[3].value,
z: zOrder?.value ?? 0
}
});
}
return valid;
}, []) || [];
}
function isOverlap(a, b) {
if (a.z !== b.z) {
return false;
}
return !(b.x2 <= a.x1 || b.x1 >= a.x2 || b.y2 <= a.y1 || b.y1 >= a.y2);
}
// src/validation/plugin/rules/layout-item-keys.ts
var layoutItemKeysAreUnique = rule(function(plugin) {
plugin.manifest.layoutFiles.forEach(({ layout }) => {
const keys = /* @__PURE__ */ new Set();
layout.value.items?.forEach(({ key }) => {
if (key?.value === void 0) {
return;
}
if (keys.has(key.value)) {
this.addError(layout.path, "must be unique", key);
} else {
keys.add(key.value);
}
});
});
});
// src/validation/plugin/rules/layout-schema.ts
import { existsSync as existsSync2 } from "node:fs";
var layoutsExistAndSchemasAreValid = rule(function(plugin) {
plugin.manifest.layoutFiles.forEach(({ layout, location }) => {
if (!existsSync2(layout.path)) {
this.addError(plugin.manifest.path, "layout not found", { location });
}
});
plugin.manifest.layoutFiles.forEach(({ layout }) => {
layout.errors.forEach(({ message, location, source }) => {
this.addError(layout.path, transformMessage(message, source), { location });
});
});
});
function transformMessage(message, source) {
if (source.keyword !== "minimum" && source.keyword !== "maximum") {
return message;
}
const match = source.instancePath.match(/\/items\/\d+\/rect\/([0-3])$/);
if (match === null) {
return message;
}
const [, index] = match;
return `${["x", "y", "width", "height"][index]} ${message}`;
}
// src/validation/plugin/rules/manifest-category.ts
var categoryMatchesName = rule(function(plugin) {
const {
manifest: {
value: { Category: category, Name: name }
}
} = plugin;
if (name?.value !== void 0 && category?.value !== name?.value) {
const val = category?.value === void 0 ? void 0 : `'${category}'`;
this.addWarning(plugin.manifest.path, `should match plugin name`, {
location: {
key: "Category"
},
...category,
suggestion: `Expected '${name}', but was ${val}`
});
}
});
// src/validation/plugin/rules/manifest-files-exist.ts
import { existsSync as existsSync4 } from "node:fs";
import { basename as basename3, extname, join as join3, resolve as resolve3 } from "node:path";
// src/system/fs.ts
import ignore from "ignore";
import { get } from "lodash";
import {
cpSync,
createReadStream,
existsSync as existsSync3,
lstatSync,
mkdirSync,
readlinkSync,
rmSync
} from "node:fs";
import { basename as basename2, join as join2, resolve as resolve2 } from "node:path";
import { createInterface } from "node:readline";
var streamDeckIgnoreFilename = ".sdignore";
var defaultIgnorePatterns = [streamDeckIgnoreFilename, ".git", "/.env*", "*.log", "*.js.map"];
async function getIgnores(path, defaultPatterns = defaultIgnorePatterns) {
const i = ignore().add(defaultPatterns);
const file = join2(path, streamDeckIgnoreFilename);
if (existsSync3(file)) {
const fileStream = createReadStream(file);
try {
const rl = createInterface({
input: fileStream,
crlfDelay: Infinity
});
for await (const line of rl) {
i.add(line);
}
} finally {
fileStream.close();
}
}
return (p) => i.ignores(p);
}
// src/validation/plugin/rules/manifest-files-exist.ts
var manifestFilesExist = rule(async function(plugin) {
const missingHighRes = /* @__PURE__ */ new Set();
const ignores = await getIgnores(this.path);
if (ignores(basename3(plugin.manifest.path))) {
this.addError(plugin.manifest.path, "Manifest file must not be ignored", {
suggestion: `Review ${streamDeckIgnoreFilename} file`
});
}
const filePaths = new Map(plugin.manifest.schema.filePathsKeywords);
plugin.manifest.value.Actions?.forEach((action) => {
if (action.Encoder?.layout?.value !== void 0 && isPredefinedLayoutLike(action.Encoder?.layout?.value)) {
filePaths.delete(action.Encoder.layout.location.instancePath);
}
});
filePaths.forEach((opts, instancePath) => {
const nodeRef = plugin.manifest.find(instancePath);
if (nodeRef?.node === void 0) {
return;
}
const { node } = nodeRef;
if (typeof node.value !== "string" || plugin.manifest.errors.find((e) => e.location?.instancePath === instancePath)) {
return;
}
const possiblePaths = typeof opts === "object" && !opts.includeExtension ? opts.extensions.map((ext) => `${node.value}${ext}`) : [node.value];
let resolvedPath = void 0;
for (const possiblePath of possiblePaths) {
const path = resolve3(this.path, possiblePath);
if (existsSync4(path)) {
if (resolvedPath !== void 0) {
this.addWarning(
plugin.manifest.path,
`multiple files named ${colorize(node.value)} found, using ${colorize(resolvedPath)}`,
node
);
break;
}
resolvedPath = possiblePath;
}
}
if (resolvedPath === void 0) {
this.addError(plugin.manifest.path, `file not found, ${colorize(node.value)}`, {
...node,
suggestion: typeof opts === "object" ? `File must be ${aggregate(opts.extensions, "or")}` : void 0
});
return;
}
if (ignores(resolvedPath)) {
this.addError(plugin.manifest.path, `file must not be ignored, ${colorize(resolvedPath)}`, {
...node,
suggestion: `Review ${streamDeckIgnoreFilename} file`
});
return;
}
if (extname(resolvedPath) === ".png") {
const fullPath = join3(this.path, resolvedPath);
if (missingHighRes.has(fullPath)) {
return;
}
if (!existsSync4(join3(this.path, `${node.value}@2x.png`))) {
this.addWarning(fullPath, "should have high-resolution (@2x) variant", {
location: {
key: node.location.key
}
});
missingHighRes.add(fullPath);
}
}
});
});
// src/validation/plugin/rules/manifest-schema.ts
import { existsSync as existsSync5 } from "node:fs";
var manifestExistsAndSchemaIsValid = rule(function(plugin) {
if (!existsSync5(this.path)) {
return;
}
if (!existsSync5(plugin.manifest.path)) {
this.addError(plugin.manifest.path, "Manifest not found");
}
plugin.manifest.errors.forEach(({ message, location }) => {
if (plugin.hasValidId && location?.instancePath === "" && message === "must contain property: UUID") {
this.addError(plugin.manifest.path, message, {
location,
suggestion: `Expected: ${plugin.id}`
});
return;
}
this.addError(plugin.manifest.path, message, { location });
});
});
// src/validation/plugin/rules/manifest-urls-exist.ts
import chalk5 from "chalk";
var manifestUrlsExist = rule(async function(plugin) {
const {
manifest: {
value: { URL: url }
}
} = plugin;
if (url?.value == void 0) {
return;
}
let parsedUrl;
try {
parsedUrl = new URL(url.value);
} catch {
this.addError(plugin.manifest.path, "must be valid URL", {
...url,
suggestion: !url.value.toLowerCase().startsWith("http") ? "Protocol must be http or https" : void 0
});
return;
}
if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") {
this.addError(plugin.manifest.path, "must have http or https protocol", url);
return;
}
try {
const { status } = await fetch(url.value, { method: "HEAD" });
if (status < 200 || status >= 300) {
this.addWarning(plugin.manifest.path, `should return success (received ${chalk5.yellow(status)})`, {
...url,
suggestion: "Status code should be 2xx"
});
}
} catch (err) {
if (err instanceof Error && typeof err.cause === "object" && err.cause && "code" in err.cause && err.cause.code === "ENOTFOUND") {
this.addError(plugin.manifest.path, "must be resolvable", url);
} else {
throw err;
}
}
});
// src/validation/plugin/rules/manifest-uuids.ts
var manifestUuids = rule(async function(plugin) {
const { value: manifest } = plugin.manifest;
if (plugin.hasValidId && manifest.UUID?.value !== void 0 && plugin.id !== manifest.UUID.value) {
this.addError(plugin.manifest.path, "must match parent directory name", {
location: manifest.UUID.location,
suggestion: `Expected: ${plugin.id}`
});
}
const uuids = /* @__PURE__ */ new Set();
manifest.Actions?.forEach(({ UUID: uuid }) => {
if (uuid?.value === void 0) {
return;
}
if (uuids.has(uuid.value)) {
this.addError(plugin.manifest.path, "must be unique", uuid);
} else {
uuids.add(uuid.value);
}
if (plugin.hasValidId && !uuid.value.startsWith(plugin.id)) {
this.addWarning(plugin.manifest.path, `should be prefixed with ${colorize(plugin.id)}`, uuid);
}
});
});
// src/validation/plugin/rules/path-input.ts
import { existsSync as existsSync6, lstatSync as lstatSync2 } from "node:fs";
import { basename as basename4 } from "node:path";
var pathIsDirectoryAndUuid = rule(function(plugin) {
const name = basename4(this.path);
if (!existsSync6(this.path)) {
this.addError(this.path, "Directory not found");
return;
}
if (!lstatSync2(this.path).isDirectory()) {
this.addError(this.path, "Path must be a directory");
return;
}
if (!name.endsWith(directorySuffix)) {
this.addError(this.path, `Name must be suffixed with ${colorize(".sdPlugin")}`);
}
if (!isValidPluginId(plugin.id)) {
this.addError(
this.path,
"Name must be in reverse DNS format, and must only contain lowercase alphanumeric characters (a-z, 0-9), hyphens (-), and periods (.)",
{
suggestion: "Example: com.elgato.wave-link"
}
);
}
});
// src/validation/plugin/index.ts
async function validatePlugin(path) {
const ctx = await createContext(path);
return validate(path, ctx, [
pathIsDirectoryAndUuid,
manifestExistsAndSchemaIsValid,
manifestFilesExist,
manifestUuids,
manifestUrlsExist,
categoryMatchesName,
layoutsExistAndSchemasAreValid,
layoutItemKeysAreUnique,
layoutItemsAreWithinBoundsAndNoOverlap
]);
}
// src/index.ts
chalk6.level = 0;
export {
ValidationLevel,
validatePlugin as validateStreamDeckPlugin
};