From a52fd846b5960c2f0b2236a9ff19ea6f2b6627b6 Mon Sep 17 00:00:00 2001 From: Eric Defore Date: Wed, 11 Feb 2026 14:29:42 -0500 Subject: [PATCH 1/3] ENG-221: Add parallel build step support via sub-arrays in .puprc Build and build_dev arrays in .puprc now support sub-arrays to run commands concurrently. Sequential steps still run one at a time, while commands within a sub-array execute in parallel via Promise.all. Co-Authored-By: Claude Opus 4.6 --- docs/commands.md | 23 ++++++++++ docs/configuration.md | 4 +- src/commands/build.ts | 81 +++++++++++++++++++++++++++--------- src/config.ts | 6 ++- src/models/workflow.ts | 6 +-- src/types.ts | 8 ++-- tests/commands/build.test.ts | 66 +++++++++++++++++++++++++++++ 7 files changed, 165 insertions(+), 29 deletions(-) diff --git a/docs/commands.md b/docs/commands.md index ec94b9b..ddc90c9 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -53,6 +53,29 @@ however, prepend your commands with `@` and that will tell `pup` to ignore failu In the above example, `npm ci` and `npm run build` will need to complete successfully for the build to succeed, but the `composer run some-script` is prepended by `@` so if it fails, the build will continue forward. +### Parallel build steps +You can run build steps in parallel by wrapping them in a sub-array. Commands within a sub-array run concurrently, and +all commands in the group must complete before the next step begins. Here's an example: + +```json +{ + "build": [ + "npm ci", + ["npm run build:css", "npm run build:js"], + "npm run postbuild" + ] +} +``` + +In the above example: + +1. `npm ci` runs first and must complete before anything else. +2. `npm run build:css` and `npm run build:js` run at the same time. Both must finish before continuing. +3. `npm run postbuild` runs last, after the parallel group has completed. + +The `@` soft-fail prefix works within parallel groups as well. If a non-soft-fail command in a parallel group fails, `pup` +will wait for all commands in the group to finish before exiting with the failure code. + ## `pup check` Runs all registered check commands. diff --git a/docs/configuration.md b/docs/configuration.md index 4f57d0e..90a3025 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -7,8 +7,8 @@ root of the project. This file is a JSON file that contains the configuration op | Property | Type | Description | |-------------|-----------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `build` | `array` | An array of CLI commands to execute for the build process of your project. | -| `build_dev` | `array` | An array of CLI commands to execute for the `--dev` build process of your project. If empty, it defaults to the value of `build` | +| `build` | `array` | An array of CLI commands to execute for the build process of your project. Supports sub-arrays for [parallel execution](/docs/commands.md#parallel-build-steps). | +| `build_dev` | `array` | An array of CLI commands to execute for the `--dev` build process of your project. If empty, it defaults to the value of `build`. Supports sub-arrays for [parallel execution](/docs/commands.md#parallel-build-steps). | | `checks` | `object` | An object of check configurations indexed by the check's slug. See the [docs for checks](/docs/checks.md) for more info. | | `env` | `array` | An array of environment variable names that, if set, should be passed to the build and workflow commands. | | `paths` | `object` | An object containing paths used by `pup`. [See below](#paths). | diff --git a/src/commands/build.ts b/src/commands/build.ts index abd2807..906358b 100644 --- a/src/commands/build.ts +++ b/src/commands/build.ts @@ -1,8 +1,40 @@ import type { Command } from 'commander'; import { getConfig } from '../config.js'; import { runCommand } from '../utils/process.js'; +import type { BuildStep, RunCommandResult } from '../types.js'; import * as output from '../utils/output.js'; +/** + * Runs a single build command, handling the `@` soft-fail prefix. + * + * @since TBD + * + * @param {string} step - The command string to execute. + * @param {string} cwd - The working directory for the command. + * @param {string[]} envVarNames - Environment variable names to forward. + * + * @returns {Promise<{ cmd: string; bailOnFailure: boolean; result: RunCommandResult }>} The command, bail flag, and result. + */ +async function runBuildStep( + step: string, + cwd: string, + envVarNames: string[] +): Promise<{ cmd: string; bailOnFailure: boolean; result: RunCommandResult }> { + let cmd = step; + let bailOnFailure = true; + + if (cmd.startsWith('@')) { + bailOnFailure = false; + cmd = cmd.slice(1); + } + + output.section(`> ${cmd}`); + + const result = await runCommand(cmd, { cwd, envVarNames }); + + return { cmd, bailOnFailure, result }; +} + /** * Registers the `build` command with the CLI program. * @@ -20,32 +52,43 @@ export function registerBuildCommand(program: Command): void { .option('--root ', 'Set the root directory for running commands.') .action(async (options: { dev?: boolean; root?: string }) => { const config = getConfig(options.root); - const buildSteps = config.getBuildCommands(options.dev); + const buildSteps: BuildStep[] = config.getBuildCommands(options.dev); const cwd = options.root ?? config.getWorkingDir(); + const envVarNames = config.getEnvVarNames(); output.log('Running build steps...'); for (const step of buildSteps) { - let cmd = step; - let bailOnFailure = true; + if (Array.isArray(step)) { + // Parallel group: run all commands concurrently + const results = await Promise.all( + step.map((cmd) => runBuildStep(cmd, cwd, envVarNames)) + ); - if (cmd.startsWith('@')) { - bailOnFailure = false; - cmd = cmd.slice(1); - } - - output.section(`> ${cmd}`); - - const result = await runCommand(cmd, { - cwd, - envVarNames: config.getEnvVarNames(), - }); + // Check for failures after all parallel commands complete + for (const { cmd, bailOnFailure, result } of results) { + if (result.exitCode !== 0) { + output.error(`[FAIL] Build step failed: ${cmd}`); + if (bailOnFailure) { + output.error('Exiting...'); + process.exit(result.exitCode); + } + } + } + } else { + // Sequential: run single command + const { cmd, bailOnFailure, result } = await runBuildStep( + step, + cwd, + envVarNames + ); - if (result.exitCode !== 0) { - output.error(`[FAIL] Build step failed: ${cmd}`); - if (bailOnFailure) { - output.error('Exiting...'); - process.exit(result.exitCode); + if (result.exitCode !== 0) { + output.error(`[FAIL] Build step failed: ${cmd}`); + if (bailOnFailure) { + output.error('Exiting...'); + process.exit(result.exitCode); + } } } } diff --git a/src/config.ts b/src/config.ts index efffbb7..a48d038 100644 --- a/src/config.ts +++ b/src/config.ts @@ -5,6 +5,7 @@ import { normalizeDir, trailingSlashIt } from './utils/directory.js'; import { WorkflowCollection, createWorkflow } from './models/workflow.js'; import type { PupConfig, + BuildStep, CheckConfig, CheckConfigInput, VersionFile, @@ -217,6 +218,7 @@ export class Config { const rawWorkflows = this.config.workflows as unknown; // Auto-create build workflow + // TODO: Add parallel build step support to workflow execution. if ( this.config.build?.length > 0 && !(rawWorkflows as Record)?.['build'] @@ -337,9 +339,9 @@ export class Config { * * @param {boolean} isDev - Whether to return dev build commands. * - * @returns {string[]} The list of build command strings. + * @returns {BuildStep[]} The list of build steps (strings run sequentially, sub-arrays run in parallel). */ - getBuildCommands(isDev = false): string[] { + getBuildCommands(isDev = false): BuildStep[] { if (isDev && this.config.build_dev?.length > 0) { return this.config.build_dev; } diff --git a/src/models/workflow.ts b/src/models/workflow.ts index 29567b4..92d8a85 100644 --- a/src/models/workflow.ts +++ b/src/models/workflow.ts @@ -1,4 +1,4 @@ -import type { Workflow } from '../types.js'; +import type { BuildStep, Workflow } from '../types.js'; /** * Creates a Workflow object from a slug and list of commands. @@ -6,11 +6,11 @@ import type { Workflow } from '../types.js'; * @since TBD * * @param {string} slug - The unique identifier for the workflow. - * @param {string[]} commands - The list of commands to execute in the workflow. + * @param {BuildStep[]} commands - The list of build steps to execute in the workflow. * * @returns {Workflow} A Workflow object with the provided slug and commands. */ -export function createWorkflow(slug: string, commands: string[]): Workflow { +export function createWorkflow(slug: string, commands: BuildStep[]): Workflow { return { slug, commands }; } diff --git a/src/types.ts b/src/types.ts index 863dea2..fce8c75 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,6 +1,8 @@ +export type BuildStep = string | string[]; + export interface PupConfig { - build: string[]; - build_dev: string[]; + build: BuildStep[]; + build_dev: BuildStep[]; workflows: Record; checks: Record; clean: string[]; @@ -104,7 +106,7 @@ export interface I18nResolvedConfig { export interface Workflow { slug: string; - commands: string[]; + commands: BuildStep[]; } export interface RunCommandResult { diff --git a/tests/commands/build.test.ts b/tests/commands/build.test.ts index a8385dd..5c73c34 100644 --- a/tests/commands/build.test.ts +++ b/tests/commands/build.test.ts @@ -62,4 +62,70 @@ describe('build command', () => { expect(result.exitCode).toBe(0); expect(result.output).toContain('dev build'); }); + + it('should run parallel build steps', async () => { + const puprc = getPuprc(); + puprc.build = [['echo "parallel-a"', 'echo "parallel-b"']]; + writePuprc(puprc, projectDir); + + const result = await runPup('build', { cwd: projectDir }); + expect(result.exitCode).toBe(0); + expect(result.output).toContain('parallel-a'); + expect(result.output).toContain('parallel-b'); + }); + + it('should run mixed sequential and parallel steps in order', async () => { + const puprc = getPuprc(); + puprc.build = [ + 'echo "step-1-sequential"', + ['echo "step-2-parallel-a"', 'echo "step-2-parallel-b"'], + 'echo "step-3-sequential"', + ]; + writePuprc(puprc, projectDir); + + const result = await runPup('build', { cwd: projectDir }); + expect(result.exitCode).toBe(0); + expect(result.output).toContain('step-1-sequential'); + expect(result.output).toContain('step-2-parallel-a'); + expect(result.output).toContain('step-2-parallel-b'); + expect(result.output).toContain('step-3-sequential'); + + // Verify sequential ordering: step-1 before parallel group, parallel group before step-3 + const idx1 = result.output.indexOf('step-1-sequential'); + const idx2a = result.output.indexOf('step-2-parallel-a'); + const idx2b = result.output.indexOf('step-2-parallel-b'); + const idx3 = result.output.indexOf('step-3-sequential'); + expect(idx1).toBeLessThan(idx2a); + expect(idx1).toBeLessThan(idx2b); + expect(idx2a).toBeLessThan(idx3); + expect(idx2b).toBeLessThan(idx3); + }); + + it('should handle soft-fail in parallel group', async () => { + const puprc = getPuprc(); + puprc.build = [ + ['@exit 1', 'echo "parallel-ok"'], + 'echo "after-parallel"', + ]; + writePuprc(puprc, projectDir); + + const result = await runPup('build', { cwd: projectDir }); + expect(result.exitCode).toBe(0); + expect(result.output).toContain('parallel-ok'); + expect(result.output).toContain('after-parallel'); + }); + + it('should fail on non-soft-fail failure in parallel group', async () => { + const puprc = getPuprc(); + puprc.build = [ + ['exit 1', 'echo "parallel-ok"'], + 'echo "should-not-run"', + ]; + writePuprc(puprc, projectDir); + + const result = await runPup('build', { cwd: projectDir }); + expect(result.exitCode).not.toBe(0); + expect(result.output).toContain('parallel-ok'); + expect(result.output).not.toContain('should-not-run'); + }); }); From 427036953f8853aa7f600efa82db12cb1c8efaae Mon Sep 17 00:00:00 2001 From: Eric Defore Date: Fri, 13 Feb 2026 15:50:07 -0500 Subject: [PATCH 2/3] ENG-221: Remove dist/ from version control --- dist/cli.js | 1013 ----------------------------------------------- dist/cli.js.map | 1 - 2 files changed, 1014 deletions(-) delete mode 100755 dist/cli.js delete mode 100644 dist/cli.js.map diff --git a/dist/cli.js b/dist/cli.js deleted file mode 100755 index 3c95740..0000000 --- a/dist/cli.js +++ /dev/null @@ -1,1013 +0,0 @@ -#!/usr/bin/env node -import { Command } from "commander"; -import fs from "fs-extra"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; -import { z } from "zod"; -import { execa } from "execa"; -import chalk from "chalk"; - -//#region src/utils/directory.ts -/** -* 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). -*/ -function trailingSlashIt(p) { - 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); -} - -//#endregion -//#region src/models/workflow.ts -/** -* Creates a Workflow object from a slug and list of commands. -* -* @since TBD -* -* @param {string} slug - The unique identifier for the workflow. -* @param {BuildStep[]} commands - The list of build steps to execute in the workflow. -* -* @returns {Workflow} A Workflow object with the provided slug and commands. -*/ -function createWorkflow(slug, commands) { - return { - slug, - commands - }; -} -/** -* Manages a collection of named workflows. -* -* @since TBD -*/ -var WorkflowCollection = class { - workflows = /* @__PURE__ */ new Map(); - /** - * Adds a workflow to the collection. - * - * @since TBD - * - * @param {Workflow} workflow - The workflow to add. - * - * @returns {void} - */ - add(workflow) { - 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) { - 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) { - return this.workflows.has(slug); - } - /** - * Returns all workflows as an array. - * - * @since TBD - * - * @returns {Workflow[]} An array containing all workflows in the collection. - */ - getAll() { - return Array.from(this.workflows.values()); - } - /** - * Returns the number of workflows in the collection. - * - * @since TBD - * - * @returns {number} The count of workflows. - */ - get size() { - return this.workflows.size; - } - /** - * Allows iterating over all workflows in the collection. - * - * @since TBD - * - * @returns {Iterator} An iterator over the workflows. - */ - [Symbol.iterator]() { - return this.workflows.values(); - } -}; - -//#endregion -//#region src/schemas.ts -/** -* A build step is either a single command string (run sequentially) or an -* array of command strings (run in parallel). -* -* @since TBD -*/ -const BuildStepSchema = z.union([z.string(), z.array(z.string())]); -/** -* Schema for a version file entry in .puprc paths.versions. -* -* @since TBD -*/ -const VersionFileInputSchema = z.object({ - file: z.string(), - regex: z.string() -}); -/** -* Parsed version file (same shape as input). -* -* @since TBD -*/ -const VersionFileSchema = z.object({ - file: z.string(), - regex: z.string() -}); -/** -* 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 -*/ -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(); -/** -* Schema for the i18n defaults section of configuration. -* -* @since TBD -*/ -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 -}); -/** -* Schema for a fully resolved i18n configuration entry (all fields required). -* -* @since TBD -*/ -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 -}); -/** -* Schema for a check configuration entry from .puprc (optional fields with defaults). -* -* @since TBD -*/ -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(); -/** -* Schema for a fully resolved check configuration with defaults applied. -* -* @since TBD -*/ -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() -}); -/** -* Schema for the paths section of configuration. -* -* @since TBD -*/ -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() -}); -/** -* Schema for the full merged pup configuration (after defaults are applied). -* -* @since TBD -*/ -const PupConfigSchema = z.object({ - build: z.array(BuildStepSchema), - build_dev: z.array(BuildStepSchema), - 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(); -/** -* Schema for validating raw .puprc input (all fields optional + passthrough for custom keys). -* -* @since TBD -*/ -const PuprcInputSchema = z.object({ - build: z.array(BuildStepSchema).optional(), - build_dev: z.array(BuildStepSchema).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(); -/** -* Schema for a workflow. -* -* @since TBD -*/ -const WorkflowSchema = z.object({ - slug: z.string(), - commands: z.array(BuildStepSchema) -}); - -//#endregion -//#region defaults/.puprc-defaults.json -var _puprc_defaults_default = { - build: [], - build_dev: [], - workflows: {}, - checks: { - "tbd": { - "fail_method": "error", - "fail_method_dev": "warn", - "skip_directories": "bin|build|vendor|node_modules|.git|.github|tests", - "skip_files": ".min.css|.min.js|.map.js|.css|.png|.jpg|.jpeg|.svg|.gif|.ico", - "dirs": ["src"] - }, - "version-conflict": { - "fail_method": "error", - "fail_method_dev": "warn" - } - }, - clean: [], - i18n: [], - i18n_defaults: { - "path": "lang", - "url": "", - "slug": "", - "textdomain": "", - "file_format": "%textdomain%-%wp_locale%.%format%", - "formats": ["po", "mo"], - "filter": { "minimum_percentage": 30 } - }, - paths: { - "build_dir": ".pup-build", - "changelog": null, - "css": [], - "js": [], - "sync_files": [], - "versions": [], - "views": [], - "zip_dir": ".pup-zip" - }, - env: ["NODE_AUTH_TOKEN"], - repo: null, - zip_use_default_ignore: true, - zip_name: null -}; - -//#endregion -//#region src/config.ts -const __filename$1 = fileURLToPath(import.meta.url); -const __dirname$1 = path.dirname(__filename$1); -/** -* Loads, merges, and provides access to the project's pup configuration. -* -* @since TBD -*/ -var Config = class { - #workingDir; - #puprcFilePath; - #config; - #workflows; - #checks; - #versionFiles; - #i18n = 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) { - 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. - */ - getDefaultConfig() { - return structuredClone(_puprc_defaults_default); - } - /** - * 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. - */ - mergeConfigWithDefaults() { - if (!fs.existsSync(this.#puprcFilePath)) return; - const puprcContents = fs.readFileSync(this.#puprcFilePath, "utf-8"); - let rawPuprc; - 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; - const configRecord = this.#config; - for (const [key, value] of Object.entries(puprc)) { - const current = configRecord[key]; - if (current === void 0 || current === null) { - configRecord[key] = value; - continue; - } - if (typeof current !== "object") { - configRecord[key] = value; - continue; - } - if (key === "checks" && typeof value === "object" && value !== null) { - const defaultChecks = current; - const newChecks = value; - configRecord[key] = newChecks; - for (const [checkSlug, checkConfig] of Object.entries(newChecks)) if (defaultChecks[checkSlug] !== void 0) configRecord[key][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. - */ - mergeConfigValue(original, newVal) { - if (typeof newVal !== "object" || newVal === null) return newVal; - if (typeof original !== "object" || original === null) return newVal; - if (Array.isArray(original)) return newVal; - if (Array.isArray(newVal)) return newVal; - const orig = original; - const nv = newVal; - const result = { ...orig }; - for (const [key, item] of Object.entries(orig)) { - if (nv[key] === void 0) 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] === void 0) result[key] = item; - return result; - } - /** - * Builds the workflow collection from configuration, including auto-generated build workflows. - * - * @since TBD - * - * @returns {WorkflowCollection} The built workflow collection. - */ - buildWorkflows() { - const collection = new WorkflowCollection(); - const rawWorkflows = this.#config.workflows; - if (this.#config.build?.length > 0 && !rawWorkflows?.["build"]) collection.add(createWorkflow("build", this.#config.build)); - if (this.#config.build_dev?.length > 0 && !rawWorkflows?.["build_dev"]) collection.add(createWorkflow("build_dev", this.#config.build_dev)); - if (rawWorkflows && typeof rawWorkflows === "object") for (const [slug, commands] of Object.entries(rawWorkflows)) 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. - */ - parseCheckConfig() { - const checks = this.#config.checks; - const result = /* @__PURE__ */ 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. - */ - parseVersionFiles() { - const versions = this.#config.paths?.versions; - const result = []; - if (!versions || !Array.isArray(versions)) return result; - for (const vf of versions) { - 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() { - 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 {BuildStep[]} The list of build steps (strings run sequentially, sub-arrays run in parallel). - */ - getBuildCommands(isDev = false) { - 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) { - 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() { - return this.#config.clean ?? []; - } - /** - * Returns the map of parsed check configurations. - * - * @since TBD - * - * @returns {Map} A map of check slug to CheckConfig. - */ - getChecks() { - return this.#checks; - } - /** - * Returns resolved i18n configurations, merging with defaults. - * - * @since TBD - * - * @returns {I18nResolvedConfig[]} The list of resolved i18n configuration objects. - */ - getI18n() { - 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; - } - let i18nArr; - if (!Array.isArray(i18nRaw)) i18nArr = [i18nRaw]; - else i18nArr = i18nRaw; - 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() { - 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() { - if (!this.#config.repo) { - const pkgPath = path.join(this.#workingDir, "package.json"); - if (fs.existsSync(pkgPath)) { - const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8")); - if (typeof pkg.repository === "string") return `git@github.com:${pkg.repository}.git`; - if (pkg.repository?.url) return pkg.repository.url; - } - const composerPath = path.join(this.#workingDir, "composer.json"); - if (fs.existsSync(composerPath)) { - const composer = JSON.parse(fs.readFileSync(composerPath, "utf-8")); - 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() { - 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() { - return this.#versionFiles; - } - /** - * Returns the workflow collection. - * - * @since TBD - * - * @returns {WorkflowCollection} The WorkflowCollection instance. - */ - getWorkflows() { - return this.#workflows; - } - /** - * Returns the working directory path. - * - * @since TBD - * - * @returns {string} The absolute working directory path with trailing slash. - */ - getWorkingDir() { - 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) { - 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() { - if (this.#config.zip_name) return this.#config.zip_name; - const pkgPath = path.join(this.#workingDir, "package.json"); - if (fs.existsSync(pkgPath)) { - const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8")); - if (pkg.name) return pkg.name.replace(/^@[^/]+\//, ""); - } - const composerPath = path.join(this.#workingDir, "composer.json"); - if (fs.existsSync(composerPath)) { - const composer = JSON.parse(fs.readFileSync(composerPath, "utf-8")); - 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() { - 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() { - return this.#config; - } -}; -let globalConfig = 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. -*/ -function getConfig(workingDir) { - if (!globalConfig) globalConfig = new Config(workingDir); - return globalConfig; -} -/** -* Resets the singleton Config instance, forcing a fresh load on next access. -* -* @since TBD -* -* @returns {void} -*/ -function resetConfig() { - globalConfig = null; -} - -//#endregion -//#region src/app.ts -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() { - const candidates = [path.resolve(__dirname, "..", "package.json"), path.resolve(__dirname, "..", "..", "package.json")]; - for (const candidate of candidates) if (fs.existsSync(candidate)) return JSON.parse(fs.readFileSync(candidate, "utf-8")).version; - return "2.0.0"; -} -const PUP_VERSION = getVersion(); -/** -* Creates and configures the Commander program instance. -* -* @since TBD -* -* @returns {Command} The configured Commander program. -*/ -function createApp() { - resetConfig(); - getConfig(); - const program = new Command(); - program.name("pup").version(PUP_VERSION).description("StellarWP's Project Utilities & Packager"); - return program; -} - -//#endregion -//#region src/utils/process.ts -/** -* 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. -*/ -async function runCommand(command, options = {}) { - let cmd = command; - let softFail = options.softFail ?? false; - 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) { - if (softFail) return { - stdout: "", - stderr: String(err), - exitCode: 0 - }; - throw err; - } -} - -//#endregion -//#region src/utils/output.ts -let 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) { - 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} -*/ -function success(message) { - 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} -*/ -function error(message) { - console.error(formatMessage(chalk.red(message))); -} -/** -* Prints a bold yellow section header. -* -* @since TBD -* -* @param {string} message - The section header text to display. -* -* @returns {void} -*/ -function section(message) { - 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} -*/ -function log(message) { - console.log(formatMessage(message)); -} - -//#endregion -//#region src/commands/build.ts -/** -* Runs a single build command, handling the `@` soft-fail prefix. -* -* @since TBD -* -* @param {string} step - The command string to execute. -* @param {string} cwd - The working directory for the command. -* -* @returns {Promise<{ cmd: string; bailOnFailure: boolean; result: RunCommandResult }>} The command, bail flag, and result. -*/ -async function runBuildStep(step, cwd) { - let cmd = step; - let bailOnFailure = true; - if (cmd.startsWith("@")) { - bailOnFailure = false; - cmd = cmd.slice(1); - } - section(`> ${cmd}`); - const result = await runCommand(cmd, { cwd }); - return { - cmd, - bailOnFailure, - result - }; -} -/** -* Registers the `build` command with the CLI program. -* -* @since TBD -* -* @param {Command} program - The Commander.js program instance. -* -* @returns {void} -*/ -function registerBuildCommand(program) { - program.command("build").description("Run the build commands.").option("--dev", "Run the dev build commands.").option("--root ", "Set the root directory for running commands.").action(async (options) => { - const config = getConfig(options.root); - const buildSteps = config.getBuildCommands(options.dev); - const cwd = options.root ?? config.getWorkingDir(); - log("Running build steps..."); - for (const step of buildSteps) if (Array.isArray(step)) { - const results = await Promise.all(step.map((cmd) => runBuildStep(cmd, cwd))); - for (const { cmd, bailOnFailure, result } of results) if (result.exitCode !== 0) { - error(`[FAIL] Build step failed: ${cmd}`); - if (bailOnFailure) { - error("Exiting..."); - process.exit(result.exitCode); - } - } - } else { - const { cmd, bailOnFailure, result } = await runBuildStep(step, cwd); - if (result.exitCode !== 0) { - error(`[FAIL] Build step failed: ${cmd}`); - if (bailOnFailure) { - error("Exiting..."); - process.exit(result.exitCode); - } - } - } - success("Build complete."); - }); -} - -//#endregion -//#region src/cli.ts -const program = createApp(); -registerBuildCommand(program); -program.parseAsync(process.argv).catch((err) => { - console.error(err instanceof Error ? err.message : String(err)); - process.exit(1); -}); - -//#endregion -export { }; -//# sourceMappingURL=cli.js.map \ No newline at end of file diff --git a/dist/cli.js.map b/dist/cli.js.map deleted file mode 100644 index 0538f7b..0000000 --- a/dist/cli.js.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"cli.js","names":["__filename","__dirname","#workingDir","#puprcFilePath","#config","#workflows","#checks","#versionFiles","puprcDefaults","#i18n"],"sources":["../src/utils/directory.ts","../src/models/workflow.ts","../src/schemas.ts","../defaults/.puprc-defaults.json","../src/config.ts","../src/app.ts","../src/utils/process.ts","../src/utils/output.ts","../src/commands/build.ts","../src/cli.ts"],"sourcesContent":["import path from 'node:path';\nimport fs from 'fs-extra';\n\n/**\n * Checks whether a child directory is inside a parent directory.\n *\n * @since TBD\n *\n * @param {string} parentDir - The parent directory path.\n * @param {string} childDir - The child directory path.\n *\n * @returns {boolean} True if childDir is inside parentDir.\n */\nexport function isInside(parentDir: string, childDir: string): boolean {\n const relative = path.relative(parentDir, childDir);\n return !!relative && !relative.startsWith('..') && !path.isAbsolute(relative);\n}\n\n/**\n * Ensures a directory path ends with a trailing separator.\n *\n * @since TBD\n *\n * @param {string} p - The path to ensure has a trailing separator.\n *\n * @returns {string} The path with a trailing separator.\n *\n * @throws {Error} If the path appears to be a file (has an extension).\n */\nexport function trailingSlashIt(p: string): string {\n const { dir, base, ext } = path.parse(p);\n\n if (ext.length > 0) {\n throw new Error('Could not add trailing slash to file path.');\n }\n\n return path.join(dir, base, path.sep);\n}\n\n/**\n * Removes a directory, but only if it is within the given working directory.\n *\n * @since TBD\n *\n * @param {string} dir - The directory path to remove.\n * @param {string} workingDir - The working directory that the target must be within.\n *\n * @returns {void}\n *\n * @throws {Error} If the directory is outside the working directory.\n */\nexport async function rmdir(dir: string, workingDir: string): Promise {\n const relative = path.relative(workingDir, dir);\n const inside = relative && !relative.startsWith('..') && !path.isAbsolute(relative);\n\n // Safety check: only remove directories within the working directory\n if (!inside) {\n throw new Error(\n `Refusing to remove directory outside working directory: ${dir}`\n );\n }\n\n if (await fs.pathExists(dir)) {\n await fs.remove(dir);\n }\n}\n\n/**\n * Resolves a relative path against a working directory.\n * Rejects absolute paths unless a default is provided.\n *\n * @since TBD\n *\n * @param {string} relativePath - The relative path to resolve.\n * @param {string} workingDir - The working directory to resolve against.\n * @param {string} [defaultPath] - Optional default path to use if an absolute path is given.\n *\n * @returns {string} The resolved absolute path.\n *\n * @throws {Error} If an absolute path is given without a default fallback.\n */\nexport function resolveRelativePath(\n relativePath: string,\n workingDir: string,\n defaultPath?: string\n): string {\n if (path.isAbsolute(relativePath)) {\n if (!defaultPath) {\n throw new Error('Absolute paths are not allowed in the .puprc file.');\n }\n\n relativePath = defaultPath;\n }\n\n if (isInside(workingDir, relativePath)) {\n return relativePath;\n }\n\n return path.join(workingDir, relativePath);\n}\n","import type { BuildStep, Workflow } from '../types.ts';\n\n/**\n * Creates a Workflow object from a slug and list of commands.\n *\n * @since TBD\n *\n * @param {string} slug - The unique identifier for the workflow.\n * @param {BuildStep[]} commands - The list of build steps to execute in the workflow.\n *\n * @returns {Workflow} A Workflow object with the provided slug and commands.\n */\nexport function createWorkflow(slug: string, commands: BuildStep[]): Workflow {\n return { slug, commands };\n}\n\n/**\n * Manages a collection of named workflows.\n *\n * @since TBD\n */\nexport class WorkflowCollection {\n private workflows: Map = new Map();\n\n /**\n * Adds a workflow to the collection.\n *\n * @since TBD\n *\n * @param {Workflow} workflow - The workflow to add.\n *\n * @returns {void}\n */\n add(workflow: Workflow): void {\n this.workflows.set(workflow.slug, workflow);\n }\n\n /**\n * Retrieves a workflow by its slug.\n *\n * @since TBD\n *\n * @param {string} slug - The slug of the workflow to retrieve.\n *\n * @returns {Workflow | undefined} The workflow if found, otherwise undefined.\n */\n get(slug: string): Workflow | undefined {\n return this.workflows.get(slug);\n }\n\n /**\n * Checks whether a workflow with the given slug exists.\n *\n * @since TBD\n *\n * @param {string} slug - The slug to check.\n *\n * @returns {boolean} True if the workflow exists, false otherwise.\n */\n has(slug: string): boolean {\n return this.workflows.has(slug);\n }\n\n /**\n * Returns all workflows as an array.\n *\n * @since TBD\n *\n * @returns {Workflow[]} An array containing all workflows in the collection.\n */\n getAll(): Workflow[] {\n return Array.from(this.workflows.values());\n }\n\n /**\n * Returns the number of workflows in the collection.\n *\n * @since TBD\n *\n * @returns {number} The count of workflows.\n */\n get size(): number {\n return this.workflows.size;\n }\n\n /**\n * Allows iterating over all workflows in the collection.\n *\n * @since TBD\n *\n * @returns {Iterator} An iterator over the workflows.\n */\n [Symbol.iterator](): Iterator {\n return this.workflows.values();\n }\n}\n","import { z } from 'zod';\n\n/**\n * A build step is either a single command string (run sequentially) or an\n * array of command strings (run in parallel).\n *\n * @since TBD\n */\nexport const BuildStepSchema = z.union([z.string(), z.array(z.string())]);\n\nexport type BuildStep = z.infer;\n\n/**\n * Schema for a version file entry in .puprc paths.versions.\n *\n * @since TBD\n */\nexport const VersionFileInputSchema = z.object({\n file: z.string(),\n regex: z.string(),\n});\n\nexport type VersionFileInput = z.infer;\n\n/**\n * Parsed version file (same shape as input).\n *\n * @since TBD\n */\nexport const VersionFileSchema = z.object({\n file: z.string(),\n regex: z.string(),\n});\n\nexport type VersionFile = z.infer;\n\n/**\n * Schema for the i18n filter configuration.\n *\n * @since TBD\n */\nconst I18nFilterSchema = z.object({\n minimum_percentage: z.number(),\n});\n\n/**\n * Schema for an i18n configuration entry from .puprc (all fields optional).\n *\n * @since TBD\n */\nexport const I18nConfigInputSchema = z.object({\n path: z.string().optional(),\n url: z.string().optional(),\n slug: z.string().optional(),\n textdomain: z.string().optional(),\n file_format: z.string().optional(),\n formats: z.array(z.string()).optional(),\n filter: z.object({\n minimum_percentage: z.number().optional(),\n }).optional(),\n}).passthrough();\n\nexport type I18nConfigInput = z.infer;\n\n/**\n * Schema for the i18n defaults section of configuration.\n *\n * @since TBD\n */\nexport const I18nDefaultsSchema = z.object({\n path: z.string(),\n url: z.string(),\n slug: z.string(),\n textdomain: z.string(),\n file_format: z.string(),\n formats: z.array(z.string()),\n filter: I18nFilterSchema,\n});\n\nexport type I18nDefaults = z.infer;\n\n/**\n * Schema for a fully resolved i18n configuration entry (all fields required).\n *\n * @since TBD\n */\nexport const I18nResolvedConfigSchema = z.object({\n path: z.string(),\n url: z.string(),\n slug: z.string(),\n textdomain: z.string(),\n file_format: z.string(),\n formats: z.array(z.string()),\n filter: I18nFilterSchema,\n});\n\nexport type I18nResolvedConfig = z.infer;\n\n/**\n * Schema for a check configuration entry from .puprc (optional fields with defaults).\n *\n * @since TBD\n */\nexport const CheckConfigInputSchema = z.object({\n fail_method: z.enum(['error', 'warn']).optional(),\n fail_method_dev: z.enum(['error', 'warn']).optional(),\n type: z.enum(['simple', 'class', 'pup', 'command']).optional(),\n file: z.string().optional(),\n command: z.string().optional(),\n configure: z.string().optional(),\n args: z.record(z.string(), z.string()).optional(),\n dirs: z.array(z.string()).optional(),\n skip_directories: z.string().optional(),\n skip_files: z.string().optional(),\n}).passthrough();\n\nexport type CheckConfigInput = z.infer;\n\n/**\n * Schema for a fully resolved check configuration with defaults applied.\n *\n * @since TBD\n */\nexport const CheckConfigSchema = z.object({\n slug: z.string(),\n fail_method: z.enum(['error', 'warn']).default('error'),\n fail_method_dev: z.enum(['error', 'warn']).default('warn'),\n type: z.enum(['simple', 'class', 'pup', 'command']).default('pup'),\n file: z.string().optional(),\n command: z.string().optional(),\n configure: z.string().optional(),\n args: z.record(z.string(), z.string()).default({}),\n dirs: z.array(z.string()).optional(),\n skip_directories: z.string().optional(),\n skip_files: z.string().optional(),\n});\n\nexport type CheckConfig = z.infer;\n\n/**\n * Schema for the paths section of configuration.\n *\n * @since TBD\n */\nexport const PathsConfigSchema = z.object({\n build_dir: z.string(),\n changelog: z.string().nullable(),\n css: z.array(z.string()),\n js: z.array(z.string()),\n sync_files: z.array(z.string()),\n versions: z.array(VersionFileInputSchema),\n views: z.array(z.string()),\n zip_dir: z.string(),\n});\n\nexport type PathsConfig = z.infer;\n\n/**\n * Schema for the full merged pup configuration (after defaults are applied).\n *\n * @since TBD\n */\nexport const PupConfigSchema = z.object({\n build: z.array(BuildStepSchema),\n build_dev: z.array(BuildStepSchema),\n workflows: z.record(z.string(), z.array(z.string())),\n checks: z.record(z.string(), CheckConfigInputSchema),\n clean: z.array(z.string()),\n i18n: z.union([z.array(I18nConfigInputSchema), I18nConfigInputSchema]),\n i18n_defaults: I18nDefaultsSchema,\n paths: PathsConfigSchema,\n env: z.array(z.string()),\n repo: z.string().nullable(),\n zip_use_default_ignore: z.boolean(),\n zip_name: z.string().nullable(),\n}).passthrough();\n\nexport type PupConfig = z.infer;\n\n/**\n * Schema for validating raw .puprc input (all fields optional + passthrough for custom keys).\n *\n * @since TBD\n */\nexport const PuprcInputSchema = z.object({\n build: z.array(BuildStepSchema).optional(),\n build_dev: z.array(BuildStepSchema).optional(),\n workflows: z.record(z.string(), z.array(z.string())).optional(),\n checks: z.record(z.string(), CheckConfigInputSchema.or(z.object({}).passthrough())).optional(),\n clean: z.array(z.string()).optional(),\n i18n: z.union([z.array(I18nConfigInputSchema), I18nConfigInputSchema]).optional(),\n i18n_defaults: I18nDefaultsSchema.partial().optional(),\n paths: PathsConfigSchema.partial().optional(),\n env: z.array(z.string()).optional(),\n repo: z.string().nullable().optional(),\n zip_use_default_ignore: z.boolean().optional(),\n zip_name: z.string().nullable().optional(),\n}).passthrough();\n\nexport type PuprcInput = z.infer;\n\n/**\n * Schema for a workflow.\n *\n * @since TBD\n */\nexport const WorkflowSchema = z.object({\n slug: z.string(),\n commands: z.array(BuildStepSchema),\n});\n\nexport type Workflow = z.infer;\n\n/**\n * Result of running a check.\n *\n * @since TBD\n */\nexport interface CheckResult {\n success: boolean;\n output: string;\n}\n\n/**\n * Result of running a shell command.\n *\n * @since TBD\n */\nexport interface RunCommandResult {\n stdout: string;\n stderr: string;\n exitCode: number;\n}\n","","import fs from 'fs-extra';\nimport path from 'node:path';\nimport { fileURLToPath } from 'node:url';\nimport { trailingSlashIt } from './utils/directory.ts';\nimport { WorkflowCollection, createWorkflow } from './models/workflow.ts';\nimport { PuprcInputSchema, CheckConfigSchema } from './schemas.ts';\nimport type {\n PupConfig,\n BuildStep,\n CheckConfig,\n VersionFile,\n VersionFileInput,\n I18nResolvedConfig,\n I18nConfigInput,\n} from './types.ts';\nimport puprcDefaults from '../defaults/.puprc-defaults.json';\n\nconst __filename = fileURLToPath(import.meta.url);\nconst __dirname = path.dirname(__filename);\n\n/**\n * Locates the defaults directory containing .distignore-defaults and docs.\n *\n * @since TBD\n *\n * @returns {string} The absolute path to the defaults directory.\n */\nexport function getDefaultsDir(): string {\n // In built dist, defaults/ is at the package root\n // During dev, it's at the repo root\n const candidates = [\n path.resolve(__dirname, '..', 'defaults'),\n path.resolve(__dirname, '..', '..', 'defaults'),\n ];\n\n for (const candidate of candidates) {\n if (fs.existsSync(candidate)) {\n return candidate;\n }\n }\n\n return candidates[0];\n}\n\n/**\n * Loads, merges, and provides access to the project's pup configuration.\n *\n * @since TBD\n */\nexport class Config {\n readonly #workingDir: string;\n readonly #puprcFilePath: string;\n readonly #config: PupConfig;\n #workflows: WorkflowCollection;\n #checks: Map;\n #versionFiles: VersionFile[];\n #i18n: I18nResolvedConfig[] | null = null;\n\n /**\n * Initializes configuration by loading and merging .puprc with defaults.\n *\n * @since TBD\n *\n * @param {string} workingDir - The project working directory. Defaults to process.cwd().\n *\n * @throws {Error} If the .puprc file is present but contains invalid JSON or fails validation.\n */\n constructor(workingDir?: string) {\n const cwd = workingDir ?? process.cwd();\n\n this.#workingDir = trailingSlashIt(path.normalize(cwd));\n this.#puprcFilePath = path.join(this.#workingDir, '.puprc');\n this.#config = this.getDefaultConfig();\n this.mergeConfigWithDefaults();\n this.#workflows = this.buildWorkflows();\n this.#checks = this.parseCheckConfig();\n this.#versionFiles = this.parseVersionFiles();\n }\n\n /**\n * Returns the default configuration from the bundled .puprc-defaults.\n *\n * @since TBD\n *\n * @returns {PupConfig} The parsed default configuration object.\n */\n private getDefaultConfig(): PupConfig {\n return structuredClone(puprcDefaults) as PupConfig;\n }\n\n /**\n * Merges the project's .puprc file into the default configuration.\n *\n * @since TBD\n *\n * @throws {Error} If the .puprc file contains invalid JSON or fails schema validation.\n */\n private mergeConfigWithDefaults(): void {\n if (!fs.existsSync(this.#puprcFilePath)) {\n return;\n }\n\n const puprcContents = fs.readFileSync(this.#puprcFilePath, 'utf-8');\n let rawPuprc: unknown;\n\n try {\n rawPuprc = JSON.parse(puprcContents);\n } catch {\n throw new Error(\n 'There is a .puprc file in this directory, but it could not be parsed. Invalid JSON in .puprc.'\n );\n }\n\n if (!rawPuprc || typeof rawPuprc !== 'object') {\n throw new Error(\n 'There is a .puprc file in this directory, but it could not be parsed. Invalid .puprc format.'\n );\n }\n\n const parseResult = PuprcInputSchema.safeParse(rawPuprc);\n\n if (!parseResult.success) {\n const issues = parseResult.error.issues\n .map((issue) => ` ${issue.path.join('.')}: ${issue.message}`)\n .join('\\n');\n throw new Error(\n `There is a .puprc file in this directory, but it contains invalid configuration:\\n${issues}`\n );\n }\n\n const puprc = parseResult.data as Record;\n const configRecord = this.#config as unknown as Record;\n\n for (const [key, value] of Object.entries(puprc)) {\n const current = configRecord[key];\n\n if (current === undefined || current === null) {\n configRecord[key] = value;\n continue;\n }\n\n if (typeof current !== 'object') {\n configRecord[key] = value;\n continue;\n }\n\n // Special handling for checks: preserve defaults + merge\n if (key === 'checks' && typeof value === 'object' && value !== null) {\n const defaultChecks = current as Record;\n const newChecks = value as Record;\n configRecord[key] = newChecks;\n\n for (const [checkSlug, checkConfig] of Object.entries(newChecks)) {\n if (defaultChecks[checkSlug] !== undefined) {\n (configRecord[key] as Record)[checkSlug] =\n this.mergeConfigValue(defaultChecks[checkSlug], checkConfig);\n }\n }\n continue;\n }\n\n configRecord[key] = this.mergeConfigValue(current, value);\n }\n }\n\n /**\n * Deep-merges two configuration values. Scalars and arrays replace; objects merge recursively.\n *\n * @since TBD\n *\n * @param {unknown} original - The original configuration value.\n * @param {unknown} newVal - The new configuration value to merge in.\n *\n * @returns {unknown} The merged configuration value.\n */\n private mergeConfigValue(original: unknown, newVal: unknown): unknown {\n if (typeof newVal !== 'object' || newVal === null) {\n return newVal;\n }\n\n if (typeof original !== 'object' || original === null) {\n return newVal;\n }\n\n if (Array.isArray(original)) {\n // Numeric-keyed arrays: replace\n return newVal;\n }\n\n if (Array.isArray(newVal)) {\n return newVal;\n }\n\n const orig = original as Record;\n const nv = newVal as Record;\n const result = { ...orig };\n\n for (const [key, item] of Object.entries(orig)) {\n if (nv[key] === undefined) continue;\n if (typeof item === 'object' && item !== null && !Array.isArray(item)) {\n result[key] = this.mergeConfigValue(item, nv[key]);\n } else {\n result[key] = nv[key];\n }\n }\n\n for (const [key, item] of Object.entries(nv)) {\n if (result[key] === undefined) {\n result[key] = item;\n }\n }\n\n return result;\n }\n\n /**\n * Builds the workflow collection from configuration, including auto-generated build workflows.\n *\n * @since TBD\n *\n * @returns {WorkflowCollection} The built workflow collection.\n */\n private buildWorkflows(): WorkflowCollection {\n const collection = new WorkflowCollection();\n\n const rawWorkflows = this.#config.workflows as unknown;\n\n // Auto-create build workflow\n // TODO: Add parallel build step support to workflow execution.\n if (\n this.#config.build?.length > 0 &&\n !(rawWorkflows as Record)?.['build']\n ) {\n collection.add(createWorkflow('build', this.#config.build));\n }\n\n if (\n this.#config.build_dev?.length > 0 &&\n !(rawWorkflows as Record)?.['build_dev']\n ) {\n collection.add(createWorkflow('build_dev', this.#config.build_dev));\n }\n\n if (rawWorkflows && typeof rawWorkflows === 'object') {\n for (const [slug, commands] of Object.entries(\n rawWorkflows as Record\n )) {\n collection.add(\n createWorkflow(slug, Array.isArray(commands) ? commands : [])\n );\n }\n }\n\n return collection;\n }\n\n /**\n * Parses the checks section of the configuration into CheckConfig objects.\n * Uses Zod schema defaults for per-field values.\n *\n * @since TBD\n *\n * @returns {Map} A map of check slug to CheckConfig.\n */\n private parseCheckConfig(): Map {\n const checks = this.#config.checks;\n const result = new Map();\n if (!checks) return result;\n\n for (const [slug, checkInput] of Object.entries(checks)) {\n const input = typeof checkInput === 'object' && checkInput !== null\n ? checkInput\n : {};\n\n const parsed = CheckConfigSchema.parse({ slug, ...input });\n result.set(slug, parsed);\n }\n\n return result;\n }\n\n /**\n * Parses and validates the version files section of the configuration.\n *\n * @since TBD\n *\n * @returns {VersionFile[]} The parsed list of version file objects.\n *\n * @throws {Error} If a version file entry is missing required properties or the file does not exist.\n */\n private parseVersionFiles(): VersionFile[] {\n const versions = this.#config.paths?.versions;\n const result: VersionFile[] = [];\n if (!versions || !Array.isArray(versions)) return result;\n\n for (const vf of versions as VersionFileInput[]) {\n if (!vf.file || !vf.regex) {\n throw new Error(\n 'Versions specified in .puprc .paths.versions must have the \"file\" and \"regex\" property.'\n );\n }\n\n const filePath = path.join(this.#workingDir, vf.file);\n if (!fs.existsSync(filePath)) {\n throw new Error(`Version file does not exist: ${vf.file}`);\n }\n\n const contents = fs.readFileSync(filePath, 'utf-8');\n const regex = new RegExp(vf.regex);\n const matches = contents.match(regex);\n\n if (!matches || !matches[1] || !matches[2]) {\n throw new Error(\n `Could not find version in file ${vf.file} using regex \"/${vf.regex}/\"`\n );\n }\n\n result.push({ file: vf.file, regex: vf.regex });\n }\n\n return result;\n }\n\n /**\n * Returns the raw merged configuration object.\n *\n * @since TBD\n *\n * @returns {PupConfig} The configuration object.\n */\n get raw(): PupConfig {\n return this.#config;\n }\n\n /**\n * Returns the build commands, preferring dev commands when isDev is true.\n *\n * @since TBD\n *\n * @param {boolean} isDev - Whether to return dev build commands.\n *\n * @returns {BuildStep[]} The list of build steps (strings run sequentially, sub-arrays run in parallel).\n */\n getBuildCommands(isDev = false): BuildStep[] {\n if (isDev && this.#config.build_dev?.length > 0) {\n return this.#config.build_dev;\n }\n return this.#config.build ?? [];\n }\n\n /**\n * Returns the build directory path, optionally as a full absolute path.\n *\n * @since TBD\n *\n * @param {boolean} fullPath - Whether to return the full absolute path.\n *\n * @returns {string} The build directory path.\n */\n getBuildDir(fullPath = true): string {\n const buildDir = this.#config.paths?.build_dir ?? '.pup-build';\n if (!fullPath) return buildDir;\n return path.resolve(this.#workingDir, buildDir);\n }\n\n /**\n * Returns the clean commands from the configuration.\n *\n * @since TBD\n *\n * @returns {string[]} The list of clean command strings.\n */\n getCleanCommands(): string[] {\n return this.#config.clean ?? [];\n }\n\n /**\n * Returns the map of parsed check configurations.\n *\n * @since TBD\n *\n * @returns {Map} A map of check slug to CheckConfig.\n */\n getChecks(): Map {\n return this.#checks;\n }\n\n /**\n * Returns resolved i18n configurations, merging with defaults.\n *\n * @since TBD\n *\n * @returns {I18nResolvedConfig[]} The list of resolved i18n configuration objects.\n */\n getI18n(): I18nResolvedConfig[] {\n if (this.#i18n !== null) return this.#i18n;\n\n const defaults = this.#config.i18n_defaults;\n const i18nRaw = this.#config.i18n;\n\n if (!i18nRaw || (Array.isArray(i18nRaw) && i18nRaw.length === 0)) {\n this.#i18n = [];\n return this.#i18n;\n }\n\n // Normalize to array\n let i18nArr: I18nConfigInput[];\n if (!Array.isArray(i18nRaw)) {\n i18nArr = [i18nRaw];\n } else {\n i18nArr = i18nRaw;\n }\n\n // Filter valid entries\n i18nArr = i18nArr.filter(\n (item) => item.url && item.textdomain && item.slug\n );\n\n if (i18nArr.length === 0) {\n this.#i18n = [];\n return this.#i18n;\n }\n\n this.#i18n = i18nArr.map((item) => ({\n path: item.path ?? defaults.path,\n url: item.url ?? defaults.url,\n slug: item.slug ?? defaults.slug,\n textdomain: item.textdomain ?? defaults.textdomain,\n file_format: item.file_format ?? defaults.file_format,\n formats: item.formats?.length ? item.formats : defaults.formats,\n filter: {\n minimum_percentage:\n item.filter?.minimum_percentage ??\n defaults.filter.minimum_percentage,\n },\n }));\n\n return this.#i18n;\n }\n\n /**\n * Returns the list of environment variable names from configuration.\n *\n * @since TBD\n *\n * @returns {string[]} The list of environment variable name strings.\n */\n getEnvVarNames(): string[] {\n return this.#config.env ?? [];\n }\n\n /**\n * Returns the git repository URL, inferring from package.json or composer.json if not set.\n *\n * @since TBD\n *\n * @returns {string} The git repository URL string.\n *\n * @throws {Error} If no repository can be determined.\n */\n getRepo(): string {\n if (!this.#config.repo) {\n // Try to infer from package.json\n const pkgPath = path.join(this.#workingDir, 'package.json');\n if (fs.existsSync(pkgPath)) {\n const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')) as {\n repository?: { url?: string } | string;\n };\n if (typeof pkg.repository === 'string') {\n return `git@github.com:${pkg.repository}.git`;\n }\n if (pkg.repository?.url) {\n return pkg.repository.url;\n }\n }\n\n // Try composer.json fallback\n const composerPath = path.join(this.#workingDir, 'composer.json');\n if (fs.existsSync(composerPath)) {\n const composer = JSON.parse(\n fs.readFileSync(composerPath, 'utf-8')\n ) as { name?: string };\n if (composer.name) {\n return `git@github.com:${composer.name}.git`;\n }\n }\n\n throw new Error(\n 'Could not find a repo in the .puprc file or the \"name\" property in package.json/composer.json.'\n );\n }\n\n const repo = this.#config.repo;\n\n if (\n !repo.includes('https://') &&\n !repo.includes('file://') &&\n !repo.includes('git://') &&\n !repo.includes('git@github.com') &&\n !fs.existsSync(repo)\n ) {\n return `git@github.com:${repo}.git`;\n }\n\n return repo;\n }\n\n /**\n * Returns the list of sync file names (.distfiles, .distinclude, etc.).\n *\n * @since TBD\n *\n * @returns {string[]} The list of sync file name strings.\n */\n getSyncFiles(): string[] {\n const defaults = ['.distfiles', '.distinclude', '.distignore', '.gitattributes'];\n const configFiles = this.#config.paths?.sync_files;\n\n if (!configFiles || !Array.isArray(configFiles) || configFiles.length === 0) {\n return defaults;\n }\n\n return [...new Set([...defaults, ...configFiles])];\n }\n\n /**\n * Returns the parsed version file configurations.\n *\n * @since TBD\n *\n * @returns {VersionFile[]} The list of version file objects.\n */\n getVersionFiles(): VersionFile[] {\n return this.#versionFiles;\n }\n\n /**\n * Returns the workflow collection.\n *\n * @since TBD\n *\n * @returns {WorkflowCollection} The WorkflowCollection instance.\n */\n getWorkflows(): WorkflowCollection {\n return this.#workflows;\n }\n\n /**\n * Returns the working directory path.\n *\n * @since TBD\n *\n * @returns {string} The absolute working directory path with trailing slash.\n */\n getWorkingDir(): string {\n return this.#workingDir;\n }\n\n /**\n * Returns the zip staging directory path, optionally as a full absolute path.\n *\n * @since TBD\n *\n * @param {boolean} fullPath - Whether to return the full absolute path.\n *\n * @returns {string} The zip staging directory path.\n */\n getZipDir(fullPath = true): string {\n const zipDir = this.#config.paths?.zip_dir ?? '.pup-zip';\n if (!fullPath) return zipDir;\n return path.resolve(this.#workingDir, zipDir);\n }\n\n /**\n * Returns the zip archive base name, inferring from package.json if not set.\n *\n * @since TBD\n *\n * @returns {string} The zip archive base name string.\n *\n * @throws {Error} If no zip name can be determined.\n */\n getZipName(): string {\n if (this.#config.zip_name) {\n return this.#config.zip_name;\n }\n\n // Try package.json name\n const pkgPath = path.join(this.#workingDir, 'package.json');\n if (fs.existsSync(pkgPath)) {\n const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')) as {\n name?: string;\n };\n if (pkg.name) {\n // Strip scope prefix (e.g., @stellarwp/pup -> pup)\n return pkg.name.replace(/^@[^/]+\\//, '');\n }\n }\n\n // Try composer.json name\n const composerPath = path.join(this.#workingDir, 'composer.json');\n if (fs.existsSync(composerPath)) {\n const composer = JSON.parse(\n fs.readFileSync(composerPath, 'utf-8')\n ) as { name?: string };\n if (composer.name) {\n return composer.name.replace(/^[^/]+\\//, '');\n }\n }\n\n throw new Error('Could not find a \"zip_name\" in .puprc');\n }\n\n /**\n * Returns whether to use the default .distignore-defaults patterns.\n *\n * @since TBD\n *\n * @returns {boolean} True if default ignore patterns should be used.\n */\n getZipUseDefaultIgnore(): boolean {\n return this.#config.zip_use_default_ignore ?? true;\n }\n\n /**\n * Serializes the configuration to a plain object.\n *\n * @since TBD\n *\n * @returns {PupConfig} The configuration as a PupConfig object.\n */\n toJSON(): PupConfig {\n return this.#config;\n }\n}\n\nlet globalConfig: Config | null = null;\n\n/**\n * Returns the singleton Config instance, creating it if needed.\n *\n * @since TBD\n *\n * @param {string} workingDir - Optional working directory to pass to the Config constructor.\n *\n * @returns {Config} The singleton Config instance.\n */\nexport function getConfig(workingDir?: string): Config {\n if (!globalConfig) {\n globalConfig = new Config(workingDir);\n }\n return globalConfig;\n}\n\n/**\n * Resets the singleton Config instance, forcing a fresh load on next access.\n *\n * @since TBD\n *\n * @returns {void}\n */\nexport function resetConfig(): void {\n globalConfig = null;\n}\n","import { Command } from 'commander';\nimport { Config, getConfig, resetConfig } from './config.ts';\nimport { fileURLToPath } from 'node:url';\nimport path from 'node:path';\nimport fs from 'fs-extra';\n\nconst __filename = fileURLToPath(import.meta.url);\nconst __dirname = path.dirname(__filename);\n\n/**\n * Reads the pup version from the nearest package.json.\n *\n * @since TBD\n *\n * @returns {string} The version string from package.json, or '2.0.0' as a fallback.\n */\nfunction getVersion(): string {\n // Try to read from package.json\n const candidates = [\n path.resolve(__dirname, '..', 'package.json'),\n path.resolve(__dirname, '..', '..', 'package.json'),\n ];\n\n for (const candidate of candidates) {\n if (fs.existsSync(candidate)) {\n const pkg = JSON.parse(fs.readFileSync(candidate, 'utf-8')) as { version: string };\n return pkg.version;\n }\n }\n\n return '2.0.0';\n}\n\nexport const PUP_VERSION = getVersion();\n\n/**\n * Creates and configures the Commander program instance.\n *\n * @since TBD\n *\n * @returns {Command} The configured Commander program.\n */\nexport function createApp(): Command {\n resetConfig();\n\n getConfig();\n\n const program = new Command();\n program\n .name('pup')\n .version(PUP_VERSION)\n .description(\"StellarWP's Project Utilities & Packager\");\n\n return program;\n}\n\nexport { Config, getConfig, resetConfig };\n","import { execa } from 'execa';\nimport type { RunCommandResult } from '../types.ts';\n\nexport interface RunOptions {\n cwd?: string;\n softFail?: boolean;\n silent?: boolean;\n}\n\n/**\n * Runs a shell command, streaming output to console.\n * Commands prefixed with `@` are treated as soft-fail (errors are ignored).\n *\n * @since TBD\n *\n * @param {string} command - The shell command to execute.\n * @param {RunOptions} options - Optional configuration for the command execution.\n *\n * @returns {Promise} The command result with stdout, stderr, and exit code.\n */\nexport async function runCommand(\n command: string,\n options: RunOptions = {}\n): Promise {\n let cmd = command;\n let softFail = options.softFail ?? false;\n\n // Handle @ prefix for soft-fail\n if (cmd.startsWith('@')) {\n cmd = cmd.slice(1);\n softFail = true;\n }\n\n try {\n const result = await execa(cmd, {\n cwd: options.cwd,\n shell: true,\n stdout: options.silent ? 'pipe' : 'inherit',\n stderr: options.silent ? 'pipe' : 'inherit',\n reject: false,\n });\n\n if (result.exitCode !== 0 && !softFail) {\n return {\n stdout: String(result.stdout ?? ''),\n stderr: String(result.stderr ?? ''),\n exitCode: result.exitCode ?? 1,\n };\n }\n\n return {\n stdout: String(result.stdout ?? ''),\n stderr: String(result.stderr ?? ''),\n exitCode: softFail ? 0 : (result.exitCode ?? 0),\n };\n } catch (err: unknown) {\n if (softFail) {\n return { stdout: '', stderr: String(err), exitCode: 0 };\n }\n throw err;\n }\n}\n\n/**\n * Runs a command and captures the output silently.\n *\n * @since TBD\n *\n * @param {string} command - The shell command to execute.\n * @param {Omit} options - Optional configuration for the command execution.\n *\n * @returns {Promise} The command result with stdout, stderr, and exit code.\n */\nexport async function runCommandSilent(\n command: string,\n options: Omit = {}\n): Promise {\n return runCommand(command, { ...options, silent: true });\n}\n","import chalk from 'chalk';\n\nlet prefix = '';\n\n/**\n * Sets a prefix string that will be prepended to all output messages.\n *\n * @since TBD\n *\n * @param {string} p - The prefix string.\n *\n * @returns {void}\n */\nexport function setPrefix(p: string): void {\n prefix = p;\n}\n\n/**\n * Returns the current output prefix.\n *\n * @since TBD\n *\n * @returns {string} The current prefix string.\n */\nexport function getPrefix(): string {\n return prefix;\n}\n\n/**\n * Formats a message with the current prefix, if one is set.\n *\n * @since TBD\n *\n * @param {string} message - The message to format.\n *\n * @returns {string} The formatted message with prefix prepended if set.\n */\nfunction formatMessage(message: string): string {\n if (prefix) {\n return `[${prefix}] ${message}`;\n }\n return message;\n}\n\n/**\n * Prints a green success message to stdout.\n *\n * @since TBD\n *\n * @param {string} message - The success message to display.\n *\n * @returns {void}\n */\nexport function success(message: string): void {\n console.log(formatMessage(chalk.green(message)));\n}\n\n/**\n * Prints a red error message to stderr.\n *\n * @since TBD\n *\n * @param {string} message - The error message to display.\n *\n * @returns {void}\n */\nexport function error(message: string): void {\n console.error(formatMessage(chalk.red(message)));\n}\n\n/**\n * Prints a yellow warning message to stdout.\n *\n * @since TBD\n *\n * @param {string} message - The warning message to display.\n *\n * @returns {void}\n */\nexport function warning(message: string): void {\n console.log(formatMessage(chalk.yellow(message)));\n}\n\n/**\n * Prints a blue informational message to stdout.\n *\n * @since TBD\n *\n * @param {string} message - The informational message to display.\n *\n * @returns {void}\n */\nexport function info(message: string): void {\n console.log(formatMessage(chalk.blue(message)));\n}\n\n/**\n * Prints a bold title with an underline rule.\n *\n * @since TBD\n *\n * @param {string} message - The title text to display.\n *\n * @returns {void}\n */\nexport function title(message: string): void {\n console.log('');\n console.log(formatMessage(chalk.bold(message)));\n console.log(formatMessage(chalk.bold('='.repeat(message.length))));\n console.log('');\n}\n\n/**\n * Prints a bold yellow section header.\n *\n * @since TBD\n *\n * @param {string} message - The section header text to display.\n *\n * @returns {void}\n */\nexport function section(message: string): void {\n console.log('');\n console.log(formatMessage(chalk.bold.yellow(message)));\n}\n\n/**\n * Prints a plain message to stdout with the current prefix.\n *\n * @since TBD\n *\n * @param {string} message - The message to print.\n *\n * @returns {void}\n */\nexport function log(message: string): void {\n console.log(formatMessage(message));\n}\n\n/**\n * Prints a message to stdout without any prefix.\n *\n * @since TBD\n *\n * @param {string} message - The message to print.\n *\n * @returns {void}\n */\nexport function writeln(message: string): void {\n console.log(message);\n}\n\n/**\n * Prints an empty line to stdout.\n *\n * @since TBD\n *\n * @returns {void}\n */\nexport function newline(): void {\n console.log('');\n}\n","import type { Command } from 'commander';\nimport { getConfig } from '../config.ts';\nimport { runCommand } from '../utils/process.ts';\nimport type { BuildStep, RunCommandResult } from '../types.ts';\nimport * as output from '../utils/output.ts';\n\n/**\n * Runs a single build command, handling the `@` soft-fail prefix.\n *\n * @since TBD\n *\n * @param {string} step - The command string to execute.\n * @param {string} cwd - The working directory for the command.\n *\n * @returns {Promise<{ cmd: string; bailOnFailure: boolean; result: RunCommandResult }>} The command, bail flag, and result.\n */\nasync function runBuildStep(\n step: string,\n cwd: string\n): Promise<{ cmd: string; bailOnFailure: boolean; result: RunCommandResult }> {\n let cmd = step;\n let bailOnFailure = true;\n\n if (cmd.startsWith('@')) {\n bailOnFailure = false;\n cmd = cmd.slice(1);\n }\n\n output.section(`> ${cmd}`);\n\n const result = await runCommand(cmd, { cwd });\n\n return { cmd, bailOnFailure, result };\n}\n\n/**\n * Registers the `build` command with the CLI program.\n *\n * @since TBD\n *\n * @param {Command} program - The Commander.js program instance.\n *\n * @returns {void}\n */\nexport function registerBuildCommand(program: Command): void {\n program\n .command('build')\n .description('Run the build commands.')\n .option('--dev', 'Run the dev build commands.')\n .option('--root ', 'Set the root directory for running commands.')\n .action(async (options: { dev?: boolean; root?: string }) => {\n const config = getConfig(options.root);\n const buildSteps: BuildStep[] = config.getBuildCommands(options.dev);\n const cwd = options.root ?? config.getWorkingDir();\n\n output.log('Running build steps...');\n\n for (const step of buildSteps) {\n if (Array.isArray(step)) {\n // Parallel group: run all commands concurrently\n const results = await Promise.all(\n step.map((cmd) => runBuildStep(cmd, cwd))\n );\n\n // Check for failures after all parallel commands complete\n for (const { cmd, bailOnFailure, result } of results) {\n if (result.exitCode !== 0) {\n output.error(`[FAIL] Build step failed: ${cmd}`);\n if (bailOnFailure) {\n output.error('Exiting...');\n process.exit(result.exitCode);\n }\n }\n }\n } else {\n // Sequential: run single command\n const { cmd, bailOnFailure, result } = await runBuildStep(\n step,\n cwd\n );\n\n if (result.exitCode !== 0) {\n output.error(`[FAIL] Build step failed: ${cmd}`);\n if (bailOnFailure) {\n output.error('Exiting...');\n process.exit(result.exitCode);\n }\n }\n }\n }\n\n output.success('Build complete.');\n });\n}\n","import { createApp } from './app.ts';\nimport { registerBuildCommand } from './commands/build.ts';\n\nconst program = createApp();\n\nregisterBuildCommand(program);\n\nprogram.parseAsync(process.argv).catch((err) => {\n console.error(err instanceof Error ? err.message : String(err));\n process.exit(1);\n});\n"],"mappings":"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"} \ No newline at end of file From cd01b553be8f8e63ae38454a602ff8bdb7e74d19 Mon Sep 17 00:00:00 2001 From: Eric Defore Date: Sun, 15 Feb 2026 10:51:25 -0500 Subject: [PATCH 3/3] ENG-221: Document parallel build output behavior Add a note explaining why parallel build steps may appear to run sequentially due to fast-finishing commands and I/O contention. --- docs/commands.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/docs/commands.md b/docs/commands.md index ddc90c9..339302d 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -76,6 +76,22 @@ In the above example: The `@` soft-fail prefix works within parallel groups as well. If a non-soft-fail command in a parallel group fails, `pup` will wait for all commands in the group to finish before exiting with the failure code. +### A note on parallel output + +Commands in a parallel group share the same terminal output. Their output will be interleaved line-by-line as each +process writes to `stdout`, and section headers for all commands in the group are printed before any command begins +producing output. + +Because of this, parallel execution may **appear** sequential in some cases even though the commands are genuinely +running simultaneously. Common reasons include: + +- **Fast-finishing commands**: If one command completes quickly (e.g. `bun install` with a warm cache), all of its + output appears before the slower command has produced much. This can look like the commands ran one after another. +- **I/O contention**: Multiple commands competing for CPU, disk, or network may cause one to stall while another + progresses, further contributing to output that appears sequential. + +If you need to verify that commands are truly running in parallel, compare the runtimes between both parallel and sequential configurations. + ## `pup check` Runs all registered check commands.