From 6ee0efc223ccbcf658888ccd4a0bacc4550cea27 Mon Sep 17 00:00:00 2001 From: Martijn Smit Date: Mon, 30 Jun 2025 19:17:38 +0200 Subject: [PATCH] feat: add an add-action command to add new actions to an existing plugin. --- README.md | 32 ++ src/cli.ts | 13 +- src/commands/add-action.ts | 450 ++++++++++++++++++ src/commands/index.ts | 1 + .../ui/generic-action.html.ejs | 47 ++ template/src/actions/generic-action.ts.ejs | 40 ++ 6 files changed, 582 insertions(+), 1 deletion(-) create mode 100644 src/commands/add-action.ts create mode 100644 template/com.elgato.template.sdPlugin/ui/generic-action.html.ejs create mode 100644 template/src/actions/generic-action.ts.ejs diff --git a/README.md b/README.md index 6a39fec..af17570 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,7 @@ Commands: dev [options] Enables developer mode. validate [options] [path] Validates the Stream Deck plugin. pack|bundle [options] [path] Creates a .streamDeckPlugin file from the plugin. + add-action [options] Adds a new action to an existing Stream Deck plugin. config Manage the local configuration. help [command] display help for command @@ -51,6 +52,37 @@ The `streamdeck create` command enables you to scaffold a new Stream Deck plugin

