mirror of
https://github.com/anthropics/claude-code-action.git
synced 2026-01-23 23:14:13 +08:00
* 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>
223 lines
7.1 KiB
TypeScript
223 lines
7.1 KiB
TypeScript
import { spawn, ChildProcess } from "child_process";
|
|
|
|
const PLUGIN_NAME_REGEX = /^[@a-zA-Z0-9_\-\/\.]+$/;
|
|
const MAX_PLUGIN_NAME_LENGTH = 512;
|
|
const PATH_TRAVERSAL_REGEX =
|
|
/\.\.\/|\/\.\.|\.\/|\/\.|(?:^|\/)\.\.$|(?:^|\/)\.$|\.\.(?![0-9])/;
|
|
const MARKETPLACE_URL_REGEX =
|
|
/^https:\/\/[a-zA-Z0-9\-._~:/?#[\]@!$&'()*+,;=%]+\.git$/;
|
|
|
|
/**
|
|
* Validates a marketplace URL for security issues
|
|
* @param url - The marketplace URL to validate
|
|
* @throws {Error} If the URL is invalid
|
|
*/
|
|
function validateMarketplaceUrl(url: string): void {
|
|
const normalized = url.trim();
|
|
|
|
if (!normalized) {
|
|
throw new Error("Marketplace URL cannot be empty");
|
|
}
|
|
|
|
if (!MARKETPLACE_URL_REGEX.test(normalized)) {
|
|
throw new Error(`Invalid marketplace URL format: ${url}`);
|
|
}
|
|
|
|
// Additional check for valid URL structure
|
|
try {
|
|
new URL(normalized);
|
|
} catch {
|
|
throw new Error(`Invalid marketplace URL: ${url}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validates a plugin name for security issues
|
|
* @param pluginName - The plugin name to validate
|
|
* @throws {Error} If the plugin name is invalid
|
|
*/
|
|
function validatePluginName(pluginName: string): void {
|
|
// Normalize Unicode to prevent homoglyph attacks (e.g., fullwidth dots, Unicode slashes)
|
|
const normalized = pluginName.normalize("NFC");
|
|
|
|
if (normalized.length > MAX_PLUGIN_NAME_LENGTH) {
|
|
throw new Error(`Plugin name too long: ${normalized.substring(0, 50)}...`);
|
|
}
|
|
|
|
if (!PLUGIN_NAME_REGEX.test(normalized)) {
|
|
throw new Error(`Invalid plugin name format: ${pluginName}`);
|
|
}
|
|
|
|
// Prevent path traversal attacks with single efficient regex check
|
|
if (PATH_TRAVERSAL_REGEX.test(normalized)) {
|
|
throw new Error(`Invalid plugin name format: ${pluginName}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Parse a newline-separated list of marketplace URLs and return an array of validated URLs
|
|
* @param marketplaces - Newline-separated list of marketplace Git URLs
|
|
* @returns Array of validated marketplace URLs (empty array if none provided)
|
|
*/
|
|
function parseMarketplaces(marketplaces?: string): string[] {
|
|
const trimmed = marketplaces?.trim();
|
|
|
|
if (!trimmed) {
|
|
return [];
|
|
}
|
|
|
|
// Split by newline and process each URL
|
|
return trimmed
|
|
.split("\n")
|
|
.map((url) => url.trim())
|
|
.filter((url) => {
|
|
if (url.length === 0) return false;
|
|
|
|
validateMarketplaceUrl(url);
|
|
return true;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
* Allows: letters, numbers, @, -, _, /, . (common npm/scoped package characters)
|
|
* 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[] {
|
|
const trimmedPlugins = plugins?.trim();
|
|
|
|
if (!trimmedPlugins) {
|
|
return [];
|
|
}
|
|
|
|
// Split by newline and process each plugin
|
|
return trimmedPlugins
|
|
.split("\n")
|
|
.map((p) => p.trim())
|
|
.filter((p) => {
|
|
if (p.length === 0) return false;
|
|
|
|
validatePluginName(p);
|
|
return true;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Executes a Claude Code CLI command with proper error handling
|
|
* @param claudeExecutable - Path to the Claude executable
|
|
* @param args - Command arguments to pass to the executable
|
|
* @param errorContext - Context string for error messages (e.g., "Failed to install plugin 'foo'")
|
|
* @returns Promise that resolves when the command completes successfully
|
|
* @throws {Error} If the command fails to execute
|
|
*/
|
|
async function executeClaudeCommand(
|
|
claudeExecutable: string,
|
|
args: string[],
|
|
errorContext: string,
|
|
): Promise<void> {
|
|
return new Promise((resolve, reject) => {
|
|
const childProcess: ChildProcess = spawn(claudeExecutable, args, {
|
|
stdio: "inherit",
|
|
});
|
|
|
|
childProcess.on("close", (code: number | null) => {
|
|
if (code === 0) {
|
|
resolve();
|
|
} else if (code === null) {
|
|
reject(new Error(`${errorContext}: process terminated by signal`));
|
|
} else {
|
|
reject(new Error(`${errorContext} (exit code: ${code})`));
|
|
}
|
|
});
|
|
|
|
childProcess.on("error", (err: Error) => {
|
|
reject(new Error(`${errorContext}: ${err.message}`));
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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(
|
|
pluginName: string,
|
|
claudeExecutable: string,
|
|
): Promise<void> {
|
|
console.log(`Installing plugin: ${pluginName}`);
|
|
|
|
return executeClaudeCommand(
|
|
claudeExecutable,
|
|
["plugin", "install", pluginName],
|
|
`Failed to install plugin '${pluginName}'`,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Adds a Claude Code plugin marketplace
|
|
* @param claudeExecutable - Path to the Claude executable
|
|
* @param marketplaceUrl - The marketplace Git URL to add
|
|
* @returns Promise that resolves when the marketplace add command completes
|
|
* @throws {Error} If the command fails to execute
|
|
*/
|
|
async function addMarketplace(
|
|
claudeExecutable: string,
|
|
marketplaceUrl: string,
|
|
): Promise<void> {
|
|
console.log(`Adding marketplace: ${marketplaceUrl}`);
|
|
|
|
return executeClaudeCommand(
|
|
claudeExecutable,
|
|
["plugin", "marketplace", "add", marketplaceUrl],
|
|
`Failed to add marketplace '${marketplaceUrl}'`,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Installs Claude Code plugins from a newline-separated list
|
|
* @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
|
|
* @throws {Error} If any plugin fails validation or installation (stops on first error)
|
|
*/
|
|
export async function installPlugins(
|
|
marketplacesInput?: string,
|
|
pluginsInput?: string,
|
|
claudeExecutable?: string,
|
|
): Promise<void> {
|
|
// Resolve executable path with explicit fallback
|
|
const resolvedExecutable = claudeExecutable || "claude";
|
|
|
|
// Parse and add all marketplaces before installing plugins
|
|
const marketplaces = parseMarketplaces(marketplacesInput);
|
|
|
|
if (marketplaces.length > 0) {
|
|
console.log(`Adding ${marketplaces.length} marketplace(s)...`);
|
|
for (const marketplace of marketplaces) {
|
|
await addMarketplace(resolvedExecutable, marketplace);
|
|
console.log(`✓ Successfully added marketplace: ${marketplace}`);
|
|
}
|
|
} else {
|
|
console.log("No marketplaces specified, skipping marketplace setup");
|
|
}
|
|
|
|
const plugins = parsePlugins(pluginsInput);
|
|
if (plugins.length > 0) {
|
|
console.log(`Installing ${plugins.length} plugin(s)...`);
|
|
for (const plugin of plugins) {
|
|
await installPlugin(plugin, resolvedExecutable);
|
|
console.log(`✓ Successfully installed: ${plugin}`);
|
|
}
|
|
} else {
|
|
console.log("No plugins specified, skipping plugins installation");
|
|
}
|
|
}
|