diff --git a/CHANGELOG.md b/CHANGELOG.md index c0c36a7..2e8cc44 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,11 @@ ## vNext +### ✨ New + +- Add `list` command to display list of installed plugins. +- Add `unlink` command to unlink installed plugins. + ### 🐞 Fix - Resolve DEP0190 warning when using Node.js 24 or higher. diff --git a/README.md b/README.md index 45a315c..c96e50a 100644 --- a/README.md +++ b/README.md @@ -25,18 +25,20 @@ Usage: streamdeck [options] [command] Options: -v display CLI version + -l, --list display list of installed plugins -h, --help display help for command Commands: create Stream Deck plugin creation wizard. link [path] Links the plugin to Stream Deck. + unlink [options] Unlinks the plugin from Stream Deck. + list [options] Display list of installed plugins. restart|r Starts the plugin in Stream Deck; if the plugin is already running, it is stopped first. stop|s Stops the plugin in Stream Deck. dev [options] Enables developer mode. validate [options] [path] Validates the Stream Deck plugin. pack|bundle [options] [path] Creates a .streamDeckPlugin file from the plugin. config Manage the local configuration. - help [command] display help for command Alias: streamdeck diff --git a/src/cli.ts b/src/cli.ts index 13c97ba..6e63678 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,10 +1,8 @@ import { program } from "commander"; -import { config, create, link, pack, restart, setDeveloperMode, stop, validate } from "./commands"; +import { config, create, link, list, pack, restart, setDeveloperMode, stop, unlink, validate } from "./commands"; import { packageManager } from "./package-manager"; -program.version(packageManager.getVersion({ checkEnvironment: true }), "-v", "display CLI version"); - program .command("create") .description("Stream Deck plugin creation wizard.") @@ -16,6 +14,19 @@ program .description("Links the plugin to Stream Deck.") .action((path) => link({ path })); +program + .command("unlink") + .argument("") + .option("-d|--delete", "Enable deletion of non-linked plugins.") + .description("Unlinks the plugin from Stream Deck.") + .action((uuid, opts) => unlink({ uuid, ...opts })); + +program + .command("list") + .option("-a|--all", "Show all plugins", false) + .description("Display list of installed plugins.") + .action((opts) => list(opts)); + program .command("restart") .alias("r") @@ -84,4 +95,14 @@ configCommand .description("Resets all configuration.") .action(() => config.reset()); -program.parse(); +program + .version(packageManager.getVersion({ checkEnvironment: true }), "-v", "display CLI version") + .option("-l, --list", "display list of installed plugins") + .action((opts) => { + if (opts.list) { + list(); + } else { + program.help(); + } + }) + .parse(); diff --git a/src/commands/index.ts b/src/commands/index.ts index 5a8248b..65af7a3 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -2,7 +2,9 @@ export * as config from "./config"; export { create } from "./create"; export { setDeveloperMode } from "./dev"; export { link } from "./link"; +export { list } from "./list"; export { pack } from "./pack"; export { restart } from "./restart"; export { stop } from "./stop"; +export { unlink } from "./unlink"; export { validate } from "./validate"; diff --git a/src/commands/link.ts b/src/commands/link.ts index f467bf3..f02b08d 100644 --- a/src/commands/link.ts +++ b/src/commands/link.ts @@ -1,5 +1,5 @@ import chalk from "chalk"; -import { lstatSync, symlinkSync } from "node:fs"; +import { lstatSync, symlinkSync, unlinkSync } from "node:fs"; import { basename, resolve } from "node:path"; import { command } from "../common/command"; @@ -37,9 +37,9 @@ export const link = command( // Check if there is a conflict with an already installed plugin. const existing = getPlugins().find((p) => p.uuid === uuid); if (existing) { - if (existing.sourcePath !== null && resolve(existing.sourcePath) === resolve(options.path)) { - feedback.success("Linked successfully"); - return; + if (existing.targetPath !== null && resolve(existing.targetPath) === resolve(options.path)) { + // Remove the existing link and re-link later to ensure a valid junction is established. + unlinkSync(resolve(getPluginsPath(), basename(options.path))); } else { return feedback .error("Linking failed") diff --git a/src/commands/list.ts b/src/commands/list.ts new file mode 100644 index 0000000..aca608c --- /dev/null +++ b/src/commands/list.ts @@ -0,0 +1,44 @@ +import chalk from "chalk"; +import { existsSync } from "node:fs"; + +import { command } from "../common/command"; +import { getPlugins } from "../stream-deck"; + +/** + * Prints a list of installed plugins + */ +export const list = command( + async (options, output) => { + const plugins = getPlugins(); + for (const plugin of plugins) { + if (!plugin.isLink && !options.all) { + continue; + } + + const { uuid, targetPath } = plugin; + + if (targetPath) { + if (existsSync(targetPath)) { + output.log(`${uuid} ${chalk.dim("→")} ${chalk.green(targetPath)}`); + } else { + output.log(`${uuid} ${chalk.dim("→")} ${chalk.red(targetPath)} ${chalk.dim("(not found)")}`); + } + } else { + output.log(uuid); + } + } + }, + { + all: false, + }, +); + +/** + * Options for the {@link list} command. + */ +type Options = { + /** + * Determines whether to show all plugins; when `false` only linked plugins are shown. Default is `false`. + */ + all?: boolean; +}; diff --git a/src/commands/stop.ts b/src/commands/stop.ts index ec3337f..70b5e35 100644 --- a/src/commands/stop.ts +++ b/src/commands/stop.ts @@ -17,7 +17,8 @@ export const stop = command(async ({ uuid }, output) => { // When Stream Deck isn't running, warn the user. if (!(await isStreamDeckRunning())) { - return output.info("Stream Deck is not running.").exit(); + output.info("Stream Deck is not running."); + return; } // Stop the plugin. diff --git a/src/commands/unlink.ts b/src/commands/unlink.ts new file mode 100644 index 0000000..c62fe8b --- /dev/null +++ b/src/commands/unlink.ts @@ -0,0 +1,85 @@ +import chalk from "chalk"; +import { unlinkSync } from "node:fs"; + +import { command } from "../common/command"; +import type { StdOut } from "../common/stdout"; +import { getPlugins, type PluginInfo } from "../stream-deck"; +import { isResourceBusyError, rm } from "../system/fs"; +import { stop } from "./stop"; + +/** + * Unlinks / uninstalls a plugin from Stream Deck's plugin directory. + */ +export const unlink = command( + async (options, output) => { + const plugin = getPlugins().find(({ uuid }) => uuid === options.uuid); + if (!plugin) { + return output.error("Plugin not found").log(`No plugin found with UUID: ${options.uuid}`).exit(1); + } + + if (!plugin.isLink) { + if (!options.delete) { + return output + .error("Plugin cannot be unlinked") + .log(`${options.uuid} is not a linked plugin`) + .log(`To uninstall and delete the plugin, re-run with the delete flag (-d|--delete)`) + .exit(1); + } + + try { + await deletePlugin(plugin, output); + } catch (e) { + if (isResourceBusyError(e)) { + return output.log().log(chalk.red("Plugin cannot be removed as it is in use")).exit(1); + } else { + throw e; + } + } + } else { + // Stop the plugin and remove the link. + await output.spin("Unlinking", async (_, spinner) => { + await stop({ quiet: true, uuid: options.uuid }); + unlinkSync(plugin.path); + + spinner.setText("Unlinked successfully"); + }); + } + }, + { + delete: false, + }, +); + +/** + * Deletes the specified plugin. + * @param plugin Plugin information. + * @param output Output to log to. + */ +async function deletePlugin(plugin: PluginInfo, output: StdOut): Promise { + // Stop the plugin and remove it. + await output.spin("Uninstalling", async (_, spinner) => { + try { + await stop({ quiet: true, uuid: plugin.uuid }); + await rm(plugin.path, { recursive: true, maxRetries: 10, retryDelay: 1000 }); + spinner.setText("Uninstalled successfully"); + } catch (e) { + spinner.setText("Uninstalling failed"); + throw e; + } + }); +} + +/** + * Options for {@link unlink}. + */ +type Options = { + /** + * Enables deleting plugins that are not symbolic-links; default `false`. + */ + delete?: boolean; + + /** + * Unique-identifier of the plugin to unlink, for example `com.elgato.wave-link`. + */ + uuid: string; +}; diff --git a/src/common/stdout.ts b/src/common/stdout.ts index ce3f9ae..bdb1dec 100644 --- a/src/common/stdout.ts +++ b/src/common/stdout.ts @@ -185,7 +185,10 @@ class ConsoleStdOut { * @param task Task that the spinner represents. * @returns A promise fulfilled when the {@link task} completes. */ - public async spin(message: string, task?: (writer: ConsoleStdOut) => Promise | void): Promise { + public async spin( + message: string, + task?: (writer: ConsoleStdOut, spin: Spinner) => Promise | void, + ): Promise { // Confirm we can spin. if (this.options.level < MessageLevel.LOG) { return; @@ -203,7 +206,9 @@ class ConsoleStdOut { } else { try { this.startSpinner(); - await task(this); + await task(this, { + setText: (message: string) => (this.message = message), + }); if (this.isLoading) { this.success(); @@ -310,6 +315,13 @@ class ConsoleStdOut { } } +/** + * Spinner that indicates a busy status. + */ +type Spinner = { + setText(message: string): void; +}; + /** * Provides logging and exiting facilities as part of reporting an error. */ diff --git a/src/stream-deck.ts b/src/stream-deck.ts index 0cbcf4a..2b02a1a 100644 --- a/src/stream-deck.ts +++ b/src/stream-deck.ts @@ -190,9 +190,9 @@ export class PathError extends Error { */ class PluginInfo { /** - * Private backing field for {@link PluginInfo.sourcePath}. + * Private backing field for {@link PluginInfo.targetPath}. */ - private _sourcePath: string | null | undefined = undefined; + private _targetPath: string | null | undefined = undefined; /** * Initializes a new instance of the {@link PluginInfo} class. @@ -201,20 +201,30 @@ class PluginInfo { * @param uuid Unique identifier of the plugin. */ constructor( - private readonly path: string, + public readonly path: string, private readonly entry: Dirent, public readonly uuid: string, ) {} /** - * Gets the source path of the plugin, when the installation path is a symbolic link; otherwise `null`. - * @returns The source path; otherwise `null`. + * Gets a value indicating whether the plugin is a linked (dev) plugin. + * @returns `true` when the plugin is linked; otherwise `false`. */ - public get sourcePath(): string | null { - if (this._sourcePath === undefined) { - this._sourcePath = this.entry.isSymbolicLink() ? readlinkSync(this.path) : null; + public get isLink(): boolean { + return this.targetPath !== null; + } + + /** + * When the installed plugin is a symbolic link, the target path is the location of the file on disk. + * @returns The target path; otherwise `null`. + */ + public get targetPath(): string | null { + if (this._targetPath === undefined) { + this._targetPath = this.entry.isSymbolicLink() ? readlinkSync(this.path) : null; } - return this._sourcePath; + return this._targetPath; } } + +export { type PluginInfo }; diff --git a/src/system/fs.ts b/src/system/fs.ts index 7178c60..c6a6162 100644 --- a/src/system/fs.ts +++ b/src/system/fs.ts @@ -1,5 +1,17 @@ import ignore from "ignore"; -import { cpSync, createReadStream, existsSync, lstatSync, mkdirSync, readlinkSync, rmSync, type Stats } from "node:fs"; +import { get } from "lodash"; +import { + cpSync, + createReadStream, + existsSync, + lstatSync, + mkdirSync, + type PathLike, + readlinkSync, + type RmOptions, + rmSync, + type Stats, +} from "node:fs"; import { lstat, mkdir, readdir, readFile } from "node:fs/promises"; import { platform } from "node:os"; import { basename, join, resolve } from "node:path"; @@ -220,6 +232,43 @@ function checkStats(path: string, check: (stats?: Stats) => boolean): boolean { return check(stats); } +/** + * Removes files and directories at the specified path. When an error is encountered, the retry delay + * will be awaited, and the removal retried. This will continue until the removal was successful, or + * the max retries is reached. + * @param path Path to remove. + * @param options Removal options. + */ +export async function rm(path: PathLike, options?: RmOptions): Promise { + const { maxRetries = 0, retryDelay = 100, ...opts } = options ?? {}; + + let callCount = 0; + const run = async (): Promise => { + try { + callCount++; + rmSync(path, opts); + } catch (e) { + if (callCount <= maxRetries && isResourceBusyError(e)) { + await new Promise((res) => setTimeout(res, retryDelay)); + await run(); + } else { + throw e; + } + } + }; + + await run(); +} + +/** + * Determines whether the specified error indicates the resource was busy. + * @param e The error + * @returns `true` when the error was caused due to a busy resource; otherwise `false`. + */ +export function isResourceBusyError(e: unknown): boolean { + return get(e, "code") === "EBUSY"; +} + /** * Information about a file. */