mirror of
https://github.com/anthropics/claude-code-action.git
synced 2026-01-22 22:44:13 +08:00
feat: change plugins input from comma-separated to newline-separated (#644)
* feat: change plugins input from comma-separated to newline-separated Changes: - Update parsePlugins() to split by newline instead of comma for consistency with marketplaces input - Update action.yml and base-action/action.yml with newline-separated format and realistic plugin examples - Add plugin_marketplaces documentation to docs/usage.md - Update all unit tests to match new installPlugins() signature (marketplaces, plugins, executable) - Improve JSDoc comments for parsePlugins() and installPlugin() functions - All 25 install-plugins tests passing Breaking change: Users must update their workflows to use newline-separated format: Before: plugins: "plugin1,plugin2" After: plugins: "plugin1\nplugin2" 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * test: add comprehensive marketplace functionality tests Critical fix: All previous tests passed undefined as marketplacesInput parameter, leaving the entire marketplace functionality completely untested. Added 13 new tests covering: - Single marketplace installation - Multiple marketplaces with newline separation - Marketplace + plugin installation order verification - Marketplace URL validation (format, protocol, .git extension) - Whitespace and empty entry handling - Error handling for marketplace operations - Custom executable path for marketplace operations Test coverage: 38 tests (was 25), 81 expect calls (was 50) All tests passing ✅ 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -102,7 +102,7 @@ inputs:
|
|||||||
required: false
|
required: false
|
||||||
default: ""
|
default: ""
|
||||||
plugins:
|
plugins:
|
||||||
description: "Comma-separated list of Claude Code plugin names to install (e.g., 'plugin1,plugin2,plugin3')"
|
description: "Newline-separated list of Claude Code plugin names to install (e.g., 'code-review@claude-code-plugins\nfeature-dev@claude-code-plugins')"
|
||||||
required: false
|
required: false
|
||||||
default: ""
|
default: ""
|
||||||
plugin_marketplaces:
|
plugin_marketplaces:
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ inputs:
|
|||||||
required: false
|
required: false
|
||||||
default: ""
|
default: ""
|
||||||
plugins:
|
plugins:
|
||||||
description: "Comma-separated list of Claude Code plugin names to install (e.g., 'plugin1,plugin2,plugin3')"
|
description: "Newline-separated list of Claude Code plugin names to install (e.g., 'code-review@claude-code-plugins\nfeature-dev@claude-code-plugins')"
|
||||||
required: false
|
required: false
|
||||||
default: ""
|
default: ""
|
||||||
plugin_marketplaces:
|
plugin_marketplaces:
|
||||||
|
|||||||
@@ -18,9 +18,9 @@ async function run() {
|
|||||||
|
|
||||||
// Install Claude Code plugins if specified
|
// Install Claude Code plugins if specified
|
||||||
await installPlugins(
|
await installPlugins(
|
||||||
|
process.env.INPUT_PLUGIN_MARKETPLACES,
|
||||||
process.env.INPUT_PLUGINS,
|
process.env.INPUT_PLUGINS,
|
||||||
process.env.INPUT_PATH_TO_CLAUDE_CODE_EXECUTABLE,
|
process.env.INPUT_PATH_TO_CLAUDE_CODE_EXECUTABLE,
|
||||||
process.env.INPUT_PLUGIN_MARKETPLACES,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const promptConfig = await preparePrompt({
|
const promptConfig = await preparePrompt({
|
||||||
|
|||||||
@@ -79,10 +79,13 @@ function parseMarketplaces(marketplaces?: string): string[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse a comma-separated list of plugin names and return an array of trimmed, non-empty plugin names
|
* Parse a newline-separated list of plugin names and return an array of trimmed, non-empty plugin names
|
||||||
* Validates plugin names to prevent command injection and path traversal attacks
|
* Validates plugin names to prevent command injection and path traversal attacks
|
||||||
* Allows: letters, numbers, @, -, _, /, . (common npm/scoped package characters)
|
* Allows: letters, numbers, @, -, _, /, . (common npm/scoped package characters)
|
||||||
* Disallows: path traversal (../, ./), shell metacharacters, and consecutive dots
|
* Disallows: path traversal (../, ./), shell metacharacters, and consecutive dots
|
||||||
|
* @param plugins - Newline-separated list of plugin names, or undefined/empty to return empty array
|
||||||
|
* @returns Array of validated plugin names (empty array if none provided)
|
||||||
|
* @throws {Error} If any plugin name fails validation
|
||||||
*/
|
*/
|
||||||
function parsePlugins(plugins?: string): string[] {
|
function parsePlugins(plugins?: string): string[] {
|
||||||
const trimmedPlugins = plugins?.trim();
|
const trimmedPlugins = plugins?.trim();
|
||||||
@@ -91,9 +94,9 @@ function parsePlugins(plugins?: string): string[] {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Split by comma and process each plugin
|
// Split by newline and process each plugin
|
||||||
return trimmedPlugins
|
return trimmedPlugins
|
||||||
.split(",")
|
.split("\n")
|
||||||
.map((p) => p.trim())
|
.map((p) => p.trim())
|
||||||
.filter((p) => {
|
.filter((p) => {
|
||||||
if (p.length === 0) return false;
|
if (p.length === 0) return false;
|
||||||
@@ -139,11 +142,17 @@ async function executeClaudeCommand(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Installs a single Claude Code plugin
|
* Installs a single Claude Code plugin
|
||||||
|
* @param pluginName - The name of the plugin to install
|
||||||
|
* @param claudeExecutable - Path to the Claude executable
|
||||||
|
* @returns Promise that resolves when the plugin is installed successfully
|
||||||
|
* @throws {Error} If the plugin installation fails
|
||||||
*/
|
*/
|
||||||
async function installPlugin(
|
async function installPlugin(
|
||||||
pluginName: string,
|
pluginName: string,
|
||||||
claudeExecutable: string,
|
claudeExecutable: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
console.log(`Installing plugin: ${pluginName}`);
|
||||||
|
|
||||||
return executeClaudeCommand(
|
return executeClaudeCommand(
|
||||||
claudeExecutable,
|
claudeExecutable,
|
||||||
["plugin", "install", pluginName],
|
["plugin", "install", pluginName],
|
||||||
@@ -172,25 +181,18 @@ async function addMarketplace(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Installs Claude Code plugins from a comma-separated list
|
* Installs Claude Code plugins from a newline-separated list
|
||||||
* @param pluginsInput - Comma-separated list of plugin names, or undefined/empty to skip installation
|
|
||||||
* @param claudeExecutable - Path to the Claude executable (defaults to "claude")
|
|
||||||
* @param marketplacesInput - Newline-separated list of marketplace Git URLs
|
* @param marketplacesInput - Newline-separated list of marketplace Git URLs
|
||||||
|
* @param pluginsInput - Newline-separated list of plugin names
|
||||||
|
* @param claudeExecutable - Path to the Claude executable (defaults to "claude")
|
||||||
* @returns Promise that resolves when all plugins are installed
|
* @returns Promise that resolves when all plugins are installed
|
||||||
* @throws {Error} If any plugin fails validation or installation (stops on first error)
|
* @throws {Error} If any plugin fails validation or installation (stops on first error)
|
||||||
*/
|
*/
|
||||||
export async function installPlugins(
|
export async function installPlugins(
|
||||||
pluginsInput: string | undefined,
|
|
||||||
claudeExecutable?: string,
|
|
||||||
marketplacesInput?: string,
|
marketplacesInput?: string,
|
||||||
|
pluginsInput?: string,
|
||||||
|
claudeExecutable?: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const plugins = parsePlugins(pluginsInput);
|
|
||||||
|
|
||||||
if (plugins.length === 0) {
|
|
||||||
console.log("No plugins to install");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resolve executable path with explicit fallback
|
// Resolve executable path with explicit fallback
|
||||||
const resolvedExecutable = claudeExecutable || "claude";
|
const resolvedExecutable = claudeExecutable || "claude";
|
||||||
|
|
||||||
@@ -207,13 +209,14 @@ export async function installPlugins(
|
|||||||
console.log("No marketplaces specified, skipping marketplace setup");
|
console.log("No marketplaces specified, skipping marketplace setup");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const plugins = parsePlugins(pluginsInput);
|
||||||
|
if (plugins.length > 0) {
|
||||||
console.log(`Installing ${plugins.length} plugin(s)...`);
|
console.log(`Installing ${plugins.length} plugin(s)...`);
|
||||||
|
|
||||||
for (const plugin of plugins) {
|
for (const plugin of plugins) {
|
||||||
console.log(`Installing plugin: ${plugin}`);
|
|
||||||
await installPlugin(plugin, resolvedExecutable);
|
await installPlugin(plugin, resolvedExecutable);
|
||||||
console.log(`✓ Successfully installed: ${plugin}`);
|
console.log(`✓ Successfully installed: ${plugin}`);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
console.log("All plugins installed successfully");
|
console.log("No plugins specified, skipping plugins installation");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,25 +39,25 @@ describe("installPlugins", () => {
|
|||||||
|
|
||||||
test("should not call spawn when no plugins are specified", async () => {
|
test("should not call spawn when no plugins are specified", async () => {
|
||||||
const spy = createMockSpawn();
|
const spy = createMockSpawn();
|
||||||
await installPlugins("");
|
await installPlugins(undefined, "");
|
||||||
expect(spy).not.toHaveBeenCalled();
|
expect(spy).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should not call spawn when plugins is undefined", async () => {
|
test("should not call spawn when plugins is undefined", async () => {
|
||||||
const spy = createMockSpawn();
|
const spy = createMockSpawn();
|
||||||
await installPlugins(undefined);
|
await installPlugins(undefined, undefined);
|
||||||
expect(spy).not.toHaveBeenCalled();
|
expect(spy).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should not call spawn when plugins is only whitespace", async () => {
|
test("should not call spawn when plugins is only whitespace", async () => {
|
||||||
const spy = createMockSpawn();
|
const spy = createMockSpawn();
|
||||||
await installPlugins(" ");
|
await installPlugins(undefined, " ");
|
||||||
expect(spy).not.toHaveBeenCalled();
|
expect(spy).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should install a single plugin with default executable", async () => {
|
test("should install a single plugin with default executable", async () => {
|
||||||
const spy = createMockSpawn();
|
const spy = createMockSpawn();
|
||||||
await installPlugins("test-plugin");
|
await installPlugins(undefined, "test-plugin");
|
||||||
|
|
||||||
expect(spy).toHaveBeenCalledTimes(1);
|
expect(spy).toHaveBeenCalledTimes(1);
|
||||||
// Only call: install plugin (no marketplace without explicit marketplace input)
|
// Only call: install plugin (no marketplace without explicit marketplace input)
|
||||||
@@ -71,7 +71,7 @@ describe("installPlugins", () => {
|
|||||||
|
|
||||||
test("should install multiple plugins sequentially", async () => {
|
test("should install multiple plugins sequentially", async () => {
|
||||||
const spy = createMockSpawn();
|
const spy = createMockSpawn();
|
||||||
await installPlugins("plugin1,plugin2,plugin3");
|
await installPlugins(undefined, "plugin1\nplugin2\nplugin3");
|
||||||
|
|
||||||
expect(spy).toHaveBeenCalledTimes(3);
|
expect(spy).toHaveBeenCalledTimes(3);
|
||||||
// Install plugins (no marketplace without explicit marketplace input)
|
// Install plugins (no marketplace without explicit marketplace input)
|
||||||
@@ -97,7 +97,7 @@ describe("installPlugins", () => {
|
|||||||
|
|
||||||
test("should use custom claude executable path when provided", async () => {
|
test("should use custom claude executable path when provided", async () => {
|
||||||
const spy = createMockSpawn();
|
const spy = createMockSpawn();
|
||||||
await installPlugins("test-plugin", "/custom/path/to/claude");
|
await installPlugins(undefined, "test-plugin", "/custom/path/to/claude");
|
||||||
|
|
||||||
expect(spy).toHaveBeenCalledTimes(1);
|
expect(spy).toHaveBeenCalledTimes(1);
|
||||||
// Only call: install plugin (no marketplace without explicit marketplace input)
|
// Only call: install plugin (no marketplace without explicit marketplace input)
|
||||||
@@ -111,7 +111,7 @@ describe("installPlugins", () => {
|
|||||||
|
|
||||||
test("should trim whitespace from plugin names before installation", async () => {
|
test("should trim whitespace from plugin names before installation", async () => {
|
||||||
const spy = createMockSpawn();
|
const spy = createMockSpawn();
|
||||||
await installPlugins(" plugin1 , plugin2 ");
|
await installPlugins(undefined, " plugin1 \n plugin2 ");
|
||||||
|
|
||||||
expect(spy).toHaveBeenCalledTimes(2);
|
expect(spy).toHaveBeenCalledTimes(2);
|
||||||
// Install plugins (no marketplace without explicit marketplace input)
|
// Install plugins (no marketplace without explicit marketplace input)
|
||||||
@@ -131,7 +131,7 @@ describe("installPlugins", () => {
|
|||||||
|
|
||||||
test("should skip empty entries in plugin list", async () => {
|
test("should skip empty entries in plugin list", async () => {
|
||||||
const spy = createMockSpawn();
|
const spy = createMockSpawn();
|
||||||
await installPlugins("plugin1,,plugin2");
|
await installPlugins(undefined, "plugin1\n\nplugin2");
|
||||||
|
|
||||||
expect(spy).toHaveBeenCalledTimes(2);
|
expect(spy).toHaveBeenCalledTimes(2);
|
||||||
// Install plugins (no marketplace without explicit marketplace input)
|
// Install plugins (no marketplace without explicit marketplace input)
|
||||||
@@ -152,7 +152,7 @@ describe("installPlugins", () => {
|
|||||||
test("should handle plugin installation error and throw", async () => {
|
test("should handle plugin installation error and throw", async () => {
|
||||||
createMockSpawn(1, false); // Exit code 1
|
createMockSpawn(1, false); // Exit code 1
|
||||||
|
|
||||||
await expect(installPlugins("failing-plugin")).rejects.toThrow(
|
await expect(installPlugins(undefined, "failing-plugin")).rejects.toThrow(
|
||||||
"Failed to install plugin 'failing-plugin' (exit code: 1)",
|
"Failed to install plugin 'failing-plugin' (exit code: 1)",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -160,7 +160,9 @@ describe("installPlugins", () => {
|
|||||||
test("should handle null exit code (process terminated by signal)", async () => {
|
test("should handle null exit code (process terminated by signal)", async () => {
|
||||||
createMockSpawn(null, false); // Exit code null (terminated by signal)
|
createMockSpawn(null, false); // Exit code null (terminated by signal)
|
||||||
|
|
||||||
await expect(installPlugins("terminated-plugin")).rejects.toThrow(
|
await expect(
|
||||||
|
installPlugins(undefined, "terminated-plugin"),
|
||||||
|
).rejects.toThrow(
|
||||||
"Failed to install plugin 'terminated-plugin': process terminated by signal",
|
"Failed to install plugin 'terminated-plugin': process terminated by signal",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -168,9 +170,9 @@ describe("installPlugins", () => {
|
|||||||
test("should stop installation on first error", async () => {
|
test("should stop installation on first error", async () => {
|
||||||
const spy = createMockSpawn(1, false); // Exit code 1
|
const spy = createMockSpawn(1, false); // Exit code 1
|
||||||
|
|
||||||
await expect(installPlugins("plugin1,plugin2,plugin3")).rejects.toThrow(
|
await expect(
|
||||||
"Failed to install plugin 'plugin1' (exit code: 1)",
|
installPlugins(undefined, "plugin1\nplugin2\nplugin3"),
|
||||||
);
|
).rejects.toThrow("Failed to install plugin 'plugin1' (exit code: 1)");
|
||||||
|
|
||||||
// Should only try to install first plugin before failing
|
// Should only try to install first plugin before failing
|
||||||
expect(spy).toHaveBeenCalledTimes(1);
|
expect(spy).toHaveBeenCalledTimes(1);
|
||||||
@@ -178,7 +180,7 @@ describe("installPlugins", () => {
|
|||||||
|
|
||||||
test("should handle plugins with special characters in names", async () => {
|
test("should handle plugins with special characters in names", async () => {
|
||||||
const spy = createMockSpawn();
|
const spy = createMockSpawn();
|
||||||
await installPlugins("org/plugin-name,@scope/plugin");
|
await installPlugins(undefined, "org/plugin-name\n@scope/plugin");
|
||||||
|
|
||||||
expect(spy).toHaveBeenCalledTimes(2);
|
expect(spy).toHaveBeenCalledTimes(2);
|
||||||
// Install plugins (no marketplace without explicit marketplace input)
|
// Install plugins (no marketplace without explicit marketplace input)
|
||||||
@@ -199,14 +201,18 @@ describe("installPlugins", () => {
|
|||||||
test("should handle spawn errors", async () => {
|
test("should handle spawn errors", async () => {
|
||||||
createMockSpawn(0, true); // Trigger error event
|
createMockSpawn(0, true); // Trigger error event
|
||||||
|
|
||||||
await expect(installPlugins("test-plugin")).rejects.toThrow(
|
await expect(installPlugins(undefined, "test-plugin")).rejects.toThrow(
|
||||||
"Failed to install plugin 'test-plugin': spawn error",
|
"Failed to install plugin 'test-plugin': spawn error",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should install plugins with custom executable and multiple plugins", async () => {
|
test("should install plugins with custom executable and multiple plugins", async () => {
|
||||||
const spy = createMockSpawn();
|
const spy = createMockSpawn();
|
||||||
await installPlugins("plugin-a,plugin-b", "/usr/local/bin/claude-custom");
|
await installPlugins(
|
||||||
|
undefined,
|
||||||
|
"plugin-a\nplugin-b",
|
||||||
|
"/usr/local/bin/claude-custom",
|
||||||
|
);
|
||||||
|
|
||||||
expect(spy).toHaveBeenCalledTimes(2);
|
expect(spy).toHaveBeenCalledTimes(2);
|
||||||
// Install plugins (no marketplace without explicit marketplace input)
|
// Install plugins (no marketplace without explicit marketplace input)
|
||||||
@@ -228,9 +234,9 @@ describe("installPlugins", () => {
|
|||||||
const spy = createMockSpawn();
|
const spy = createMockSpawn();
|
||||||
|
|
||||||
// Should throw due to invalid characters (semicolon and spaces)
|
// Should throw due to invalid characters (semicolon and spaces)
|
||||||
await expect(installPlugins("plugin-name; rm -rf /")).rejects.toThrow(
|
await expect(
|
||||||
"Invalid plugin name format",
|
installPlugins(undefined, "plugin-name; rm -rf /"),
|
||||||
);
|
).rejects.toThrow("Invalid plugin name format");
|
||||||
|
|
||||||
// Mock should never be called because validation fails first
|
// Mock should never be called because validation fails first
|
||||||
expect(spy).not.toHaveBeenCalled();
|
expect(spy).not.toHaveBeenCalled();
|
||||||
@@ -239,9 +245,9 @@ describe("installPlugins", () => {
|
|||||||
test("should reject plugin names with path traversal using ../", async () => {
|
test("should reject plugin names with path traversal using ../", async () => {
|
||||||
const spy = createMockSpawn();
|
const spy = createMockSpawn();
|
||||||
|
|
||||||
await expect(installPlugins("../../../malicious-plugin")).rejects.toThrow(
|
await expect(
|
||||||
"Invalid plugin name format",
|
installPlugins(undefined, "../../../malicious-plugin"),
|
||||||
);
|
).rejects.toThrow("Invalid plugin name format");
|
||||||
|
|
||||||
expect(spy).not.toHaveBeenCalled();
|
expect(spy).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -249,9 +255,9 @@ describe("installPlugins", () => {
|
|||||||
test("should reject plugin names with path traversal using ./", async () => {
|
test("should reject plugin names with path traversal using ./", async () => {
|
||||||
const spy = createMockSpawn();
|
const spy = createMockSpawn();
|
||||||
|
|
||||||
await expect(installPlugins("./../../@scope/package")).rejects.toThrow(
|
await expect(
|
||||||
"Invalid plugin name format",
|
installPlugins(undefined, "./../../@scope/package"),
|
||||||
);
|
).rejects.toThrow("Invalid plugin name format");
|
||||||
|
|
||||||
expect(spy).not.toHaveBeenCalled();
|
expect(spy).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -259,7 +265,7 @@ describe("installPlugins", () => {
|
|||||||
test("should reject plugin names with consecutive dots", async () => {
|
test("should reject plugin names with consecutive dots", async () => {
|
||||||
const spy = createMockSpawn();
|
const spy = createMockSpawn();
|
||||||
|
|
||||||
await expect(installPlugins(".../.../package")).rejects.toThrow(
|
await expect(installPlugins(undefined, ".../.../package")).rejects.toThrow(
|
||||||
"Invalid plugin name format",
|
"Invalid plugin name format",
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -269,7 +275,7 @@ describe("installPlugins", () => {
|
|||||||
test("should reject plugin names with hidden path traversal", async () => {
|
test("should reject plugin names with hidden path traversal", async () => {
|
||||||
const spy = createMockSpawn();
|
const spy = createMockSpawn();
|
||||||
|
|
||||||
await expect(installPlugins("package/../other")).rejects.toThrow(
|
await expect(installPlugins(undefined, "package/../other")).rejects.toThrow(
|
||||||
"Invalid plugin name format",
|
"Invalid plugin name format",
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -278,7 +284,7 @@ describe("installPlugins", () => {
|
|||||||
|
|
||||||
test("should accept plugin names with single dots in version numbers", async () => {
|
test("should accept plugin names with single dots in version numbers", async () => {
|
||||||
const spy = createMockSpawn();
|
const spy = createMockSpawn();
|
||||||
await installPlugins("plugin-v1.0.2");
|
await installPlugins(undefined, "plugin-v1.0.2");
|
||||||
|
|
||||||
expect(spy).toHaveBeenCalledTimes(1);
|
expect(spy).toHaveBeenCalledTimes(1);
|
||||||
// Only call: install plugin (no marketplace without explicit marketplace input)
|
// Only call: install plugin (no marketplace without explicit marketplace input)
|
||||||
@@ -292,7 +298,7 @@ describe("installPlugins", () => {
|
|||||||
|
|
||||||
test("should accept plugin names with multiple dots in semantic versions", async () => {
|
test("should accept plugin names with multiple dots in semantic versions", async () => {
|
||||||
const spy = createMockSpawn();
|
const spy = createMockSpawn();
|
||||||
await installPlugins("@scope/plugin-v1.0.0-beta.1");
|
await installPlugins(undefined, "@scope/plugin-v1.0.0-beta.1");
|
||||||
|
|
||||||
expect(spy).toHaveBeenCalledTimes(1);
|
expect(spy).toHaveBeenCalledTimes(1);
|
||||||
// Only call: install plugin (no marketplace without explicit marketplace input)
|
// Only call: install plugin (no marketplace without explicit marketplace input)
|
||||||
@@ -308,7 +314,7 @@ describe("installPlugins", () => {
|
|||||||
const spy = createMockSpawn();
|
const spy = createMockSpawn();
|
||||||
|
|
||||||
// Using fullwidth dots (U+FF0E) and fullwidth solidus (U+FF0F)
|
// Using fullwidth dots (U+FF0E) and fullwidth solidus (U+FF0F)
|
||||||
await expect(installPlugins("../malicious")).rejects.toThrow(
|
await expect(installPlugins(undefined, "../malicious")).rejects.toThrow(
|
||||||
"Invalid plugin name format",
|
"Invalid plugin name format",
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -318,7 +324,7 @@ describe("installPlugins", () => {
|
|||||||
test("should reject path traversal at end of path", async () => {
|
test("should reject path traversal at end of path", async () => {
|
||||||
const spy = createMockSpawn();
|
const spy = createMockSpawn();
|
||||||
|
|
||||||
await expect(installPlugins("package/..")).rejects.toThrow(
|
await expect(installPlugins(undefined, "package/..")).rejects.toThrow(
|
||||||
"Invalid plugin name format",
|
"Invalid plugin name format",
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -328,7 +334,7 @@ describe("installPlugins", () => {
|
|||||||
test("should reject single dot directory reference", async () => {
|
test("should reject single dot directory reference", async () => {
|
||||||
const spy = createMockSpawn();
|
const spy = createMockSpawn();
|
||||||
|
|
||||||
await expect(installPlugins("package/.")).rejects.toThrow(
|
await expect(installPlugins(undefined, "package/.")).rejects.toThrow(
|
||||||
"Invalid plugin name format",
|
"Invalid plugin name format",
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -338,10 +344,256 @@ describe("installPlugins", () => {
|
|||||||
test("should reject path traversal in middle of path", async () => {
|
test("should reject path traversal in middle of path", async () => {
|
||||||
const spy = createMockSpawn();
|
const spy = createMockSpawn();
|
||||||
|
|
||||||
await expect(installPlugins("package/../other")).rejects.toThrow(
|
await expect(installPlugins(undefined, "package/../other")).rejects.toThrow(
|
||||||
"Invalid plugin name format",
|
"Invalid plugin name format",
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(spy).not.toHaveBeenCalled();
|
expect(spy).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Marketplace functionality tests
|
||||||
|
test("should add a single marketplace before installing plugins", async () => {
|
||||||
|
const spy = createMockSpawn();
|
||||||
|
await installPlugins(
|
||||||
|
"https://github.com/user/marketplace.git",
|
||||||
|
"test-plugin",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(spy).toHaveBeenCalledTimes(2);
|
||||||
|
// First call: add marketplace
|
||||||
|
expect(spy).toHaveBeenNthCalledWith(
|
||||||
|
1,
|
||||||
|
"claude",
|
||||||
|
[
|
||||||
|
"plugin",
|
||||||
|
"marketplace",
|
||||||
|
"add",
|
||||||
|
"https://github.com/user/marketplace.git",
|
||||||
|
],
|
||||||
|
{ stdio: "inherit" },
|
||||||
|
);
|
||||||
|
// Second call: install plugin
|
||||||
|
expect(spy).toHaveBeenNthCalledWith(
|
||||||
|
2,
|
||||||
|
"claude",
|
||||||
|
["plugin", "install", "test-plugin"],
|
||||||
|
{ stdio: "inherit" },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should add multiple marketplaces with newline separation", async () => {
|
||||||
|
const spy = createMockSpawn();
|
||||||
|
await installPlugins(
|
||||||
|
"https://github.com/user/m1.git\nhttps://github.com/user/m2.git",
|
||||||
|
"test-plugin",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(spy).toHaveBeenCalledTimes(3); // 2 marketplaces + 1 plugin
|
||||||
|
// First two calls: add marketplaces
|
||||||
|
expect(spy).toHaveBeenNthCalledWith(
|
||||||
|
1,
|
||||||
|
"claude",
|
||||||
|
["plugin", "marketplace", "add", "https://github.com/user/m1.git"],
|
||||||
|
{ stdio: "inherit" },
|
||||||
|
);
|
||||||
|
expect(spy).toHaveBeenNthCalledWith(
|
||||||
|
2,
|
||||||
|
"claude",
|
||||||
|
["plugin", "marketplace", "add", "https://github.com/user/m2.git"],
|
||||||
|
{ stdio: "inherit" },
|
||||||
|
);
|
||||||
|
// Third call: install plugin
|
||||||
|
expect(spy).toHaveBeenNthCalledWith(
|
||||||
|
3,
|
||||||
|
"claude",
|
||||||
|
["plugin", "install", "test-plugin"],
|
||||||
|
{ stdio: "inherit" },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should add marketplaces before installing multiple plugins", async () => {
|
||||||
|
const spy = createMockSpawn();
|
||||||
|
await installPlugins(
|
||||||
|
"https://github.com/user/marketplace.git",
|
||||||
|
"plugin1\nplugin2",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(spy).toHaveBeenCalledTimes(3); // 1 marketplace + 2 plugins
|
||||||
|
// First call: add marketplace
|
||||||
|
expect(spy).toHaveBeenNthCalledWith(
|
||||||
|
1,
|
||||||
|
"claude",
|
||||||
|
[
|
||||||
|
"plugin",
|
||||||
|
"marketplace",
|
||||||
|
"add",
|
||||||
|
"https://github.com/user/marketplace.git",
|
||||||
|
],
|
||||||
|
{ stdio: "inherit" },
|
||||||
|
);
|
||||||
|
// Next calls: install plugins
|
||||||
|
expect(spy).toHaveBeenNthCalledWith(
|
||||||
|
2,
|
||||||
|
"claude",
|
||||||
|
["plugin", "install", "plugin1"],
|
||||||
|
{ stdio: "inherit" },
|
||||||
|
);
|
||||||
|
expect(spy).toHaveBeenNthCalledWith(
|
||||||
|
3,
|
||||||
|
"claude",
|
||||||
|
["plugin", "install", "plugin2"],
|
||||||
|
{ stdio: "inherit" },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should handle only marketplaces without plugins", async () => {
|
||||||
|
const spy = createMockSpawn();
|
||||||
|
await installPlugins("https://github.com/user/marketplace.git", undefined);
|
||||||
|
|
||||||
|
expect(spy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(spy).toHaveBeenNthCalledWith(
|
||||||
|
1,
|
||||||
|
"claude",
|
||||||
|
[
|
||||||
|
"plugin",
|
||||||
|
"marketplace",
|
||||||
|
"add",
|
||||||
|
"https://github.com/user/marketplace.git",
|
||||||
|
],
|
||||||
|
{ stdio: "inherit" },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should skip empty marketplace entries", async () => {
|
||||||
|
const spy = createMockSpawn();
|
||||||
|
await installPlugins(
|
||||||
|
"https://github.com/user/m1.git\n\nhttps://github.com/user/m2.git",
|
||||||
|
"test-plugin",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(spy).toHaveBeenCalledTimes(3); // 2 marketplaces (skip empty) + 1 plugin
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should trim whitespace from marketplace URLs", async () => {
|
||||||
|
const spy = createMockSpawn();
|
||||||
|
await installPlugins(
|
||||||
|
" https://github.com/user/marketplace.git \n https://github.com/user/m2.git ",
|
||||||
|
"test-plugin",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(spy).toHaveBeenCalledTimes(3);
|
||||||
|
expect(spy).toHaveBeenNthCalledWith(
|
||||||
|
1,
|
||||||
|
"claude",
|
||||||
|
[
|
||||||
|
"plugin",
|
||||||
|
"marketplace",
|
||||||
|
"add",
|
||||||
|
"https://github.com/user/marketplace.git",
|
||||||
|
],
|
||||||
|
{ stdio: "inherit" },
|
||||||
|
);
|
||||||
|
expect(spy).toHaveBeenNthCalledWith(
|
||||||
|
2,
|
||||||
|
"claude",
|
||||||
|
["plugin", "marketplace", "add", "https://github.com/user/m2.git"],
|
||||||
|
{ stdio: "inherit" },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should reject invalid marketplace URL format", async () => {
|
||||||
|
const spy = createMockSpawn();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
installPlugins("not-a-valid-url", "test-plugin"),
|
||||||
|
).rejects.toThrow("Invalid marketplace URL format");
|
||||||
|
|
||||||
|
expect(spy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should reject marketplace URL without .git extension", async () => {
|
||||||
|
const spy = createMockSpawn();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
installPlugins("https://github.com/user/marketplace", "test-plugin"),
|
||||||
|
).rejects.toThrow("Invalid marketplace URL format");
|
||||||
|
|
||||||
|
expect(spy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should reject marketplace URL with non-https protocol", async () => {
|
||||||
|
const spy = createMockSpawn();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
installPlugins("http://github.com/user/marketplace.git", "test-plugin"),
|
||||||
|
).rejects.toThrow("Invalid marketplace URL format");
|
||||||
|
|
||||||
|
expect(spy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should skip whitespace-only marketplace input", async () => {
|
||||||
|
const spy = createMockSpawn();
|
||||||
|
await installPlugins(" ", "test-plugin");
|
||||||
|
|
||||||
|
// Should skip marketplaces and only install plugin
|
||||||
|
expect(spy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(spy).toHaveBeenNthCalledWith(
|
||||||
|
1,
|
||||||
|
"claude",
|
||||||
|
["plugin", "install", "test-plugin"],
|
||||||
|
{ stdio: "inherit" },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should handle marketplace addition error", async () => {
|
||||||
|
createMockSpawn(1, false); // Exit code 1
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
installPlugins("https://github.com/user/marketplace.git", "test-plugin"),
|
||||||
|
).rejects.toThrow(
|
||||||
|
"Failed to add marketplace 'https://github.com/user/marketplace.git' (exit code: 1)",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should stop if marketplace addition fails before installing plugins", async () => {
|
||||||
|
const spy = createMockSpawn(1, false); // Exit code 1
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
installPlugins(
|
||||||
|
"https://github.com/user/marketplace.git",
|
||||||
|
"plugin1\nplugin2",
|
||||||
|
),
|
||||||
|
).rejects.toThrow("Failed to add marketplace");
|
||||||
|
|
||||||
|
// Should only try to add marketplace, not install any plugins
|
||||||
|
expect(spy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should use custom executable for marketplace operations", async () => {
|
||||||
|
const spy = createMockSpawn();
|
||||||
|
await installPlugins(
|
||||||
|
"https://github.com/user/marketplace.git",
|
||||||
|
"test-plugin",
|
||||||
|
"/custom/path/to/claude",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(spy).toHaveBeenCalledTimes(2);
|
||||||
|
expect(spy).toHaveBeenNthCalledWith(
|
||||||
|
1,
|
||||||
|
"/custom/path/to/claude",
|
||||||
|
[
|
||||||
|
"plugin",
|
||||||
|
"marketplace",
|
||||||
|
"add",
|
||||||
|
"https://github.com/user/marketplace.git",
|
||||||
|
],
|
||||||
|
{ stdio: "inherit" },
|
||||||
|
);
|
||||||
|
expect(spy).toHaveBeenNthCalledWith(
|
||||||
|
2,
|
||||||
|
"/custom/path/to/claude",
|
||||||
|
["plugin", "install", "test-plugin"],
|
||||||
|
{ stdio: "inherit" },
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -32,8 +32,10 @@ jobs:
|
|||||||
# --max-turns 10
|
# --max-turns 10
|
||||||
# --model claude-4-0-sonnet-20250805
|
# --model claude-4-0-sonnet-20250805
|
||||||
|
|
||||||
|
# Optional: add custom plugin marketplaces
|
||||||
|
# plugin_marketplaces: "https://github.com/user/marketplace1.git\nhttps://github.com/user/marketplace2.git"
|
||||||
# Optional: install Claude Code plugins
|
# Optional: install Claude Code plugins
|
||||||
# plugins: "plugin1,plugin2,plugin3"
|
# plugins: "code-review@claude-code-plugins\nfeature-dev@claude-code-plugins"
|
||||||
|
|
||||||
# Optional: add custom trigger phrase (default: @claude)
|
# Optional: add custom trigger phrase (default: @claude)
|
||||||
# trigger_phrase: "/claude"
|
# trigger_phrase: "/claude"
|
||||||
@@ -76,7 +78,8 @@ jobs:
|
|||||||
| `allowed_non_write_users` | **⚠️ RISKY**: Comma-separated list of usernames to allow without write permissions, or '\*' for all users. Only works with `github_token` input. See [Security](./security.md) | No | "" |
|
| `allowed_non_write_users` | **⚠️ RISKY**: Comma-separated list of usernames to allow without write permissions, or '\*' for all users. Only works with `github_token` input. See [Security](./security.md) | No | "" |
|
||||||
| `path_to_claude_code_executable` | Optional path to a custom Claude Code executable. Skips automatic installation. Useful for Nix, custom containers, or specialized environments | No | "" |
|
| `path_to_claude_code_executable` | Optional path to a custom Claude Code executable. Skips automatic installation. Useful for Nix, custom containers, or specialized environments | No | "" |
|
||||||
| `path_to_bun_executable` | Optional path to a custom Bun executable. Skips automatic Bun installation. Useful for Nix, custom containers, or specialized environments | No | "" |
|
| `path_to_bun_executable` | Optional path to a custom Bun executable. Skips automatic Bun installation. Useful for Nix, custom containers, or specialized environments | No | "" |
|
||||||
| `plugins` | Comma-separated list of Claude Code plugin names to install (e.g., `plugin1,plugin2,plugin3`). Plugins are installed before Claude Code execution | No | "" |
|
| `plugin_marketplaces` | Newline-separated list of Claude Code plugin marketplace Git URLs to install from (e.g., see example in workflow above). Marketplaces are added before plugin installation | No | "" |
|
||||||
|
| `plugins` | Newline-separated list of Claude Code plugin names to install (e.g., see example in workflow above). Plugins are installed before Claude Code execution | No | "" |
|
||||||
|
|
||||||
### Deprecated Inputs
|
### Deprecated Inputs
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user