Skip to content
Open
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
0a4ecd3
ENG-219: Add get-version command
d4mation Feb 11, 2026
8c2c95a
Merge branch 'ENG-219/app-bootstrap' into ENG-219/command-get-version
d4mation Feb 11, 2026
529866d
Merge branch 'ENG-219/app-bootstrap' into ENG-219/command-get-version
d4mation Feb 11, 2026
b063ed1
Merge branch 'ENG-219/app-bootstrap' into ENG-219/command-get-version
d4mation Feb 11, 2026
6649e64
Merge branch 'ENG-219/app-bootstrap' into ENG-219/command-get-version
d4mation Feb 11, 2026
46f18fb
ENG-219: Use createTempProject in get-version tests for isolation
d4mation Feb 11, 2026
2912a4f
Merge branch 'ENG-219/app-bootstrap' into ENG-219/command-get-version
d4mation Feb 11, 2026
58b2263
Merge branch 'ENG-219/app-bootstrap' into ENG-219/command-get-version
d4mation Feb 12, 2026
7191246
Merge branch 'ENG-219/app-bootstrap' into ENG-219/command-get-version
d4mation Feb 13, 2026
524fe69
Merge branch 'ENG-219/app-bootstrap' into ENG-219/command-get-version
d4mation Feb 13, 2026
7506e7b
Merge branch 'ENG-219/app-bootstrap' into ENG-219/command-get-version
d4mation Feb 13, 2026
8488462
ENG-219: Merge app-bootstrap and update imports to .ts
d4mation Feb 13, 2026
41a4c47
ENG-219: Remove unused workingDir arg from getConfig() call
d4mation Feb 13, 2026
d99bf89
Merge branch 'ENG-219/app-bootstrap' into ENG-219/command-get-version
d4mation Feb 13, 2026
65f3160
ENG-219: Use named capture groups and iterate version files
d4mation Feb 13, 2026
029df3b
ENG-219: Mock dependencies in get-version tests
d4mation Feb 13, 2026
b9f7d7d
ENG-219: Add test for second capture group backward compatibility
d4mation Feb 13, 2026
aba8600
ENG-219: Remove --root option from get-version command
d4mation Feb 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { createApp } from './app.js';
import { registerGetVersionCommand } from './commands/get-version.js';

const program = createApp();

registerGetVersionCommand(program);