+ +## Adding actions to a plugin + +The `streamdeck add-action` command allows you to quickly add new actions to an existing Stream Deck plugin. The command can be used both interactively (with prompts) or with command-line parameters for automation. + +### Interactive mode + +Running the command without parameters will start an interactive prompt: + +```bash +streamdeck add-action +``` + +This will ask you for: +- Action name (display name) +- Action identifier (unique ID within the plugin) +- Action description +- Whether to create a property inspector UI + +### Command-line mode + +You can also provide all parameters directly via command-line options: + +```bash +streamdeck add-action --name "My Action" --action-id "my-action" --description "Does something awesome" (--ui|--no-ui) [--yes] +``` + +Notes: +- The `--yes` option can be used to skip confirmation prompts, allowing for quick creation of actions without manual intervention. +- The `--ui` option specifies whether to create a property inspector UI for the action. The `--no-ui` option can be used to skip creating a UI file. + ## Further Reading - Learn more about [Stream Deck CLI commands](https://docs.elgato.com/streamdeck/cli). diff --git a/src/cli.ts b/src/cli.ts index 1dce67f..fbd5d32 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 { addAction, config, create, link, pack, restart, setDeveloperMode, stop, validate } from "./commands"; import { packageManager } from "./package-manager"; program.version(packageManager.getVersion({ checkEnvironment: true }), "-v", "display CLI version"); @@ -57,6 +57,17 @@ program .option("--no-update-check", "Disables updating schemas", true) .action((path, opts) => pack({ ...opts, path })); +program + .command("add-action") + .description("Adds a new action to an existing Stream Deck plugin.") + .option("-n, --name ", "Name of the action") + .option("-i, --action-id ", "Action identifier (appended to plugin UUID)") + .option("-d, --description ", "Description of the action") + .option("--ui", "Create property inspector UI", true) + .option("--no-ui", "Skip creating property inspector UI") + .option("-y, --yes", "Skip confirmation prompt", false) + .action((opts) => addAction(opts)); + const configCommand = program.command("config").description("Manage the local configuration."); configCommand diff --git a/src/commands/add-action.ts b/src/commands/add-action.ts new file mode 100644 index 0000000..8d10d9f --- /dev/null +++ b/src/commands/add-action.ts @@ -0,0 +1,450 @@ +import { Manifest } from "@elgato/schemas/streamdeck/plugins"; +import chalk from "chalk"; +import inquirer from "inquirer"; +import fs from "node:fs"; +import { join } from "node:path"; + +import { command } from "../common/command"; +import { createCopier } from "../common/file-copier"; +import { StdOut } from "../common/stdout"; +import { readJsonFile } from "../system/fs"; +import { relative } from "../system/path"; + +/** + * Options for the add-action command. + */ +export type AddActionOptions = { + /** + * Name of the action. + */ + name?: string; + + /** + * Action identifier (will be appended to plugin UUID). + */ + actionId?: string; + + /** + * Description of the action. + */ + description?: string; + + /** + * Whether to create a property inspector UI. + */ + ui?: boolean; + + /** + * Skip confirmation prompt. + */ + yes?: boolean; +}; + +/** + * Adds a new action to an existing Stream Deck plugin. + */ +export const addAction = command( + async (options, stdout) => { + // Validate we're in a plugin directory + const pluginPath = await validatePluginDirectory(stdout); + if (!pluginPath) { + return stdout.error("Not in a valid Stream Deck plugin directory").exit(1); + } + + // Read the existing manifest + const manifestPath = join(pluginPath.sdPluginPath, "manifest.json"); + const manifest = await readJsonFile(manifestPath); + + // Get plugin UUID from manifest + const pluginUuid = manifest.UUID; + if (!pluginUuid) { + return stdout.error("Invalid manifest: missing UUID").exit(1); + } + + // Get action information (either from options or prompts) + const actionInfo = await getActionInfo(options, pluginUuid, manifest.Actions || [], stdout); + + // Confirm action creation (unless --yes flag is used) + if (!options.yes && !(await confirmActionCreation(actionInfo, stdout))) { + return stdout.info("Aborted").exit(); + } + + stdout.log(); + stdout.log(`Creating ${chalk.blue(actionInfo.name)} action...`); + + // Create action files + await stdout.spin("Creating action class", () => createActionClass(pluginPath, actionInfo)); + await stdout.spin("Creating UI file", () => createUIFile(pluginPath, actionInfo)); + await stdout.spin("Creating action images", () => createActionImages(pluginPath, actionInfo)); + await stdout.spin("Updating manifest", () => updateManifest(manifestPath, actionInfo)); + await stdout.spin("Updating plugin registration", () => updatePluginRegistration(pluginPath, actionInfo)); + + stdout.log().log(chalk.green("Successfully created action!")); + }, + { + name: undefined as any, + actionId: undefined as any, + description: undefined as any, + } as Required>, +); + +/** + * Information about the action being created. + */ +interface ActionInfo { + name: string; + uuid: string; + description: string; + className: string; + fileName: string; + hasUI: boolean; +} + +/** + * Information about the plugin directory structure. + */ +interface PluginPaths { + pluginPath: string; + sdPluginPath: string; + srcPath: string; +} + +/** + * Validates that we're in a valid Stream Deck plugin directory. + */ +async function validatePluginDirectory(stdout: StdOut): Promise { + const cwd = process.cwd(); + + // Look for .sdPlugin directory + const entries = fs.readdirSync(cwd, { withFileTypes: true }); + const sdPluginDir = entries.find((entry) => entry.isDirectory() && entry.name.endsWith(".sdPlugin")); + + if (!sdPluginDir) { + return null; + } + + const sdPluginPath = join(cwd, sdPluginDir.name); + const manifestPath = join(sdPluginPath, "manifest.json"); + const srcPath = join(cwd, "src"); + + // Validate required files exist + if (!fs.existsSync(manifestPath)) { + stdout.error("manifest.json not found in .sdPlugin directory"); + return null; + } + + if (!fs.existsSync(srcPath)) { + stdout.error("src directory not found"); + return null; + } + + return { + pluginPath: cwd, + sdPluginPath, + srcPath, + }; +} + +/** + * Prompts the user to confirm action creation. + */ +async function confirmActionCreation(actionInfo: ActionInfo, stdout: StdOut): Promise { + stdout.log().log(chalk.cyan("Action Summary:")); + stdout.log(` Name: ${actionInfo.name}`); + stdout.log(` UUID: ${actionInfo.uuid}`); + stdout.log(` Description: ${actionInfo.description}`); + stdout.log(` Class: ${actionInfo.className}`); + stdout.log(` Has UI: ${actionInfo.hasUI ? "Yes" : "No"}`); + + const { confirm } = await inquirer.prompt([ + { + type: "confirm", + name: "confirm", + message: "Create this action?", + default: true, + }, + ]); + + return confirm; +} + +/** + * Creates the action class file. + */ +async function createActionClass(pluginPaths: PluginPaths, actionInfo: ActionInfo): Promise { + const actionsDir = join(pluginPaths.srcPath, "actions"); + if (!fs.existsSync(actionsDir)) { + fs.mkdirSync(actionsDir, { recursive: true }); + } + + const actionFilePath = join(actionsDir, `${actionInfo.fileName}.ts`); + + const template = createCopier({ + source: relative("../template/src/actions"), + dest: actionsDir, + data: { + uuid: actionInfo.uuid, + className: actionInfo.className, + name: actionInfo.name, + description: actionInfo.description, + }, + }); + + // Copy and render the action template + await template.copy("generic-action.ts.ejs", `${actionInfo.fileName}.ts`); +} + +/** + * Creates the UI file for the action. + */ +async function createUIFile(pluginPaths: PluginPaths, actionInfo: ActionInfo): Promise { + if (!actionInfo.hasUI) return; + + const uiDir = join(pluginPaths.sdPluginPath, "ui"); + if (!fs.existsSync(uiDir)) { + fs.mkdirSync(uiDir, { recursive: true }); + } + + const uiFilePath = join(uiDir, `${actionInfo.fileName}.html`); + + const template = createCopier({ + source: relative("../template/com.elgato.template.sdPlugin/ui"), + dest: uiDir, + data: { + name: actionInfo.name, + fileName: actionInfo.fileName, + }, + }); + + // Copy and render the UI template + await template.copy("generic-action.html.ejs", `${actionInfo.fileName}.html`); +} + +/** + * Creates action image placeholders. + */ +async function createActionImages(pluginPaths: PluginPaths, actionInfo: ActionInfo): Promise { + const imagesDir = join(pluginPaths.sdPluginPath, "imgs", "actions", actionInfo.fileName); + if (!fs.existsSync(imagesDir)) { + fs.mkdirSync(imagesDir, { recursive: true }); + } + + const template = createCopier({ + source: relative("../template/com.elgato.template.sdPlugin/imgs/actions/counter"), + dest: imagesDir, + data: {}, + }); + + // Copy placeholder images + await template.copy("icon.png"); + await template.copy("icon@2x.png"); + await template.copy("key.png"); + await template.copy("key@2x.png"); +} + +/** + * Updates the manifest with the new action. + */ +async function updateManifest(manifestPath: string, actionInfo: ActionInfo): Promise { + const manifest = await readJsonFile(manifestPath); + + const newAction = { + Name: actionInfo.name, + UUID: actionInfo.uuid, + Icon: `imgs/actions/${actionInfo.fileName}/icon`, + Tooltip: actionInfo.description, + Controllers: ["Keypad"], + States: [ + { + Image: `imgs/actions/${actionInfo.fileName}/key`, + }, + ], + } as any; + + if (actionInfo.hasUI) { + newAction.PropertyInspectorPath = `ui/${actionInfo.fileName}.html`; + } + + manifest.Actions = manifest.Actions || []; + manifest.Actions.push(newAction); + + fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, "\t")); +} + +/** + * Updates the plugin registration to include the new action. + */ +async function updatePluginRegistration(pluginPaths: PluginPaths, actionInfo: ActionInfo): Promise { + const pluginFilePath = join(pluginPaths.srcPath, "plugin.ts"); + + if (!fs.existsSync(pluginFilePath)) { + return; // Skip if plugin.ts doesn't exist + } + + let content = fs.readFileSync(pluginFilePath, "utf-8"); + + // Add import + const importStatement = `import { ${actionInfo.className} } from "./actions/${actionInfo.fileName}";`; + const importRegex = /import.*from.*["']@elgato\/streamdeck["'];/; + const match = content.match(importRegex); + + if (match) { + content = content.replace(match[0], `${match[0]}\n\n${importStatement}`); + } else { + // Fallback: add import at the top + content = `${importStatement}\n${content}`; + } + + // Add registration + const registrationStatement = `streamDeck.actions.registerAction(new ${actionInfo.className}());`; + const connectRegex = /streamDeck\.connect\(\);/; + const connectMatch = content.match(connectRegex); + + if (connectMatch) { + content = content.replace( + connectMatch[0], + `// Register the ${actionInfo.name.toLowerCase()} action.\n${registrationStatement}\n\n${connectMatch[0]}`, + ); + } else { + // Fallback: add at the end + content = `${content}\n\n// Register the ${actionInfo.name.toLowerCase()} action.\n${registrationStatement}`; + } + + fs.writeFileSync(pluginFilePath, content); +} + +/** + * Converts a string to PascalCase. + */ +function toPascalCase(str: string): string { + return str + .split(/[-_\s]+/) + .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) + .join(""); +} + +/** + * Converts a string to kebab-case. + */ +function toKebabCase(str: string): string { + return str + .replace(/([a-z])([A-Z])/g, "$1-$2") + .replace(/[\s_]+/g, "-") + .toLowerCase(); +} + +/** + * Gets action information from options or prompts. + */ +async function getActionInfo( + options: AddActionOptions, + pluginUuid: string, + existingActions: any[], + stdout: StdOut, +): Promise { + const existingUuids = existingActions.map((action) => action.UUID); + + // Check if boolean options were explicitly provided via CLI + // We need to check the original process.argv to see if flags were actually used + const argv = process.argv.join(" "); + const uiExplicitlySet = argv.includes("--ui") || argv.includes("--no-ui"); + const yesExplicitlySet = argv.includes("--yes") || argv.includes("--no-yes"); + + // If all required options are provided, use them + if (options.name && options.actionId) { + // Validate the provided actionId + if (!/^[a-z0-9-]+$/.test(options.actionId)) { + return stdout.error("Action identifier must contain only lowercase letters, numbers, and hyphens").exit(1); + } + + const fullUuid = `${pluginUuid}.${options.actionId}`; + if (existingUuids.indexOf(fullUuid) !== -1) { + return stdout.error("An action with this identifier already exists").exit(1); + } + + const className = toPascalCase(options.actionId); + const fileName = toKebabCase(options.actionId); + + return { + name: options.name, + uuid: fullUuid, + description: options.description || `${options.name} action`, + className, + fileName, + hasUI: uiExplicitlySet ? options.ui! : true, // Default to true if not explicitly set + }; + } + + // Otherwise, prompt for missing information + const prompts: any[] = []; + + if (!options.name) { + prompts.push({ + type: "input", + name: "name", + message: "Action name:", + validate: (input: string) => { + if (!input.trim()) return "Action name is required"; + return true; + }, + }); + } + + if (!options.actionId) { + prompts.push({ + type: "input", + name: "actionId", + message: "Action identifier (will be appended to plugin UUID):", + validate: (input: string) => { + if (!input.trim()) return "Action identifier is required"; + if (!/^[a-z0-9-]+$/.test(input)) + return "Action identifier must contain only lowercase letters, numbers, and hyphens"; + const fullUuid = `${pluginUuid}.${input}`; + if (existingUuids.indexOf(fullUuid) !== -1) return "An action with this identifier already exists"; + return true; + }, + }); + } + + if (!options.description) { + prompts.push({ + type: "input", + name: "description", + message: "Action description:", + default: (answers: any) => `${options.name || answers.name} action`, + }); + } + + // Only prompt for UI if not explicitly set via CLI + if (!uiExplicitlySet) { + prompts.push({ + type: "confirm", + name: "hasUI", + message: "Create property inspector UI?", + default: true, + }); + } + + // Handle case where no prompts are needed + let answers: any = {}; + if (prompts.length > 0) { + answers = await inquirer.prompt(prompts); + } + + const name = options.name || answers.name; + const actionId = options.actionId || answers.actionId; + const description = options.description || answers.description; + const hasUI = uiExplicitlySet ? options.ui! : answers.hasUI !== undefined ? answers.hasUI : true; + + const className = toPascalCase(actionId); + const fileName = toKebabCase(actionId); + + return { + name, + uuid: `${pluginUuid}.${actionId}`, + description, + className, + fileName, + hasUI, + }; +} diff --git a/src/commands/index.ts b/src/commands/index.ts index 5a8248b..3dcea2c 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -1,3 +1,4 @@ +export { addAction } from "./add-action"; export * as config from "./config"; export { create } from "./create"; export { setDeveloperMode } from "./dev"; diff --git a/template/com.elgato.template.sdPlugin/ui/generic-action.html.ejs b/template/com.elgato.template.sdPlugin/ui/generic-action.html.ejs new file mode 100644 index 0000000..f694ce7 --- /dev/null +++ b/template/com.elgato.template.sdPlugin/ui/generic-action.html.ejs @@ -0,0 +1,47 @@ + + + + + <%= name %> Settings + + + + + + + + + + + + + + diff --git a/template/src/actions/generic-action.ts.ejs b/template/src/actions/generic-action.ts.ejs new file mode 100644 index 0000000..2739f14 --- /dev/null +++ b/template/src/actions/generic-action.ts.ejs @@ -0,0 +1,40 @@ +import { action, KeyDownEvent, SingletonAction, WillAppearEvent } from "@elgato/streamdeck"; + +/** + * <%= description %> + */ +@action({ UUID: "<%= uuid %>" }) +export class <%= className %> extends SingletonAction { + /** + * The {@link SingletonAction.onWillAppear} event is useful for setting the visual representation of an action when it becomes visible. This could be due to the Stream Deck first + * starting up, or the user navigating between pages / folders etc.. There is also an inverse of this event in the form of {@link streamDeck.client.onWillDisappear}. In this example, + * we're setting up the initial state of the action. + */ + override onWillAppear(ev: WillAppearEvent): void | Promise { + // TODO: Implement action appearance logic + return ev.action.setTitle("<%= name %>"); + } + + /** + * Listens for the {@link SingletonAction.onKeyDown} event which is emitted by Stream Deck when an action is pressed. Stream Deck provides various events for tracking interaction + * with devices including key down/up, dial rotations, and device connectivity, etc. When triggered, {@link ev} object contains information about the event including any payloads + * and action information where applicable. + */ + override async onKeyDown(ev: KeyDownEvent): Promise { + // TODO: Implement action key down logic + const { settings } = ev.payload; + + // Example: Update action title or perform some action + await ev.action.setTitle("Pressed!"); + } +} + +/** + * Settings for {@link <%= className %>}. + */ +type Settings = { + // TODO: Define your action settings here + // Example: + // someProperty?: string; + // count?: number; +};