From ff051ce4acf59f2d3b6500643147e3eaa608e7a9 Mon Sep 17 00:00:00 2001 From: Eric Defore Date: Wed, 11 Feb 2026 10:58:08 -0500 Subject: [PATCH 01/22] ENG-219: Add app bootstrap, config, and shared utilities Co-Authored-By: Claude Opus 4.6 --- .../.distignore-defaults | 0 .puprc-defaults => defaults/.puprc-defaults | 0 src/app.ts | 55 ++ src/cli.ts | 8 + src/config.ts | 684 ++++++++++++++++++ src/models/workflow.ts | 96 +++ src/types.ts | 114 +++ src/utils/directory.ts | 95 +++ src/utils/env.ts | 22 + src/utils/output.ts | 162 +++++ src/utils/process.ts | 86 +++ tests/commands/invalid-puprc.test.ts | 22 + .../fake-project-with-tbds/bootstrap.php | 11 + .../fake-project-with-tbds/other-file.php | 3 + .../fake-project-with-tbds/package.json | 4 + .../src/OtherFileWithBadVersion.php | 6 + .../fake-project-with-tbds/src/Plugin.php | 14 + .../src/Thing/AnotherFile.php | 35 + tests/fixtures/fake-project/bootstrap.php | 11 + tests/fixtures/fake-project/other-file.php | 3 + tests/fixtures/fake-project/package.json | 4 + .../src/OtherFileWithBadVersion.php | 6 + tests/fixtures/fake-project/src/Plugin.php | 7 + tests/fixtures/test-workflow-script.sh | 30 + tests/helpers/setup.ts | 118 +++ 25 files changed, 1596 insertions(+) rename .distignore-defaults => defaults/.distignore-defaults (100%) rename .puprc-defaults => defaults/.puprc-defaults (100%) create mode 100644 src/app.ts create mode 100644 src/cli.ts create mode 100644 src/config.ts create mode 100644 src/models/workflow.ts create mode 100644 src/types.ts create mode 100644 src/utils/directory.ts create mode 100644 src/utils/env.ts create mode 100644 src/utils/output.ts create mode 100644 src/utils/process.ts create mode 100644 tests/commands/invalid-puprc.test.ts create mode 100644 tests/fixtures/fake-project-with-tbds/bootstrap.php create mode 100644 tests/fixtures/fake-project-with-tbds/other-file.php create mode 100644 tests/fixtures/fake-project-with-tbds/package.json create mode 100644 tests/fixtures/fake-project-with-tbds/src/OtherFileWithBadVersion.php create mode 100644 tests/fixtures/fake-project-with-tbds/src/Plugin.php create mode 100644 tests/fixtures/fake-project-with-tbds/src/Thing/AnotherFile.php create mode 100644 tests/fixtures/fake-project/bootstrap.php create mode 100644 tests/fixtures/fake-project/other-file.php create mode 100644 tests/fixtures/fake-project/package.json create mode 100644 tests/fixtures/fake-project/src/OtherFileWithBadVersion.php create mode 100644 tests/fixtures/fake-project/src/Plugin.php create mode 100755 tests/fixtures/test-workflow-script.sh create mode 100644 tests/helpers/setup.ts diff --git a/.distignore-defaults b/defaults/.distignore-defaults similarity index 100% rename from .distignore-defaults rename to defaults/.distignore-defaults diff --git a/.puprc-defaults b/defaults/.puprc-defaults similarity index 100% rename from .puprc-defaults rename to defaults/.puprc-defaults diff --git a/src/app.ts b/src/app.ts new file mode 100644 index 0000000..ce0a6ec --- /dev/null +++ b/src/app.ts @@ -0,0 +1,55 @@ +import { Command } from 'commander'; +import { Config, getConfig, resetConfig } from './config.js'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; +import fs from 'fs-extra'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +/** + * Reads the pup version from the nearest package.json. + * + * @since TBD + * + * @returns {string} The version string from package.json, or '2.0.0' as a fallback. + */ +function getVersion(): string { + // Try to read from package.json + const candidates = [ + path.resolve(__dirname, '..', 'package.json'), + path.resolve(__dirname, '..', '..', 'package.json'), + ]; + + for (const candidate of candidates) { + if (fs.existsSync(candidate)) { + const pkg = JSON.parse(fs.readFileSync(candidate, 'utf-8')) as { version: string }; + return pkg.version; + } + } + + return '2.0.0'; +} + +export const PUP_VERSION = getVersion(); + +/** + * Creates and configures the Commander program instance. + * + * @since TBD + * + * @returns {Command} The configured Commander program. + */ +export function createApp(): Command { + resetConfig(); + + const program = new Command(); + program + .name('pup') + .version(PUP_VERSION) + .description("StellarWP's Project Utilities & Packager"); + + return program; +} + +export { Config, getConfig, resetConfig }; diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..50ff300 --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,8 @@ +import { createApp } from './app.js'; + +const program = createApp(); + +program.parseAsync(process.argv).catch((err) => { + console.error(err instanceof Error ? err.message : String(err)); + process.exit(1); +}); diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..18b69e5 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,684 @@ +import fs from 'fs-extra'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { normalizeDir, trailingSlashIt } from './utils/directory.js'; +import { WorkflowCollection, createWorkflow } from './models/workflow.js'; +import type { + PupConfig, + CheckConfig, + CheckConfigInput, + VersionFile, + VersionFileInput, + I18nResolvedConfig, + I18nConfigInput, +} from './types.js'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +/** + * Locates the defaults directory containing .puprc-defaults and .distignore-defaults. + * + * @since TBD + * + * @returns {string} The absolute path to the defaults directory. + */ +export function getDefaultsDir(): string { + // In built dist, defaults/ is at the package root + // During dev, it's at the repo root + const candidates = [ + path.resolve(__dirname, '..', 'defaults'), + path.resolve(__dirname, '..', '..', 'defaults'), + ]; + + for (const candidate of candidates) { + if (fs.existsSync(candidate)) { + return candidate; + } + } + + return candidates[0]; +} + +/** + * Loads, merges, and provides access to the project's pup configuration. + * + * @since TBD + */ +export class Config { + private workingDir: string; + private puprcFilePath: string; + private config: PupConfig; + private _hasInvalidPuprc = false; + private _puprcParseError = ''; + private _workflows: WorkflowCollection = new WorkflowCollection(); + private _checks: Map = new Map(); + private _versionFiles: VersionFile[] = []; + private _i18n: I18nResolvedConfig[] | null = null; + + /** + * Initializes configuration by loading and merging .puprc with defaults. + * + * @since TBD + * + * @param {string} workingDir - The project working directory. Defaults to process.cwd(). + * + * @returns {void} + */ + constructor(workingDir?: string) { + const cwd = workingDir ?? process.cwd(); + + this.workingDir = trailingSlashIt(normalizeDir(cwd)); + this.puprcFilePath = path.join(this.workingDir, '.puprc'); + this.config = this.getDefaultConfig(); + this.mergeConfigWithDefaults(); + this.buildWorkflows(); + this.parseCheckConfig(); + this.parseVersionFiles(); + } + + /** + * Loads the default configuration from .puprc-defaults. + * + * @since TBD + * + * @returns {PupConfig} The parsed default configuration object. + */ + private getDefaultConfig(): PupConfig { + const defaultsPath = path.join(getDefaultsDir(), '.puprc-defaults'); + const contents = fs.readFileSync(defaultsPath, 'utf-8'); + const parsed = JSON.parse(contents) as PupConfig; + return parsed; + } + + /** + * Merges the project's .puprc file into the default configuration. + * + * @since TBD + * + * @returns {void} + */ + private mergeConfigWithDefaults(): void { + if (!fs.existsSync(this.puprcFilePath)) { + return; + } + + const puprcContents = fs.readFileSync(this.puprcFilePath, 'utf-8'); + let puprc: Record; + + try { + puprc = JSON.parse(puprcContents) as Record; + } catch { + this._hasInvalidPuprc = true; + this._puprcParseError = 'Invalid JSON in .puprc'; + return; + } + + if (!puprc || typeof puprc !== 'object') { + this._hasInvalidPuprc = true; + this._puprcParseError = 'Invalid .puprc format'; + return; + } + + const configRecord = this.config as unknown as Record; + + for (const [key, value] of Object.entries(puprc)) { + const current = configRecord[key]; + + if (current === undefined || current === null) { + configRecord[key] = value; + continue; + } + + if (typeof current !== 'object' || current === null) { + configRecord[key] = value; + continue; + } + + // Special handling for checks: preserve defaults + merge + if (key === 'checks' && typeof value === 'object' && value !== null) { + const defaultChecks = current as Record; + const newChecks = value as Record; + configRecord[key] = newChecks; + + for (const [checkSlug, checkConfig] of Object.entries(newChecks)) { + if (defaultChecks[checkSlug] !== undefined) { + (configRecord[key] as Record)[checkSlug] = + this.mergeConfigValue(defaultChecks[checkSlug], checkConfig); + } + } + continue; + } + + configRecord[key] = this.mergeConfigValue(current, value); + } + } + + /** + * Deep-merges two configuration values. Scalars and arrays replace; objects merge recursively. + * + * @since TBD + * + * @param {unknown} original - The original configuration value. + * @param {unknown} newVal - The new configuration value to merge in. + * + * @returns {unknown} The merged configuration value. + */ + private mergeConfigValue(original: unknown, newVal: unknown): unknown { + if (typeof newVal !== 'object' || newVal === null) { + return newVal; + } + + if (typeof original !== 'object' || original === null) { + return newVal; + } + + if (Array.isArray(original)) { + // Numeric-keyed arrays: replace + return newVal; + } + + if (Array.isArray(newVal)) { + return newVal; + } + + const orig = original as Record; + const nv = newVal as Record; + const result = { ...orig }; + + for (const [key, item] of Object.entries(orig)) { + if (nv[key] === undefined) continue; + if (typeof item === 'object' && item !== null && !Array.isArray(item)) { + result[key] = this.mergeConfigValue(item, nv[key]); + } else { + result[key] = nv[key]; + } + } + + for (const [key, item] of Object.entries(nv)) { + if (result[key] === undefined) { + result[key] = item; + } + } + + return result; + } + + /** + * Builds the workflow collection from configuration, including auto-generated build workflows. + * + * @since TBD + * + * @returns {void} + */ + private buildWorkflows(): void { + const collection = new WorkflowCollection(); + + const rawWorkflows = this.config.workflows as unknown; + + // Auto-create build workflow + if ( + this.config.build?.length > 0 && + !(rawWorkflows as Record)?.['build'] + ) { + collection.add(createWorkflow('build', this.config.build)); + } + + if ( + this.config.build_dev?.length > 0 && + !(rawWorkflows as Record)?.['build_dev'] + ) { + collection.add(createWorkflow('build_dev', this.config.build_dev)); + } + + if (rawWorkflows && typeof rawWorkflows === 'object') { + for (const [slug, commands] of Object.entries( + rawWorkflows as Record + )) { + collection.add( + createWorkflow(slug, Array.isArray(commands) ? commands : []) + ); + } + } + + this._workflows = collection; + } + + /** + * Parses the checks section of the configuration into CheckConfig objects. + * + * @since TBD + * + * @returns {void} + */ + private parseCheckConfig(): void { + const checks = this.config.checks; + if (!checks) return; + + for (const [slug, checkInput] of Object.entries(checks)) { + const input = ( + typeof checkInput === 'object' && checkInput !== null + ? checkInput + : {} + ) as CheckConfigInput; + + const config: CheckConfig = { + slug, + fail_method: input.fail_method ?? 'error', + fail_method_dev: input.fail_method_dev ?? 'warn', + type: input.type ?? 'pup', + file: input.file, + command: input.command, + configure: input.configure, + args: input.args ?? {}, + dirs: input.dirs, + skip_directories: input.skip_directories, + skip_files: input.skip_files, + }; + + this._checks.set(slug, config); + } + } + + /** + * Parses and validates the version files section of the configuration. + * + * @since TBD + * + * @returns {void} + * + * @throws {Error} If a version file entry is missing required properties or the file does not exist. + */ + private parseVersionFiles(): void { + const versions = this.config.paths?.versions; + if (!versions || !Array.isArray(versions)) return; + + for (const vf of versions as VersionFileInput[]) { + if (!vf.file || !vf.regex) { + throw new Error( + 'Versions specified in .puprc .paths.versions must have the "file" and "regex" property.' + ); + } + + const filePath = path.resolve(this.workingDir, vf.file); + if (!fs.existsSync(filePath)) { + throw new Error(`Version file does not exist: ${vf.file}`); + } + + const contents = fs.readFileSync(filePath, 'utf-8'); + const regex = new RegExp(vf.regex); + const matches = contents.match(regex); + + if (!matches || !matches[1] || !matches[2]) { + throw new Error( + `Could not find version in file ${vf.file} using regex "/${vf.regex}/"` + ); + } + + this._versionFiles.push({ file: vf.file, regex: vf.regex }); + } + } + + /** + * Returns the raw merged configuration object. + * + * @since TBD + * + * @returns {PupConfig} The configuration object. + */ + get raw(): PupConfig { + return this.config; + } + + /** + * Returns the build commands, preferring dev commands when isDev is true. + * + * @since TBD + * + * @param {boolean} isDev - Whether to return dev build commands. + * + * @returns {string[]} The list of build command strings. + */ + getBuildCommands(isDev = false): string[] { + if (isDev && this.config.build_dev?.length > 0) { + return this.config.build_dev; + } + return this.config.build ?? []; + } + + /** + * Returns the build directory path, optionally as a full absolute path. + * + * @since TBD + * + * @param {boolean} fullPath - Whether to return the full absolute path. + * + * @returns {string} The build directory path. + */ + getBuildDir(fullPath = true): string { + const buildDir = this.config.paths?.build_dir ?? '.pup-build'; + if (!fullPath) return buildDir; + return path.resolve(this.workingDir, buildDir); + } + + /** + * Returns the clean commands from the configuration. + * + * @since TBD + * + * @returns {string[]} The list of clean command strings. + */ + getCleanCommands(): string[] { + return this.config.clean ?? []; + } + + /** + * Returns the map of parsed check configurations. + * + * @since TBD + * + * @returns {Map} A map of check slug to CheckConfig. + */ + getChecks(): Map { + return this._checks; + } + + /** + * Returns resolved i18n configurations, merging with defaults. + * + * @since TBD + * + * @returns {I18nResolvedConfig[]} The list of resolved i18n configuration objects. + */ + getI18n(): I18nResolvedConfig[] { + if (this._i18n !== null) return this._i18n; + + const defaults = this.config.i18n_defaults; + let i18nRaw = this.config.i18n; + + if (!i18nRaw || (Array.isArray(i18nRaw) && i18nRaw.length === 0)) { + this._i18n = []; + return this._i18n; + } + + // Normalize to array + let i18nArr: I18nConfigInput[]; + if (!Array.isArray(i18nRaw)) { + i18nArr = [i18nRaw]; + } else { + i18nArr = i18nRaw; + } + + // Filter valid entries + i18nArr = i18nArr.filter( + (item) => item.url && item.textdomain && item.slug + ); + + if (i18nArr.length === 0) { + this._i18n = []; + return this._i18n; + } + + this._i18n = i18nArr.map((item) => ({ + path: item.path ?? defaults.path, + url: item.url ?? defaults.url, + slug: item.slug ?? defaults.slug, + textdomain: item.textdomain ?? defaults.textdomain, + file_format: item.file_format ?? defaults.file_format, + formats: item.formats?.length ? item.formats : defaults.formats, + filter: { + minimum_percentage: + item.filter?.minimum_percentage ?? + defaults.filter.minimum_percentage, + }, + })); + + return this._i18n; + } + + /** + * Returns the list of environment variable names to forward to subprocesses. + * + * @since TBD + * + * @returns {string[]} The list of environment variable name strings. + */ + getEnvVarNames(): string[] { + return this.config.env ?? []; + } + + /** + * Returns the git repository URL, inferring from package.json or composer.json if not set. + * + * @since TBD + * + * @returns {string} The git repository URL string. + * + * @throws {Error} If no repository can be determined. + */ + getRepo(): string { + if (!this.config.repo) { + // Try to infer from package.json + const pkgPath = path.join(this.workingDir, 'package.json'); + if (fs.existsSync(pkgPath)) { + const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')) as { + repository?: { url?: string } | string; + }; + if (typeof pkg.repository === 'string') { + return `git@github.com:${pkg.repository}.git`; + } + if (pkg.repository?.url) { + return pkg.repository.url; + } + } + + // Try composer.json fallback + const composerPath = path.join(this.workingDir, 'composer.json'); + if (fs.existsSync(composerPath)) { + const composer = JSON.parse( + fs.readFileSync(composerPath, 'utf-8') + ) as { name?: string }; + if (composer.name) { + return `git@github.com:${composer.name}.git`; + } + } + + throw new Error( + 'Could not find a repo in the .puprc file or the "name" property in package.json/composer.json.' + ); + } + + const repo = this.config.repo; + + if ( + !repo.includes('https://') && + !repo.includes('file://') && + !repo.includes('git://') && + !repo.includes('git@github.com') && + !fs.existsSync(repo) + ) { + return `git@github.com:${repo}.git`; + } + + return repo; + } + + /** + * Returns the list of sync file names (.distfiles, .distinclude, etc.). + * + * @since TBD + * + * @returns {string[]} The list of sync file name strings. + */ + getSyncFiles(): string[] { + const defaults = ['.distfiles', '.distinclude', '.distignore', '.gitattributes']; + const configFiles = this.config.paths?.sync_files; + + if (!configFiles || !Array.isArray(configFiles) || configFiles.length === 0) { + return defaults; + } + + return [...new Set([...defaults, ...configFiles])]; + } + + /** + * Returns the parsed version file configurations. + * + * @since TBD + * + * @returns {VersionFile[]} The list of version file objects. + */ + getVersionFiles(): VersionFile[] { + return this._versionFiles; + } + + /** + * Returns the workflow collection. + * + * @since TBD + * + * @returns {WorkflowCollection} The WorkflowCollection instance. + */ + getWorkflows(): WorkflowCollection { + return this._workflows; + } + + /** + * Returns the working directory path. + * + * @since TBD + * + * @returns {string} The absolute working directory path with trailing slash. + */ + getWorkingDir(): string { + return this.workingDir; + } + + /** + * Returns the zip staging directory path, optionally as a full absolute path. + * + * @since TBD + * + * @param {boolean} fullPath - Whether to return the full absolute path. + * + * @returns {string} The zip staging directory path. + */ + getZipDir(fullPath = true): string { + const zipDir = this.config.paths?.zip_dir ?? '.pup-zip'; + if (!fullPath) return zipDir; + return path.resolve(this.workingDir, zipDir); + } + + /** + * Returns the zip archive base name, inferring from package.json if not set. + * + * @since TBD + * + * @returns {string} The zip archive base name string. + * + * @throws {Error} If no zip name can be determined. + */ + getZipName(): string { + if (this.config.zip_name) { + return this.config.zip_name; + } + + // Try package.json name + const pkgPath = path.join(this.workingDir, 'package.json'); + if (fs.existsSync(pkgPath)) { + const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')) as { + name?: string; + }; + if (pkg.name) { + // Strip scope prefix (e.g., @stellarwp/pup -> pup) + return pkg.name.replace(/^@[^/]+\//, ''); + } + } + + // Try composer.json name + const composerPath = path.join(this.workingDir, 'composer.json'); + if (fs.existsSync(composerPath)) { + const composer = JSON.parse( + fs.readFileSync(composerPath, 'utf-8') + ) as { name?: string }; + if (composer.name) { + return composer.name.replace(/^[^/]+\//, ''); + } + } + + throw new Error('Could not find a "zip_name" in .puprc'); + } + + /** + * Returns whether to use the default .distignore-defaults patterns. + * + * @since TBD + * + * @returns {boolean} True if default ignore patterns should be used. + */ + getZipUseDefaultIgnore(): boolean { + return this.config.zip_use_default_ignore ?? true; + } + + /** + * Returns whether the .puprc file failed to parse. + * + * @since TBD + * + * @returns {boolean} True if the .puprc file is invalid. + */ + hasInvalidPuprc(): boolean { + return this._hasInvalidPuprc; + } + + /** + * Returns the parse error message if .puprc is invalid. + * + * @since TBD + * + * @returns {string} The error message string, or an empty string if no error. + */ + getPuprcParseError(): string { + return this._puprcParseError; + } + + /** + * Serializes the configuration to a plain object. + * + * @since TBD + * + * @returns {PupConfig} The configuration as a PupConfig object. + */ + toJSON(): PupConfig { + return this.config; + } +} + +let globalConfig: Config | null = null; + +/** + * Returns the singleton Config instance, creating it if needed. + * + * @since TBD + * + * @param {string} workingDir - Optional working directory to pass to the Config constructor. + * + * @returns {Config} The singleton Config instance. + */ +export function getConfig(workingDir?: string): Config { + if (!globalConfig) { + globalConfig = new Config(workingDir); + } + return globalConfig; +} + +/** + * Resets the singleton Config instance, forcing a fresh load on next access. + * + * @since TBD + * + * @returns {void} + */ +export function resetConfig(): void { + globalConfig = null; +} diff --git a/src/models/workflow.ts b/src/models/workflow.ts new file mode 100644 index 0000000..29567b4 --- /dev/null +++ b/src/models/workflow.ts @@ -0,0 +1,96 @@ +import type { Workflow } from '../types.js'; + +/** + * Creates a Workflow object from a slug and list of commands. + * + * @since TBD + * + * @param {string} slug - The unique identifier for the workflow. + * @param {string[]} commands - The list of commands to execute in the workflow. + * + * @returns {Workflow} A Workflow object with the provided slug and commands. + */ +export function createWorkflow(slug: string, commands: string[]): Workflow { + return { slug, commands }; +} + +/** + * Manages a collection of named workflows. + * + * @since TBD + */ +export class WorkflowCollection { + private workflows: Map = new Map(); + + /** + * Adds a workflow to the collection. + * + * @since TBD + * + * @param {Workflow} workflow - The workflow to add. + * + * @returns {void} + */ + add(workflow: Workflow): void { + this.workflows.set(workflow.slug, workflow); + } + + /** + * Retrieves a workflow by its slug. + * + * @since TBD + * + * @param {string} slug - The slug of the workflow to retrieve. + * + * @returns {Workflow | undefined} The workflow if found, otherwise undefined. + */ + get(slug: string): Workflow | undefined { + return this.workflows.get(slug); + } + + /** + * Checks whether a workflow with the given slug exists. + * + * @since TBD + * + * @param {string} slug - The slug to check. + * + * @returns {boolean} True if the workflow exists, false otherwise. + */ + has(slug: string): boolean { + return this.workflows.has(slug); + } + + /** + * Returns all workflows as an array. + * + * @since TBD + * + * @returns {Workflow[]} An array containing all workflows in the collection. + */ + getAll(): Workflow[] { + return Array.from(this.workflows.values()); + } + + /** + * Returns the number of workflows in the collection. + * + * @since TBD + * + * @returns {number} The count of workflows. + */ + get size(): number { + return this.workflows.size; + } + + /** + * Allows iterating over all workflows in the collection. + * + * @since TBD + * + * @returns {Iterator} An iterator over the workflows. + */ + [Symbol.iterator](): Iterator { + return this.workflows.values(); + } +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..863dea2 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,114 @@ +export interface PupConfig { + build: string[]; + build_dev: string[]; + workflows: Record; + checks: Record; + clean: string[]; + i18n: I18nConfigInput[] | I18nConfigInput; + i18n_defaults: I18nDefaults; + paths: PathsConfig; + env: string[]; + repo: string | null; + zip_use_default_ignore: boolean; + zip_name: string | null; +} + +export interface PathsConfig { + build_dir: string; + changelog: string | null; + css: string[]; + js: string[]; + sync_files: string[]; + versions: VersionFileInput[]; + views: string[]; + zip_dir: string; +} + +export interface VersionFileInput { + file: string; + regex: string; +} + +export interface VersionFile { + file: string; + regex: string; +} + +export interface CheckConfigInput { + fail_method?: 'error' | 'warn'; + fail_method_dev?: 'error' | 'warn'; + type?: 'simple' | 'class' | 'pup' | 'command'; + file?: string; + command?: string; + configure?: string; + args?: Record; + dirs?: string[]; + skip_directories?: string; + skip_files?: string; +} + +export interface CheckConfig { + slug: string; + fail_method: 'error' | 'warn'; + fail_method_dev: 'error' | 'warn'; + type: 'simple' | 'class' | 'pup' | 'command'; + file?: string; + command?: string; + configure?: string; + args: Record; + dirs?: string[]; + skip_directories?: string; + skip_files?: string; +} + +export interface CheckResult { + success: boolean; + output: string; +} + +export interface I18nConfigInput { + path?: string; + url?: string; + slug?: string; + textdomain?: string; + file_format?: string; + formats?: string[]; + filter?: { + minimum_percentage?: number; + }; +} + +export interface I18nDefaults { + path: string; + url: string; + slug: string; + textdomain: string; + file_format: string; + formats: string[]; + filter: { + minimum_percentage: number; + }; +} + +export interface I18nResolvedConfig { + path: string; + url: string; + slug: string; + textdomain: string; + file_format: string; + formats: string[]; + filter: { + minimum_percentage: number; + }; +} + +export interface Workflow { + slug: string; + commands: string[]; +} + +export interface RunCommandResult { + stdout: string; + stderr: string; + exitCode: number; +} diff --git a/src/utils/directory.ts b/src/utils/directory.ts new file mode 100644 index 0000000..dfb3c12 --- /dev/null +++ b/src/utils/directory.ts @@ -0,0 +1,95 @@ +import path from 'node:path'; +import fs from 'fs-extra'; + +/** + * Normalizes a directory path by replacing backslashes with forward slashes. + * + * @since TBD + * + * @param {string} dir - The directory path to normalize. + * + * @returns {string} The normalized path with forward slashes. + */ +export function normalizeDir(dir: string): string { + return dir.replace(/\\/g, '/'); +} + +/** + * Ensures a path ends with a trailing forward slash. + * + * @since TBD + * + * @param {string} p - The path to ensure has a trailing slash. + * + * @returns {string} The path with a trailing forward slash. + */ +export function trailingSlashIt(p: string): string { + if (p.endsWith('/')) { + return p; + } + return p + '/'; +} + +/** + * Removes a directory, but only if it is within the given working directory. + * + * @since TBD + * + * @param {string} dir - The directory path to remove. + * @param {string} workingDir - The working directory that the target must be within. + * + * @returns {void} + * + * @throws {Error} If the directory is outside the working directory. + */ +export async function rmdir(dir: string, workingDir: string): Promise { + const normalized = normalizeDir(dir); + const normalizedWorking = normalizeDir(workingDir); + + // Safety check: only remove directories within the working directory + if (!normalized.startsWith(normalizedWorking)) { + throw new Error( + `Refusing to remove directory outside working directory: ${dir}` + ); + } + + if (await fs.pathExists(dir)) { + await fs.remove(dir); + } +} + +/** + * Resolves a relative path against a working directory. + * Strips any existing prefix and rejects absolute paths unless a default is provided. + * + * @since TBD + * + * @param {string} relativePath - The relative path to resolve. + * @param {string} workingDir - The working directory to resolve against. + * @param {string} [defaultPath] - Optional default path to use if an absolute path is given. + * + * @returns {string} The resolved absolute path. + * + * @throws {Error} If an absolute path is given without a default fallback. + */ +export function resolveRelativePath( + relativePath: string, + workingDir: string, + defaultPath?: string +): string { + const prefix = trailingSlashIt(workingDir); + let normalized = normalizeDir(relativePath); + + // Strip the prefix if it's already there + normalized = normalized.replace(prefix, ''); + + // Don't allow absolute paths + if (normalized.startsWith('/')) { + if (defaultPath) { + return path.join(prefix, defaultPath); + } + throw new Error('Absolute paths are not allowed in the .puprc file.'); + } + + return path.join(prefix, normalized); +} diff --git a/src/utils/env.ts b/src/utils/env.ts new file mode 100644 index 0000000..c0df7ac --- /dev/null +++ b/src/utils/env.ts @@ -0,0 +1,22 @@ +/** + * Builds an environment object from configured env var names. + * Reads the current process.env values for the listed variable names. + * + * @since TBD + * + * @param {string[]} envVarNames - The list of environment variable names to include. + * + * @returns {Record} An object mapping variable names to their values. + */ +export function buildEnv(envVarNames: string[]): Record { + const env: Record = {}; + + for (const name of envVarNames) { + const value = process.env[name]; + if (value !== undefined) { + env[name] = value; + } + } + + return env; +} diff --git a/src/utils/output.ts b/src/utils/output.ts new file mode 100644 index 0000000..67c02bc --- /dev/null +++ b/src/utils/output.ts @@ -0,0 +1,162 @@ +import chalk from 'chalk'; + +let prefix = ''; + +/** + * Sets a prefix string that will be prepended to all output messages. + * + * @since TBD + * + * @param {string} p - The prefix string. + * + * @returns {void} + */ +export function setPrefix(p: string): void { + prefix = p; +} + +/** + * Returns the current output prefix. + * + * @since TBD + * + * @returns {string} The current prefix string. + */ +export function getPrefix(): string { + return prefix; +} + +/** + * Formats a message with the current prefix, if one is set. + * + * @since TBD + * + * @param {string} message - The message to format. + * + * @returns {string} The formatted message with prefix prepended if set. + */ +function formatMessage(message: string): string { + if (prefix) { + return `[${prefix}] ${message}`; + } + return message; +} + +/** + * Prints a green success message to stdout. + * + * @since TBD + * + * @param {string} message - The success message to display. + * + * @returns {void} + */ +export function success(message: string): void { + console.log(formatMessage(chalk.green(message))); +} + +/** + * Prints a red error message to stderr. + * + * @since TBD + * + * @param {string} message - The error message to display. + * + * @returns {void} + */ +export function error(message: string): void { + console.error(formatMessage(chalk.red(message))); +} + +/** + * Prints a yellow warning message to stdout. + * + * @since TBD + * + * @param {string} message - The warning message to display. + * + * @returns {void} + */ +export function warning(message: string): void { + console.log(formatMessage(chalk.yellow(message))); +} + +/** + * Prints a blue informational message to stdout. + * + * @since TBD + * + * @param {string} message - The informational message to display. + * + * @returns {void} + */ +export function info(message: string): void { + console.log(formatMessage(chalk.blue(message))); +} + +/** + * Prints a bold title with an underline rule. + * + * @since TBD + * + * @param {string} message - The title text to display. + * + * @returns {void} + */ +export function title(message: string): void { + console.log(''); + console.log(formatMessage(chalk.bold(message))); + console.log(formatMessage(chalk.bold('='.repeat(message.length)))); + console.log(''); +} + +/** + * Prints a bold yellow section header. + * + * @since TBD + * + * @param {string} message - The section header text to display. + * + * @returns {void} + */ +export function section(message: string): void { + console.log(''); + console.log(formatMessage(chalk.bold.yellow(message))); +} + +/** + * Prints a plain message to stdout with the current prefix. + * + * @since TBD + * + * @param {string} message - The message to print. + * + * @returns {void} + */ +export function log(message: string): void { + console.log(formatMessage(message)); +} + +/** + * Prints a message to stdout without any prefix. + * + * @since TBD + * + * @param {string} message - The message to print. + * + * @returns {void} + */ +export function writeln(message: string): void { + console.log(message); +} + +/** + * Prints an empty line to stdout. + * + * @since TBD + * + * @returns {void} + */ +export function newline(): void { + console.log(''); +} diff --git a/src/utils/process.ts b/src/utils/process.ts new file mode 100644 index 0000000..e579354 --- /dev/null +++ b/src/utils/process.ts @@ -0,0 +1,86 @@ +import { execa } from 'execa'; +import { buildEnv } from './env.js'; +import type { RunCommandResult } from '../types.js'; + +export interface RunOptions { + cwd?: string; + envVarNames?: string[]; + softFail?: boolean; + silent?: boolean; +} + +/** + * Runs a shell command, streaming output to console. + * Commands prefixed with `@` are treated as soft-fail (errors are ignored). + * + * @since TBD + * + * @param {string} command - The shell command to execute. + * @param {RunOptions} options - Optional configuration for the command execution. + * + * @returns {Promise} The command result with stdout, stderr, and exit code. + */ +export async function runCommand( + command: string, + options: RunOptions = {} +): Promise { + let cmd = command; + let softFail = options.softFail ?? false; + + // Handle @ prefix for soft-fail + if (cmd.startsWith('@')) { + cmd = cmd.slice(1); + softFail = true; + } + + const env = options.envVarNames + ? { ...process.env, ...buildEnv(options.envVarNames) } + : process.env; + + try { + const result = await execa(cmd, { + cwd: options.cwd, + env: env as Record, + shell: true, + stdout: options.silent ? 'pipe' : 'inherit', + stderr: options.silent ? 'pipe' : 'inherit', + reject: false, + }); + + if (result.exitCode !== 0 && !softFail) { + return { + stdout: String(result.stdout ?? ''), + stderr: String(result.stderr ?? ''), + exitCode: result.exitCode ?? 1, + }; + } + + return { + stdout: String(result.stdout ?? ''), + stderr: String(result.stderr ?? ''), + exitCode: softFail ? 0 : (result.exitCode ?? 0), + }; + } catch (err: unknown) { + if (softFail) { + return { stdout: '', stderr: String(err), exitCode: 0 }; + } + throw err; + } +} + +/** + * Runs a command and captures the output silently. + * + * @since TBD + * + * @param {string} command - The shell command to execute. + * @param {Omit} options - Optional configuration for the command execution. + * + * @returns {Promise} The command result with stdout, stderr, and exit code. + */ +export async function runCommandSilent( + command: string, + options: Omit = {} +): Promise { + return runCommand(command, { ...options, silent: true }); +} diff --git a/tests/commands/invalid-puprc.test.ts b/tests/commands/invalid-puprc.test.ts new file mode 100644 index 0000000..5aee240 --- /dev/null +++ b/tests/commands/invalid-puprc.test.ts @@ -0,0 +1,22 @@ +import { + runPup, + resetFixtures, + fakeProjectDir, +} from '../helpers/setup.js'; +import fs from 'fs-extra'; +import path from 'node:path'; + +describe('invalid .puprc', () => { + afterEach(() => { + resetFixtures(); + }); + + it('should handle invalid JSON in .puprc', async () => { + const puprcPath = path.join(fakeProjectDir, '.puprc'); + fs.writeFileSync(puprcPath, '{invalid json}'); + + const result = await runPup('info'); + // Should still run but report invalid config + expect(result.stdout + result.stderr).toBeTruthy(); + }); +}); diff --git a/tests/fixtures/fake-project-with-tbds/bootstrap.php b/tests/fixtures/fake-project-with-tbds/bootstrap.php new file mode 100644 index 0000000..033f192 --- /dev/null +++ b/tests/fixtures/fake-project-with-tbds/bootstrap.php @@ -0,0 +1,11 @@ + { + return { + build: ['ls -a'], + paths: { + versions: [ + { + file: 'bootstrap.php', + regex: "(define\\( +['\"]FAKE_PROJECT_VERSION['\"], +['\"])([^'\"]+)", + }, + { + file: 'bootstrap.php', + regex: '(Version: )(.+)', + }, + { + file: 'src/Plugin.php', + regex: "(const VERSION = ['\"])([^'\"]+)", + }, + { + file: 'package.json', + regex: '("version": ")([^"]+)', + }, + ], + }, + zip_name: 'fake-project', + }; +} + +export function getPuprc(overrides: Record = {}): Record { + return { ...getDefaultPuprc(), ...overrides }; +} + +export function writePuprc(config: Record, projectDir = fakeProjectDir): string { + const puprcPath = path.join(projectDir, '.puprc'); + fs.writeFileSync(puprcPath, JSON.stringify(config, null, 2)); + tempFiles.push(puprcPath); + return puprcPath; +} + +export function writeDefaultPuprc(projectDir = fakeProjectDir): string { + return writePuprc(getDefaultPuprc(), projectDir); +} + +export function rmPuprc(projectDir = fakeProjectDir): void { + const puprcPath = path.join(projectDir, '.puprc'); + if (fs.existsSync(puprcPath)) { + fs.removeSync(puprcPath); + } +} + +export function runPup( + args: string, + options: { cwd?: string } = {} +): Promise<{ stdout: string; stderr: string; output: string; exitCode: number }> { + const cwd = options.cwd ?? fakeProjectDir; + const argv = args.split(/\s+/).filter(Boolean); + + return new Promise((resolve) => { + execFile('node', [cliPath, ...argv], { + cwd, + env: { ...process.env, FORCE_COLOR: '0' }, + maxBuffer: 10 * 1024 * 1024, + }, (error, stdout, stderr) => { + const out = stdout ?? ''; + const err = stderr ?? ''; + resolve({ + stdout: out, + stderr: err, + output: out + err, + exitCode: error?.code && typeof error.code === 'number' ? error.code : (error ? 1 : 0), + }); + }); + }); +} + +export function resetFixtures(): void { + // Remove any .puprc files we wrote + for (const f of tempFiles) { + if (fs.existsSync(f)) { + fs.removeSync(f); + } + } + tempFiles.length = 0; + + // Clean up pup temp directories and generated files in fixture dirs + for (const dir of [fakeProjectDir, fakeProjectWithTbdsDir]) { + for (const tmp of [ + '.pup-build', '.pup-zip', + '.pup-distfiles', '.pup-distinclude', '.pup-distignore', + '.distignore', '.distinclude', '.distfiles', '.gitattributes', + ]) { + const tmpPath = path.join(dir, tmp); + if (fs.existsSync(tmpPath)) { + fs.removeSync(tmpPath); + } + } + + // Remove any generated zip files + const entries = fs.readdirSync(dir); + for (const entry of entries) { + if (entry.endsWith('.zip')) { + fs.removeSync(path.join(dir, entry)); + } + } + } +} From d130c3eefd73bb2bbeef455b1cc5665f38047795 Mon Sep 17 00:00:00 2001 From: Eric Defore Date: Wed, 11 Feb 2026 12:08:15 -0500 Subject: [PATCH 02/22] ENG-219: Remove PHP test and static analysis workflows PHP config files (composer.json, phpstan.neon.dist, etc.) were moved out in this branch, causing these workflows to fail. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/static-analysis.yml | 21 --------------------- .github/workflows/tests-php.yml | 25 ------------------------- 2 files changed, 46 deletions(-) delete mode 100644 .github/workflows/static-analysis.yml delete mode 100644 .github/workflows/tests-php.yml diff --git a/.github/workflows/static-analysis.yml b/.github/workflows/static-analysis.yml deleted file mode 100644 index 029679d..0000000 --- a/.github/workflows/static-analysis.yml +++ /dev/null @@ -1,21 +0,0 @@ -name: Static Analysis -on: - push: -jobs: - phpstsan: - name: phpstan - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v2 - - name: Configure PHP environment - uses: shivammathur/setup-php@v2 - with: - php-version: '8.0' - extensions: mbstring, intl - coverage: none - - uses: ramsey/composer-install@v2 - with: - composer-options: "--ignore-platform-reqs --optimize-autoloader" - - name: Run PHPStan static analysis - run: composer test:analysis \ No newline at end of file diff --git a/.github/workflows/tests-php.yml b/.github/workflows/tests-php.yml deleted file mode 100644 index bdd9e71..0000000 --- a/.github/workflows/tests-php.yml +++ /dev/null @@ -1,25 +0,0 @@ -name: Tests -on: - push: -jobs: - tests: - name: tests - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v2 - - name: Configure PHP environment - uses: shivammathur/setup-php@v2 - with: - php-version: '8.0' - extensions: mbstring, intl - coverage: none - - uses: ramsey/composer-install@v2 - with: - composer-options: "--ignore-platform-reqs --optimize-autoloader" - - name: Setup git - run: | - git config --global user.name "GitHub Actions" - git config --global user.email "<>" - - name: Run tests - run: php vendor/bin/codecept run cli --debug \ No newline at end of file From b0a05166d0de0009d600e48ba28724fb71e47b8d Mon Sep 17 00:00:00 2001 From: Eric Defore Date: Wed, 11 Feb 2026 12:10:05 -0500 Subject: [PATCH 03/22] =?UTF-8?q?ENG-219:=20Fix=20lint=20error=20in=20conf?= =?UTF-8?q?ig.ts=20(let=20=E2=86=92=20const)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- src/config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/config.ts b/src/config.ts index 18b69e5..efffbb7 100644 --- a/src/config.ts +++ b/src/config.ts @@ -394,7 +394,7 @@ export class Config { if (this._i18n !== null) return this._i18n; const defaults = this.config.i18n_defaults; - let i18nRaw = this.config.i18n; + const i18nRaw = this.config.i18n; if (!i18nRaw || (Array.isArray(i18nRaw) && i18nRaw.length === 0)) { this._i18n = []; From 56a1585e52a32c7cb3fb2f0749467fbb51e78507 Mon Sep 17 00:00:00 2001 From: Eric Defore Date: Wed, 11 Feb 2026 12:14:41 -0500 Subject: [PATCH 04/22] ENG-219: Add build step before running tests in CI Tests spawn node dist/cli.js, so the project must be built first. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/tests.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 562eeea..c5d1001 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -12,6 +12,7 @@ jobs: with: node-version: ${{ matrix.node-version }} - run: npm ci + - run: npm run build - name: Setup git run: | git config --global user.name "GitHub Actions" From 12df1104e97121a58a0f0624cd62f5d4c5e3c711 Mon Sep 17 00:00:00 2001 From: Eric Defore Date: Wed, 11 Feb 2026 12:15:50 -0500 Subject: [PATCH 05/22] ENG-219: Add npm caching to CI workflows Co-Authored-By: Claude Opus 4.6 --- .github/workflows/lint.yml | 1 + .github/workflows/tests.yml | 1 + 2 files changed, 2 insertions(+) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 55364f8..39e4765 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -8,6 +8,7 @@ jobs: - uses: actions/setup-node@v4 with: node-version: 20 + cache: 'npm' - run: npm ci - run: npm run lint - name: Typecheck diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index c5d1001..852a01b 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -11,6 +11,7 @@ jobs: - uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} + cache: 'npm' - run: npm ci - run: npm run build - name: Setup git From 3fb33854b7cb3231b39048e8894d617f18731074 Mon Sep 17 00:00:00 2001 From: Eric Defore Date: Wed, 11 Feb 2026 12:25:17 -0500 Subject: [PATCH 06/22] ENG-219: Check for invalid .puprc during app initialization Matches the PHP behavior where invalid .puprc is reported before any command runs. Co-Authored-By: Claude Opus 4.6 --- src/app.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/app.ts b/src/app.ts index ce0a6ec..b80894a 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,5 +1,6 @@ import { Command } from 'commander'; import { Config, getConfig, resetConfig } from './config.js'; +import * as output from './utils/output.js'; import { fileURLToPath } from 'node:url'; import path from 'node:path'; import fs from 'fs-extra'; @@ -43,6 +44,12 @@ export const PUP_VERSION = getVersion(); export function createApp(): Command { resetConfig(); + const config = getConfig(); + if (config.hasInvalidPuprc()) { + output.error('There is a .puprc file in this directory, but it could not be parsed.'); + output.error(`JSON Error: ${config.getPuprcParseError()}`); + } + const program = new Command(); program .name('pup') From 741d07d11fee4273f6a5349fb3abefc4b899d436 Mon Sep 17 00:00:00 2001 From: Eric Defore Date: Wed, 11 Feb 2026 12:34:09 -0500 Subject: [PATCH 07/22] ENG-219: Add createTempProject helper for test isolation Tests that modify .puprc now use isolated temp directories instead of the shared fixture directory, allowing parallel test execution. Co-Authored-By: Claude Opus 4.6 --- tests/commands/invalid-puprc.test.ts | 18 ++++++++++----- tests/helpers/setup.ts | 34 ++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 6 deletions(-) diff --git a/tests/commands/invalid-puprc.test.ts b/tests/commands/invalid-puprc.test.ts index 5aee240..a10a5c6 100644 --- a/tests/commands/invalid-puprc.test.ts +++ b/tests/commands/invalid-puprc.test.ts @@ -1,22 +1,28 @@ import { runPup, - resetFixtures, - fakeProjectDir, + createTempProject, + cleanupTempProjects, } from '../helpers/setup.js'; import fs from 'fs-extra'; import path from 'node:path'; describe('invalid .puprc', () => { + let projectDir: string; + + beforeEach(() => { + projectDir = createTempProject(); + }); + afterEach(() => { - resetFixtures(); + cleanupTempProjects(); }); it('should handle invalid JSON in .puprc', async () => { - const puprcPath = path.join(fakeProjectDir, '.puprc'); + const puprcPath = path.join(projectDir, '.puprc'); fs.writeFileSync(puprcPath, '{invalid json}'); - const result = await runPup('info'); + const result = await runPup('info', { cwd: projectDir }); // Should still run but report invalid config - expect(result.stdout + result.stderr).toBeTruthy(); + expect(result.output).toContain('could not be parsed'); }); }); diff --git a/tests/helpers/setup.ts b/tests/helpers/setup.ts index e11266d..152a211 100644 --- a/tests/helpers/setup.ts +++ b/tests/helpers/setup.ts @@ -1,4 +1,5 @@ import path from 'node:path'; +import os from 'node:os'; import { execFile } from 'node:child_process'; import fs from 'fs-extra'; @@ -10,6 +11,7 @@ export const fakeProjectWithTbdsDir = path.resolve(fixturesDir, 'fake-project-wi export const cliPath = path.resolve(pupRoot, 'dist', 'cli.js'); const tempFiles: string[] = []; +const tempDirs: string[] = []; export function getDefaultPuprc(): Record { return { @@ -85,6 +87,38 @@ export function runPup( }); } +/** + * Creates an isolated copy of a fixture directory in a temp location. + * + * @since TBD + * + * @param {string} fixture - The fixture directory name to copy (e.g. 'fake-project'). + * + * @returns {string} The path to the isolated temp directory. + */ +export function createTempProject(fixture = 'fake-project'): string { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), `pup-test-${fixture}-`)); + fs.copySync(path.resolve(fixturesDir, fixture), tmpDir); + tempDirs.push(tmpDir); + return tmpDir; +} + +/** + * Removes all temp directories created by createTempProject. + * + * @since TBD + * + * @returns {void} + */ +export function cleanupTempProjects(): void { + for (const dir of tempDirs) { + if (fs.existsSync(dir)) { + fs.removeSync(dir); + } + } + tempDirs.length = 0; +} + export function resetFixtures(): void { // Remove any .puprc files we wrote for (const f of tempFiles) { From ec2249e1b56d9696938da66936abaa6e67553224 Mon Sep 17 00:00:00 2001 From: Eric Defore Date: Wed, 11 Feb 2026 16:24:04 -0500 Subject: [PATCH 08/22] ENG-219: Add node_modules caching to CI workflows Cache node_modules using actions/cache keyed on bun.lock hash to speed up dependency installation across runs. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/lint.yml | 6 ++++++ .github/workflows/tests.yml | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 24fc8c5..1e76f12 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -6,6 +6,12 @@ jobs: steps: - uses: actions/checkout@v4 - uses: oven-sh/setup-bun@v2 + - uses: actions/cache@v4 + with: + path: node_modules + key: ${{ runner.os }}-node-modules-${{ hashFiles('bun.lock') }} + restore-keys: | + ${{ runner.os }}-node-modules- - run: bun install --frozen-lockfile - run: bun run lint - name: Typecheck diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 8d14a2f..b0eceee 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -6,6 +6,12 @@ jobs: steps: - uses: actions/checkout@v4 - uses: oven-sh/setup-bun@v2 + - uses: actions/cache@v4 + with: + path: node_modules + key: ${{ runner.os }}-node-modules-${{ hashFiles('bun.lock') }} + restore-keys: | + ${{ runner.os }}-node-modules- - run: bun install --frozen-lockfile - run: bun run build - name: Setup git From 75308bc800a2eacda00e77801ab12bb2ba6a5f1f Mon Sep 17 00:00:00 2001 From: Eric Defore Date: Fri, 13 Feb 2026 10:12:40 -0500 Subject: [PATCH 09/22] ENG-219: Address PR #30 review comments Use native Node.js path APIs in directory utilities instead of custom string manipulation. Cache bun's install cache in CI instead of node_modules. Add unit tests for directory utilities. --- .github/workflows/lint.yml | 14 ++-- .github/workflows/tests.yml | 14 ++-- src/config.ts | 4 +- src/utils/directory.ts | 57 ++++++++------- tests/utils/directory.test.ts | 128 ++++++++++++++++++++++++++++++++++ 5 files changed, 181 insertions(+), 36 deletions(-) create mode 100644 tests/utils/directory.test.ts diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 086ce21..28e55f8 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -6,12 +6,18 @@ jobs: steps: - uses: actions/checkout@v6 - uses: oven-sh/setup-bun@v2 - - uses: actions/cache@v4 + - name: Get bun cache directory + id: bun-cache + shell: bash + run: | + echo "dir=$(bun pm cache)" >> $GITHUB_OUTPUT + - name: Cache bun dependencies + uses: actions/cache@v5 with: - path: node_modules - key: ${{ runner.os }}-node-modules-${{ hashFiles('bun.lock') }} + path: ${{ steps.bun-cache.outputs.dir }} + key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }} restore-keys: | - ${{ runner.os }}-node-modules- + ${{ runner.os }}-bun- - run: bun install --frozen-lockfile - run: bun run lint - name: Typecheck diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index d1b5a55..a715c7f 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -6,12 +6,18 @@ jobs: steps: - uses: actions/checkout@v6 - uses: oven-sh/setup-bun@v2 - - uses: actions/cache@v4 + - name: Get bun cache directory + id: bun-cache + shell: bash + run: | + echo "dir=$(bun pm cache)" >> $GITHUB_OUTPUT + - name: Cache bun dependencies + uses: actions/cache@v5 with: - path: node_modules - key: ${{ runner.os }}-node-modules-${{ hashFiles('bun.lock') }} + path: ${{ steps.bun-cache.outputs.dir }} + key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }} restore-keys: | - ${{ runner.os }}-node-modules- + ${{ runner.os }}-bun- - run: bun install --frozen-lockfile - run: bun run build - run: bun run test diff --git a/src/config.ts b/src/config.ts index efffbb7..6f91eba 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,7 +1,7 @@ import fs from 'fs-extra'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { normalizeDir, trailingSlashIt } from './utils/directory.js'; +import { trailingSlashIt } from './utils/directory.js'; import { WorkflowCollection, createWorkflow } from './models/workflow.js'; import type { PupConfig, @@ -68,7 +68,7 @@ export class Config { constructor(workingDir?: string) { const cwd = workingDir ?? process.cwd(); - this.workingDir = trailingSlashIt(normalizeDir(cwd)); + this.workingDir = trailingSlashIt(path.normalize(cwd)); this.puprcFilePath = path.join(this.workingDir, '.puprc'); this.config = this.getDefaultConfig(); this.mergeConfigWithDefaults(); diff --git a/src/utils/directory.ts b/src/utils/directory.ts index dfb3c12..bf73bb6 100644 --- a/src/utils/directory.ts +++ b/src/utils/directory.ts @@ -2,32 +2,39 @@ import path from 'node:path'; import fs from 'fs-extra'; /** - * Normalizes a directory path by replacing backslashes with forward slashes. + * Checks whether a child directory is inside a parent directory. * * @since TBD * - * @param {string} dir - The directory path to normalize. + * @param {string} parentDir - The parent directory path. + * @param {string} childDir - The child directory path. * - * @returns {string} The normalized path with forward slashes. + * @returns {boolean} True if childDir is inside parentDir. */ -export function normalizeDir(dir: string): string { - return dir.replace(/\\/g, '/'); +export function isInside(parentDir: string, childDir: string): boolean { + const relative = path.relative(parentDir, childDir); + return !!relative && !relative.startsWith('..') && !path.isAbsolute(relative); } /** - * Ensures a path ends with a trailing forward slash. + * Ensures a directory path ends with a trailing separator. * * @since TBD * - * @param {string} p - The path to ensure has a trailing slash. + * @param {string} p - The path to ensure has a trailing separator. * - * @returns {string} The path with a trailing forward slash. + * @returns {string} The path with a trailing separator. + * + * @throws {Error} If the path appears to be a file (has an extension). */ export function trailingSlashIt(p: string): string { - if (p.endsWith('/')) { - return p; + const { dir, base, ext } = path.parse(p); + + if (ext.length > 0) { + throw new Error('Could not add trailing slash to file path.'); } - return p + '/'; + + return path.join(dir, base, path.sep); } /** @@ -43,11 +50,11 @@ export function trailingSlashIt(p: string): string { * @throws {Error} If the directory is outside the working directory. */ export async function rmdir(dir: string, workingDir: string): Promise { - const normalized = normalizeDir(dir); - const normalizedWorking = normalizeDir(workingDir); + const relative = path.relative(workingDir, dir); + const inside = relative && !relative.startsWith('..') && !path.isAbsolute(relative); // Safety check: only remove directories within the working directory - if (!normalized.startsWith(normalizedWorking)) { + if (!inside) { throw new Error( `Refusing to remove directory outside working directory: ${dir}` ); @@ -60,7 +67,7 @@ export async function rmdir(dir: string, workingDir: string): Promise { /** * Resolves a relative path against a working directory. - * Strips any existing prefix and rejects absolute paths unless a default is provided. + * Rejects absolute paths unless a default is provided. * * @since TBD * @@ -77,19 +84,17 @@ export function resolveRelativePath( workingDir: string, defaultPath?: string ): string { - const prefix = trailingSlashIt(workingDir); - let normalized = normalizeDir(relativePath); + if (path.isAbsolute(relativePath)) { + if (!defaultPath) { + throw new Error('Absolute paths are not allowed in the .puprc file.'); + } - // Strip the prefix if it's already there - normalized = normalized.replace(prefix, ''); + relativePath = defaultPath; + } - // Don't allow absolute paths - if (normalized.startsWith('/')) { - if (defaultPath) { - return path.join(prefix, defaultPath); - } - throw new Error('Absolute paths are not allowed in the .puprc file.'); + if (isInside(workingDir, relativePath)) { + return relativePath; } - return path.join(prefix, normalized); + return path.join(workingDir, relativePath); } diff --git a/tests/utils/directory.test.ts b/tests/utils/directory.test.ts new file mode 100644 index 0000000..f8c3155 --- /dev/null +++ b/tests/utils/directory.test.ts @@ -0,0 +1,128 @@ +import path from 'node:path'; +import os from 'node:os'; +import fs from 'fs-extra'; +import { + isInside, + trailingSlashIt, + rmdir, + resolveRelativePath, +} from '../../src/utils/directory.js'; + +describe('isInside', () => { + it('should return true for a child inside the parent', () => { + expect(isInside('/home/user/project', '/home/user/project/src')).toBe(true); + }); + + it('should return true for a deeply nested child', () => { + expect(isInside('/home/user/project', '/home/user/project/src/utils/file')).toBe(true); + }); + + it('should return false for a path outside the parent', () => { + expect(isInside('/home/user/project', '/home/user/other')).toBe(false); + }); + + it('should return false for the same directory', () => { + expect(isInside('/home/user/project', '/home/user/project')).toBe(false); + }); + + it('should return false for a parent of the given directory', () => { + expect(isInside('/home/user/project', '/home/user')).toBe(false); + }); +}); + +describe('trailingSlashIt', () => { + it('should add a trailing separator to a directory path', () => { + const result = trailingSlashIt('/home/user/project'); + expect(result.endsWith(path.sep)).toBe(true); + }); + + it('should handle a path that already ends with a separator', () => { + const result = trailingSlashIt(`/home/user/project${path.sep}`); + expect(result).toBe(`/home/user/project${path.sep}`); + }); + + it('should throw for a file path with an extension', () => { + expect(() => trailingSlashIt('/home/user/project/file.txt')).toThrow( + 'Could not add trailing slash to file path.' + ); + }); + + it('should handle a relative directory path', () => { + const result = trailingSlashIt('src/utils'); + expect(result.endsWith(path.sep)).toBe(true); + }); +}); + +describe('rmdir', () => { + let tempDir: string; + let workingDir: string; + + beforeEach(() => { + workingDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pup-rmdir-')); + tempDir = path.join(workingDir, 'subdir'); + fs.mkdirSync(tempDir); + }); + + afterEach(() => { + if (fs.existsSync(workingDir)) { + fs.removeSync(workingDir); + } + }); + + it('should remove a directory inside the working directory', async () => { + expect(fs.existsSync(tempDir)).toBe(true); + await rmdir(tempDir, workingDir); + expect(fs.existsSync(tempDir)).toBe(false); + }); + + it('should not throw if the directory does not exist', async () => { + const nonExistent = path.join(workingDir, 'does-not-exist'); + await expect(rmdir(nonExistent, workingDir)).resolves.toBeUndefined(); + }); + + it('should throw for a directory outside the working directory', async () => { + const outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pup-outside-')); + try { + await expect(rmdir(outsideDir, workingDir)).rejects.toThrow( + 'Refusing to remove directory outside working directory' + ); + } finally { + fs.removeSync(outsideDir); + } + }); + + it('should throw when trying to remove the working directory itself', async () => { + await expect(rmdir(workingDir, workingDir)).rejects.toThrow( + 'Refusing to remove directory outside working directory' + ); + }); +}); + +describe('resolveRelativePath', () => { + const workingDir = '/home/user/project'; + + it('should join a relative path with the working directory', () => { + expect(resolveRelativePath('src/utils', workingDir)).toBe( + path.join(workingDir, 'src/utils') + ); + }); + + it('should throw for an absolute path even if inside the working directory', () => { + const fullPath = path.join(workingDir, 'src/utils'); + expect(() => resolveRelativePath(fullPath, workingDir)).toThrow( + 'Absolute paths are not allowed in the .puprc file.' + ); + }); + + it('should throw for an absolute path without a default', () => { + expect(() => resolveRelativePath('/etc/passwd', workingDir)).toThrow( + 'Absolute paths are not allowed in the .puprc file.' + ); + }); + + it('should use the default path when an absolute path is given with a default', () => { + expect(resolveRelativePath('/etc/passwd', workingDir, '.pup-build')).toBe( + path.join(workingDir, '.pup-build') + ); + }); +}); From 8bc0588a567da182d6ba65626af464c719b82019 Mon Sep 17 00:00:00 2001 From: Eric Defore Date: Fri, 13 Feb 2026 14:58:13 -0500 Subject: [PATCH 10/22] ENG-219: Use bundler module resolution for .ts imports Switch from NodeNext to bundler moduleResolution since tsdown handles bundling, not tsc. This allows importing .ts files directly, matching actual filenames on disk. Remove unused declaration/declarationMap options (tsdown has dts: false) and add noEmit (required by allowImportingTsExtensions). --- tsconfig.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tsconfig.json b/tsconfig.json index a1763ae..446ade2 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,8 +1,8 @@ { "compilerOptions": { "target": "ES2022", - "module": "NodeNext", - "moduleResolution": "NodeNext", + "module": "ESNext", + "moduleResolution": "bundler", "lib": ["ES2022"], "outDir": "dist", "rootDir": "src", @@ -11,8 +11,8 @@ "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "resolveJsonModule": true, - "declaration": true, - "declarationMap": true, + "allowImportingTsExtensions": true, + "noEmit": true, "sourceMap": true }, "include": ["src/**/*"], From 35d61a58bcc9739cca8d6393ba24a0e73f0841fe Mon Sep 17 00:00:00 2001 From: Eric Defore Date: Fri, 13 Feb 2026 14:58:40 -0500 Subject: [PATCH 11/22] ENG-219: Change .js to .ts import paths With bundler moduleResolution, imports can use .ts extensions that match the actual filenames on disk instead of the .js convention required by NodeNext resolution. --- src/app.ts | 4 ++-- src/cli.ts | 2 +- src/config.ts | 6 +++--- src/models/workflow.ts | 2 +- src/utils/process.ts | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/app.ts b/src/app.ts index b80894a..794d2d6 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,6 +1,6 @@ import { Command } from 'commander'; -import { Config, getConfig, resetConfig } from './config.js'; -import * as output from './utils/output.js'; +import { Config, getConfig, resetConfig } from './config.ts'; +import * as output from './utils/output.ts'; import { fileURLToPath } from 'node:url'; import path from 'node:path'; import fs from 'fs-extra'; diff --git a/src/cli.ts b/src/cli.ts index 50ff300..369530d 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,4 +1,4 @@ -import { createApp } from './app.js'; +import { createApp } from './app.ts'; const program = createApp(); diff --git a/src/config.ts b/src/config.ts index 6f91eba..868f5cc 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,8 +1,8 @@ import fs from 'fs-extra'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { trailingSlashIt } from './utils/directory.js'; -import { WorkflowCollection, createWorkflow } from './models/workflow.js'; +import { trailingSlashIt } from './utils/directory.ts'; +import { WorkflowCollection, createWorkflow } from './models/workflow.ts'; import type { PupConfig, CheckConfig, @@ -11,7 +11,7 @@ import type { VersionFileInput, I18nResolvedConfig, I18nConfigInput, -} from './types.js'; +} from './types.ts'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); diff --git a/src/models/workflow.ts b/src/models/workflow.ts index 29567b4..42ebb6c 100644 --- a/src/models/workflow.ts +++ b/src/models/workflow.ts @@ -1,4 +1,4 @@ -import type { Workflow } from '../types.js'; +import type { Workflow } from '../types.ts'; /** * Creates a Workflow object from a slug and list of commands. diff --git a/src/utils/process.ts b/src/utils/process.ts index e579354..8893148 100644 --- a/src/utils/process.ts +++ b/src/utils/process.ts @@ -1,6 +1,6 @@ import { execa } from 'execa'; import { buildEnv } from './env.js'; -import type { RunCommandResult } from '../types.js'; +import type { RunCommandResult } from '../types.ts'; export interface RunOptions { cwd?: string; From f6c5a1d7fa29682d653c4864ae3a79612b1346b4 Mon Sep 17 00:00:00 2001 From: Eric Defore Date: Fri, 13 Feb 2026 15:00:01 -0500 Subject: [PATCH 12/22] ENG-219: Remove redundant env forwarding from process.ts execa inherits process.env by default, making the explicit buildEnv() call unnecessary. Remove envVarNames from RunOptions, remove env option from execa call, and delete the now-unused env.ts utility. The env config key on Config is still available via getEnvVarNames(). --- src/utils/env.ts | 22 ---------------------- src/utils/process.ts | 7 ------- 2 files changed, 29 deletions(-) delete mode 100644 src/utils/env.ts diff --git a/src/utils/env.ts b/src/utils/env.ts deleted file mode 100644 index c0df7ac..0000000 --- a/src/utils/env.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * Builds an environment object from configured env var names. - * Reads the current process.env values for the listed variable names. - * - * @since TBD - * - * @param {string[]} envVarNames - The list of environment variable names to include. - * - * @returns {Record} An object mapping variable names to their values. - */ -export function buildEnv(envVarNames: string[]): Record { - const env: Record = {}; - - for (const name of envVarNames) { - const value = process.env[name]; - if (value !== undefined) { - env[name] = value; - } - } - - return env; -} diff --git a/src/utils/process.ts b/src/utils/process.ts index 8893148..e0d0cee 100644 --- a/src/utils/process.ts +++ b/src/utils/process.ts @@ -1,10 +1,8 @@ import { execa } from 'execa'; -import { buildEnv } from './env.js'; import type { RunCommandResult } from '../types.ts'; export interface RunOptions { cwd?: string; - envVarNames?: string[]; softFail?: boolean; silent?: boolean; } @@ -33,14 +31,9 @@ export async function runCommand( softFail = true; } - const env = options.envVarNames - ? { ...process.env, ...buildEnv(options.envVarNames) } - : process.env; - try { const result = await execa(cmd, { cwd: options.cwd, - env: env as Record, shell: true, stdout: options.silent ? 'pipe' : 'inherit', stderr: options.silent ? 'pipe' : 'inherit', From 959c4a6e05c969089f4a305365280574e3789b03 Mon Sep 17 00:00:00 2001 From: Eric Defore Date: Fri, 13 Feb 2026 15:00:21 -0500 Subject: [PATCH 13/22] ENG-219: Rename .puprc-defaults to .puprc-defaults.json Add .json extension so the file can be imported as a JSON module via resolveJsonModule, allowing tsdown to bundle the defaults inline rather than reading from the filesystem at runtime. --- defaults/{.puprc-defaults => .puprc-defaults.json} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename defaults/{.puprc-defaults => .puprc-defaults.json} (100%) diff --git a/defaults/.puprc-defaults b/defaults/.puprc-defaults.json similarity index 100% rename from defaults/.puprc-defaults rename to defaults/.puprc-defaults.json From 8c31609f717d0c95dc23d8cf99fab6c8f8a2db6b Mon Sep 17 00:00:00 2001 From: Eric Defore Date: Fri, 13 Feb 2026 15:00:42 -0500 Subject: [PATCH 14/22] ENG-219: Add Zod schemas as single source of truth for types Add zod dependency and create schemas.ts with Zod schemas for all config types. Types are now derived via z.infer<> rather than manual interfaces, ensuring runtime validation and compile-time types stay in sync. types.ts re-exports everything from schemas.ts so existing imports remain compatible. --- bun.lock | 3 + package.json | 3 +- src/schemas.ts | 223 +++++++++++++++++++++++++++++++++++++++++++++++++ src/types.ts | 149 ++++++++------------------------- 4 files changed, 263 insertions(+), 115 deletions(-) create mode 100644 src/schemas.ts diff --git a/bun.lock b/bun.lock index 8f64ac0..e72d061 100644 --- a/bun.lock +++ b/bun.lock @@ -12,6 +12,7 @@ "fs-extra": "^11.2.0", "picomatch": "^4.0.0", "simple-git": "^3.22.0", + "zod": "^4.3.6", }, "devDependencies": { "@jest/types": "^30.2.0", @@ -931,6 +932,8 @@ "zip-stream": ["zip-stream@6.0.1", "", { "dependencies": { "archiver-utils": "^5.0.0", "compress-commons": "^6.0.2", "readable-stream": "^4.0.0" } }, "sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA=="], + "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + "@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], diff --git a/package.json b/package.json index 471f1da..e12fb8c 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,8 @@ "execa": "^8.0.1", "fs-extra": "^11.2.0", "picomatch": "^4.0.0", - "simple-git": "^3.22.0" + "simple-git": "^3.22.0", + "zod": "^4.3.6" }, "devDependencies": { "@jest/types": "^30.2.0", diff --git a/src/schemas.ts b/src/schemas.ts new file mode 100644 index 0000000..eec7cca --- /dev/null +++ b/src/schemas.ts @@ -0,0 +1,223 @@ +import { z } from 'zod'; + +/** + * Schema for a version file entry in .puprc paths.versions. + * + * @since TBD + */ +export const VersionFileInputSchema = z.object({ + file: z.string(), + regex: z.string(), +}); + +export type VersionFileInput = z.infer; + +/** + * Parsed version file (same shape as input). + * + * @since TBD + */ +export const VersionFileSchema = z.object({ + file: z.string(), + regex: z.string(), +}); + +export type VersionFile = z.infer; + +/** + * Schema for the i18n filter configuration. + * + * @since TBD + */ +const I18nFilterSchema = z.object({ + minimum_percentage: z.number(), +}); + +/** + * Schema for an i18n configuration entry from .puprc (all fields optional). + * + * @since TBD + */ +export const I18nConfigInputSchema = z.object({ + path: z.string().optional(), + url: z.string().optional(), + slug: z.string().optional(), + textdomain: z.string().optional(), + file_format: z.string().optional(), + formats: z.array(z.string()).optional(), + filter: z.object({ + minimum_percentage: z.number().optional(), + }).optional(), +}).passthrough(); + +export type I18nConfigInput = z.infer; + +/** + * Schema for the i18n defaults section of configuration. + * + * @since TBD + */ +export const I18nDefaultsSchema = z.object({ + path: z.string(), + url: z.string(), + slug: z.string(), + textdomain: z.string(), + file_format: z.string(), + formats: z.array(z.string()), + filter: I18nFilterSchema, +}); + +export type I18nDefaults = z.infer; + +/** + * Schema for a fully resolved i18n configuration entry (all fields required). + * + * @since TBD + */ +export const I18nResolvedConfigSchema = z.object({ + path: z.string(), + url: z.string(), + slug: z.string(), + textdomain: z.string(), + file_format: z.string(), + formats: z.array(z.string()), + filter: I18nFilterSchema, +}); + +export type I18nResolvedConfig = z.infer; + +/** + * Schema for a check configuration entry from .puprc (optional fields with defaults). + * + * @since TBD + */ +export const CheckConfigInputSchema = z.object({ + fail_method: z.enum(['error', 'warn']).optional(), + fail_method_dev: z.enum(['error', 'warn']).optional(), + type: z.enum(['simple', 'class', 'pup', 'command']).optional(), + file: z.string().optional(), + command: z.string().optional(), + configure: z.string().optional(), + args: z.record(z.string(), z.string()).optional(), + dirs: z.array(z.string()).optional(), + skip_directories: z.string().optional(), + skip_files: z.string().optional(), +}).passthrough(); + +export type CheckConfigInput = z.infer; + +/** + * Schema for a fully resolved check configuration with defaults applied. + * + * @since TBD + */ +export const CheckConfigSchema = z.object({ + slug: z.string(), + fail_method: z.enum(['error', 'warn']).default('error'), + fail_method_dev: z.enum(['error', 'warn']).default('warn'), + type: z.enum(['simple', 'class', 'pup', 'command']).default('pup'), + file: z.string().optional(), + command: z.string().optional(), + configure: z.string().optional(), + args: z.record(z.string(), z.string()).default({}), + dirs: z.array(z.string()).optional(), + skip_directories: z.string().optional(), + skip_files: z.string().optional(), +}); + +export type CheckConfig = z.infer; + +/** + * Schema for the paths section of configuration. + * + * @since TBD + */ +export const PathsConfigSchema = z.object({ + build_dir: z.string(), + changelog: z.string().nullable(), + css: z.array(z.string()), + js: z.array(z.string()), + sync_files: z.array(z.string()), + versions: z.array(VersionFileInputSchema), + views: z.array(z.string()), + zip_dir: z.string(), +}); + +export type PathsConfig = z.infer; + +/** + * Schema for the full merged pup configuration (after defaults are applied). + * + * @since TBD + */ +export const PupConfigSchema = z.object({ + build: z.array(z.string()), + build_dev: z.array(z.string()), + workflows: z.record(z.string(), z.array(z.string())), + checks: z.record(z.string(), CheckConfigInputSchema), + clean: z.array(z.string()), + i18n: z.union([z.array(I18nConfigInputSchema), I18nConfigInputSchema]), + i18n_defaults: I18nDefaultsSchema, + paths: PathsConfigSchema, + env: z.array(z.string()), + repo: z.string().nullable(), + zip_use_default_ignore: z.boolean(), + zip_name: z.string().nullable(), +}).passthrough(); + +export type PupConfig = z.infer; + +/** + * Schema for validating raw .puprc input (all fields optional + passthrough for custom keys). + * + * @since TBD + */ +export const PuprcInputSchema = z.object({ + build: z.array(z.string()).optional(), + build_dev: z.array(z.string()).optional(), + workflows: z.record(z.string(), z.array(z.string())).optional(), + checks: z.record(z.string(), CheckConfigInputSchema.or(z.object({}).passthrough())).optional(), + clean: z.array(z.string()).optional(), + i18n: z.union([z.array(I18nConfigInputSchema), I18nConfigInputSchema]).optional(), + i18n_defaults: I18nDefaultsSchema.partial().optional(), + paths: PathsConfigSchema.partial().optional(), + env: z.array(z.string()).optional(), + repo: z.string().nullable().optional(), + zip_use_default_ignore: z.boolean().optional(), + zip_name: z.string().nullable().optional(), +}).passthrough(); + +export type PuprcInput = z.infer; + +/** + * Schema for a workflow. + * + * @since TBD + */ +export const WorkflowSchema = z.object({ + slug: z.string(), + commands: z.array(z.string()), +}); + +export type Workflow = z.infer; + +/** + * Result of running a check. + * + * @since TBD + */ +export interface CheckResult { + success: boolean; + output: string; +} + +/** + * Result of running a shell command. + * + * @since TBD + */ +export interface RunCommandResult { + stdout: string; + stderr: string; + exitCode: number; +} diff --git a/src/types.ts b/src/types.ts index 863dea2..fd37b44 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,114 +1,35 @@ -export interface PupConfig { - build: string[]; - build_dev: string[]; - workflows: Record; - checks: Record; - clean: string[]; - i18n: I18nConfigInput[] | I18nConfigInput; - i18n_defaults: I18nDefaults; - paths: PathsConfig; - env: string[]; - repo: string | null; - zip_use_default_ignore: boolean; - zip_name: string | null; -} - -export interface PathsConfig { - build_dir: string; - changelog: string | null; - css: string[]; - js: string[]; - sync_files: string[]; - versions: VersionFileInput[]; - views: string[]; - zip_dir: string; -} - -export interface VersionFileInput { - file: string; - regex: string; -} - -export interface VersionFile { - file: string; - regex: string; -} - -export interface CheckConfigInput { - fail_method?: 'error' | 'warn'; - fail_method_dev?: 'error' | 'warn'; - type?: 'simple' | 'class' | 'pup' | 'command'; - file?: string; - command?: string; - configure?: string; - args?: Record; - dirs?: string[]; - skip_directories?: string; - skip_files?: string; -} - -export interface CheckConfig { - slug: string; - fail_method: 'error' | 'warn'; - fail_method_dev: 'error' | 'warn'; - type: 'simple' | 'class' | 'pup' | 'command'; - file?: string; - command?: string; - configure?: string; - args: Record; - dirs?: string[]; - skip_directories?: string; - skip_files?: string; -} - -export interface CheckResult { - success: boolean; - output: string; -} - -export interface I18nConfigInput { - path?: string; - url?: string; - slug?: string; - textdomain?: string; - file_format?: string; - formats?: string[]; - filter?: { - minimum_percentage?: number; - }; -} - -export interface I18nDefaults { - path: string; - url: string; - slug: string; - textdomain: string; - file_format: string; - formats: string[]; - filter: { - minimum_percentage: number; - }; -} - -export interface I18nResolvedConfig { - path: string; - url: string; - slug: string; - textdomain: string; - file_format: string; - formats: string[]; - filter: { - minimum_percentage: number; - }; -} - -export interface Workflow { - slug: string; - commands: string[]; -} - -export interface RunCommandResult { - stdout: string; - stderr: string; - exitCode: number; -} +/** + * Re-exports all types and schemas from schemas.ts. + * Types are defined via Zod schemas in schemas.ts using z.infer<>. + * + * @since TBD + */ +export type { + PupConfig, + PuprcInput, + PathsConfig, + VersionFileInput, + VersionFile, + CheckConfigInput, + CheckConfig, + CheckResult, + I18nConfigInput, + I18nDefaults, + I18nResolvedConfig, + Workflow, + RunCommandResult, +} from './schemas.ts'; + +export { + PupConfigSchema, + PuprcInputSchema, + PathsConfigSchema, + VersionFileInputSchema, + VersionFileSchema, + CheckConfigInputSchema, + CheckConfigSchema, + I18nConfigInputSchema, + I18nDefaultsSchema, + I18nResolvedConfigSchema, + WorkflowSchema, +} from './schemas.ts'; From bf9d9656d89fdae5ab44d7c79f6ba41a538fa4ff Mon Sep 17 00:00:00 2001 From: Eric Defore Date: Fri, 13 Feb 2026 15:02:10 -0500 Subject: [PATCH 15/22] ENG-219: Use JS # private fields and readonly in Config Replace TypeScript private keyword with JS # private fields for runtime enforcement. Mark immutable properties (workingDir, puprcFilePath, config) as readonly. Make buildWorkflows(), parseCheckConfig(), and parseVersionFiles() return values assigned in the constructor instead of mutating instance state. --- src/config.ts | 164 ++++++++++++++++++++++++++------------------------ 1 file changed, 84 insertions(+), 80 deletions(-) diff --git a/src/config.ts b/src/config.ts index 868f5cc..3796149 100644 --- a/src/config.ts +++ b/src/config.ts @@ -46,15 +46,15 @@ export function getDefaultsDir(): string { * @since TBD */ export class Config { - private workingDir: string; - private puprcFilePath: string; - private config: PupConfig; - private _hasInvalidPuprc = false; - private _puprcParseError = ''; - private _workflows: WorkflowCollection = new WorkflowCollection(); - private _checks: Map = new Map(); - private _versionFiles: VersionFile[] = []; - private _i18n: I18nResolvedConfig[] | null = null; + readonly #workingDir: string; + readonly #puprcFilePath: string; + readonly #config: PupConfig; + #hasInvalidPuprc = false; + #puprcParseError = ''; + #workflows: WorkflowCollection; + #checks: Map; + #versionFiles: VersionFile[]; + #i18n: I18nResolvedConfig[] | null = null; /** * Initializes configuration by loading and merging .puprc with defaults. @@ -62,19 +62,17 @@ export class Config { * @since TBD * * @param {string} workingDir - The project working directory. Defaults to process.cwd(). - * - * @returns {void} */ constructor(workingDir?: string) { const cwd = workingDir ?? process.cwd(); - this.workingDir = trailingSlashIt(path.normalize(cwd)); - this.puprcFilePath = path.join(this.workingDir, '.puprc'); - this.config = this.getDefaultConfig(); + this.#workingDir = trailingSlashIt(path.normalize(cwd)); + this.#puprcFilePath = path.join(this.#workingDir, '.puprc'); + this.#config = this.getDefaultConfig(); this.mergeConfigWithDefaults(); - this.buildWorkflows(); - this.parseCheckConfig(); - this.parseVersionFiles(); + this.#workflows = this.buildWorkflows(); + this.#checks = this.parseCheckConfig(); + this.#versionFiles = this.parseVersionFiles(); } /** @@ -99,28 +97,28 @@ export class Config { * @returns {void} */ private mergeConfigWithDefaults(): void { - if (!fs.existsSync(this.puprcFilePath)) { + if (!fs.existsSync(this.#puprcFilePath)) { return; } - const puprcContents = fs.readFileSync(this.puprcFilePath, 'utf-8'); + const puprcContents = fs.readFileSync(this.#puprcFilePath, 'utf-8'); let puprc: Record; try { puprc = JSON.parse(puprcContents) as Record; } catch { - this._hasInvalidPuprc = true; - this._puprcParseError = 'Invalid JSON in .puprc'; + this.#hasInvalidPuprc = true; + this.#puprcParseError = 'Invalid JSON in .puprc'; return; } if (!puprc || typeof puprc !== 'object') { - this._hasInvalidPuprc = true; - this._puprcParseError = 'Invalid .puprc format'; + this.#hasInvalidPuprc = true; + this.#puprcParseError = 'Invalid .puprc format'; return; } - const configRecord = this.config as unknown as Record; + const configRecord = this.#config as unknown as Record; for (const [key, value] of Object.entries(puprc)) { const current = configRecord[key]; @@ -209,26 +207,26 @@ export class Config { * * @since TBD * - * @returns {void} + * @returns {WorkflowCollection} The built workflow collection. */ - private buildWorkflows(): void { + private buildWorkflows(): WorkflowCollection { const collection = new WorkflowCollection(); - const rawWorkflows = this.config.workflows as unknown; + const rawWorkflows = this.#config.workflows as unknown; // Auto-create build workflow if ( - this.config.build?.length > 0 && + this.#config.build?.length > 0 && !(rawWorkflows as Record)?.['build'] ) { - collection.add(createWorkflow('build', this.config.build)); + collection.add(createWorkflow('build', this.#config.build)); } if ( - this.config.build_dev?.length > 0 && + this.#config.build_dev?.length > 0 && !(rawWorkflows as Record)?.['build_dev'] ) { - collection.add(createWorkflow('build_dev', this.config.build_dev)); + collection.add(createWorkflow('build_dev', this.#config.build_dev)); } if (rawWorkflows && typeof rawWorkflows === 'object') { @@ -241,7 +239,7 @@ export class Config { } } - this._workflows = collection; + return collection; } /** @@ -249,11 +247,12 @@ export class Config { * * @since TBD * - * @returns {void} + * @returns {Map} A map of check slug to CheckConfig. */ - private parseCheckConfig(): void { - const checks = this.config.checks; - if (!checks) return; + private parseCheckConfig(): Map { + const checks = this.#config.checks; + const result = new Map(); + if (!checks) return result; for (const [slug, checkInput] of Object.entries(checks)) { const input = ( @@ -276,8 +275,10 @@ export class Config { skip_files: input.skip_files, }; - this._checks.set(slug, config); + result.set(slug, config); } + + return result; } /** @@ -285,13 +286,14 @@ export class Config { * * @since TBD * - * @returns {void} + * @returns {VersionFile[]} The parsed list of version file objects. * * @throws {Error} If a version file entry is missing required properties or the file does not exist. */ - private parseVersionFiles(): void { - const versions = this.config.paths?.versions; - if (!versions || !Array.isArray(versions)) return; + private parseVersionFiles(): VersionFile[] { + const versions = this.#config.paths?.versions; + const result: VersionFile[] = []; + if (!versions || !Array.isArray(versions)) return result; for (const vf of versions as VersionFileInput[]) { if (!vf.file || !vf.regex) { @@ -300,7 +302,7 @@ export class Config { ); } - const filePath = path.resolve(this.workingDir, vf.file); + const filePath = path.resolve(this.#workingDir, vf.file); if (!fs.existsSync(filePath)) { throw new Error(`Version file does not exist: ${vf.file}`); } @@ -315,8 +317,10 @@ export class Config { ); } - this._versionFiles.push({ file: vf.file, regex: vf.regex }); + result.push({ file: vf.file, regex: vf.regex }); } + + return result; } /** @@ -327,7 +331,7 @@ export class Config { * @returns {PupConfig} The configuration object. */ get raw(): PupConfig { - return this.config; + return this.#config; } /** @@ -340,10 +344,10 @@ export class Config { * @returns {string[]} The list of build command strings. */ getBuildCommands(isDev = false): string[] { - if (isDev && this.config.build_dev?.length > 0) { - return this.config.build_dev; + if (isDev && this.#config.build_dev?.length > 0) { + return this.#config.build_dev; } - return this.config.build ?? []; + return this.#config.build ?? []; } /** @@ -356,9 +360,9 @@ export class Config { * @returns {string} The build directory path. */ getBuildDir(fullPath = true): string { - const buildDir = this.config.paths?.build_dir ?? '.pup-build'; + const buildDir = this.#config.paths?.build_dir ?? '.pup-build'; if (!fullPath) return buildDir; - return path.resolve(this.workingDir, buildDir); + return path.resolve(this.#workingDir, buildDir); } /** @@ -369,7 +373,7 @@ export class Config { * @returns {string[]} The list of clean command strings. */ getCleanCommands(): string[] { - return this.config.clean ?? []; + return this.#config.clean ?? []; } /** @@ -380,7 +384,7 @@ export class Config { * @returns {Map} A map of check slug to CheckConfig. */ getChecks(): Map { - return this._checks; + return this.#checks; } /** @@ -391,14 +395,14 @@ export class Config { * @returns {I18nResolvedConfig[]} The list of resolved i18n configuration objects. */ getI18n(): I18nResolvedConfig[] { - if (this._i18n !== null) return this._i18n; + if (this.#i18n !== null) return this.#i18n; - const defaults = this.config.i18n_defaults; - const i18nRaw = this.config.i18n; + const defaults = this.#config.i18n_defaults; + const i18nRaw = this.#config.i18n; if (!i18nRaw || (Array.isArray(i18nRaw) && i18nRaw.length === 0)) { - this._i18n = []; - return this._i18n; + this.#i18n = []; + return this.#i18n; } // Normalize to array @@ -415,11 +419,11 @@ export class Config { ); if (i18nArr.length === 0) { - this._i18n = []; - return this._i18n; + this.#i18n = []; + return this.#i18n; } - this._i18n = i18nArr.map((item) => ({ + this.#i18n = i18nArr.map((item) => ({ path: item.path ?? defaults.path, url: item.url ?? defaults.url, slug: item.slug ?? defaults.slug, @@ -433,18 +437,18 @@ export class Config { }, })); - return this._i18n; + return this.#i18n; } /** - * Returns the list of environment variable names to forward to subprocesses. + * Returns the list of environment variable names from configuration. * * @since TBD * * @returns {string[]} The list of environment variable name strings. */ getEnvVarNames(): string[] { - return this.config.env ?? []; + return this.#config.env ?? []; } /** @@ -457,9 +461,9 @@ export class Config { * @throws {Error} If no repository can be determined. */ getRepo(): string { - if (!this.config.repo) { + if (!this.#config.repo) { // Try to infer from package.json - const pkgPath = path.join(this.workingDir, 'package.json'); + const pkgPath = path.join(this.#workingDir, 'package.json'); if (fs.existsSync(pkgPath)) { const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')) as { repository?: { url?: string } | string; @@ -473,7 +477,7 @@ export class Config { } // Try composer.json fallback - const composerPath = path.join(this.workingDir, 'composer.json'); + const composerPath = path.join(this.#workingDir, 'composer.json'); if (fs.existsSync(composerPath)) { const composer = JSON.parse( fs.readFileSync(composerPath, 'utf-8') @@ -488,7 +492,7 @@ export class Config { ); } - const repo = this.config.repo; + const repo = this.#config.repo; if ( !repo.includes('https://') && @@ -512,7 +516,7 @@ export class Config { */ getSyncFiles(): string[] { const defaults = ['.distfiles', '.distinclude', '.distignore', '.gitattributes']; - const configFiles = this.config.paths?.sync_files; + const configFiles = this.#config.paths?.sync_files; if (!configFiles || !Array.isArray(configFiles) || configFiles.length === 0) { return defaults; @@ -529,7 +533,7 @@ export class Config { * @returns {VersionFile[]} The list of version file objects. */ getVersionFiles(): VersionFile[] { - return this._versionFiles; + return this.#versionFiles; } /** @@ -540,7 +544,7 @@ export class Config { * @returns {WorkflowCollection} The WorkflowCollection instance. */ getWorkflows(): WorkflowCollection { - return this._workflows; + return this.#workflows; } /** @@ -551,7 +555,7 @@ export class Config { * @returns {string} The absolute working directory path with trailing slash. */ getWorkingDir(): string { - return this.workingDir; + return this.#workingDir; } /** @@ -564,9 +568,9 @@ export class Config { * @returns {string} The zip staging directory path. */ getZipDir(fullPath = true): string { - const zipDir = this.config.paths?.zip_dir ?? '.pup-zip'; + const zipDir = this.#config.paths?.zip_dir ?? '.pup-zip'; if (!fullPath) return zipDir; - return path.resolve(this.workingDir, zipDir); + return path.resolve(this.#workingDir, zipDir); } /** @@ -579,12 +583,12 @@ export class Config { * @throws {Error} If no zip name can be determined. */ getZipName(): string { - if (this.config.zip_name) { - return this.config.zip_name; + if (this.#config.zip_name) { + return this.#config.zip_name; } // Try package.json name - const pkgPath = path.join(this.workingDir, 'package.json'); + const pkgPath = path.join(this.#workingDir, 'package.json'); if (fs.existsSync(pkgPath)) { const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')) as { name?: string; @@ -596,7 +600,7 @@ export class Config { } // Try composer.json name - const composerPath = path.join(this.workingDir, 'composer.json'); + const composerPath = path.join(this.#workingDir, 'composer.json'); if (fs.existsSync(composerPath)) { const composer = JSON.parse( fs.readFileSync(composerPath, 'utf-8') @@ -617,7 +621,7 @@ export class Config { * @returns {boolean} True if default ignore patterns should be used. */ getZipUseDefaultIgnore(): boolean { - return this.config.zip_use_default_ignore ?? true; + return this.#config.zip_use_default_ignore ?? true; } /** @@ -628,7 +632,7 @@ export class Config { * @returns {boolean} True if the .puprc file is invalid. */ hasInvalidPuprc(): boolean { - return this._hasInvalidPuprc; + return this.#hasInvalidPuprc; } /** @@ -639,7 +643,7 @@ export class Config { * @returns {string} The error message string, or an empty string if no error. */ getPuprcParseError(): string { - return this._puprcParseError; + return this.#puprcParseError; } /** @@ -650,7 +654,7 @@ export class Config { * @returns {PupConfig} The configuration as a PupConfig object. */ toJSON(): PupConfig { - return this.config; + return this.#config; } } From 658a8fa19e174a2b7714fc9722fcca13c1758d63 Mon Sep 17 00:00:00 2001 From: Eric Defore Date: Fri, 13 Feb 2026 15:02:31 -0500 Subject: [PATCH 16/22] ENG-219: Remove redundant null check in config merge The null case is already handled by the preceding condition at line 126 which checks for both undefined and null, so the duplicate null check in the typeof guard is unreachable. --- src/config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/config.ts b/src/config.ts index 3796149..af5717a 100644 --- a/src/config.ts +++ b/src/config.ts @@ -128,7 +128,7 @@ export class Config { continue; } - if (typeof current !== 'object' || current === null) { + if (typeof current !== 'object') { configRecord[key] = value; continue; } From 391ea515c4e04c0f4cd6e1d5abb6ce24995216a9 Mon Sep 17 00:00:00 2001 From: Eric Defore Date: Fri, 13 Feb 2026 15:02:42 -0500 Subject: [PATCH 17/22] ENG-219: Use path.join for version file paths Version file paths from .puprc should always be relative. path.join preserves relativity while path.resolve would silently accept absolute paths, which could point outside the project directory. --- src/config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/config.ts b/src/config.ts index af5717a..9e65d16 100644 --- a/src/config.ts +++ b/src/config.ts @@ -302,7 +302,7 @@ export class Config { ); } - const filePath = path.resolve(this.#workingDir, vf.file); + const filePath = path.join(this.#workingDir, vf.file); if (!fs.existsSync(filePath)) { throw new Error(`Version file does not exist: ${vf.file}`); } From 3c45c6fe12b9a7e242e1c5f6885ae84829309b83 Mon Sep 17 00:00:00 2001 From: Eric Defore Date: Fri, 13 Feb 2026 15:03:08 -0500 Subject: [PATCH 18/22] ENG-219: Import .puprc-defaults as JSON module Replace filesystem read of .puprc-defaults with a static JSON import so tsdown bundles the defaults inline. This eliminates a runtime file read and ensures defaults are always available regardless of the working directory. getDefaultsDir() is kept for downstream use (docs/, .distignore-defaults). --- src/config.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/config.ts b/src/config.ts index 9e65d16..5edafd2 100644 --- a/src/config.ts +++ b/src/config.ts @@ -12,12 +12,13 @@ import type { I18nResolvedConfig, I18nConfigInput, } from './types.ts'; +import puprcDefaults from '../defaults/.puprc-defaults.json'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); /** - * Locates the defaults directory containing .puprc-defaults and .distignore-defaults. + * Locates the defaults directory containing .distignore-defaults and docs. * * @since TBD * @@ -76,17 +77,14 @@ export class Config { } /** - * Loads the default configuration from .puprc-defaults. + * Returns the default configuration from the bundled .puprc-defaults. * * @since TBD * * @returns {PupConfig} The parsed default configuration object. */ private getDefaultConfig(): PupConfig { - const defaultsPath = path.join(getDefaultsDir(), '.puprc-defaults'); - const contents = fs.readFileSync(defaultsPath, 'utf-8'); - const parsed = JSON.parse(contents) as PupConfig; - return parsed; + return structuredClone(puprcDefaults) as PupConfig; } /** From f7e5f20a4eaa94b93494ab59ff0b6eaa31a9fd01 Mon Sep 17 00:00:00 2001 From: Eric Defore Date: Fri, 13 Feb 2026 15:03:41 -0500 Subject: [PATCH 19/22] ENG-219: Validate .puprc input with Zod schema Use PuprcInputSchema.safeParse() to validate .puprc contents after JSON.parse() but before merging with defaults. This catches type errors early (e.g. "build: Expected array, received number") instead of silently merging invalid values that fail later with confusing errors. The schema uses .passthrough() to preserve custom user keys. --- src/config.ts | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/src/config.ts b/src/config.ts index 5edafd2..9e3e550 100644 --- a/src/config.ts +++ b/src/config.ts @@ -3,6 +3,7 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { trailingSlashIt } from './utils/directory.ts'; import { WorkflowCollection, createWorkflow } from './models/workflow.ts'; +import { PuprcInputSchema } from './schemas.ts'; import type { PupConfig, CheckConfig, @@ -100,22 +101,34 @@ export class Config { } const puprcContents = fs.readFileSync(this.#puprcFilePath, 'utf-8'); - let puprc: Record; + let rawPuprc: unknown; try { - puprc = JSON.parse(puprcContents) as Record; + rawPuprc = JSON.parse(puprcContents); } catch { this.#hasInvalidPuprc = true; this.#puprcParseError = 'Invalid JSON in .puprc'; return; } - if (!puprc || typeof puprc !== 'object') { + if (!rawPuprc || typeof rawPuprc !== 'object') { this.#hasInvalidPuprc = true; this.#puprcParseError = 'Invalid .puprc format'; return; } + const parseResult = PuprcInputSchema.safeParse(rawPuprc); + + if (!parseResult.success) { + const issues = parseResult.error.issues + .map((issue) => ` ${issue.path.join('.')}: ${issue.message}`) + .join('\n'); + this.#hasInvalidPuprc = true; + this.#puprcParseError = `Invalid .puprc configuration:\n${issues}`; + return; + } + + const puprc = parseResult.data as Record; const configRecord = this.#config as unknown as Record; for (const [key, value] of Object.entries(puprc)) { From 4aeabfd26c08d3c2105c74751736b694b9dc0f9a Mon Sep 17 00:00:00 2001 From: Eric Defore Date: Fri, 13 Feb 2026 15:04:43 -0500 Subject: [PATCH 20/22] ENG-219: Throw on invalid .puprc instead of silent fallback Config constructor now throws when .puprc is present but contains invalid JSON or fails Zod validation. This is a departure from the PHP version which silently fell back to defaults, but makes it immediately clear why things aren't working. Remove hasInvalidPuprc() and getPuprcParseError() methods, and the corresponding check in createApp(). --- src/app.ts | 7 +------ src/config.ts | 46 ++++++++++++---------------------------------- 2 files changed, 13 insertions(+), 40 deletions(-) diff --git a/src/app.ts b/src/app.ts index 794d2d6..be8790e 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,6 +1,5 @@ import { Command } from 'commander'; import { Config, getConfig, resetConfig } from './config.ts'; -import * as output from './utils/output.ts'; import { fileURLToPath } from 'node:url'; import path from 'node:path'; import fs from 'fs-extra'; @@ -44,11 +43,7 @@ export const PUP_VERSION = getVersion(); export function createApp(): Command { resetConfig(); - const config = getConfig(); - if (config.hasInvalidPuprc()) { - output.error('There is a .puprc file in this directory, but it could not be parsed.'); - output.error(`JSON Error: ${config.getPuprcParseError()}`); - } + getConfig(); const program = new Command(); program diff --git a/src/config.ts b/src/config.ts index 9e3e550..57e9ace 100644 --- a/src/config.ts +++ b/src/config.ts @@ -51,8 +51,6 @@ export class Config { readonly #workingDir: string; readonly #puprcFilePath: string; readonly #config: PupConfig; - #hasInvalidPuprc = false; - #puprcParseError = ''; #workflows: WorkflowCollection; #checks: Map; #versionFiles: VersionFile[]; @@ -64,6 +62,8 @@ export class Config { * @since TBD * * @param {string} workingDir - The project working directory. Defaults to process.cwd(). + * + * @throws {Error} If the .puprc file is present but contains invalid JSON or fails validation. */ constructor(workingDir?: string) { const cwd = workingDir ?? process.cwd(); @@ -93,7 +93,7 @@ export class Config { * * @since TBD * - * @returns {void} + * @throws {Error} If the .puprc file contains invalid JSON or fails schema validation. */ private mergeConfigWithDefaults(): void { if (!fs.existsSync(this.#puprcFilePath)) { @@ -106,15 +106,15 @@ export class Config { try { rawPuprc = JSON.parse(puprcContents); } catch { - this.#hasInvalidPuprc = true; - this.#puprcParseError = 'Invalid JSON in .puprc'; - return; + throw new Error( + 'There is a .puprc file in this directory, but it could not be parsed. Invalid JSON in .puprc.' + ); } if (!rawPuprc || typeof rawPuprc !== 'object') { - this.#hasInvalidPuprc = true; - this.#puprcParseError = 'Invalid .puprc format'; - return; + throw new Error( + 'There is a .puprc file in this directory, but it could not be parsed. Invalid .puprc format.' + ); } const parseResult = PuprcInputSchema.safeParse(rawPuprc); @@ -123,9 +123,9 @@ export class Config { const issues = parseResult.error.issues .map((issue) => ` ${issue.path.join('.')}: ${issue.message}`) .join('\n'); - this.#hasInvalidPuprc = true; - this.#puprcParseError = `Invalid .puprc configuration:\n${issues}`; - return; + throw new Error( + `There is a .puprc file in this directory, but it contains invalid configuration:\n${issues}` + ); } const puprc = parseResult.data as Record; @@ -635,28 +635,6 @@ export class Config { return this.#config.zip_use_default_ignore ?? true; } - /** - * Returns whether the .puprc file failed to parse. - * - * @since TBD - * - * @returns {boolean} True if the .puprc file is invalid. - */ - hasInvalidPuprc(): boolean { - return this.#hasInvalidPuprc; - } - - /** - * Returns the parse error message if .puprc is invalid. - * - * @since TBD - * - * @returns {string} The error message string, or an empty string if no error. - */ - getPuprcParseError(): string { - return this.#puprcParseError; - } - /** * Serializes the configuration to a plain object. * From ea8ee0ec37e2f00d84532f687f3c916f1dfd7613 Mon Sep 17 00:00:00 2001 From: Eric Defore Date: Fri, 13 Feb 2026 15:05:19 -0500 Subject: [PATCH 21/22] ENG-219: Consolidate check defaults with Zod schema Replace manual per-field defaults in parseCheckConfig() with CheckConfigSchema.parse(), which applies .default() values defined in the schema. This eliminates the duplication where check defaults were applied in both the merge step and the parsing step. --- src/config.ts | 29 +++++++---------------------- 1 file changed, 7 insertions(+), 22 deletions(-) diff --git a/src/config.ts b/src/config.ts index 57e9ace..0ddaa78 100644 --- a/src/config.ts +++ b/src/config.ts @@ -3,11 +3,10 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { trailingSlashIt } from './utils/directory.ts'; import { WorkflowCollection, createWorkflow } from './models/workflow.ts'; -import { PuprcInputSchema } from './schemas.ts'; +import { PuprcInputSchema, CheckConfigSchema } from './schemas.ts'; import type { PupConfig, CheckConfig, - CheckConfigInput, VersionFile, VersionFileInput, I18nResolvedConfig, @@ -255,6 +254,7 @@ export class Config { /** * Parses the checks section of the configuration into CheckConfig objects. + * Uses Zod schema defaults for per-field values. * * @since TBD * @@ -266,27 +266,12 @@ export class Config { if (!checks) return result; for (const [slug, checkInput] of Object.entries(checks)) { - const input = ( - typeof checkInput === 'object' && checkInput !== null - ? checkInput - : {} - ) as CheckConfigInput; - - const config: CheckConfig = { - slug, - fail_method: input.fail_method ?? 'error', - fail_method_dev: input.fail_method_dev ?? 'warn', - type: input.type ?? 'pup', - file: input.file, - command: input.command, - configure: input.configure, - args: input.args ?? {}, - dirs: input.dirs, - skip_directories: input.skip_directories, - skip_files: input.skip_files, - }; + const input = typeof checkInput === 'object' && checkInput !== null + ? checkInput + : {}; - result.set(slug, config); + const parsed = CheckConfigSchema.parse({ slug, ...input }); + result.set(slug, parsed); } return result; From a497018e5a51ef9720ff7da6fffffbab49e86335 Mon Sep 17 00:00:00 2001 From: Eric Defore Date: Fri, 13 Feb 2026 16:09:15 -0500 Subject: [PATCH 22/22] ENG-219: Import version directly from package.json Replace the getVersion() function that searched for package.json at runtime with a direct JSON import. tsconfig already has resolveJsonModule enabled, so the bundler inlines the value at build time. --- src/app.ts | 33 ++------------------------------- 1 file changed, 2 insertions(+), 31 deletions(-) diff --git a/src/app.ts b/src/app.ts index be8790e..84fc6f4 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,37 +1,8 @@ import { Command } from 'commander'; import { Config, getConfig, resetConfig } from './config.ts'; -import { fileURLToPath } from 'node:url'; -import path from 'node:path'; -import fs from 'fs-extra'; +import { version } from '../package.json'; -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -/** - * Reads the pup version from the nearest package.json. - * - * @since TBD - * - * @returns {string} The version string from package.json, or '2.0.0' as a fallback. - */ -function getVersion(): string { - // Try to read from package.json - const candidates = [ - path.resolve(__dirname, '..', 'package.json'), - path.resolve(__dirname, '..', '..', 'package.json'), - ]; - - for (const candidate of candidates) { - if (fs.existsSync(candidate)) { - const pkg = JSON.parse(fs.readFileSync(candidate, 'utf-8')) as { version: string }; - return pkg.version; - } - } - - return '2.0.0'; -} - -export const PUP_VERSION = getVersion(); +export const PUP_VERSION = version; /** * Creates and configures the Commander program instance.