program.parseAsync(process.argv).catch((err) => {
console.error(err instanceof Error ? err.message : String(err));
process.exit(1);
Expand Down
81 changes: 81 additions & 0 deletions src/commands/get-version.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import type { Command } from 'commander';
import fs from 'fs-extra';
import path from 'node:path';
import { getConfig } from '../config.js';
import { runCommandSilent } from '../utils/process.js';
import * as output from '../utils/output.js';

/**
* Extracts the project version from the first configured version file. Appends a dev suffix
* with git timestamp and hash when the dev option is set.
*
* @since TBD
*
* @param {object} options - The options object.
* @param {boolean} [options.dev] - Whether to append a dev suffix to the version.
* @param {string} [options.root] - The root directory for resolving version files.
*
* @returns {Promise<string>} The resolved version string, or 'unknown' if not found.
*/
export async function getVersion(options: {
dev?: boolean;
root?: string;
}): Promise<string> {
const config = getConfig(options.root);
const versionFiles = config.getVersionFiles();
const cwd = options.root ?? config.getWorkingDir();

if (versionFiles.length === 0) {
return 'unknown';
}

const versionFile = versionFiles[0];
const filePath = path.resolve(cwd, versionFile.file);
const contents = await fs.readFile(filePath, 'utf-8');
const regex = new RegExp(versionFile.regex);
const matches = contents.match(regex);

let version: string;
if (!matches || !matches[2]) {
version = 'unknown';
} else {
version = matches[2];
}
Comment on lines +26 to +58
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I so dislike this API.

First question: why are version files an array, and why do we just always use the first item? We should either change this (with soft deprecation) and make it an object, or test every pair individually and use the first successful one. Its espesially odd to have that fixture here -

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": ")([^"]+)',
},
],
.

Second, the requirement to have exactly two capturing groups is super-odd. Why don't we use named capture groups? It's supported with PHP and JS (https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Regular_expressions/Named_capturing_group and https://www.php.net/manual/uk/function.preg-match.php#example-4) with compatible syntax (?<name>pattern). Couldn't we agree on a (?<version>pattern) named group that should represent the version string? We can also can soft deprecate that odd matches[2] here and in the config check.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why are version files an array

This is for pup check:version-conflict primarily. It helps ensure that if you have the version defined in multiple places (such as the WP Plugin Header, a Constant, a version listed in readme.txt, etc.) that they're correctly updated. That check will ensure they match.

and why do we just always use the first item

That does seem to be a strange choice for pup get-version specifically. I've updated it so that it will look through them all until it finds a successful match, and then it will return the first match.

If no configured version files are configured, then it will now throw an Error. If there are configured Version files and it cannot find a version in any of them, that will also now throw an Error.

Why don't we use named capture groups

I've implemented this now :) It'll try for a named capture group first, then fall back to the second capture group. That was a weird behavior, but it was how the PHP version worked 🤷

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Weirdly, get-version has a --root flag and it isn't clear what the use case for it would be. We probably should just remove it. It existed in the PHP version, but I don't know why you would want to call pup get-version --root some-dir to change the relative path for the version files when checking 🤔

I've tested it a bit and Doing something like:

- root
   |_ .puprc
   |_ src/

And running

cd src/
bunx pup -- get-version --root ../
php pup.phar -- get-version --root ../

doesn't work at all, and something like that is the only use case I could think of for this.

I'm going to remove that option flag from this command to prevent that confusion.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The only way it would make sense would be if it did change the root for the Config. But since that's not how the other use cases utilize it, I think that making it work by changing where the Config was loaded from for this command would be confusing.


if (options.dev) {
const timestampResult = await runCommandSilent(
'git show -s --format=%ct HEAD',
{ cwd }
);
const hashResult = await runCommandSilent(
'git rev-parse --short=8 HEAD',
{ cwd }
);
const timestamp = timestampResult.stdout.trim();
const hash = hashResult.stdout.trim();
version += `-dev-${timestamp}-${hash}`;
}

return version;
}

/**
* Registers the `get-version` command with the CLI program.
*
* @since TBD
*
* @param {Command} program - The Commander.js program instance.
*
* @returns {void}
*/
export function registerGetVersionCommand(program: Command): void {
program
.command('get-version')
.description('Gets the version for the product.')
.option('--dev', 'Get the dev version.')
.option('--root <dir>', 'Set the root directory for running commands.')
.action(async (options: { dev?: boolean; root?: string }) => {
const version = await getVersion(options);
output.writeln(version);
});
}
15 changes: 15 additions & 0 deletions src/models/version-file.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import type { VersionFile } from '../types.js';

/**
* Creates a VersionFile object from a file path and regex pattern.
*
* @since TBD
*
* @param {string} file - The file path.
* @param {string} regex - The regex pattern.
*
* @returns {VersionFile} A VersionFile object with the provided file path and regex pattern.
*/
export function createVersionFile(file: string, regex: string): VersionFile {
return { file, regex };
}
33 changes: 33 additions & 0 deletions tests/commands/get-version.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import {
runPup,
writePuprc,
getPuprc,
createTempProject,
cleanupTempProjects,
} from '../helpers/setup.js';

describe('get-version command', () => {
let projectDir: string;

beforeEach(() => {
projectDir = createTempProject();
writePuprc(getPuprc(), projectDir);
});

afterEach(() => {
cleanupTempProjects();
});

it('should get the version from the project', async () => {
const result = await runPup('get-version', { cwd: projectDir });
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain('1.0.0');
});

it('should get the dev version from the project', async () => {
const result = await runPup('get-version --dev', { cwd: projectDir });
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain('1.0.0');
expect(result.stdout).toContain('dev');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Couldn't we mock runCommandSilent to create a better assertion?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've mocked this :) Now it'll ensure that the git hash information is properly retrieved.

});
});