From 725678c126cf962609168e4bf63cd81299454839 Mon Sep 17 00:00:00 2001 From: Richard Herman Date: Fri, 23 Feb 2024 18:46:26 +0000 Subject: [PATCH 01/11] feat: add .streamDeckPlugin creation --- package-lock.json | 12 +++++ package.json | 1 + src/commands/index.ts | 1 + src/commands/pack.ts | 44 ++++++++++++++++ src/commands/validate.ts | 66 +++++++++++++++--------- src/common/command.ts | 2 +- src/index.ts | 19 +++---- src/system/fs.ts | 105 +++++++++++++++++++++++++++++++++++++++ src/validation/result.ts | 38 ++++++++------ 9 files changed, 239 insertions(+), 49 deletions(-) create mode 100644 src/commands/pack.ts create mode 100644 src/system/fs.ts diff --git a/package-lock.json b/package-lock.json index 1f46f6d..96c90bc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,6 +31,7 @@ "@types/tar": "^6.1.11", "@typescript-eslint/eslint-plugin": "^6.7.4", "@typescript-eslint/parser": "^6.7.4", + "@zip.js/zip.js": "^2.7.34", "ajv": "^8.12.0", "chalk": "^5.3.0", "commander": "^11.0.0", @@ -1350,6 +1351,17 @@ "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==", "dev": true }, + "node_modules/@zip.js/zip.js": { + "version": "2.7.34", + "resolved": "https://registry.npmjs.org/@zip.js/zip.js/-/zip.js-2.7.34.tgz", + "integrity": "sha512-SWAK+hLYKRHswhakNUirPYrdsflSFOxykUckfbWDcPvP8tjLuV5EWyd3GHV0hVaJLDps40jJnv8yQVDbWnQDfg==", + "dev": true, + "engines": { + "bun": ">=0.7.0", + "deno": ">=1.0.0", + "node": ">=16.5.0" + } + }, "node_modules/acorn": { "version": "8.11.3", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz", diff --git a/package.json b/package.json index e05641e..7442d90 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "@types/tar": "^6.1.11", "@typescript-eslint/eslint-plugin": "^6.7.4", "@typescript-eslint/parser": "^6.7.4", + "@zip.js/zip.js": "^2.7.34", "ajv": "^8.12.0", "chalk": "^5.3.0", "commander": "^11.0.0", diff --git a/src/commands/index.ts b/src/commands/index.ts index 578ba5d..5a8248b 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -2,6 +2,7 @@ export * as config from "./config"; export { create } from "./create"; export { setDeveloperMode } from "./dev"; export { link } from "./link"; +export { pack } from "./pack"; export { restart } from "./restart"; export { stop } from "./stop"; export { validate } from "./validate"; diff --git a/src/commands/pack.ts b/src/commands/pack.ts new file mode 100644 index 0000000..25d2764 --- /dev/null +++ b/src/commands/pack.ts @@ -0,0 +1,44 @@ +import { ZipWriter } from "@zip.js/zip.js"; +import { createReadStream, createWriteStream } from "node:fs"; +import { basename, join, resolve } from "node:path"; +import { Readable, Writable } from "node:stream"; +import { command } from "../common/command"; +import { getPluginId } from "../stream-deck"; +import { getFiles } from "../system/fs"; +import { defaultOptions, validate, type ValidateOptions } from "./validate"; + +/** + * TODO: + * - Add an `-o|--output` option. + * - Add a `--dry-run` option. + * - Add output information, similar to `npm pack`. + */ + +/** + * Packs the plugin to a `.streamDeckPlugin` files. + */ +export const pack = command(async (options, stdout) => { + await validate({ + ...options, + quietSuccess: true + }); + + const path = resolve(options.path); + const output = resolve(process.cwd(), `${getPluginId(path)}.streamDeckPlugin`); + const fileStream = Writable.toWeb(createWriteStream(output)); + const zip = new ZipWriter(fileStream); + const prefix = basename(path); + + for (const file of getFiles(path)) { + const name = join(prefix, file.path.relative).replaceAll("\\", "/"); + await zip.add(name, Readable.toWeb(createReadStream(file.path.absolute))); + } + + await zip.close(); + stdout.success("Created"); +}, defaultOptions); + +/** + * Options available to {@link pack}. + */ +type PackOptions = ValidateOptions; diff --git a/src/commands/validate.ts b/src/commands/validate.ts index d5e3dca..eff1585 100644 --- a/src/commands/validate.ts +++ b/src/commands/validate.ts @@ -1,5 +1,4 @@ import { resolve } from "node:path"; -import { cwd } from "node:process"; import { command } from "../common/command"; import { store } from "../common/storage"; import { packageManager } from "../package-manager"; @@ -10,36 +9,50 @@ import { validatePlugin } from "../validation/plugin"; */ const LAST_UPDATE_CHECK_STORE_KEY = "validateSchemasLastUpdateCheck"; +/** + * Default {@link ValidateOptions}. + */ +export const defaultOptions = { + forceUpdateCheck: false, + quietSuccess: false, + path: process.cwd(), + updateCheck: true +} satisfies ValidateOptions; + /** * Validates the given path, and outputs the results. */ -export const validate = command( - async (options, stdout) => { - // Determine whether the schemas should be updated. - if (canUpdateCheck(options)) { - const update = await packageManager.checkUpdate("@elgato/schemas"); - if (update) { - await stdout.spin("Updating validation rules", async () => { - await packageManager.install(update); - stdout.info(`Validation rules updated`); - }); - } +export const validate = command(async (options, stdout) => { + // Check for conflicting options. + if (!options.updateCheck && options.forceUpdateCheck) { + console.log(`error: option '--force-update-check' cannot be used with option '--no-update-check'`); + process.exit(1); + } - // Log the update check. - store.set(LAST_UPDATE_CHECK_STORE_KEY, new Date()); + // Determine whether the schemas should be updated. + if (canUpdateCheck(options)) { + const update = await packageManager.checkUpdate("@elgato/schemas"); + if (update) { + await stdout.spin("Updating validation rules", async () => { + await packageManager.install(update); + stdout.info(`Validation rules updated`); + }); } - // Validate the plugin, and log the result. - const result = await validatePlugin(resolve(options.path)); + // Log the update check. + store.set(LAST_UPDATE_CHECK_STORE_KEY, new Date()); + } + + // Validate the plugin and write the output (ignoring success if we should be quiet). + const result = await validatePlugin(resolve(options.path)); + if (result.hasErrors() || result.hasWarnings() || !options.quietSuccess) { result.writeTo(stdout); - stdout.exit(result.success ? 0 : 1); - }, - { - forceUpdateCheck: false, - path: cwd(), - updateCheck: true } -); + + if (result.hasErrors()) { + stdout.exit(1); + } +}, defaultOptions); /** * Determines whether an update check can occur for the JSON schemas used to validate files. @@ -70,7 +83,7 @@ function canUpdateCheck(opts: Required): boolean { /** * Options available to {@link validate}. */ -type ValidateOptions = { +export type ValidateOptions = { /** * Determines whether an update check **must** happen. */ @@ -81,6 +94,11 @@ type ValidateOptions = { */ readonly path?: string; + /** + * Determines whether to hide the success message + */ + readonly quietSuccess?: boolean; + /** * Determines whether an update check can occur. */ diff --git a/src/common/command.ts b/src/common/command.ts index 103a133..eedb8e3 100644 --- a/src/common/command.ts +++ b/src/common/command.ts @@ -14,7 +14,7 @@ import { createConsole, createQuietConsole, StdOut } from "./stdout"; export function command( fn: (options: Options, output: StdOut) => Promise | void, ...[defaultOptions]: OptionalWhenEmpty, never, Required>> -): (...[options]: OptionalWhenEmpty, GlobalOptions & T>) => void { +): (...[options]: OptionalWhenEmpty, GlobalOptions & T>) => Promise | void { return async (...[options]: OptionalWhenEmpty, GlobalOptions & T>) => { const opts = _.merge({ quiet: false }, defaultOptions as Required>, options as GlobalOptions & PickRequired); diff --git a/src/index.ts b/src/index.ts index 86d1548..60cd82b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,5 @@ import { program } from "commander"; -import { config, create, link, restart, setDeveloperMode, stop, validate } from "./commands"; +import { config, create, link, pack, restart, setDeveloperMode, stop, validate } from "./commands"; import { packageManager } from "./package-manager"; program.version(packageManager.getVersion(), "-v, --version", "display CLI version"); @@ -41,15 +41,16 @@ program .argument("[path]", "Path of the plugin to validate") .option("--force-update-check", "Forces an update check", false) .option("--no-update-check", "Disables updating schemas", true) - .action((path, { forceUpdateCheck, updateCheck }) => { - // Check for conflicting options. - if (!updateCheck && forceUpdateCheck) { - console.log(`error: option '--force-update-check' cannot be used with option '--no-update-check'`); - process.exit(1); - } + .action((path, { forceUpdateCheck, updateCheck }) => validate({ forceUpdateCheck, path, updateCheck })); - validate({ forceUpdateCheck, path, updateCheck }); - }); +program + .command("pack") + .alias("bundle") + .description("Create a .streamDeckPlugin file from the plugin.") + .argument("[path]", "Path of the plugin to pack") + .option("--force-update-check", "Forces an update check", false) + .option("--no-update-check", "Disables updating schemas", true) + .action((path, { forceUpdateCheck, updateCheck }) => pack({ forceUpdateCheck, path, updateCheck })); const configCommand = program.command("config").description("Manage the local configuration."); diff --git a/src/system/fs.ts b/src/system/fs.ts new file mode 100644 index 0000000..f4cb0fa --- /dev/null +++ b/src/system/fs.ts @@ -0,0 +1,105 @@ +import { existsSync, lstatSync, readdirSync } from "node:fs"; +import { platform } from "node:os"; +import { basename, resolve } from "node:path"; + +/** + * Gets file information for all files within the specified {@link path}. + * @param path Path to the directory to read. + * @yields Files in the directory. + */ +export function* getFiles(path: string): Generator { + if (!existsSync(path)) { + return; + } + + if (!lstatSync(path).isDirectory()) { + throw new Error("Path is not a directory"); + } + + // We don't use withFileTypes as this yields incomplete results in Node.js 20.5.1; as we need the size anyway, we instead can rely on lstatSync as a direct call. + for (const entry of readdirSync(path, { encoding: "utf-8", recursive: true })) { + const absolute = resolve(path, entry); + const stats = lstatSync(absolute); + + if (!stats.isFile()) { + continue; + } + + yield { + name: basename(absolute), + path: { + absolute, + relative: entry + }, + size: { + bytes: stats.size, + text: sizeAsString(stats.size) + } + }; + } +} + +/** + * Gets the platform-specific string representation of the file size, to 1 decimal place. For example, given 1060 bytes: + * - macOS would yield "1.1 kB" as it is decimal based (1000). + * - Windows would yield "1.0 KiB" as it is binary-based (1024). + * @param bytes Size in bytes. + * @returns String representation of the size. + */ +export function sizeAsString(bytes: number): string { + const isBinary = platform() === "win32"; + const units = isBinary ? ["KiB", "MiB", "GiB", "TiB"] : ["kB", "MB", "GB", "TB"]; + const unitSize = isBinary ? 1024 : 1000; + + if (bytes < unitSize) { + return `${bytes} B`; + } + + let i = -1; + do { + bytes /= unitSize; + i++; + } while (Math.round(bytes) >= unitSize); + + return `${bytes.toFixed(1)} ${units[i]}`; +} + +/** + * Information about a file. + */ +export type FileInfo = { + /** + * Name of the file. + */ + name: string; + + /** + * Path to the file. + */ + path: { + /** + * Absolute path to the file. + */ + absolute: string; + + /** + * Relative path to the file. + */ + relative: string; + }; + + /** + * Size of the file in bytes. + */ + size: { + /** + * Size in bytes. + */ + bytes: number; + + /** + * String representation of the size, for example "1.8 GiB", "2.3 kB", etc. + */ + text: string; + }; +}; diff --git a/src/validation/result.ts b/src/validation/result.ts index 186c0a6..72fcb23 100644 --- a/src/validation/result.ts +++ b/src/validation/result.ts @@ -16,14 +16,6 @@ export class ValidationResult extends Array implement */ private warningCount = 0; - /** - * Determines whether the validation result is considered successful. - * @returns `true` when validation passed; otherwise `false`. - */ - public get success(): boolean { - return this.errorCount === 0; - } - /** * Adds a new validation entry to the result. * @param path Directory or file path the entry is associated with. @@ -45,6 +37,22 @@ export class ValidationResult extends Array implement collection.add(entry); } + /** + * Determines whether the result contains errors. + * @returns `true` when the result has errors. + */ + public hasErrors(): boolean { + return this.errorCount > 0; + } + + /** + * Determines whether the result contains warnings. + * @returns `true` when the result has warnings. + */ + public hasWarnings(): boolean { + return this.warningCount > 0; + } + /** * Writes the results to the specified {@link output}. * @param output Output to write to. @@ -56,26 +64,26 @@ export class ValidationResult extends Array implement this.forEach((collection) => collection.writeTo(output)); } - // Validation was successful. - if (this.errorCount === 0 && this.warningCount === 0) { - output.success("Validation successful"); + // Both errors and warnings. + if (this.hasErrors() && this.hasWarnings()) { + output.error(`${pluralize("problem", this.errorCount + this.warningCount)} (${pluralize("error", this.errorCount)}, ${pluralize("warning", this.warningCount)})`); return; } // Only errors. - if (this.warningCount === 0) { + if (this.hasErrors()) { output.error(`Failed with ${pluralize("error", this.errorCount)}`); return; } // Only warnings. - if (this.errorCount === 0) { + if (this.hasWarnings()) { output.warn(pluralize("warning", this.warningCount)); return; } - // Both errors and warnings. - output.error(`${pluralize("problem", this.errorCount + this.warningCount)} (${pluralize("error", this.errorCount)}, ${pluralize("warning", this.warningCount)})`); + // Validation was successful. + output.success("Validation successful"); /** * Pluralizes the {@link noun} based on the {@link count}. From 7d88faa3c8f0d45299f539f83b52cc0456a55c83 Mon Sep 17 00:00:00 2001 From: Richard Herman Date: Fri, 23 Feb 2024 18:48:32 +0000 Subject: [PATCH 02/11] refactor: relocate moveSync --- src/common/path.ts | 41 +---------------------------------------- src/package-manager.ts | 3 ++- src/system/fs.ts | 41 ++++++++++++++++++++++++++++++++++++++++- 3 files changed, 43 insertions(+), 42 deletions(-) diff --git a/src/common/path.ts b/src/common/path.ts index 9cb52b0..5ab9575 100644 --- a/src/common/path.ts +++ b/src/common/path.ts @@ -1,4 +1,4 @@ -import { cpSync, existsSync, lstatSync, mkdirSync, readdirSync, readlinkSync, rmSync, Stats } from "node:fs"; +import { existsSync, lstatSync, readdirSync, readlinkSync, Stats } from "node:fs"; import { delimiter, dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; @@ -55,45 +55,6 @@ export function isSafeBaseName(value: string): boolean { return !invalidCharacters.some((invalid) => value.includes(invalid)); } -/** - * Synchronously moves the {@link source} to the {@link dest} path. - * @param source Source path being moved. - * @param dest Destination where the {@link source} will be moved to. - * @param options Options that define the move. - */ -export function moveSync(source: string, dest: string, options?: MoveOptions): void { - if (!existsSync(source)) { - throw new Error("Source does not exist"); - } - - if (!lstatSync(source).isDirectory()) { - throw new Error("Source must be a directory"); - } - - if (existsSync(dest)) { - if (options?.overwrite) { - rmSync(dest, { recursive: true }); - } else { - throw new Error("Destination already exists"); - } - } - - // Ensure the new directory exists, copy the contents, and clean-up. - mkdirSync(dest, { recursive: true }); - cpSync(source, dest, { recursive: true }); - rmSync(source, { recursive: true }); -} - -/** - * Defines how a path will be relocated. - */ -type MoveOptions = { - /** - * When the destination path already exists, it will be overwritten. - */ - overwrite?: boolean; -}; - /** * Resolves the specified {@link path} relatives to the entry point. * @param path Path being resolved. diff --git a/src/package-manager.ts b/src/package-manager.ts index 6deaf62..7b4a5f3 100644 --- a/src/package-manager.ts +++ b/src/package-manager.ts @@ -5,7 +5,8 @@ import { Readable } from "node:stream"; import semver from "semver"; import tar from "tar"; import { dependencies, version } from "../package.json"; -import { moveSync, relative } from "./common/path"; +import { relative } from "./common/path"; +import { moveSync } from "./system/fs"; /** * Light-weight package manager that wraps npm, capable of updating locally-scoped installed packages. diff --git a/src/system/fs.ts b/src/system/fs.ts index f4cb0fa..52232d7 100644 --- a/src/system/fs.ts +++ b/src/system/fs.ts @@ -1,4 +1,4 @@ -import { existsSync, lstatSync, readdirSync } from "node:fs"; +import { cpSync, existsSync, lstatSync, mkdirSync, readdirSync, rmSync } from "node:fs"; import { platform } from "node:os"; import { basename, resolve } from "node:path"; @@ -39,6 +39,45 @@ export function* getFiles(path: string): Generator { } } +/** + * Synchronously moves the {@link source} to the {@link dest} path. + * @param source Source path being moved. + * @param dest Destination where the {@link source} will be moved to. + * @param options Options that define the move. + */ +export function moveSync(source: string, dest: string, options?: MoveOptions): void { + if (!existsSync(source)) { + throw new Error("Source does not exist"); + } + + if (!lstatSync(source).isDirectory()) { + throw new Error("Source must be a directory"); + } + + if (existsSync(dest)) { + if (options?.overwrite) { + rmSync(dest, { recursive: true }); + } else { + throw new Error("Destination already exists"); + } + } + + // Ensure the new directory exists, copy the contents, and clean-up. + mkdirSync(dest, { recursive: true }); + cpSync(source, dest, { recursive: true }); + rmSync(source, { recursive: true }); +} + +/** + * Defines how a path will be relocated. + */ +type MoveOptions = { + /** + * When the destination path already exists, it will be overwritten. + */ + overwrite?: boolean; +}; + /** * Gets the platform-specific string representation of the file size, to 1 decimal place. For example, given 1060 bytes: * - macOS would yield "1.1 kB" as it is decimal based (1000). From 105a77e788585e68b7107f2aa280c02c21edc2ea Mon Sep 17 00:00:00 2001 From: Richard Herman Date: Fri, 23 Feb 2024 18:51:43 +0000 Subject: [PATCH 03/11] refactor: relocate isDirectory and isFile --- src/common/path.ts | 40 ++-------------------------------------- src/system/fs.ts | 39 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 40 insertions(+), 39 deletions(-) diff --git a/src/common/path.ts b/src/common/path.ts index 5ab9575..359aa85 100644 --- a/src/common/path.ts +++ b/src/common/path.ts @@ -1,6 +1,7 @@ -import { existsSync, lstatSync, readdirSync, readlinkSync, Stats } from "node:fs"; +import { existsSync, readdirSync } from "node:fs"; import { delimiter, dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import { isDirectory, isFile } from "../system/fs"; /** * Determines whether the specified {@link application} is accessible from the environment's PATH variable and can be executed. @@ -17,24 +18,6 @@ export function isExecutable(application: string): boolean { return false; } -/** - * Determines whether the specified {@link path}, or the path it links to, is a directory. - * @param path The path. - * @returns `true` when the path, or the path it links to, is a directory; otherwise `false`. - */ -export function isDirectory(path: string): boolean { - return checkStats(path, (stats) => stats?.isDirectory() === true); -} - -/** - * Determines whether the specified {@link path}, or the path it links to, is a file. - * @param path The path. - * @returns `true` when the path, or the path it links to, is a file; otherwise `false`. - */ -export function isFile(path: string): boolean { - return checkStats(path, (stats) => stats?.isFile() === true); -} - /** * Validates the specified {@link value} represents a valid directory name. * @param value Value to validate. @@ -64,25 +47,6 @@ export function relative(path: string): string { return resolve(dirname(fileURLToPath(import.meta.url)), path); } -/** - * Checks the stats of a given path and applies the {@link check} to them to determine the result. - * @param path Path to check; when the path represents a symbolic link, the link is referenced. - * @param check Function used to determine if the stats fulfil the check. - * @returns `true` when the stats of the {@link path} fulfil the {@link check}; otherwise `false`. - */ -function checkStats(path: string, check: (stats?: Stats) => boolean): boolean { - const stats = lstatSync(path, { throwIfNoEntry: false }); - if (stats === undefined) { - return false; - } - - if (stats.isSymbolicLink()) { - return checkStats(readlinkSync(path), check); - } - - return check(stats); -} - /** * Collection of characters that are considered invalid when part of a directory name. */ diff --git a/src/system/fs.ts b/src/system/fs.ts index 52232d7..fe35914 100644 --- a/src/system/fs.ts +++ b/src/system/fs.ts @@ -1,4 +1,4 @@ -import { cpSync, existsSync, lstatSync, mkdirSync, readdirSync, rmSync } from "node:fs"; +import { cpSync, existsSync, lstatSync, mkdirSync, readdirSync, readlinkSync, rmSync, type Stats } from "node:fs"; import { platform } from "node:os"; import { basename, resolve } from "node:path"; @@ -39,6 +39,24 @@ export function* getFiles(path: string): Generator { } } +/** + * Determines whether the specified {@link path}, or the path it links to, is a directory. + * @param path The path. + * @returns `true` when the path, or the path it links to, is a directory; otherwise `false`. + */ +export function isDirectory(path: string): boolean { + return checkStats(path, (stats) => stats?.isDirectory() === true); +} + +/** + * Determines whether the specified {@link path}, or the path it links to, is a file. + * @param path The path. + * @returns `true` when the path, or the path it links to, is a file; otherwise `false`. + */ +export function isFile(path: string): boolean { + return checkStats(path, (stats) => stats?.isFile() === true); +} + /** * Synchronously moves the {@link source} to the {@link dest} path. * @param source Source path being moved. @@ -103,6 +121,25 @@ export function sizeAsString(bytes: number): string { return `${bytes.toFixed(1)} ${units[i]}`; } +/** + * Checks the stats of a given path and applies the {@link check} to them to determine the result. + * @param path Path to check; when the path represents a symbolic link, the link is referenced. + * @param check Function used to determine if the stats fulfil the check. + * @returns `true` when the stats of the {@link path} fulfil the {@link check}; otherwise `false`. + */ +function checkStats(path: string, check: (stats?: Stats) => boolean): boolean { + const stats = lstatSync(path, { throwIfNoEntry: false }); + if (stats === undefined) { + return false; + } + + if (stats.isSymbolicLink()) { + return checkStats(readlinkSync(path), check); + } + + return check(stats); +} + /** * Information about a file. */ From 982a877a4c229f570335d8c9a1441efbcc1606b9 Mon Sep 17 00:00:00 2001 From: Richard Herman Date: Fri, 23 Feb 2024 18:53:16 +0000 Subject: [PATCH 04/11] refactor: re-locate path helpers --- src/commands/create.ts | 2 +- src/common/storage.ts | 2 +- src/config.ts | 2 +- src/package-manager.ts | 2 +- src/{common => system}/path.ts | 0 5 files changed, 4 insertions(+), 4 deletions(-) rename src/{common => system}/path.ts (100%) diff --git a/src/commands/create.ts b/src/commands/create.ts index a0f30d6..51dfe3d 100644 --- a/src/commands/create.ts +++ b/src/commands/create.ts @@ -5,7 +5,7 @@ import path from "node:path"; import { command } from "../common/command"; import { createCopier } from "../common/file-copier"; -import { invalidCharacters, isExecutable, isSafeBaseName, relative } from "../common/path"; +import { invalidCharacters, isExecutable, isSafeBaseName, relative } from "../system/path"; import { run } from "../common/runner"; import { StdOut } from "../common/stdout"; import { getConfig } from "../config"; diff --git a/src/common/storage.ts b/src/common/storage.ts index 619c00f..f24487a 100644 --- a/src/common/storage.ts +++ b/src/common/storage.ts @@ -1,5 +1,5 @@ import { readFileSync, writeFileSync } from "fs"; -import { relative } from "./path"; +import { relative } from "../system/path"; const storePath = relative("../.cli.cache"); diff --git a/src/config.ts b/src/config.ts index aeb38c3..0291fec 100644 --- a/src/config.ts +++ b/src/config.ts @@ -6,7 +6,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { homedir, platform } from "node:os"; import { dirname, join } from "node:path"; -import { relative } from "./common/path"; +import { relative } from "./system/path"; let __config: Config | undefined = undefined; diff --git a/src/package-manager.ts b/src/package-manager.ts index 7b4a5f3..23dc536 100644 --- a/src/package-manager.ts +++ b/src/package-manager.ts @@ -5,7 +5,7 @@ import { Readable } from "node:stream"; import semver from "semver"; import tar from "tar"; import { dependencies, version } from "../package.json"; -import { relative } from "./common/path"; +import { relative } from "./system/path"; import { moveSync } from "./system/fs"; /** diff --git a/src/common/path.ts b/src/system/path.ts similarity index 100% rename from src/common/path.ts rename to src/system/path.ts From 0eb5e215eee287095383a33b189713c74104a653 Mon Sep 17 00:00:00 2001 From: Richard Herman Date: Sat, 24 Feb 2024 19:06:04 +0000 Subject: [PATCH 05/11] feat: add ability to ignore files when packing --- package-lock.json | 77 ++++++++++--------- package.json | 1 + src/system/fs.ts | 58 ++++++++++++-- .../plugin/rules/manifest-files-exist.ts | 23 +++++- 4 files changed, 113 insertions(+), 46 deletions(-) diff --git a/package-lock.json b/package-lock.json index 96c90bc..c569207 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41,6 +41,7 @@ "eslint-plugin-jsdoc": "^46.8.2", "eslint-plugin-prettier": "^5.0.0", "find-process": "^1.4.7", + "ignore": "^5.3.1", "inquirer": "^9.2.11", "is-interactive": "^2.0.0", "lodash": "^4.17.21", @@ -1303,6 +1304,35 @@ } } }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/globby": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "dev": true, + "dependencies": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/@typescript-eslint/utils": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-6.21.0.tgz", @@ -1800,6 +1830,15 @@ "node": ">=8" } }, + "node_modules/dir-glob/node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/doctrine": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", @@ -2551,26 +2590,6 @@ "node": ">=4" } }, - "node_modules/globby": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", - "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", - "dev": true, - "dependencies": { - "array-union": "^2.1.0", - "dir-glob": "^3.0.1", - "fast-glob": "^3.2.9", - "ignore": "^5.2.0", - "merge2": "^1.4.1", - "slash": "^3.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/gopd": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", @@ -3438,15 +3457,6 @@ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", "dev": true }, - "node_modules/path-type": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", - "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", - "dev": true, - "engines": { - "node": ">=8" - } - }, "node_modules/picomatch": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", @@ -3835,15 +3845,6 @@ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "dev": true }, - "node_modules/slash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", - "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", - "dev": true, - "engines": { - "node": ">=8" - } - }, "node_modules/smob": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/smob/-/smob-1.4.1.tgz", diff --git a/package.json b/package.json index 7442d90..94e6020 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,7 @@ "eslint-plugin-jsdoc": "^46.8.2", "eslint-plugin-prettier": "^5.0.0", "find-process": "^1.4.7", + "ignore": "^5.3.1", "inquirer": "^9.2.11", "is-interactive": "^2.0.0", "lodash": "^4.17.21", diff --git a/src/system/fs.ts b/src/system/fs.ts index fe35914..85d9562 100644 --- a/src/system/fs.ts +++ b/src/system/fs.ts @@ -1,13 +1,19 @@ -import { cpSync, existsSync, lstatSync, mkdirSync, readdirSync, readlinkSync, rmSync, type Stats } from "node:fs"; +import ignore from "ignore"; +import { cpSync, createReadStream, existsSync, lstatSync, mkdirSync, readlinkSync, rmSync, type Stats } from "node:fs"; +import { lstat, readdir } from "node:fs/promises"; import { platform } from "node:os"; -import { basename, resolve } from "node:path"; +import { basename, join, resolve } from "node:path"; +import { createInterface } from "node:readline"; + +export const streamDeckIgnoreFilename = ".sdignore"; /** - * Gets file information for all files within the specified {@link path}. + * Gets files within the specified {@link path}, and their associated information, for example file size. When a `.sdignore` file is found within the {@link path}, it is respected, + * and the files are ignored. * @param path Path to the directory to read. * @yields Files in the directory. */ -export function* getFiles(path: string): Generator { +export async function* getFiles(path: string): AsyncGenerator { if (!existsSync(path)) { return; } @@ -16,11 +22,19 @@ export function* getFiles(path: string): Generator { throw new Error("Path is not a directory"); } + const ignores = await getIgnores(path); + // We don't use withFileTypes as this yields incomplete results in Node.js 20.5.1; as we need the size anyway, we instead can rely on lstatSync as a direct call. - for (const entry of readdirSync(path, { encoding: "utf-8", recursive: true })) { + for (const entry of await readdir(path, { encoding: "utf-8", recursive: true })) { + // Ensure the file isn't ignored. + if (ignores(entry)) { + continue; + } + const absolute = resolve(path, entry); - const stats = lstatSync(absolute); + const stats = await lstat(absolute); + // We only want files. if (!stats.isFile()) { continue; } @@ -39,6 +53,38 @@ export function* getFiles(path: string): Generator { } } +/** + * Builds an ignore predicate from the `.sdignore` file located within the specified {@link path}. The predicate will return `true` when the path supplied to it should be ignored. + * @param path Path to the directory that contains the optional `.sdignore` file. + * @returns Predicate that determines whether the path should be ignored; returns `true` when the path should be ignored. + */ +export async function getIgnores(path: string): Promise<(path: string) => boolean> { + const file = join(path, streamDeckIgnoreFilename); + if (!existsSync(file)) { + return () => false; + } + + // Open the ".sdignore" to determine the ignore patterns. + const fileStream = createReadStream(file); + const i = ignore().add(streamDeckIgnoreFilename); + + try { + const rl = createInterface({ + input: fileStream, + crlfDelay: Infinity + }); + + // Treat each line as a pattern, adding it to the ignore. + for await (const line of rl) { + i.add(line); + } + } finally { + fileStream.close(); + } + + return (p) => i.ignores(p); +} + /** * Determines whether the specified {@link path}, or the path it links to, is a directory. * @param path The path. diff --git a/src/validation/plugin/rules/manifest-files-exist.ts b/src/validation/plugin/rules/manifest-files-exist.ts index 4a8357d..d996673 100644 --- a/src/validation/plugin/rules/manifest-files-exist.ts +++ b/src/validation/plugin/rules/manifest-files-exist.ts @@ -1,17 +1,26 @@ import { existsSync } from "node:fs"; -import { extname, join, resolve } from "node:path"; +import { basename, extname, join, resolve } from "node:path"; import { colorize } from "../../../common/stdout"; import { aggregate } from "../../../common/utils"; import { FilePathOptions } from "../../../json"; import { isPredefinedLayoutLike } from "../../../stream-deck"; +import { getIgnores, streamDeckIgnoreFilename } from "../../../system/fs"; import { rule } from "../../rule"; import { type PluginContext } from "../plugin"; /** * Validates the files defined within the manifest exist. */ -export const manifestFilesExist = rule(function (plugin: PluginContext) { +export const manifestFilesExist = rule(async function (plugin: PluginContext) { const missingHighRes = new Set(); + const ignores = await getIgnores(this.path); + + // Validate the manifest is flagged to be ignored. + if (ignores(basename(plugin.manifest.path))) { + this.addError(plugin.manifest.path, "Manifest file must not be ignored", { + suggestion: `Review ${streamDeckIgnoreFilename} file` + }); + } // Determine the values that require validating based on the JSON schema. const filePaths = new Map(plugin.manifest.schema.filePathsKeywords); @@ -65,6 +74,16 @@ export const manifestFilesExist = rule(function (plugin: PluginCo return; } + // Validate the file will be included when packing the plugin. + if (ignores(resolvedPath)) { + this.addError(plugin.manifest.path, `file must not be ignored, ${colorize(resolvedPath)}`, { + ...node, + suggestion: `Review ${streamDeckIgnoreFilename} file` + }); + + return; + } + // Validate there is a high-res version of the image, when the resolved path is a .png file. if (extname(resolvedPath) === ".png") { const fullPath = join(this.path, resolvedPath); From 7d033733c13d89f9c5b7fd529c900ca56a18809a Mon Sep 17 00:00:00 2001 From: Richard Herman Date: Sat, 24 Feb 2024 20:00:28 +0000 Subject: [PATCH 06/11] feat: add summary of packaging --- src/commands/pack.ts | 140 +++++++++++++++++++++++++++++++++++++++---- src/system/fs.ts | 20 ++++++- 2 files changed, 147 insertions(+), 13 deletions(-) diff --git a/src/commands/pack.ts b/src/commands/pack.ts index 25d2764..07739c8 100644 --- a/src/commands/pack.ts +++ b/src/commands/pack.ts @@ -1,10 +1,12 @@ +import { Manifest } from "@elgato/schemas/streamdeck/plugins"; import { ZipWriter } from "@zip.js/zip.js"; +import chalk from "chalk"; import { createReadStream, createWriteStream } from "node:fs"; import { basename, join, resolve } from "node:path"; import { Readable, Writable } from "node:stream"; import { command } from "../common/command"; import { getPluginId } from "../stream-deck"; -import { getFiles } from "../system/fs"; +import { getFiles, readJsonFile, sizeAsString, type FileInfo } from "../system/fs"; import { defaultOptions, validate, type ValidateOptions } from "./validate"; /** @@ -18,27 +20,141 @@ import { defaultOptions, validate, type ValidateOptions } from "./validate"; * Packs the plugin to a `.streamDeckPlugin` files. */ export const pack = command(async (options, stdout) => { + // Validate the plugin. await validate({ ...options, quietSuccess: true }); + // Build the package. const path = resolve(options.path); - const output = resolve(process.cwd(), `${getPluginId(path)}.streamDeckPlugin`); - const fileStream = Writable.toWeb(createWriteStream(output)); - const zip = new ZipWriter(fileStream); - const prefix = basename(path); - - for (const file of getFiles(path)) { - const name = join(prefix, file.path.relative).replaceAll("\\", "/"); - await zip.add(name, Readable.toWeb(createReadStream(file.path.absolute))); - } + const pkgBuilder = getPackageBuilder(path); + const contents = await getPackageContents(path, pkgBuilder.add); + pkgBuilder.close(); + + // Output the contents. + stdout.log(); + stdout.log(`📦 ${contents.manifest.Name} (v${contents.manifest.Version})`); + stdout.log(); + stdout.log(chalk.cyan("Plugin Contents")); + + contents.files.forEach((file, i) => { + stdout.log(`${chalk.dim(i === contents.files.length - 1 ? "└─" : "├─")} ${file.size.text.padEnd(contents.sizePad)} ${file.path.relative}`); + }); - await zip.close(); - stdout.success("Created"); + // Output the details. + stdout + .log() + .log(chalk.cyan("Plugin Details")) + .log(` Name: ${contents.manifest.Name}`) + .log(` Version: ${contents.manifest.Version}`) + .log(` UUID: ${contents.manifest.UUID}`) + .log(` Filename: ${basename(pkgBuilder.path)}`) + .log(` Unpacked size: ${sizeAsString(contents.size)}`) + .log(` Total files: ${contents.files.length}`) + .log(); + + stdout.success("Successfully created package"); }, defaultOptions); +/** + * Gets a package builder capable of constructing a `.streamDeckPlugin` file. + * @param path Path where the package will be output too. + * @returns The package builder. + */ +function getPackageBuilder(path: string): PackageBuilder { + const pkgPath = resolve(process.cwd(), `${getPluginId(path)}.streamDeckPlugin`); + + const entryPrefix = basename(path); + const zipStream = new ZipWriter(Writable.toWeb(createWriteStream(pkgPath))); + + return { + path: pkgPath, + add: async (file: FileInfo): Promise => { + const name = join(entryPrefix, file.path.relative).replaceAll("\\", "/"); + await zipStream.add(name, Readable.toWeb(createReadStream(file.path.absolute))); + }, + close: () => zipStream.close() + }; +} + +/** + * Gets the package contents for the specified {@link path}, assuming it has been validated prior. + * @param path Path to the plugin to package. + * @param fileFn Optional function called for each file that is considered part of the package. + * @returns Information about the package contents. + */ +async function getPackageContents(path: string, fileFn?: (file: FileInfo) => Promise | void): Promise { + // Get the manifest, and generate the base contents. + const manifest = await readJsonFile(join(path, "manifest.json")); + const contents: PackageInfo = { + files: [], + manifest, + size: 0, + sizePad: 0 + }; + + // Add each file to the contents. + for await (const file of getFiles(path)) { + contents.files.push(file); + contents.sizePad = Math.max(contents.sizePad, file.size.text.length); + contents.size += file.size.bytes; + + if (fileFn) { + await fileFn(file); + } + } + + return contents; +} + /** * Options available to {@link pack}. */ type PackOptions = ValidateOptions; + +/** + * Information about the package. + */ +type PackageInfo = { + /** + * Files within the package. + */ + files: FileInfo[]; + + /** + * Manifest the package is associated with. + */ + manifest: Manifest; + + /** + * Unpacked size of the package, in bytes. + */ + size: number; + + /** + * Padding used to align file sizes and their names when outputting the contents to a console. + */ + sizePad: number; +}; + +/** + * Package builder capable of adding files to a package, and outputting them to a `.streamDeckPlugin` file. + */ +type PackageBuilder = { + /** + * Path to the `.streamDeckPlugin` file. + */ + path: string; + + /** + * Adds the specified {@link file} to the package. + * @param file File to add. + */ + add(file: FileInfo): Promise; + + /** + * Closes the package builder. + */ + close(): void; +}; diff --git a/src/system/fs.ts b/src/system/fs.ts index 85d9562..43b6373 100644 --- a/src/system/fs.ts +++ b/src/system/fs.ts @@ -1,6 +1,6 @@ import ignore from "ignore"; import { cpSync, createReadStream, existsSync, lstatSync, mkdirSync, readlinkSync, rmSync, type Stats } from "node:fs"; -import { lstat, readdir } from "node:fs/promises"; +import { lstat, readdir, readFile } from "node:fs/promises"; import { platform } from "node:os"; import { basename, join, resolve } from "node:path"; import { createInterface } from "node:readline"; @@ -132,6 +132,24 @@ export function moveSync(source: string, dest: string, options?: MoveOptions): v rmSync(source, { recursive: true }); } +/** + * Reads the specified {@link path} and parses the contents as JSON. + * @param path Path to the JSON file. + * @returns Contents parsed as JSON. + */ +export async function readJsonFile(path: string): Promise { + if (!existsSync(path)) { + throw new Error(`JSON file not found, ${path}`); + } + + try { + const contents = await readFile(path, { encoding: "utf-8" }); + return JSON.parse(contents); + } catch (cause) { + throw new Error(`Failed to pase JSON file, ${path}`, { cause }); + } +} + /** * Defines how a path will be relocated. */ From a814576373c9526e214668921885c6741c5796d5 Mon Sep 17 00:00:00 2001 From: Richard Herman Date: Sat, 24 Feb 2024 22:08:15 +0000 Subject: [PATCH 07/11] feat: add --dry-run option --- src/commands/pack.ts | 102 ++++++++++++++++++++++++++----------------- src/index.ts | 5 ++- 2 files changed, 64 insertions(+), 43 deletions(-) diff --git a/src/commands/pack.ts b/src/commands/pack.ts index 07739c8..538939d 100644 --- a/src/commands/pack.ts +++ b/src/commands/pack.ts @@ -12,59 +12,74 @@ import { defaultOptions, validate, type ValidateOptions } from "./validate"; /** * TODO: * - Add an `-o|--output` option. - * - Add a `--dry-run` option. - * - Add output information, similar to `npm pack`. */ /** * Packs the plugin to a `.streamDeckPlugin` files. */ -export const pack = command(async (options, stdout) => { - // Validate the plugin. - await validate({ - ...options, - quietSuccess: true - }); - - // Build the package. - const path = resolve(options.path); - const pkgBuilder = getPackageBuilder(path); - const contents = await getPackageContents(path, pkgBuilder.add); - pkgBuilder.close(); - - // Output the contents. - stdout.log(); - stdout.log(`📦 ${contents.manifest.Name} (v${contents.manifest.Version})`); - stdout.log(); - stdout.log(chalk.cyan("Plugin Contents")); - - contents.files.forEach((file, i) => { - stdout.log(`${chalk.dim(i === contents.files.length - 1 ? "└─" : "├─")} ${file.size.text.padEnd(contents.sizePad)} ${file.path.relative}`); - }); - - // Output the details. - stdout - .log() - .log(chalk.cyan("Plugin Details")) - .log(` Name: ${contents.manifest.Name}`) - .log(` Version: ${contents.manifest.Version}`) - .log(` UUID: ${contents.manifest.UUID}`) - .log(` Filename: ${basename(pkgBuilder.path)}`) - .log(` Unpacked size: ${sizeAsString(contents.size)}`) - .log(` Total files: ${contents.files.length}`) - .log(); - - stdout.success("Successfully created package"); -}, defaultOptions); +export const pack = command( + async (options, stdout) => { + // Validate the plugin. + await validate({ + ...options, + quietSuccess: true + }); + + // Build the package. + const path = resolve(options.path); + const pkgBuilder = getPackageBuilder(path, options.dryRun); + const contents = await getPackageContents(path, pkgBuilder.add); + pkgBuilder.close(); + + // Output the contents. + stdout.log(`📦 ${contents.manifest.Name} (v${contents.manifest.Version})`); + stdout.log(); + stdout.log(chalk.cyan("Plugin Contents")); + + contents.files.forEach((file, i) => { + stdout.log(`${chalk.dim(i === contents.files.length - 1 ? "└─" : "├─")} ${file.size.text.padEnd(contents.sizePad)} ${file.path.relative}`); + }); + + // Output the details. + stdout + .log() + .log(chalk.cyan("Plugin Details")) + .log(` Name: ${contents.manifest.Name}`) + .log(` Version: ${contents.manifest.Version}`) + .log(` UUID: ${contents.manifest.UUID}`) + .log(` Filename: ${basename(pkgBuilder.path)}`) + .log(` Unpacked size: ${sizeAsString(contents.size)}`) + .log(` Total files: ${contents.files.length}`); + + if (!options.dryRun) { + stdout.log().success("Successfully packaged plugin"); + } + }, + { + ...defaultOptions, + dryRun: false + } +); /** * Gets a package builder capable of constructing a `.streamDeckPlugin` file. * @param path Path where the package will be output too. + * @param dryRun When `true`, the builder will not create a package output. * @returns The package builder. */ -function getPackageBuilder(path: string): PackageBuilder { +function getPackageBuilder(path: string, dryRun = false): PackageBuilder { const pkgPath = resolve(process.cwd(), `${getPluginId(path)}.streamDeckPlugin`); + // When a dry-run, return a mock builder. + if (dryRun) { + return { + path: pkgPath, + add: () => Promise.resolve(), + close: (): void => {} + }; + } + + // Otherwise prepare the builder const entryPrefix = basename(path); const zipStream = new ZipWriter(Writable.toWeb(createWriteStream(pkgPath))); @@ -111,7 +126,12 @@ async function getPackageContents(path: string, fileFn?: (file: FileInfo) => Pro /** * Options available to {@link pack}. */ -type PackOptions = ValidateOptions; +type PackOptions = ValidateOptions & { + /** + * When `true`, the package will be evaluated, but not created. + */ + dryRun?: boolean; +}; /** * Information about the package. diff --git a/src/index.ts b/src/index.ts index 60cd82b..bbc3beb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -41,16 +41,17 @@ program .argument("[path]", "Path of the plugin to validate") .option("--force-update-check", "Forces an update check", false) .option("--no-update-check", "Disables updating schemas", true) - .action((path, { forceUpdateCheck, updateCheck }) => validate({ forceUpdateCheck, path, updateCheck })); + .action((path, opts) => validate({ ...opts, path })); program .command("pack") .alias("bundle") .description("Create a .streamDeckPlugin file from the plugin.") .argument("[path]", "Path of the plugin to pack") + .option("--dry-run", "Generates a report without creating a package", false) .option("--force-update-check", "Forces an update check", false) .option("--no-update-check", "Disables updating schemas", true) - .action((path, { forceUpdateCheck, updateCheck }) => pack({ forceUpdateCheck, path, updateCheck })); + .action((path, opts) => pack({ ...opts, path })); const configCommand = program.command("config").description("Manage the local configuration."); From 74587a8cf7b56cac8e81193ddfe190258a8ecc69 Mon Sep 17 00:00:00 2001 From: Richard Herman Date: Sat, 24 Feb 2024 22:10:56 +0000 Subject: [PATCH 08/11] docs: add notes --- src/commands/pack.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/commands/pack.ts b/src/commands/pack.ts index 538939d..0cedd1a 100644 --- a/src/commands/pack.ts +++ b/src/commands/pack.ts @@ -11,7 +11,9 @@ import { defaultOptions, validate, type ValidateOptions } from "./validate"; /** * TODO: - * - Add an `-o|--output` option. + * - Add an `-f|--force` option; generate the file even if it already exists. + * - Add an `-o|--output` option; output directory where the file will be created. + * - Add an `-v|--version` option; version the manifest.json file. Optional, when empty reads package.json, or value is used. */ /** From 8c639c8843d644f794c59918595fadc2e8b207bd Mon Sep 17 00:00:00 2001 From: Richard Herman Date: Mon, 26 Feb 2024 11:59:46 +0000 Subject: [PATCH 09/11] feat: add --output and --force options to pack --- src/commands/pack.ts | 73 ++++++++++++++++++++++++++++---------------- src/index.ts | 2 ++ src/system/fs.ts | 14 ++++++++- 3 files changed, 62 insertions(+), 27 deletions(-) diff --git a/src/commands/pack.ts b/src/commands/pack.ts index 0cedd1a..189bc4f 100644 --- a/src/commands/pack.ts +++ b/src/commands/pack.ts @@ -1,12 +1,13 @@ import { Manifest } from "@elgato/schemas/streamdeck/plugins"; import { ZipWriter } from "@zip.js/zip.js"; import chalk from "chalk"; -import { createReadStream, createWriteStream } from "node:fs"; -import { basename, join, resolve } from "node:path"; +import { createReadStream, createWriteStream, existsSync } from "node:fs"; +import { rm } from "node:fs/promises"; +import { basename, dirname, join, resolve } from "node:path"; import { Readable, Writable } from "node:stream"; import { command } from "../common/command"; import { getPluginId } from "../stream-deck"; -import { getFiles, readJsonFile, sizeAsString, type FileInfo } from "../system/fs"; +import { getFiles, mkdirIfNotExists, readJsonFile, sizeAsString, type FileInfo } from "../system/fs"; import { defaultOptions, validate, type ValidateOptions } from "./validate"; /** @@ -27,13 +28,26 @@ export const pack = command( quietSuccess: true }); - // Build the package. - const path = resolve(options.path); - const pkgBuilder = getPackageBuilder(path, options.dryRun); - const contents = await getPackageContents(path, pkgBuilder.add); + // Determine the source, and output. + const sourcePath = resolve(options.path); + const outputPath = resolve(options.output, `${getPluginId(sourcePath)}.streamDeckPlugin`); + + // Check if there is already a file at the desired save location. + if (existsSync(outputPath)) { + if (options.force) { + await rm(outputPath); + } else { + stdout.error("File already exists").log("Specify a different -o|-output location, or -f|--force saving to overwrite the existing file").exit(1); + } + } + + // Create the package + await mkdirIfNotExists(dirname(outputPath)); + const pkgBuilder = getPackageBuilder(sourcePath, outputPath, options.dryRun); + const contents = await getPackageContents(sourcePath, pkgBuilder.add); pkgBuilder.close(); - // Output the contents. + // Print a summary of the contents. stdout.log(`📦 ${contents.manifest.Name} (v${contents.manifest.Version})`); stdout.log(); stdout.log(chalk.cyan("Plugin Contents")); @@ -42,51 +56,53 @@ export const pack = command( stdout.log(`${chalk.dim(i === contents.files.length - 1 ? "└─" : "├─")} ${file.size.text.padEnd(contents.sizePad)} ${file.path.relative}`); }); - // Output the details. + // Print the package details. stdout .log() .log(chalk.cyan("Plugin Details")) .log(` Name: ${contents.manifest.Name}`) .log(` Version: ${contents.manifest.Version}`) .log(` UUID: ${contents.manifest.UUID}`) - .log(` Filename: ${basename(pkgBuilder.path)}`) + .log(` Filename: ${basename(outputPath)}`) .log(` Unpacked size: ${sizeAsString(contents.size)}`) - .log(` Total files: ${contents.files.length}`); + .log(` Total files: ${contents.files.length}`) + .log(); if (!options.dryRun) { - stdout.log().success("Successfully packaged plugin"); + stdout.success("Successfully packaged plugin").log().log(outputPath); + } else { + stdout.log(chalk.dim(outputPath)); } }, { ...defaultOptions, - dryRun: false + dryRun: false, + force: false, + output: process.cwd() } ); /** * Gets a package builder capable of constructing a `.streamDeckPlugin` file. - * @param path Path where the package will be output too. + * @param sourcePath Source path to the package contents. + * @param outputPath Path where the package will be output too. * @param dryRun When `true`, the builder will not create a package output. * @returns The package builder. */ -function getPackageBuilder(path: string, dryRun = false): PackageBuilder { - const pkgPath = resolve(process.cwd(), `${getPluginId(path)}.streamDeckPlugin`); - +function getPackageBuilder(sourcePath: string, outputPath: string, dryRun = false): PackageBuilder { // When a dry-run, return a mock builder. if (dryRun) { return { - path: pkgPath, add: () => Promise.resolve(), close: (): void => {} }; } // Otherwise prepare the builder - const entryPrefix = basename(path); - const zipStream = new ZipWriter(Writable.toWeb(createWriteStream(pkgPath))); + const entryPrefix = basename(sourcePath); + const zipStream = new ZipWriter(Writable.toWeb(createWriteStream(outputPath))); return { - path: pkgPath, add: async (file: FileInfo): Promise => { const name = join(entryPrefix, file.path.relative).replaceAll("\\", "/"); await zipStream.add(name, Readable.toWeb(createReadStream(file.path.absolute))); @@ -133,6 +149,16 @@ type PackOptions = ValidateOptions & { * When `true`, the package will be evaluated, but not created. */ dryRun?: boolean; + + /** + * When `true`, the output will overwrite an existing `.streamDeckPlugin` file if it already exists. + */ + force?: boolean; + + /** + * Output directory where the plugin package will be written too; defaults to cwd. + */ + output?: string; }; /** @@ -164,11 +190,6 @@ type PackageInfo = { * Package builder capable of adding files to a package, and outputting them to a `.streamDeckPlugin` file. */ type PackageBuilder = { - /** - * Path to the `.streamDeckPlugin` file. - */ - path: string; - /** * Adds the specified {@link file} to the package. * @param file File to add. diff --git a/src/index.ts b/src/index.ts index bbc3beb..6dca5a8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -49,6 +49,8 @@ program .description("Create a .streamDeckPlugin file from the plugin.") .argument("[path]", "Path of the plugin to pack") .option("--dry-run", "Generates a report without creating a package", false) + .option("-f|--force", "Forces saving, overwriting an package if it exists", false) + .option("-o|--output ", "Specifies the path for the output directory") .option("--force-update-check", "Forces an update check", false) .option("--no-update-check", "Disables updating schemas", true) .action((path, opts) => pack({ ...opts, path })); diff --git a/src/system/fs.ts b/src/system/fs.ts index 43b6373..3ae4e42 100644 --- a/src/system/fs.ts +++ b/src/system/fs.ts @@ -1,6 +1,6 @@ import ignore from "ignore"; import { cpSync, createReadStream, existsSync, lstatSync, mkdirSync, readlinkSync, rmSync, type Stats } from "node:fs"; -import { lstat, readdir, readFile } from "node:fs/promises"; +import { lstat, mkdir, readdir, readFile } from "node:fs/promises"; import { platform } from "node:os"; import { basename, join, resolve } from "node:path"; import { createInterface } from "node:readline"; @@ -132,6 +132,18 @@ export function moveSync(source: string, dest: string, options?: MoveOptions): v rmSync(source, { recursive: true }); } +/** + * Makes the directory specified by the {@link path} when it does not exist; when it exists, the path is validated to ensure it is a directory. + * @param path Path of the directory to make. + */ +export async function mkdirIfNotExists(path: string): Promise { + if (!existsSync(path)) { + await mkdir(path, { recursive: true }); + } else if (!isDirectory(path)) { + throw new Error("Path exists, but is not a directory"); + } +} + /** * Reads the specified {@link path} and parses the contents as JSON. * @param path Path to the JSON file. From 26885e9f877f4d022b1fc32c407f28cc51401f82 Mon Sep 17 00:00:00 2001 From: Richard Herman Date: Mon, 26 Feb 2024 13:13:44 +0000 Subject: [PATCH 10/11] feat: add --version flag to packing --- CHANGELOG.md | 2 +- src/commands/pack.ts | 80 ++++++++++++++++++++++++++++++++-------- src/commands/validate.ts | 16 +++++++- src/common/stdout.ts | 13 +++++++ src/index.ts | 3 +- 5 files changed, 94 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ff68ac..828e865 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,7 @@ ### ✨ New - Add `streamdeck validate` command for validating Stream Deck plugins. -- Add `-v, --version` option to display current version of CLI. +- Add `-v` option to display current version of CLI. - Add "Open in VSCode" prompt, as part of creation wizard, for macOS. ### 🐞 Bug Fixes diff --git a/src/commands/pack.ts b/src/commands/pack.ts index 189bc4f..0567cba 100644 --- a/src/commands/pack.ts +++ b/src/commands/pack.ts @@ -1,37 +1,41 @@ import { Manifest } from "@elgato/schemas/streamdeck/plugins"; import { ZipWriter } from "@zip.js/zip.js"; import chalk from "chalk"; -import { createReadStream, createWriteStream, existsSync } from "node:fs"; -import { rm } from "node:fs/promises"; +import { createReadStream, createWriteStream, existsSync, writeFileSync } from "node:fs"; +import { readFile, rm } from "node:fs/promises"; import { basename, dirname, join, resolve } from "node:path"; import { Readable, Writable } from "node:stream"; import { command } from "../common/command"; +import { StdoutError } from "../common/stdout"; import { getPluginId } from "../stream-deck"; import { getFiles, mkdirIfNotExists, readJsonFile, sizeAsString, type FileInfo } from "../system/fs"; import { defaultOptions, validate, type ValidateOptions } from "./validate"; -/** - * TODO: - * - Add an `-f|--force` option; generate the file even if it already exists. - * - Add an `-o|--output` option; output directory where the file will be created. - * - Add an `-v|--version` option; version the manifest.json file. Optional, when empty reads package.json, or value is used. - */ - /** * Packs the plugin to a `.streamDeckPlugin` files. */ export const pack = command( async (options, stdout) => { - // Validate the plugin. - await validate({ - ...options, - quietSuccess: true - }); - // Determine the source, and output. const sourcePath = resolve(options.path); const outputPath = resolve(options.output, `${getPluginId(sourcePath)}.streamDeckPlugin`); + // Version (optionally) and validate. + const versioner = options.version !== null ? await version(sourcePath, options.version) : undefined; + try { + await validate({ + ...options, + quietSuccess: true + }); + } catch (err) { + if (err instanceof StdoutError) { + versioner?.undo(); + stdout.exit(1); + } + + throw err; + } + // Check if there is already a file at the desired save location. if (existsSync(outputPath)) { if (options.force) { @@ -78,7 +82,8 @@ export const pack = command( ...defaultOptions, dryRun: false, force: false, - output: process.cwd() + output: process.cwd(), + version: null } ); @@ -141,6 +146,34 @@ async function getPackageContents(path: string, fileFn?: (file: FileInfo) => Pro return contents; } +/** + * Versions the manifest at the specified {@link path}. + * @param path Path to the directory where the manifest is contained. + * @param version Desired version. + * @returns Object that allows for the versioning to be undone. + */ +async function version(path: string, version: string): Promise { + const manifestPath = resolve(path, "manifest.json"); + const write = (contents: string): void => writeFileSync(manifestPath, contents, { encoding: "utf-8" }); + let original: string | undefined; + + if (existsSync(manifestPath)) { + original = await readFile(manifestPath, { encoding: "utf-8" }); + + const manifest = JSON.parse(original); + manifest.Version = version; + write(JSON.stringify(manifest, undefined, "\t")); + } + + return { + undo: (): void => { + if (original !== undefined) { + write(original); + } + } + }; +} + /** * Options available to {@link pack}. */ @@ -159,6 +192,11 @@ type PackOptions = ValidateOptions & { * Output directory where the plugin package will be written too; defaults to cwd. */ output?: string; + + /** + * Optional version of the plugin; this will be set in the manifest before bundling. + */ + version?: string | null; }; /** @@ -201,3 +239,13 @@ type PackageBuilder = { */ close(): void; }; + +/** + * Version reverter capable of undoing the versioning of a manifest. + */ +type VersionReverter = { + /** + * Reverts the manifest to the original instance. + */ + undo(): void; +}; diff --git a/src/commands/validate.ts b/src/commands/validate.ts index eff1585..37fc110 100644 --- a/src/commands/validate.ts +++ b/src/commands/validate.ts @@ -1,5 +1,6 @@ import { resolve } from "node:path"; import { command } from "../common/command"; +import { StdoutError } from "../common/stdout"; import { store } from "../common/storage"; import { packageManager } from "../package-manager"; import { validatePlugin } from "../validation/plugin"; @@ -23,10 +24,21 @@ export const defaultOptions = { * Validates the given path, and outputs the results. */ export const validate = command(async (options, stdout) => { + /** + * Exits the validation command with a failure. + */ + const fail = (): never => { + if (!options.quietSuccess) { + stdout.exit(1); + } + + throw new StdoutError(); + }; + // Check for conflicting options. if (!options.updateCheck && options.forceUpdateCheck) { console.log(`error: option '--force-update-check' cannot be used with option '--no-update-check'`); - process.exit(1); + fail(); } // Determine whether the schemas should be updated. @@ -50,7 +62,7 @@ export const validate = command(async (options, stdout) => { } if (result.hasErrors()) { - stdout.exit(1); + fail(); } }, defaultOptions); diff --git a/src/common/stdout.ts b/src/common/stdout.ts index 4327784..ff4469f 100644 --- a/src/common/stdout.ts +++ b/src/common/stdout.ts @@ -71,6 +71,19 @@ type ConsoleStdOutOptions = { */ export type StdOut = ConsoleStdOut; +/** + * Error thrown when the stdout should exit with an error code. + */ +export class StdoutError extends Error { + /** + * Initializes a new instance of the {@link StdoutError} class. + * @param message Message associated with the error. + */ + constructor(message?: string) { + super(message); + } +} + /** * Provides interactive console writer that writes to the stdout, including a spinner and status results. */ diff --git a/src/index.ts b/src/index.ts index 6dca5a8..d7b8b8e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,7 +2,7 @@ import { program } from "commander"; import { config, create, link, pack, restart, setDeveloperMode, stop, validate } from "./commands"; import { packageManager } from "./package-manager"; -program.version(packageManager.getVersion(), "-v, --version", "display CLI version"); +program.version(packageManager.getVersion(), "-v", "display CLI version"); program .command("create") @@ -51,6 +51,7 @@ program .option("--dry-run", "Generates a report without creating a package", false) .option("-f|--force", "Forces saving, overwriting an package if it exists", false) .option("-o|--output ", "Specifies the path for the output directory") + .option("--version ") .option("--force-update-check", "Forces an update check", false) .option("--no-update-check", "Disables updating schemas", true) .action((path, opts) => pack({ ...opts, path })); From 3c2cfce766c55eb6774cb5f873d1087f32e2313f Mon Sep 17 00:00:00 2001 From: Richard Herman Date: Mon, 26 Feb 2024 14:00:44 +0000 Subject: [PATCH 11/11] refactor: update output of packing --- src/commands/pack.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/commands/pack.ts b/src/commands/pack.ts index 0567cba..c5ad5e7 100644 --- a/src/commands/pack.ts +++ b/src/commands/pack.ts @@ -45,6 +45,8 @@ export const pack = command( } } + stdout.spin("Preparing plugin"); + // Create the package await mkdirIfNotExists(dirname(outputPath)); const pkgBuilder = getPackageBuilder(sourcePath, outputPath, options.dryRun); @@ -67,15 +69,15 @@ export const pack = command( .log(` Name: ${contents.manifest.Name}`) .log(` Version: ${contents.manifest.Version}`) .log(` UUID: ${contents.manifest.UUID}`) - .log(` Filename: ${basename(outputPath)}`) - .log(` Unpacked size: ${sizeAsString(contents.size)}`) .log(` Total files: ${contents.files.length}`) + .log(` Unpacked size: ${sizeAsString(contents.size)}`) + .log(` File name: ${basename(outputPath)}`) .log(); - if (!options.dryRun) { - stdout.success("Successfully packaged plugin").log().log(outputPath); + if (options.dryRun) { + stdout.info("No package created, --dry-run flag is present").log().log(chalk.dim(outputPath)); } else { - stdout.log(chalk.dim(outputPath)); + stdout.success("Successfully packaged plugin").log().log(outputPath); } }, {