Skip to content
Merged
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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] <uuid> Unlinks the plugin from Stream Deck.
list [options] Display list of installed plugins.
restart|r <uuid> Starts the plugin in Stream Deck; if the plugin is already running, it is stopped first.
stop|s <uuid> 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
Expand Down
29 changes: 25 additions & 4 deletions src/cli.ts
Original file line number Diff line number Diff line change
@@ -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.")
Expand All @@ -16,6 +14,19 @@ program
.description("Links the plugin to Stream Deck.")
.action((path) => link({ path }));

program
.command("unlink")
.argument("<uuid>")
.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")
Expand Down Expand Up @@ -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();
2 changes: 2 additions & 0 deletions src/commands/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
8 changes: 4 additions & 4 deletions src/commands/link.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -37,9 +37,9 @@ export const link = command<LinkOptions>(
// 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")
Expand Down
44 changes: 44 additions & 0 deletions src/commands/list.ts
Original file line number Diff line number Diff line change
@@ -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<Options>(
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;
};
3 changes: 2 additions & 1 deletion src/commands/stop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ export const stop = command<StopOptions>(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.
Expand Down
85 changes: 85 additions & 0 deletions src/commands/unlink.ts
Original file line number Diff line number Diff line change
@@ -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<Options>(
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<void> {
// 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;
};
16 changes: 14 additions & 2 deletions src/common/stdout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number | void> | void): Promise<void> {
public async spin(
message: string,
task?: (writer: ConsoleStdOut, spin: Spinner) => Promise<number | void> | void,
): Promise<void> {
// Confirm we can spin.
if (this.options.level < MessageLevel.LOG) {
return;
Expand All @@ -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();
Expand Down Expand Up @@ -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.
*/
Expand Down
28 changes: 19 additions & 9 deletions src/stream-deck.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 };
Loading
Loading