diff --git a/src/cli/actions/remoteAction.ts b/src/cli/actions/remoteAction.ts index 689d1d8c6..7bd2d6a2a 100644 --- a/src/cli/actions/remoteAction.ts +++ b/src/cli/actions/remoteAction.ts @@ -21,6 +21,10 @@ export const runRemoteAction = async ( throw new RepomixError('Git is not installed or not in the system PATH.'); } + if (!isValidRemoteValue(repoUrl)) { + throw new RepomixError('Invalid repository URL or user/repo format'); + } + const spinner = new Spinner('Cloning repository...'); const tempDirPath = await createTempDirectory(); @@ -111,3 +115,20 @@ export const copyOutputToCurrentDirectory = async ( throw new RepomixError(`Failed to copy output file: ${(error as Error).message}`); } }; + +export function isValidRemoteValue(remoteValue: string): boolean { + // Check the short form of the GitHub URL. e.g. yamadashy/repomix + const namePattern = '[a-zA-Z0-9](?:[a-zA-Z0-9._-]*[a-zA-Z0-9])?'; + const shortFormRegex = new RegExp(`^${namePattern}/${namePattern}$`); + if (shortFormRegex.test(remoteValue)) { + return true; + } + + // Check the direct form of the GitHub URL. e.g. https://github.com/yamadashy/repomix or https://gist.github.com/yamadashy/1234567890abcdef + try { + new URL(remoteValue); + return true; + } catch (error) { + return false; + } +} diff --git a/src/core/file/gitCommand.ts b/src/core/file/gitCommand.ts index 844abe223..8a5c795d6 100644 --- a/src/core/file/gitCommand.ts +++ b/src/core/file/gitCommand.ts @@ -2,26 +2,11 @@ import { execFile } from 'node:child_process'; import fs from 'node:fs/promises'; import path from 'node:path'; import { promisify } from 'node:util'; +import { RepomixError } from '../../shared/errorHandle.js'; import { logger } from '../../shared/logger.js'; const execFileAsync = promisify(execFile); -export function isValidRemoteUrl(url: string): boolean { - // Check the short form of the GitHub URL. e.g. yamadashy/repomix - const shortFormRegex = /^[a-zA-Z0-9_-]+\/[a-zA-Z0-9_-]+$/; - if (shortFormRegex.test(url)) { - return true; - } - - // Check the direct form of the GitHub URL. e.g. https://github.com/yamadashy/repomix or https://gist.github.com/yamadashy/1234567890abcdef - try { - new URL(url); - return true; - } catch (error) { - return false; - } -} - export const isGitInstalled = async ( deps = { execFileAsync, @@ -44,8 +29,11 @@ export const execGitShallowClone = async ( execFileAsync, }, ) => { - if (!isValidRemoteUrl(url)) { - throw new Error('Invalid repository URL or user/repo format'); + // Check if the URL is valid + try { + new URL(url); + } catch (error) { + throw new RepomixError(`Invalid repository URL. Please provide a valid URL. url: ${url}`); } if (remoteBranch) { diff --git a/src/index.ts b/src/index.ts index 3fa09d676..1a4cf9866 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,10 +1,17 @@ -export type { RepomixConfigFile as RepomixConfig } from './config/configSchema.js'; - -export { pack } from './core/packager.js'; - +// CLI export { run as cli } from './cli/cliRun.js'; export type { CliOptions } from './cli/cliRun.js'; +// Config +export type { RepomixConfigFile as RepomixConfig } from './config/configSchema.js'; + +// Init action export { runInitAction } from './cli/actions/initAction.js'; -export { runRemoteAction } from './cli/actions/remoteAction.js'; + +// Default action export { runDefaultAction } from './cli/actions/defaultAction.js'; +export { pack } from './core/packager.js'; + +// Remote action +export { runRemoteAction } from './cli/actions/remoteAction.js'; +export { isValidRemoteValue } from './cli/actions/remoteAction.js'; diff --git a/tests/cli/actions/remoteAction.test.ts b/tests/cli/actions/remoteAction.test.ts index 8e33aee25..2cc5d68ac 100644 --- a/tests/cli/actions/remoteAction.test.ts +++ b/tests/cli/actions/remoteAction.test.ts @@ -1,7 +1,12 @@ import * as fs from 'node:fs/promises'; import path from 'node:path'; import { beforeEach, describe, expect, test, vi } from 'vitest'; -import { copyOutputToCurrentDirectory, formatGitUrl, runRemoteAction } from '../../../src/cli/actions/remoteAction.js'; +import { + copyOutputToCurrentDirectory, + formatGitUrl, + isValidRemoteValue, + runRemoteAction, +} from '../../../src/cli/actions/remoteAction.js'; vi.mock('node:fs/promises', async (importOriginal) => { const actual = await importOriginal(); @@ -76,4 +81,76 @@ describe('remoteAction functions', () => { ); }); }); + + describe('isValidRemoteValue', () => { + describe('GitHub shorthand format (user/repo)', () => { + test('should accept valid repository names', () => { + // Test cases for valid repository names with various allowed characters + const validUrls = [ + 'user/repo', + 'user123/repo-name', + 'org-name/repo_name', + 'user.name/repo.test', + 'user_name/repo_test', + 'a/b', // Minimum length case + 'user-name123/repo-test123.sub_123', // Complex case + ]; + + for (const url of validUrls) { + expect(isValidRemoteValue(url), `URL should be valid: ${url}`).toBe(true); + } + }); + + test('should reject invalid repository names', () => { + // Test cases for invalid patterns and disallowed characters + const invalidUrls = [ + '', // Empty string + 'user', // Missing slash + '/repo', // Missing username + 'user/', // Missing repository name + '-user/repo', // Starts with hyphen + 'user/-repo', // Repository starts with hyphen + 'user./repo', // Username ends with dot + 'user/repo.', // Repository ends with dot + 'user/repo#branch', // Contains invalid character + 'user/repo/extra', // Extra path segment + 'us!er/repo', // Contains invalid character + 'user/re*po', // Contains invalid character + 'user//repo', // Double slash + '.user/repo', // Starts with dot + 'user/.repo', // Repository starts with dot + ]; + + for (const url of invalidUrls) { + expect(isValidRemoteValue(url), `URL should be invalid: ${url}`).toBe(false); + } + }); + }); + + describe('Full URL format', () => { + test('should accept valid URLs', () => { + // Test cases for standard URL formats + const validUrls = [ + 'https://example.com', + 'http://localhost', + 'https://github.com/user/repo', + 'https://gitlab.com/user/repo', + 'https://domain.com/path/to/something', + ]; + + for (const url of validUrls) { + expect(isValidRemoteValue(url), `URL should be valid: ${url}`).toBe(true); + } + }); + + test('should reject invalid URLs', () => { + // Test cases for malformed URLs + const invalidUrls = ['not-a-url', 'http://', 'https://', '://no-protocol.com', 'http://[invalid]']; + + for (const url of invalidUrls) { + expect(isValidRemoteValue(url), `URL should be invalid: ${url}`).toBe(false); + } + }); + }); + }); });