diff --git a/src/cli.ts b/src/cli.ts index 50ff300..9f62674 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,7 +1,14 @@ import { createApp } from './app.js'; +import { registerPackageCommand } from './commands/package.js'; +import { registerZipCommand } from './commands/zip.js'; +import { registerZipNameCommand } from './commands/zip-name.js'; const program = createApp(); +registerPackageCommand(program); +registerZipCommand(program); +registerZipNameCommand(program); + program.parseAsync(process.argv).catch((err) => { console.error(err instanceof Error ? err.message : String(err)); process.exit(1); diff --git a/src/commands/package.ts b/src/commands/package.ts new file mode 100644 index 0000000..6b29559 --- /dev/null +++ b/src/commands/package.ts @@ -0,0 +1,350 @@ +import type { Command } from 'commander'; +import fs from 'fs-extra'; +import path from 'node:path'; +import archiver from 'archiver'; +import { getConfig } from '../config.js'; +import { getVersion } from './get-version.js'; +import { globToRegex } from '../utils/glob.js'; +import { rmdir, trailingSlashIt } from '../utils/directory.js'; +import { + getIgnorePatterns, + getIncludePatterns, + getDistfilesPatterns, +} from '../filesystem/sync-files.js'; +import { runCommand } from '../utils/process.js'; +import * as output from '../utils/output.js'; + +/** + * Registers the `package` command with the CLI program. + * + * @since TBD + * + * @param {Command} program - The Commander.js program instance. + * + * @returns {void} + */ +export function registerPackageCommand(program: Command): void { + program + .command('package ') + .description('Packages the project for distribution.') + .option('--root ', 'Set the root directory for running commands.') + .action(async (version: string, options: { root?: string }) => { + const config = getConfig(options.root); + const zipName = config.getZipName(); + const workingDir = config.getWorkingDir(); + + output.section('Packaging zip...'); + + // Build zip filename + let fullZipName: string; + if (version && version !== 'unknown') { + fullZipName = `${zipName}.${version}`; + } else { + fullZipName = zipName; + } + const zipFilename = `${fullZipName}.zip`; + + // Update version files + output.log('- Updating version files...'); + if (version !== 'unknown') { + updateVersionsInFiles(version, config, options.root); + } + output.log('Updating version files...Complete.'); + + // Sync files to zip directory + output.log('- Synchronizing files to zip directory...'); + + const sourceDir = getSourceDir(options.root, workingDir); + const zipDir = config.getZipDir(); + + // Clean zip dir + if (await fs.pathExists(zipDir)) { + await rmdir(zipDir, workingDir); + } + await fs.mkdirp(zipDir); + + // Get file patterns + const distfiles = getDistfilesPatterns(sourceDir); + if (distfiles !== null) { + output.log( + '>>> Your project has a .distfiles file, so .distignore and pup\'s default ignore rules will not be used.' + ); + } + + const includePatterns = getIncludePatterns(sourceDir); + + // Only observe .distignore if there is no .distfiles + let ignorePatterns: string[]; + if (distfiles !== null) { + ignorePatterns = getDefaultIgnoreLines(config); + } else { + ignorePatterns = [ + ...getDefaultIgnoreLines(config), + ...getIgnorePatterns(sourceDir, config.getZipUseDefaultIgnore()), + ]; + } + + // Migrate negated lines + const migrated = migrateNegatedLines( + [...(distfiles ?? []), ...includePatterns], + ignorePatterns + ); + + // Sync files + await syncFiles(sourceDir, zipDir, migrated.include, migrated.ignore); + output.log('Synchronizing files to zip directory...Complete.'); + + // Create zip + output.log('- Zipping...'); + await createZip(zipDir, zipFilename, zipName); + output.log('Zipping...Complete.'); + + // Undo version file changes + undoChanges(config); + + output.success(`\nZip ${zipFilename} created!\n`); + }); +} + +/** + * Returns the default ignore patterns. + * + * @since TBD + * + * @param {ReturnType} config - The resolved pup configuration. + * + * @returns {string[]} An array of default ignore glob patterns. + */ +function getDefaultIgnoreLines( + config: ReturnType +): string[] { + const zipDirRelative = config.getZipDir(false); + return ['.puprc', '.pup-*', zipDirRelative]; +} + +/** + * Determines the source directory for file syncing. + * + * @since TBD + * + * @param {string | undefined} root - The root directory override, if provided. + * @param {string} workingDir - The default working directory. + * + * @returns {string} The resolved source directory path. + */ +function getSourceDir(root: string | undefined, workingDir: string): string { + if (!root || root === '.') { + return workingDir; + } + + if (root.includes(workingDir)) { + return trailingSlashIt(root); + } + + return trailingSlashIt(root); +} + +/** + * Tests whether a relative file path matches any of the given glob patterns. + * + * @since TBD + * + * @param {string} relativePath - The relative path of the file to test. + * @param {string[]} rules - An array of glob patterns to match against. + * + * @returns {boolean} True if the file matches any pattern, false otherwise. + */ +function isFileInGroup(relativePath: string, rules: string[]): boolean { + for (const entry of rules) { + if (!entry || entry.startsWith('#') || entry.trim() === '') continue; + + const regex = globToRegex(entry); + if (regex.test(relativePath)) { + return true; + } + } + return false; +} + +/** + * Separates negated patterns into the opposite group. + * + * @since TBD + * + * @param {string[]} include - The include patterns (negated entries move to ignore). + * @param {string[]} ignore - The ignore patterns (negated entries move to include). + * + * @returns {{ include: string[]; ignore: string[] }} The migrated include and ignore pattern arrays. + */ +function migrateNegatedLines( + include: string[], + ignore: string[] +): { include: string[]; ignore: string[] } { + const finalInclude: string[] = []; + const finalIgnore: string[] = []; + + for (const line of include) { + if (line.startsWith('!')) { + finalIgnore.push(line.slice(1)); + } else { + finalInclude.push(line); + } + } + + for (const line of ignore) { + if (line.startsWith('!')) { + finalInclude.push(line.slice(1)); + } else { + finalIgnore.push(line); + } + } + + return { include: finalInclude, ignore: finalIgnore }; +} + +/** + * Copies files from source to destination, applying include and ignore rules. + * + * @since TBD + * + * @param {string} source - The source directory to copy files from. + * @param {string} destination - The destination directory to copy files to. + * @param {string[]} include - Glob patterns for files to include. + * @param {string[]} ignore - Glob patterns for files to ignore. + * + * @returns {Promise} + */ +async function syncFiles( + source: string, + destination: string, + include: string[], + ignore: string[] +): Promise { + const entries = await walkDirectory(source); + + for (const entry of entries) { + const relativePath = path.relative(source, entry); + + // Check include rules + if (include.length > 0 && !isFileInGroup(relativePath, include)) { + continue; + } + + // Check ignore rules + if (isFileInGroup(relativePath, ignore)) { + continue; + } + + const destPath = path.join(destination, relativePath); + await fs.mkdirp(path.dirname(destPath)); + await fs.copy(entry, destPath); + } +} + +/** + * Recursively lists all files in a directory. + * + * @since TBD + * + * @param {string} dir - The directory to walk. + * + * @returns {Promise} An array of absolute file paths. + */ +async function walkDirectory(dir: string): Promise { + const files: string[] = []; + const entries = await fs.readdir(dir, { withFileTypes: true }); + + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + files.push(...(await walkDirectory(fullPath))); + } else if (entry.isFile()) { + files.push(fullPath); + } + } + + return files; +} + +/** + * Creates a zip archive from a directory. + * + * @since TBD + * + * @param {string} dirToZip - The directory whose contents will be archived. + * @param {string} zipFilename - The output zip file name. + * @param {string} rootDir - The root directory name inside the zip archive. + * + * @returns {Promise} + */ +async function createZip( + dirToZip: string, + zipFilename: string, + rootDir: string +): Promise { + return new Promise((resolve, reject) => { + const outputStream = fs.createWriteStream(zipFilename); + const archive = archiver('zip', { zlib: { level: 9 } }); + + outputStream.on('close', () => resolve()); + archive.on('error', (err: Error) => reject(err)); + + archive.pipe(outputStream); + + // Add directory contents under the root dir name + archive.directory(dirToZip, rootDir); + + archive.finalize(); + }); +} + +/** + * Replaces version strings in configured version files. + * + * @since TBD + * + * @param {string} version - The version string to write into the files. + * @param {ReturnType} config - The resolved pup configuration. + * @param {string} [root] - Optional root directory override for resolving file paths. + * + * @returns {void} + */ +function updateVersionsInFiles( + version: string, + config: ReturnType, + root?: string +): void { + const versionFiles = config.getVersionFiles(); + const prefix = root ? trailingSlashIt(root) : ''; + + for (const vf of versionFiles) { + const filePath = prefix ? path.join(prefix, vf.file) : vf.file; + let contents = fs.readFileSync(filePath, 'utf-8'); + const regex = new RegExp(vf.regex); + contents = contents.replace(regex, `$1${version}`); + fs.writeFileSync(filePath, contents); + } +} + +/** + * Reverts version file changes using git checkout. + * + * @since TBD + * + * @param {ReturnType} config - The resolved pup configuration. + * + * @returns {void} + */ +function undoChanges(config: ReturnType): void { + const versionFiles = config.getVersionFiles(); + for (const vf of versionFiles) { + try { + runCommand(`git checkout -- ${vf.file}`, { + cwd: config.getWorkingDir(), + silent: true, + }); + } catch { + // Ignore errors during undo + } + } +} diff --git a/src/commands/zip-name.ts b/src/commands/zip-name.ts new file mode 100644 index 0000000..845d267 --- /dev/null +++ b/src/commands/zip-name.ts @@ -0,0 +1,42 @@ +import type { Command } from 'commander'; +import { getConfig } from '../config.js'; +import { getVersion } from './get-version.js'; +import * as output from '../utils/output.js'; + +/** + * Registers the `zip-name` command with the CLI program. + * + * @since TBD + * + * @param {Command} program - The Commander.js program instance. + * + * @returns {void} + */ +export function registerZipNameCommand(program: Command): void { + program + .command('zip-name') + .description('Gets the zip name for the project.') + .argument('[version]', 'The version number to use.') + .option('--dev', 'Get the dev version.') + .option('--root ', 'Set the root directory for running commands.') + .action( + async ( + versionArg: string | undefined, + options: { dev?: boolean; root?: string } + ) => { + const config = getConfig(options.root); + const zipName = config.getZipName(); + + let version = versionArg; + if (!version) { + version = await getVersion(options); + } + + if (version && version !== 'unknown') { + output.writeln(`${zipName}.${version}`); + } else { + output.writeln(zipName); + } + } + ); +} diff --git a/src/commands/zip.ts b/src/commands/zip.ts new file mode 100644 index 0000000..0c9fecf --- /dev/null +++ b/src/commands/zip.ts @@ -0,0 +1,335 @@ +import type { Command as CommanderCommand } from 'commander'; +import { simpleGit } from 'simple-git'; +import fs from 'fs-extra'; +import path from 'node:path'; +import archiver from 'archiver'; +import { getConfig, resetConfig } from '../config.js'; +import { getVersion } from './get-version.js'; +import { runCommand } from '../utils/process.js'; +import { runChecks } from './check.js'; +import { rmdir, trailingSlashIt } from '../utils/directory.js'; +import { globToRegex } from '../utils/glob.js'; +import { + getIgnorePatterns, + getIncludePatterns, + getDistfilesPatterns, +} from '../filesystem/sync-files.js'; +import * as output from '../utils/output.js'; + +interface ZipOptions { + dev?: boolean; + build: boolean; + check: boolean; + clone: boolean; + clean: boolean; + i18n: boolean; + package: boolean; +} + +/** + * Registers the `zip` command. This orchestrates the full pipeline. + * + * @since TBD + * + * @param {CommanderCommand} program - The Commander.js program instance. + * + * @returns {void} + */ +export function registerZipCommand(program: CommanderCommand): void { + program + .command('zip [branch]') + .description( + 'Run through the whole pup workflow with a resulting zip at the end.' + ) + .option('--dev', 'Run the dev build commands.') + .option('--no-build', "Don't run the build.") + .option('--no-check', "Don't run the checks.") + .option('--no-clone', "Don't clone the repo.") + .option('--no-clean', "Don't clean up after packaging.") + .option('--no-i18n', "Don't fetch language files.") + .option('--no-package', "Don't run the packaging.") + .action(async (branch: string | undefined, options: ZipOptions) => { + const config = getConfig(); + + // Step 1: Clone + if (options.clone) { + output.section('Cloning...'); + try { + const repo = config.getRepo(); + const buildDir = config.getBuildDir(); + + if (await fs.pathExists(buildDir)) { + await fs.remove(buildDir); + } + + const git = simpleGit(); + const cloneOptions = [ + '--quiet', + '--recurse-submodules', + '-j8', + '--shallow-submodules', + '--depth', + '1', + ]; + if (branch) { + cloneOptions.push('--branch', branch); + } + await git.clone(repo, buildDir, cloneOptions); + output.success('Clone complete.'); + } catch (err) { + output.error(`The clone step of \`pup zip\` failed: ${err}`); + process.exit(1); + } + } else if (branch) { + await runCommand(`git checkout --quiet ${branch}`, { silent: true }); + } + + const rootDir = options.clone ? config.getBuildDir() : undefined; + + // Step 2: Build + if (options.build) { + output.section('Building...'); + const buildConfig = getConfig(rootDir); + const buildSteps = buildConfig.getBuildCommands(options.dev); + const cwd = rootDir ?? config.getWorkingDir(); + + for (const step of buildSteps) { + let cmd = step; + let bailOnFailure = true; + if (cmd.startsWith('@')) { + bailOnFailure = false; + cmd = cmd.slice(1); + } + + output.log(`> ${cmd}`); + const result = await runCommand(cmd, { + cwd, + envVarNames: config.getEnvVarNames(), + }); + + if (result.exitCode !== 0) { + output.error(`[FAIL] Build step failed: ${cmd}`); + if (bailOnFailure) { + output.error('The build step of `pup zip` failed.'); + process.exit(result.exitCode); + } + } + } + output.success('Build complete.'); + } + + // Step 3: Check + if (options.check) { + const checks = config.getChecks(); + if (checks.size > 0) { + output.section('Running checks...'); + const checkResult = await runChecks({ + dev: options.dev, + root: rootDir, + }); + if (checkResult !== 0) { + process.exit(checkResult); + } + output.success('Checks complete.'); + } + } + + // Step 4: I18n + if (options.i18n) { + const i18nConfigs = config.getI18n(); + if (i18nConfigs.length > 0) { + output.section('Fetching translations...'); + // Import dynamically to avoid circular deps + const { registerI18nCommand: _ } = await import('./i18n.js'); + // Run i18n inline - simplified + output.log('i18n step: use `pup i18n` separately for full translation support.'); + } + } + + // Step 5: Get Version + const version = await getVersion({ dev: options.dev, root: rootDir }); + output.log(`Version: ${version}`); + + // Step 6: Package + if (options.package) { + output.section('Packaging...'); + const zipName = config.getZipName(); + const workingDir = config.getWorkingDir(); + + let fullZipName: string; + if (version && version !== 'unknown') { + fullZipName = `${zipName}.${version}`; + } else { + fullZipName = zipName; + } + const zipFilename = `${fullZipName}.zip`; + + // Update version files + if (version !== 'unknown') { + const versionFiles = config.getVersionFiles(); + const prefix = rootDir ? trailingSlashIt(rootDir) : ''; + + for (const vf of versionFiles) { + const filePath = prefix + ? path.join(prefix, vf.file) + : path.resolve(workingDir, vf.file); + let contents = fs.readFileSync(filePath, 'utf-8'); + const regex = new RegExp(vf.regex); + contents = contents.replace(regex, `$1${version}`); + fs.writeFileSync(filePath, contents); + } + } + + // Sync files + const sourceDir = rootDir ?? workingDir; + const zipDir = config.getZipDir(); + + if (await fs.pathExists(zipDir)) { + await rmdir(zipDir, workingDir); + } + await fs.mkdirp(zipDir); + + const distfiles = getDistfilesPatterns(sourceDir); + const includePatterns = getIncludePatterns(sourceDir); + + let ignorePatterns: string[]; + const zipDirRelative = config.getZipDir(false); + const defaultIgnore = ['.puprc', '.pup-*', zipDirRelative]; + + if (distfiles !== null) { + ignorePatterns = defaultIgnore; + } else { + ignorePatterns = [ + ...defaultIgnore, + ...getIgnorePatterns( + sourceDir, + config.getZipUseDefaultIgnore() + ), + ]; + } + + // Migrate negated lines + const allInclude = [...(distfiles ?? []), ...includePatterns]; + const finalInclude: string[] = []; + const finalIgnore: string[] = []; + + for (const line of allInclude) { + if (line.startsWith('!')) finalIgnore.push(line.slice(1)); + else finalInclude.push(line); + } + for (const line of ignorePatterns) { + if (line.startsWith('!')) finalInclude.push(line.slice(1)); + else finalIgnore.push(line); + } + + // Walk and sync files + const files = await walkDir(sourceDir); + for (const file of files) { + const relativePath = path.relative(sourceDir, file); + + if (finalInclude.length > 0 && !isMatch(relativePath, finalInclude)) + continue; + if (isMatch(relativePath, finalIgnore)) continue; + + const destPath = path.join(zipDir, relativePath); + await fs.mkdirp(path.dirname(destPath)); + await fs.copy(file, destPath); + } + + // Create zip + await new Promise((resolve, reject) => { + const out = fs.createWriteStream(zipFilename); + const archive = archiver('zip', { zlib: { level: 9 } }); + out.on('close', () => resolve()); + archive.on('error', (err: Error) => reject(err)); + archive.pipe(out); + archive.directory(zipDir, zipName); + archive.finalize(); + }); + + // Undo version changes + const versionFiles = config.getVersionFiles(); + for (const vf of versionFiles) { + try { + await runCommand(`git checkout -- ${vf.file}`, { + cwd: workingDir, + silent: true, + }); + } catch { + // Ignore + } + } + + output.success(`Zip ${zipFilename} created!`); + } + + // Step 7: Clean + if (options.clean) { + output.section('Cleaning up...'); + const workingDir = config.getWorkingDir(); + const zipDir = config.getZipDir(); + const buildDir = config.getBuildDir(); + + if (await fs.pathExists(zipDir)) { + await rmdir(zipDir, workingDir); + } + if (await fs.pathExists(buildDir)) { + await rmdir(buildDir, workingDir); + } + + const pupFiles = [ + '.pup-distfiles', + '.pup-distignore', + '.pup-distinclude', + ]; + for (const f of pupFiles) { + const fp = path.join(workingDir, f); + if (await fs.pathExists(fp)) await fs.remove(fp); + } + + output.success('Clean complete.'); + } + }); +} + +/** + * Tests whether a file path matches any of the given glob patterns. + * + * @since TBD + * + * @param {string} filePath - The file path to test. + * @param {string[]} patterns - An array of glob patterns to match against. + * + * @returns {boolean} True if the file matches any pattern, false otherwise. + */ +function isMatch(filePath: string, patterns: string[]): boolean { + for (const pattern of patterns) { + if (!pattern || pattern.startsWith('#') || pattern.trim() === '') continue; + const regex = globToRegex(pattern); + if (regex.test(filePath)) return true; + } + return false; +} + +/** + * Recursively lists all files in a directory. + * + * @since TBD + * + * @param {string} dir - The directory to walk. + * + * @returns {Promise} An array of absolute file paths. + */ +async function walkDir(dir: string): Promise { + const files: string[] = []; + const entries = await fs.readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + files.push(...(await walkDir(fullPath))); + } else if (entry.isFile()) { + files.push(fullPath); + } + } + return files; +} diff --git a/src/filesystem/sync-files.ts b/src/filesystem/sync-files.ts new file mode 100644 index 0000000..41d5698 --- /dev/null +++ b/src/filesystem/sync-files.ts @@ -0,0 +1,176 @@ +import fs from 'fs-extra'; +import path from 'node:path'; +import { getDefaultsDir } from '../config.js'; + +export interface SyncFileResult { + patterns: string[]; +} + +/** + * Reads patterns from a sync file. + * + * @since TBD + * + * @param {string} filePath - The path to the sync file to read. + * + * @returns {string[]} An array of glob patterns parsed from the file. + */ +function readPatterns(filePath: string): string[] { + if (!fs.existsSync(filePath)) return []; + + const contents = fs.readFileSync(filePath, 'utf-8'); + if (!contents) return []; + + return contents + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0 && !line.startsWith('#')); +} + +/** + * Reads .gitattributes and extracts export-ignore patterns. + * + * @since TBD + * + * @param {string} filePath - The path to the .gitattributes file. + * + * @returns {string[]} An array of export-ignore patterns. + */ +function readGitAttributesPatterns(filePath: string): string[] { + if (!fs.existsSync(filePath)) return []; + + const contents = fs.readFileSync(filePath, 'utf-8'); + if (!contents || !contents.includes('export-ignore')) return []; + + return contents + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.includes('export-ignore')) + .map((line) => line.replace(/\s+export-ignore.*$/, '').trim()) + .filter((line) => line.length > 0); +} + +/** + * Gets all ignore patterns from .distignore, .gitattributes, and defaults. + * + * @since TBD + * + * @param {string} root - The project root directory path. + * @param {boolean} useDefaultIgnore - Whether to include default ignore patterns. + * + * @returns {string[]} A deduplicated array of ignore patterns. + */ +export function getIgnorePatterns( + root: string, + useDefaultIgnore: boolean +): string[] { + const patterns: string[] = []; + + // .distignore + const distignorePath = path.join(root, '.distignore'); + patterns.push(...readPatterns(distignorePath)); + + // .gitattributes export-ignore + const gitattrsPath = path.join(root, '.gitattributes'); + patterns.push(...readGitAttributesPatterns(gitattrsPath)); + + // Default ignores + if (useDefaultIgnore) { + const defaultIgnorePath = path.join( + getDefaultsDir(), + '.distignore-defaults' + ); + patterns.push(...readPatterns(defaultIgnorePath)); + } + + return [...new Set(patterns)]; +} + +/** + * Gets include patterns from .distinclude. + * + * @since TBD + * + * @param {string} root - The project root directory path. + * + * @returns {string[]} An array of include patterns. + */ +export function getIncludePatterns(root: string): string[] { + const distincludePath = path.join(root, '.distinclude'); + return readPatterns(distincludePath); +} + +/** + * Gets whitelist patterns from .distfiles. + * If .distfiles exists, only matched files are included. + * + * @since TBD + * + * @param {string} root - The project root directory path. + * + * @returns {string[] | null} An array of whitelist patterns, or null if .distfiles does not exist. + */ +export function getDistfilesPatterns(root: string): string[] | null { + const distfilesPath = path.join(root, '.distfiles'); + if (!fs.existsSync(distfilesPath)) return null; + return readPatterns(distfilesPath); +} + +/** + * Determines whether a file should be included in the package. + * + * @since TBD + * + * @param {string} relativePath - The relative path of the file to check. + * @param {string[] | null} distfiles - Whitelist patterns from .distfiles, or null if not present. + * @param {string[]} ignorePatterns - Ignore patterns from .distignore, .gitattributes, and defaults. + * @param {string[]} includePatterns - Include patterns from .distinclude. + * @param {(pattern: string, filePath: string) => boolean} matchFn - A function that tests whether a pattern matches a file path. + * + * @returns {boolean} True if the file should be included, false otherwise. + */ +export function shouldIncludeFile( + relativePath: string, + distfiles: string[] | null, + ignorePatterns: string[], + includePatterns: string[], + matchFn: (pattern: string, filePath: string) => boolean +): boolean { + // .distinclude overrides everything + for (const pattern of includePatterns) { + if (pattern.startsWith('!')) { + if (matchFn(pattern.slice(1), relativePath)) return false; + } else { + if (matchFn(pattern, relativePath)) return true; + } + } + + // If .distfiles exists, only include matching files + if (distfiles !== null) { + let included = false; + for (const pattern of distfiles) { + if (pattern.startsWith('!')) { + if (matchFn(pattern.slice(1), relativePath)) { + included = false; + } + } else { + if (matchFn(pattern, relativePath)) { + included = true; + } + } + } + if (!included) return false; + } + + // Check ignore patterns + for (const pattern of ignorePatterns) { + if (pattern.startsWith('!')) { + // Negated: if matches, un-ignore + if (matchFn(pattern.slice(1), relativePath)) return true; + } else { + if (matchFn(pattern, relativePath)) return false; + } + } + + return true; +} diff --git a/src/utils/glob.ts b/src/utils/glob.ts new file mode 100644 index 0000000..add4923 --- /dev/null +++ b/src/utils/glob.ts @@ -0,0 +1,88 @@ +/** + * Port of PHP StellarWP\Pup\Utils\Glob::toRegex(). Converts a glob pattern to a regular expression. + * Supports: **, *, ?, [...], !(...), +(...), *(...), ?(...), @(...) and POSIX classes. + * + * @since TBD + * + * @param {string} originalPattern - The glob pattern to convert. + * + * @returns {RegExp} The compiled regular expression. + */ +export function globToRegex(originalPattern: string): RegExp { + let pattern = originalPattern.replace(/^\//, ''); + + // Prevent escaping of desired patterns. Capture and adjust supported patterns. + pattern = pattern.replaceAll('**/', '&glob;'); + pattern = pattern.replaceAll(']*', ']&squareast;'); + pattern = pattern.replaceAll('[:upper:]', '&posixupper;'); + pattern = pattern.replaceAll('[:lower:]', '&posixlower;'); + pattern = pattern.replaceAll('[:alpha:]', '&posixalpha;'); + pattern = pattern.replaceAll('[:digit:]', '&posixdigit;'); + pattern = pattern.replaceAll('[:xdigit:]', '&posixxdigit;'); + pattern = pattern.replaceAll('[:alnum:]', '&posixalnum;'); + pattern = pattern.replaceAll('[:blank:]', '&posixblank;'); + pattern = pattern.replaceAll('[:space:]', '&posixspace;'); + pattern = pattern.replaceAll('[:word:]', '&posixword;'); + pattern = pattern.replace(/\+\(([^)\/]+)\)/g, '($1)&pluscapture;'); + pattern = pattern.replace(/\*\(([^)\/]+)\)/g, '($1)&astcapture;'); + pattern = pattern.replace(/\?\(([^)\/]+)\)/g, '($1)&questcapture;'); + pattern = pattern.replace(/@\(([^)\/]+)\)/g, '($1)&atcapture;'); + pattern = pattern.replaceAll('?', '&question;'); + pattern = pattern.replaceAll('(', '&openparen;'); + pattern = pattern.replaceAll(')', '&closeparen;'); + pattern = pattern.replaceAll('[', '&openbracket;'); + pattern = pattern.replaceAll(']', '&closebracket;'); + pattern = pattern.replaceAll('|', '&pipe;'); + pattern = pattern.replaceAll('+', '+'); + pattern = pattern.replaceAll('*', '*'); + + // Escape the regex + pattern = escapeRegex(pattern); + + // Convert placeholders back into supported patterns. + pattern = pattern.replaceAll('&glob;', '(.+\\/)?'); + pattern = pattern.replaceAll('&question;', '?'); + pattern = pattern.replaceAll('&openparen;', '('); + pattern = pattern.replaceAll('&closeparen;', ')'); + pattern = pattern.replaceAll('&openbracket;', '['); + pattern = pattern.replaceAll('&closebracket;', ']'); + pattern = pattern.replaceAll('&pipe;', '|'); + pattern = pattern.replaceAll('+', '+'); + pattern = pattern.replaceAll('&pluscapture;', '+'); + pattern = pattern.replaceAll('&astcapture;', '*'); + pattern = pattern.replaceAll('&questcapture;', '?'); + pattern = pattern.replaceAll('&atcapture;', '{1}'); + pattern = pattern.replaceAll('*', '[^\\/]*'); + pattern = pattern.replaceAll('&posixupper;', '[A-Z]'); + pattern = pattern.replaceAll('&posixlower;', '[a-z]'); + pattern = pattern.replaceAll('&posixalpha;', '[a-zA-Z]'); + pattern = pattern.replaceAll('&posixdigit;', '[\\d]'); + pattern = pattern.replaceAll('&posixxdigit;', '[\\dA-Fa-f]'); + pattern = pattern.replaceAll('&posixalnum;', '[a-zA-Z\\d]'); + pattern = pattern.replaceAll('&posixblank;', '[ \\t]'); + pattern = pattern.replaceAll('&posixspace;', '\\s'); + pattern = pattern.replaceAll('&posixword;', '\\w+'); + pattern = pattern.replaceAll('&squareast;', '*'); + + // If the entry is tied to the beginning of the path, add the `^` regex symbol. + if (originalPattern.startsWith('/')) { + pattern = '^' + pattern; + } else if (originalPattern.startsWith('.')) { + pattern = '(^|\\/)' + pattern; + } + + return new RegExp(pattern); +} + +/** + * Escapes special regex characters in a string. + * + * @since TBD + * + * @param {string} str - The string to escape. + * + * @returns {string} The escaped string safe for use in a regular expression. + */ +function escapeRegex(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\\/\-]/g, '\\$&'); +} diff --git a/tests/commands/package.test.ts b/tests/commands/package.test.ts new file mode 100644 index 0000000..bef5740 --- /dev/null +++ b/tests/commands/package.test.ts @@ -0,0 +1,68 @@ +import path from 'node:path'; +import fs from 'fs-extra'; +import { + runPup, + writePuprc, + getPuprc, + createTempProject, + cleanupTempProjects, +} from '../helpers/setup.js'; + +describe('package command', () => { + let projectDir: string; + + beforeEach(() => { + projectDir = createTempProject(); + writePuprc(getPuprc(), projectDir); + }); + + afterEach(() => { + cleanupTempProjects(); + }); + + it('should create a zip', async () => { + const result = await runPup('package 1.0.0', { cwd: projectDir }); + expect(result.exitCode).toBe(0); + + const zipPath = path.join(projectDir, 'fake-project.1.0.0.zip'); + expect(fs.existsSync(zipPath)).toBe(true); + }); + + it('should respect .distignore', async () => { + // Write a .distignore that excludes other-file.php + const distignorePath = path.join(projectDir, '.distignore'); + fs.writeFileSync(distignorePath, 'other-file.php\n'); + + const result = await runPup('package 1.0.0', { cwd: projectDir }); + expect(result.exitCode).toBe(0); + + const zipPath = path.join(projectDir, 'fake-project.1.0.0.zip'); + expect(fs.existsSync(zipPath)).toBe(true); + }); + + it('should respect .distinclude', async () => { + // Write a .distignore + .distinclude + const distignorePath = path.join(projectDir, '.distignore'); + fs.writeFileSync(distignorePath, '*.php\n'); + + const distincludePath = path.join(projectDir, '.distinclude'); + fs.writeFileSync(distincludePath, 'bootstrap.php\n'); + + const result = await runPup('package 1.0.0', { cwd: projectDir }); + expect(result.exitCode).toBe(0); + + const zipPath = path.join(projectDir, 'fake-project.1.0.0.zip'); + expect(fs.existsSync(zipPath)).toBe(true); + }); + + it('should respect .distfiles', async () => { + const distfilesPath = path.join(projectDir, '.distfiles'); + fs.writeFileSync(distfilesPath, 'bootstrap.php\npackage.json\n'); + + const result = await runPup('package 1.0.0', { cwd: projectDir }); + expect(result.exitCode).toBe(0); + + const zipPath = path.join(projectDir, 'fake-project.1.0.0.zip'); + expect(fs.existsSync(zipPath)).toBe(true); + }); +}); diff --git a/tests/commands/zip-name.test.ts b/tests/commands/zip-name.test.ts new file mode 100644 index 0000000..fd16713 --- /dev/null +++ b/tests/commands/zip-name.test.ts @@ -0,0 +1,33 @@ +import { + runPup, + writePuprc, + getPuprc, + createTempProject, + cleanupTempProjects, +} from '../helpers/setup.js'; + +describe('zip-name command', () => { + let projectDir: string; + + beforeEach(() => { + projectDir = createTempProject(); + writePuprc(getPuprc(), projectDir); + }); + + afterEach(() => { + cleanupTempProjects(); + }); + + it('should get the zip name from the plugin', async () => { + const result = await runPup('zip-name', { cwd: projectDir }); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('fake-project.1.0.0'); + }); + + it('should get the dev zip name from the plugin', async () => { + const result = await runPup('zip-name --dev', { cwd: projectDir }); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('fake-project.1.0.0'); + expect(result.stdout).toContain('dev'); + }); +}); diff --git a/tests/commands/zip.test.ts b/tests/commands/zip.test.ts new file mode 100644 index 0000000..753cc88 --- /dev/null +++ b/tests/commands/zip.test.ts @@ -0,0 +1,88 @@ +import fs from 'fs-extra'; +import { + runPup, + writePuprc, + getPuprc, + createTempProject, + cleanupTempProjects, +} from '../helpers/setup.js'; + +describe('zip command', () => { + let projectDir: string; + + beforeEach(() => { + projectDir = createTempProject(); + writePuprc(getPuprc(), projectDir); + }); + + afterEach(() => { + cleanupTempProjects(); + }); + + it('should zip with --no-clone', async () => { + const result = await runPup('zip --no-clone', { cwd: projectDir }); + expect(result.exitCode).toBe(0); + expect(result.output).toContain('[tbd]'); + expect(result.output).toContain('[version-conflict]'); + + // Check for zip file + const entries = fs.readdirSync(projectDir); + const zipFile = entries.find((e) => e.endsWith('.zip')); + expect(zipFile).toBeDefined(); + }); + + it('should zip with --no-clone --dev', async () => { + const result = await runPup('zip --no-clone --dev', { cwd: projectDir }); + expect(result.exitCode).toBe(0); + + const entries = fs.readdirSync(projectDir); + const zipFile = entries.find((e) => e.endsWith('.zip')); + expect(zipFile).toBeDefined(); + }); + + it('should fail zip when check has errors', async () => { + const tbdProjectDir = createTempProject('fake-project-with-tbds'); + writePuprc(getPuprc(), tbdProjectDir); + + const result = await runPup('zip --no-clone', { cwd: tbdProjectDir }); + expect(result.exitCode).not.toBe(0); + }); + + it('should zip when check has errors but set to warn', async () => { + const tbdProjectDir = createTempProject('fake-project-with-tbds'); + const puprc = getPuprc(); + puprc.checks = { + tbd: { + fail_method: 'warn', + }, + 'version-conflict': {}, + }; + writePuprc(puprc, tbdProjectDir); + + const result = await runPup('zip --no-clone', { cwd: tbdProjectDir }); + expect(result.exitCode).toBe(0); + expect(result.output).toContain('[tbd]'); + expect(result.output).toContain('[version-conflict]'); + + const entries = fs.readdirSync(tbdProjectDir); + const zipFile = entries.find((e) => e.endsWith('.zip')); + expect(zipFile).toBeDefined(); + }); + + it('should zip with --no-clone --no-check', async () => { + const tbdProjectDir = createTempProject('fake-project-with-tbds'); + writePuprc(getPuprc(), tbdProjectDir); + + const result = await runPup('zip --no-clone --no-check', { cwd: tbdProjectDir }); + expect(result.exitCode).toBe(0); + + const entries = fs.readdirSync(tbdProjectDir); + const zipFile = entries.find((e) => e.endsWith('.zip')); + expect(zipFile).toBeDefined(); + }); + + it('should skip build with --no-build', async () => { + const result = await runPup('zip --no-clone --no-build', { cwd: projectDir }); + expect(result.exitCode).toBe(0); + }); +}); diff --git a/tests/utils/glob.test.ts b/tests/utils/glob.test.ts new file mode 100644 index 0000000..06211ae --- /dev/null +++ b/tests/utils/glob.test.ts @@ -0,0 +1,142 @@ +import { globToRegex } from '../../src/utils/glob'; + +describe('globToRegex', () => { + const testCases = [ + { + glob: '/license.txt', + match: ['license.txt'], + notMatch: ['src/license.txt'], + }, + { + glob: 'src/**/*.js', + match: ['src/js/index.js', 'src/js/other.js', 'src/bork/other/other.js'], + notMatch: ['src/js/index.css', 'src/js/other.css', 'src/bork/other/other.php'], + }, + { + glob: 'src/js?/*.js', + match: ['src/js/index.js', 'src/js/other.js', 'src/j/other.js'], + notMatch: [ + 'src/js/index.css', + 'src/js/other.css', + 'src/jb/other.js', + 'src/bork/other/other.js', + 'src/bork/other/other.php', + ], + }, + { + glob: 'src/+(js|app)/*.js', + match: ['src/js/index.js', 'src/js/other.js', 'src/app/other.js'], + notMatch: [ + 'src/js/index.css', + 'src/js/other.css', + 'src/jb/other.js', + 'src/bork/other/other.js', + 'src/bork/other/other.php', + ], + }, + { + glob: 'src/@(js|app)/*.js', + match: ['src/js/index.js', 'src/js/other.js', 'src/app/other.js'], + notMatch: ['src/jsapp/other.js', 'src/jsjs/other.js'], + }, + { + glob: 'src/j?(s|b)/*.js', + match: ['src/js/index.js', 'src/js/other.js', 'src/jb/other.js'], + notMatch: ['src/jss/other.js', 'src/jsb/other.js', 'src/app/other.js'], + }, + { + glob: 'src/j*(s|b)/*.js', + match: [ + 'src/js/index.js', + 'src/jb/other.js', + 'src/j/other.js', + 'src/jss/other.js', + 'src/jsb/other.js', + ], + notMatch: ['src/jj/other.js', 'src/app/other.js'], + }, + { + glob: 'src/[:upper:]+/*.js', + match: ['src/JS/index.js', 'src/CSS/other.js'], + notMatch: [ + 'src/jS/index.css', + 'src/js/other.css', + 'src/bork/other/other.js', + 'src/bork/other/other.php', + ], + }, + { + glob: 'src/[:lower:]+/*.js', + match: ['src/js/index.js', 'src/js/other.js'], + notMatch: ['src/JS/index.js', 'src/Js/other.js'], + }, + { + glob: 'src/[:word:]/*.js', + match: ['src/js/index.js', 'src/js/other.js'], + notMatch: [ + 'src/js/index.css', + 'src/js/other.css', + 'src/bork/other/other.js', + 'src/bork/other/other.php', + ], + }, + { + glob: 'src/[:lower:][:digit:]+/*.js', + match: ['src/v1/index.js', 'src/v2234/other.js'], + notMatch: ['src/js/other.js', 'src/bork/other/other.js', 'src/bork/other/other.php'], + }, + { + glob: 'src/[:lower:][:xdigit:]+/*.js', + match: ['src/v1/index.js', 'src/v2F/other.js'], + notMatch: [ + 'src/js/other.js', + 'src/vG/other.js', + 'src/bork/other/other.js', + 'src/bork/other/other.php', + ], + }, + { + glob: 'src/v[:blank:]*[:digit:]/*.js', + match: ['src/v1/index.js', 'src/v 2/other.js', 'src/v 3/other.js'], + notMatch: ['src/bork/other/other.js', 'src/bork/other/other.php'], + }, + { + glob: 'src/v[:space:]*[:digit:]/*.js', + match: ['src/v1/index.js', 'src/v 2/other.js', 'src/v 3/other.js'], + notMatch: ['src/bork/other/other.js', 'src/bork/other/other.php'], + }, + { + glob: 'src/v+/*.js', + match: ['src/v/index.js', 'src/vv/other.js', 'src/vvv/other.js'], + notMatch: ['src/bork/other/other.js', 'src/bork/other/other.php'], + }, + { + glob: 'src/v*/**/js/**/*.js', + match: [ + 'src/vasdf/js/index.js', + 'src/vasdf/asdf/js/index.js', + 'src/vasdf/asdf/js/asdf/index.js', + 'src/vasdf/asdf/js/asdf/asdf/index.js', + ], + notMatch: ['src/bork/other/other.js', 'src/bork/other/other.php'], + }, + ]; + + for (const tc of testCases) { + describe(`glob: ${tc.glob}`, () => { + it('should match expected paths', () => { + const regex = globToRegex(tc.glob); + for (const file of tc.match) { + expect(file).toMatch(regex); + } + }); + + it('should not match unexpected paths', () => { + const regex = globToRegex(tc.glob); + for (const file of tc.notMatch) { + expect(file).not.toMatch(regex); + } + }); + }); + } +});