From cf578f5d998fffbd5c7e4dcc53d03840e46ebfdc Mon Sep 17 00:00:00 2001 From: Richard Herman <1429781+GeekyEggo@users.noreply.github.com> Date: Sun, 22 Jun 2025 18:30:03 +0200 Subject: [PATCH 01/11] feat: add basic -l, --list, and list command --- src/cli.ts | 5 ++++- src/commands/index.ts | 1 + src/commands/list.ts | 22 ++++++++++++++++++++++ 3 files changed, 27 insertions(+), 1 deletion(-) create mode 100644 src/commands/list.ts diff --git a/src/cli.ts b/src/cli.ts index 1dce67f..4ce4216 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,6 +1,6 @@ import { program } from "commander"; -import { config, create, link, pack, restart, setDeveloperMode, stop, validate } from "./commands"; +import { config, create, link, list, pack, restart, setDeveloperMode, stop, validate } from "./commands"; import { packageManager } from "./package-manager"; program.version(packageManager.getVersion({ checkEnvironment: true }), "-v", "display CLI version"); @@ -16,6 +16,9 @@ program .description("Links the plugin to Stream Deck.") .action((path) => link({ path })); +program.option("-l,--list").description("Prints a list of installed plugins").action(list); +program.command("list").description("Prints a list of installed plugins").action(list); + program .command("restart") .alias("r") diff --git a/src/commands/index.ts b/src/commands/index.ts index 5a8248b..55f146e 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 { list } from "./list"; export { pack } from "./pack"; export { restart } from "./restart"; export { stop } from "./stop"; diff --git a/src/commands/list.ts b/src/commands/list.ts new file mode 100644 index 0000000..5c56c55 --- /dev/null +++ b/src/commands/list.ts @@ -0,0 +1,22 @@ +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 { sourcePath, uuid } of plugins) { + output.log(uuid); + if (sourcePath) { + if (existsSync(sourcePath)) { + console.log(chalk.dim(" └"), chalk.green(sourcePath)); + } else { + console.log(chalk.dim(" └ Not Found:"), chalk.red(sourcePath)); + } + } + } +}); From a90b926f7d760825c3ef7c7f15c111c1c33e5ed7 Mon Sep 17 00:00:00 2001 From: Richard Herman <1429781+GeekyEggo@users.noreply.github.com> Date: Thu, 26 Jun 2025 15:11:38 +0200 Subject: [PATCH 02/11] refactor: rename sourcePath to targetPath --- src/commands/link.ts | 2 +- src/commands/list.ts | 2 +- src/stream-deck.ts | 18 +++++++++--------- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/commands/link.ts b/src/commands/link.ts index f467bf3..246992f 100644 --- a/src/commands/link.ts +++ b/src/commands/link.ts @@ -37,7 +37,7 @@ 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)) { + if (existing.targetPath !== null && resolve(existing.targetPath) === resolve(options.path)) { feedback.success("Linked successfully"); return; } else { diff --git a/src/commands/list.ts b/src/commands/list.ts index 5c56c55..4eebf72 100644 --- a/src/commands/list.ts +++ b/src/commands/list.ts @@ -9,7 +9,7 @@ import { getPlugins } from "../stream-deck"; */ export const list = command(async (options, output) => { const plugins = getPlugins(); - for (const { sourcePath, uuid } of plugins) { + for (const { targetPath: sourcePath, uuid } of plugins) { output.log(uuid); if (sourcePath) { if (existsSync(sourcePath)) { diff --git a/src/stream-deck.ts b/src/stream-deck.ts index 0cbcf4a..367bb77 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,20 @@ 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`. + * 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 sourcePath(): string | null { - if (this._sourcePath === undefined) { - this._sourcePath = this.entry.isSymbolicLink() ? readlinkSync(this.path) : 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; } } From 52e5b0ee927847d5b79d3b792300770c0df416af Mon Sep 17 00:00:00 2001 From: Richard Herman <1429781+GeekyEggo@users.noreply.github.com> Date: Thu, 26 Jun 2025 15:13:33 +0200 Subject: [PATCH 03/11] refactor: rename sourcePath to targetPath --- src/commands/list.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/commands/list.ts b/src/commands/list.ts index 4eebf72..3c4700e 100644 --- a/src/commands/list.ts +++ b/src/commands/list.ts @@ -9,13 +9,13 @@ import { getPlugins } from "../stream-deck"; */ export const list = command(async (options, output) => { const plugins = getPlugins(); - for (const { targetPath: sourcePath, uuid } of plugins) { + for (const { targetPath, uuid } of plugins) { output.log(uuid); - if (sourcePath) { - if (existsSync(sourcePath)) { - console.log(chalk.dim(" └"), chalk.green(sourcePath)); + if (targetPath) { + if (existsSync(targetPath)) { + console.log(chalk.dim(" └"), chalk.green(targetPath)); } else { - console.log(chalk.dim(" └ Not Found:"), chalk.red(sourcePath)); + console.log(chalk.dim(" └ Not Found:"), chalk.red(targetPath)); } } } From e04418f7956a7f6247d5be9d24638c5968cb972d Mon Sep 17 00:00:00 2001 From: Richard Herman <1429781+GeekyEggo@users.noreply.github.com> Date: Thu, 26 Jun 2025 15:42:48 +0200 Subject: [PATCH 04/11] feat: add initial unlink command --- src/cli.ts | 8 +++++- src/commands/index.ts | 1 + src/commands/stop.ts | 3 ++- src/commands/unlink.ts | 55 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 65 insertions(+), 2 deletions(-) create mode 100644 src/commands/unlink.ts diff --git a/src/cli.ts b/src/cli.ts index 4ce4216..96acfe9 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,6 +1,6 @@ import { program } from "commander"; -import { config, create, link, list, 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"); @@ -16,6 +16,12 @@ 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.") + .action((uuid, opts) => unlink({ uuid, ...opts })); + program.option("-l,--list").description("Prints a list of installed plugins").action(list); program.command("list").description("Prints a list of installed plugins").action(list); diff --git a/src/commands/index.ts b/src/commands/index.ts index 55f146e..65af7a3 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -6,4 +6,5 @@ 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/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..9130b90 --- /dev/null +++ b/src/commands/unlink.ts @@ -0,0 +1,55 @@ +import { unlinkSync } from "node:fs"; + +import { command } from "../common/command"; +import { getPlugins } from "../stream-deck"; +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.targetPath) { + 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); + } + + // TODO: Stop the plugin and remove it. + console.log("TODO: Stop the plugin and remove it."); + return; + } else { + // Stop the plugin and remove the link. + await stop({ quiet: true, uuid: options.uuid }); + unlinkSync(plugin.path); + } + + output.success("Unlinked successfully"); + }, + { + delete: false, + }, +); + +/** + * 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; +}; From 79ea9b5b3d074e6a21af724830082587c2a5cf78 Mon Sep 17 00:00:00 2001 From: Richard Herman Date: Fri, 27 Jun 2025 16:34:27 +0100 Subject: [PATCH 05/11] feat: add option to show all plugins vs linked --- src/cli.ts | 8 ++++++-- src/commands/list.ts | 42 ++++++++++++++++++++++++++++++++---------- src/commands/unlink.ts | 2 +- src/stream-deck.ts | 8 ++++++++ 4 files changed, 47 insertions(+), 13 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 96acfe9..6b33ea0 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -22,8 +22,12 @@ program .option("-d|--delete", "Enable deletion of non-linked plugins.") .action((uuid, opts) => unlink({ uuid, ...opts })); -program.option("-l,--list").description("Prints a list of installed plugins").action(list); -program.command("list").description("Prints a list of installed plugins").action(list); +program.option("-l|--list").description("Prints a list of installed plugins").action(list); +program + .command("list") + .option("-a|--all", "Show all plugins", false) + .description("Prints a list of installed plugins") + .action((opts) => list(opts)); program .command("restart") diff --git a/src/commands/list.ts b/src/commands/list.ts index 3c4700e..aca608c 100644 --- a/src/commands/list.ts +++ b/src/commands/list.ts @@ -7,16 +7,38 @@ import { getPlugins } from "../stream-deck"; /** * Prints a list of installed plugins */ -export const list = command(async (options, output) => { - const plugins = getPlugins(); - for (const { targetPath, uuid } of plugins) { - output.log(uuid); - if (targetPath) { - if (existsSync(targetPath)) { - console.log(chalk.dim(" └"), chalk.green(targetPath)); +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 { - console.log(chalk.dim(" └ Not Found:"), chalk.red(targetPath)); + 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/unlink.ts b/src/commands/unlink.ts index 9130b90..941e9cb 100644 --- a/src/commands/unlink.ts +++ b/src/commands/unlink.ts @@ -14,7 +14,7 @@ export const unlink = command( return output.error("Plugin not found").log(`No plugin found with UUID: ${options.uuid}`).exit(1); } - if (!plugin.targetPath) { + if (!plugin.isLink) { if (!options.delete) { return output .error("Plugin cannot be unlinked") diff --git a/src/stream-deck.ts b/src/stream-deck.ts index 367bb77..b179899 100644 --- a/src/stream-deck.ts +++ b/src/stream-deck.ts @@ -206,6 +206,14 @@ class PluginInfo { public readonly uuid: string, ) {} + /** + * Gets a value indicating whether the plugin is a linked (dev) plugin. + * @returns `true` when the plugin is linked; otherwise `false`. + */ + 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`. From eca9151fd09f2677e3e2b2e95b73a6e80ff3a253 Mon Sep 17 00:00:00 2001 From: Richard Herman Date: Fri, 27 Jun 2025 17:03:43 +0100 Subject: [PATCH 06/11] fix: descriptions, remove global list option --- src/cli.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 6b33ea0..3bfb417 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -20,13 +20,13 @@ 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.option("-l|--list").description("Prints a list of installed plugins").action(list); program .command("list") .option("-a|--all", "Show all plugins", false) - .description("Prints a list of installed plugins") + .description("Display list of installed plugins.") .action((opts) => list(opts)); program From 964dd5f1a35f891ae02695b6f3d39cf8443efe39 Mon Sep 17 00:00:00 2001 From: Richard Herman Date: Fri, 27 Jun 2025 17:12:05 +0100 Subject: [PATCH 07/11] feat: re-introduce top-level -l and --list --- src/cli.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 3bfb417..fa42669 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -3,8 +3,6 @@ import { program } from "commander"; 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.") @@ -96,4 +94,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(); From 3aeaf6132aca5f01082cd523948edddeeec279e4 Mon Sep 17 00:00:00 2001 From: Richard Herman Date: Fri, 27 Jun 2025 17:50:34 +0100 Subject: [PATCH 08/11] feat: add option to remove non-linked plugins --- src/commands/unlink.ts | 20 +++++++++++++------- src/common/stdout.ts | 16 ++++++++++++++-- src/system/fs.ts | 42 +++++++++++++++++++++++++++++++++++++++++- 3 files changed, 68 insertions(+), 10 deletions(-) diff --git a/src/commands/unlink.ts b/src/commands/unlink.ts index 941e9cb..9a9b692 100644 --- a/src/commands/unlink.ts +++ b/src/commands/unlink.ts @@ -2,6 +2,7 @@ import { unlinkSync } from "node:fs"; import { command } from "../common/command"; import { getPlugins } from "../stream-deck"; +import { rm } from "../system/fs"; import { stop } from "./stop"; /** @@ -23,16 +24,21 @@ export const unlink = command( .exit(1); } - // TODO: Stop the plugin and remove it. - console.log("TODO: Stop the plugin and remove it."); - return; + // Stop the plugin and remove it. + await output.spin("Uninstalling", async (_, spinner) => { + await stop({ quiet: true, uuid: options.uuid }); + await rm(plugin.path, { recursive: true, maxRetries: 10, retryDelay: 1000 }); + spinner.setText("Uninstalled successfully"); + }); } else { // Stop the plugin and remove the link. - await stop({ quiet: true, uuid: options.uuid }); - unlinkSync(plugin.path); - } + await output.spin("Unlinking", async (_, spinner) => { + await stop({ quiet: true, uuid: options.uuid }); + unlinkSync(plugin.path); - output.success("Unlinked successfully"); + spinner.setText("Unlinked successfully"); + }); + } }, { delete: false, 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/system/fs.ts b/src/system/fs.ts index 7178c60..941c3d0 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,34 @@ 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 && get(e, "code") === "EBUSY") { + await new Promise((res) => setTimeout(res, retryDelay)); + await run(); + } else { + throw e; + } + } + }; + + await run(); +} + /** * Information about a file. */ From 6549d9bbc8079484118128e4d1a6eea3cbf63bb5 Mon Sep 17 00:00:00 2001 From: Richard Herman Date: Fri, 27 Jun 2025 17:53:47 +0100 Subject: [PATCH 09/11] docs: new commands --- CHANGELOG.md | 7 +++++++ README.md | 6 ++++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 824df3e..94fec3c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,13 @@ # Change Log +## vNext + +### ✨ New + +- Add `list` command to display list of installed plugins. +- Add `unlink` command to unlink installed plugins. + ## 1.4.0 ### ♻️ Update diff --git a/README.md b/README.md index 6a39fec..c96e50a 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ # Stream Deck CLI [![SDK documentation](https://img.shields.io/badge/Documentation-2ea043?labelColor=grey&logo=gitbook&logoColor=white)](https://docs.elgato.com/streamdeck/cli) -[![Elgato homepage](https://img.shields.io/badge/Elgato-3431cf?labelColor=grey&logo=data:image/svg+xml;base64,PHN2ZyByb2xlPSJpbWciIHZpZXdCb3g9IjAgMCAyNCAyNCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48dGl0bGU+RWxnYXRvPC90aXRsZT48cGF0aCBmaWxsPSIjZmZmZmZmIiBkPSJtMTMuODgxOCA4LjM5NjQuMDI2MS4wMTk2IDkuOTQ5NCA1LjcxNzJjLS40ODg0IDIuNzI5LTEuOTE5NiA1LjIyMjMtNC4wMzg0IDcuMDI1M0ExMS45MjYyIDExLjkyNjIgMCAwIDEgMTIuMDk3IDI0Yy0zLjE5MjUgMC02LjE5MzktMS4yNDc3LTguNDUyNy0zLjUxNDRDMS4zODY4IDE4LjIxODguMTQyNyAxNS4yMDQ0LjE0MjcgMTJjMC0zLjIwNDIgMS4yNDQtNi4yMTg3IDMuNTAxNS04LjQ4NTRDNS45MDE5IDEuMjQ4IDguOTAzMiAwIDEyLjA5NyAwYzIuNDM5NCAwIDQuNzg0Ny43MzMzIDYuNzgzIDIuMTE4NyAxLjk1MjYgMS4zNTQgMy40NDY2IDMuMjM1NyA0LjMyMjcgNS40NDIyLjExMTIuMjgyOS4yMTQ5LjU3MzYuMzA1MS44NjU3bC0yLjEyNTUgMS4yMzU5YTkuNDkyNCA5LjQ5MjQgMCAwIDAtLjI2MTktLjg2OTRjLTEuMzU0LTMuODMwMy00Ljk4MTMtNi40MDQ4LTkuMDIzNy02LjQwNDhDNi44MTcxIDIuMzg4MyAyLjUyMiA2LjcwMDUgMi41MjIgMTJjMCA1LjI5OTUgNC4yOTUgOS42MTE1IDkuNTc0OCA5LjYxMTUgMi4wNTIgMCA0LjAwODQtLjY0NDIgNS42NTk2LTEuODY0NyAxLjYxNzItMS4xOTU1IDIuODAzNi0yLjgzMzcgMy40MzA5LTQuNzM2NGwuMDA2NS0uMDQxOUw5LjU5MDYgOC4zMDQ4djcuMjI1Nmw0LjAwMDQtMi4zMTM4IDIuMDYgMS4xODExLTUuOTk2MiAzLjQ2ODgtMi4xMi0xLjIxMjZWNy4xOTQzbDIuMTE3NC0xLjIyNDUgNC4yMzA5IDIuNDI3OS0uMDAxMy0uMDAxMyIvPjwvc3ZnPg==)](https://elgato.com) +[![Elgato homepage](https://img.shields.io/badge/Elgato-3431cf?labelColor=grey&logo=elgato)](https://elgato.com) [![Join the Marketplace Makers Discord](https://img.shields.io/badge/Marketplace%20Makers-5662f6?labelColor=grey&logo=discord&logoColor=white)](https://discord.gg/GehBUcu627) [![Stream Deck CLI npm package](https://img.shields.io/npm/v/%40elgato/cli?logo=npm&logoColor=white)](https://www.npmjs.com/package/@elgato/cli) [![Build status](https://img.shields.io/github/actions/workflow/status/elgatosf/cli/build.yml?branch=main&label=Build&logo=GitHub)](https://github.com/elgatosf/cli/actions) @@ -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 From d00bedca7d8b8814dc618f91946c0279c3c10e44 Mon Sep 17 00:00:00 2001 From: Richard Herman Date: Mon, 30 Jun 2025 13:40:19 +0100 Subject: [PATCH 10/11] feat: improve feedback when failing to uninstall --- src/commands/unlink.ts | 40 ++++++++++++++++++++++++++++++++-------- src/stream-deck.ts | 2 ++ src/system/fs.ts | 11 ++++++++++- 3 files changed, 44 insertions(+), 9 deletions(-) diff --git a/src/commands/unlink.ts b/src/commands/unlink.ts index 9a9b692..c62fe8b 100644 --- a/src/commands/unlink.ts +++ b/src/commands/unlink.ts @@ -1,8 +1,10 @@ +import chalk from "chalk"; import { unlinkSync } from "node:fs"; import { command } from "../common/command"; -import { getPlugins } from "../stream-deck"; -import { rm } from "../system/fs"; +import type { StdOut } from "../common/stdout"; +import { getPlugins, type PluginInfo } from "../stream-deck"; +import { isResourceBusyError, rm } from "../system/fs"; import { stop } from "./stop"; /** @@ -24,12 +26,15 @@ export const unlink = command( .exit(1); } - // Stop the plugin and remove it. - await output.spin("Uninstalling", async (_, spinner) => { - await stop({ quiet: true, uuid: options.uuid }); - await rm(plugin.path, { recursive: true, maxRetries: 10, retryDelay: 1000 }); - spinner.setText("Uninstalled successfully"); - }); + 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) => { @@ -45,6 +50,25 @@ export const unlink = command( }, ); +/** + * 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}. */ diff --git a/src/stream-deck.ts b/src/stream-deck.ts index b179899..2b02a1a 100644 --- a/src/stream-deck.ts +++ b/src/stream-deck.ts @@ -226,3 +226,5 @@ class PluginInfo { return this._targetPath; } } + +export { type PluginInfo }; diff --git a/src/system/fs.ts b/src/system/fs.ts index 941c3d0..c6a6162 100644 --- a/src/system/fs.ts +++ b/src/system/fs.ts @@ -248,7 +248,7 @@ export async function rm(path: PathLike, options?: RmOptions): Promise { callCount++; rmSync(path, opts); } catch (e) { - if (callCount <= maxRetries && get(e, "code") === "EBUSY") { + if (callCount <= maxRetries && isResourceBusyError(e)) { await new Promise((res) => setTimeout(res, retryDelay)); await run(); } else { @@ -260,6 +260,15 @@ export async function rm(path: PathLike, options?: RmOptions): Promise { 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. */ From 9bcee913ca7632c6644481b170429a9430b4cd5d Mon Sep 17 00:00:00 2001 From: Richard Herman Date: Mon, 30 Jun 2025 15:13:43 +0100 Subject: [PATCH 11/11] refactor: re-link existing links --- src/commands/link.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/commands/link.ts b/src/commands/link.ts index 246992f..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"; @@ -38,8 +38,8 @@ export const link = command( const existing = getPlugins().find((p) => p.uuid === uuid); if (existing) { if (existing.targetPath !== null && resolve(existing.targetPath) === resolve(options.path)) { - feedback.success("Linked successfully"); - return; + // 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")