diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index cb31601..28e55f8 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -6,6 +6,18 @@ jobs: steps: - uses: actions/checkout@v6 - uses: oven-sh/setup-bun@v2 + - 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: ${{ steps.bun-cache.outputs.dir }} + key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }} + restore-keys: | + ${{ runner.os }}-bun- - run: bun install --frozen-lockfile - run: bun run lint - name: Typecheck 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 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index fc31a03..a715c7f 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -6,5 +6,18 @@ jobs: steps: - uses: actions/checkout@v6 - uses: oven-sh/setup-bun@v2 + - 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: ${{ steps.bun-cache.outputs.dir }} + key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }} + restore-keys: | + ${{ runner.os }}-bun- - run: bun install --frozen-lockfile + - run: bun run build - run: bun run test 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/.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.json similarity index 100% rename from .puprc-defaults rename to defaults/.puprc-defaults.json 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/app.ts b/src/app.ts new file mode 100644 index 0000000..84fc6f4 --- /dev/null +++ b/src/app.ts @@ -0,0 +1,28 @@ +import { Command } from 'commander'; +import { Config, getConfig, resetConfig } from './config.ts'; +import { version } from '../package.json'; + +export const PUP_VERSION = version; + +/** + * Creates and configures the Commander program instance. + * + * @since TBD + * + * @returns {Command} The configured Commander program. + */ +export function createApp(): Command { + resetConfig(); + + getConfig(); + + 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..369530d --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,8 @@ +import { createApp } from './app.ts'; + +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..0ddaa78 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,662 @@ +import fs from 'fs-extra'; +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, CheckConfigSchema } from './schemas.ts'; +import type { + PupConfig, + CheckConfig, + VersionFile, + VersionFileInput, + 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 .distignore-defaults and docs. + * + * @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 { + readonly #workingDir: string; + readonly #puprcFilePath: string; + readonly #config: PupConfig; + #workflows: WorkflowCollection; + #checks: Map; + #versionFiles: VersionFile[]; + #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(). + * + * @throws {Error} If the .puprc file is present but contains invalid JSON or fails validation. + */ + 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.mergeConfigWithDefaults(); + this.#workflows = this.buildWorkflows(); + this.#checks = this.parseCheckConfig(); + this.#versionFiles = this.parseVersionFiles(); + } + + /** + * Returns the default configuration from the bundled .puprc-defaults. + * + * @since TBD + * + * @returns {PupConfig} The parsed default configuration object. + */ + private getDefaultConfig(): PupConfig { + return structuredClone(puprcDefaults) as PupConfig; + } + + /** + * Merges the project's .puprc file into the default configuration. + * + * @since TBD + * + * @throws {Error} If the .puprc file contains invalid JSON or fails schema validation. + */ + private mergeConfigWithDefaults(): void { + if (!fs.existsSync(this.#puprcFilePath)) { + return; + } + + const puprcContents = fs.readFileSync(this.#puprcFilePath, 'utf-8'); + let rawPuprc: unknown; + + try { + rawPuprc = JSON.parse(puprcContents); + } catch { + 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') { + 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); + + if (!parseResult.success) { + const issues = parseResult.error.issues + .map((issue) => ` ${issue.path.join('.')}: ${issue.message}`) + .join('\n'); + throw new Error( + `There is a .puprc file in this directory, but it contains invalid configuration:\n${issues}` + ); + } + + const puprc = parseResult.data as Record; + 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') { + 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 {WorkflowCollection} The built workflow collection. + */ + private buildWorkflows(): WorkflowCollection { + 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 : []) + ); + } + } + + return collection; + } + + /** + * Parses the checks section of the configuration into CheckConfig objects. + * Uses Zod schema defaults for per-field values. + * + * @since TBD + * + * @returns {Map} A map of check slug to CheckConfig. + */ + 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 = typeof checkInput === 'object' && checkInput !== null + ? checkInput + : {}; + + const parsed = CheckConfigSchema.parse({ slug, ...input }); + result.set(slug, parsed); + } + + return result; + } + + /** + * Parses and validates the version files section of the configuration. + * + * @since TBD + * + * @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(): 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) { + throw new Error( + 'Versions specified in .puprc .paths.versions must have the "file" and "regex" property.' + ); + } + + const filePath = path.join(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}/"` + ); + } + + result.push({ file: vf.file, regex: vf.regex }); + } + + return result; + } + + /** + * 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; + const 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 from configuration. + * + * @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; + } + + /** + * 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..42ebb6c --- /dev/null +++ b/src/models/workflow.ts @@ -0,0 +1,96 @@ +import type { Workflow } from '../types.ts'; + +/** + * 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/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 new file mode 100644 index 0000000..fd37b44 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,35 @@ +/** + * 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'; diff --git a/src/utils/directory.ts b/src/utils/directory.ts new file mode 100644 index 0000000..bf73bb6 --- /dev/null +++ b/src/utils/directory.ts @@ -0,0 +1,100 @@ +import path from 'node:path'; +import fs from 'fs-extra'; + +/** + * Checks whether a child directory is inside a parent directory. + * + * @since TBD + * + * @param {string} parentDir - The parent directory path. + * @param {string} childDir - The child directory path. + * + * @returns {boolean} True if childDir is inside parentDir. + */ +export function isInside(parentDir: string, childDir: string): boolean { + const relative = path.relative(parentDir, childDir); + return !!relative && !relative.startsWith('..') && !path.isAbsolute(relative); +} + +/** + * Ensures a directory path ends with a trailing separator. + * + * @since TBD + * + * @param {string} p - The path to ensure has a trailing separator. + * + * @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 { + const { dir, base, ext } = path.parse(p); + + if (ext.length > 0) { + throw new Error('Could not add trailing slash to file path.'); + } + + return path.join(dir, base, path.sep); +} + +/** + * 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 relative = path.relative(workingDir, dir); + const inside = relative && !relative.startsWith('..') && !path.isAbsolute(relative); + + // Safety check: only remove directories within the working directory + if (!inside) { + 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. + * 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 { + if (path.isAbsolute(relativePath)) { + if (!defaultPath) { + throw new Error('Absolute paths are not allowed in the .puprc file.'); + } + + relativePath = defaultPath; + } + + if (isInside(workingDir, relativePath)) { + return relativePath; + } + + return path.join(workingDir, relativePath); +} 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..e0d0cee --- /dev/null +++ b/src/utils/process.ts @@ -0,0 +1,79 @@ +import { execa } from 'execa'; +import type { RunCommandResult } from '../types.ts'; + +export interface RunOptions { + cwd?: 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; + } + + try { + const result = await execa(cmd, { + cwd: options.cwd, + 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..a10a5c6 --- /dev/null +++ b/tests/commands/invalid-puprc.test.ts @@ -0,0 +1,28 @@ +import { + runPup, + 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(() => { + cleanupTempProjects(); + }); + + it('should handle invalid JSON in .puprc', async () => { + const puprcPath = path.join(projectDir, '.puprc'); + fs.writeFileSync(puprcPath, '{invalid json}'); + + const result = await runPup('info', { cwd: projectDir }); + // Should still run but report invalid config + expect(result.output).toContain('could not be parsed'); + }); +}); 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), + }); + }); + }); +} + +/** + * 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) { + 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)); + } + } + } +} 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') + ); + }); +}); 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/**/*"],