UNPKG

@elgato/cli

Version:

Stream Deck CLI tool for building with Stream Deck.

1,056 lines (1,024 loc) 33.2 kB
/**! * @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 };