diff --git a/bin/pos-cli-modules-version.js b/bin/pos-cli-modules-version.js index 1c833ca6..b7208a9e 100644 --- a/bin/pos-cli-modules-version.js +++ b/bin/pos-cli-modules-version.js @@ -5,9 +5,10 @@ import { createNewVersion } from '../lib/modules/version.js'; program .name('pos-cli modules version') - .arguments('[version]', 'a valid semver version') + .arguments('[version]', 'semver bump type (major|minor|patch) or an explicit semver version (default: patch)') .option('-p, --package [file]', 'use version from file as latest release, default: package.json') .option('--path ', 'module root directory, default is current directory') + .option('--no-git', 'skip git commit and tag creation') .action(async (version, options) => { if (options.path) process.chdir(options.path); await createNewVersion(version, options); diff --git a/lib/modules/version.js b/lib/modules/version.js index 4e05eacd..5a6657d2 100644 --- a/lib/modules/version.js +++ b/lib/modules/version.js @@ -1,3 +1,6 @@ +import fs from 'fs'; +import path from 'path'; +import { execSync } from 'child_process'; import semver from 'semver'; import files from '../files.js'; import logger from '../logger.js'; @@ -5,6 +8,12 @@ import report from '../logger/report.js'; import { moduleConfig } from '../modules.js'; import { POS_MODULE_FILE as moduleManifestFileName } from './paths.js'; +const BUMP_TYPES = ['major', 'minor', 'patch']; +const TEMPLATE_VALUES_FILE = 'template-values.json'; + +const templateValuesPath = (moduleName) => + path.join('modules', moduleName, TEMPLATE_VALUES_FILE); + const readVersionFromPackage = (options) => { let packageJSONPath = 'package.json'; if (typeof options.package === 'string') { @@ -13,10 +22,26 @@ const readVersionFromPackage = (options) => { return files.readJSON(packageJSONPath, { throwDoesNotExistError: true }).version; }; +const resolveVersion = (currentVersion, versionArg) => { + if (!versionArg || BUMP_TYPES.includes(versionArg)) { + const bumpType = versionArg || 'patch'; + return semver.inc(currentVersion, bumpType); + } + return versionArg; +}; + const storeNewVersion = (config, version) => { files.writeJSON(moduleManifestFileName, { ...config, version }); }; +const updateTemplateValues = (version, moduleName) => { + const filePath = templateValuesPath(moduleName); + if (!fs.existsSync(filePath)) return; + const tv = files.readJSON(filePath, { exit: false }); + if (!tv || !('version' in tv)) return; + files.writeJSON(filePath, { ...tv, version }); +}; + const validateVersions = (config, version, moduleName) => { if (!semver.valid(config.version)) { report('[ERR] The current version is not valid'); @@ -39,15 +64,59 @@ const validateVersions = (config, version, moduleName) => { return true; }; +const isGitRepo = () => { + try { + execSync('git rev-parse --git-dir', { stdio: 'pipe' }); + return true; + } catch { + return false; + } +}; + +const isWorkingTreeClean = () => { + const status = execSync('git status --porcelain', { encoding: 'utf8' }).trim(); + return status.length === 0; +}; + +const commitAndTag = (version, moduleName) => { + const filesToAdd = [moduleManifestFileName]; + const tvPath = templateValuesPath(moduleName); + if (fs.existsSync(tvPath)) filesToAdd.push(tvPath); + execSync(`git add ${filesToAdd.join(' ')}`, { stdio: 'pipe' }); + execSync(`git commit -m "${version}"`, { stdio: 'pipe' }); + execSync(`git tag ${version}`, { stdio: 'pipe' }); +}; + const createNewVersion = async (version, options) => { + const useGit = options.git !== false && isGitRepo(); + + if (useGit && !isWorkingTreeClean()) { + report('[ERR] Working tree is not clean'); + logger.Error('There are uncommitted changes. Please commit or stash them before bumping the version.'); + process.exitCode = 1; + return; + } + const config = await moduleConfig(); const moduleName = config['machine_name']; - const finalVersion = options.package ? readVersionFromPackage(options) : version; + + let finalVersion; + if (options.package) { + finalVersion = readVersionFromPackage(options); + } else { + finalVersion = resolveVersion(config.version, version); + } + if (!validateVersions(config, finalVersion, moduleName)) { process.exitCode = 1; return; } storeNewVersion(config, finalVersion); + updateTemplateValues(finalVersion, moduleName); + + if (useGit) { + commitAndTag(finalVersion, moduleName); + } }; export { createNewVersion }; diff --git a/package-lock.json b/package-lock.json index 2afb8583..d5f7ac23 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,7 +37,6 @@ "lodash.debounce": "^4.0.8", "lodash.flatten": "^4.4.0", "lodash.reject": "^4.6.0", - "lodash.uniq": "^4.5.0", "mime": "^4.1.0", "multer": "^2.0.2", "mustache": "^4.2.0", @@ -5847,12 +5846,6 @@ "integrity": "sha512-qkTuvgEzYdyhiJBx42YPzPo71R1aEr0z79kAv7Ixg8wPFEjgRgJdUsGMG3Hf3OYSF/kHI79XhNlt+5Ar6OzwxQ==", "license": "MIT" }, - "node_modules/lodash.uniq": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", - "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==", - "license": "MIT" - }, "node_modules/log-symbols": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-7.0.1.tgz", diff --git a/test/unit/modulesVersion.test.js b/test/unit/modulesVersion.test.js index 94b73f86..27fe285b 100644 --- a/test/unit/modulesVersion.test.js +++ b/test/unit/modulesVersion.test.js @@ -1,6 +1,7 @@ /** * Unit tests for `pos-cli modules version` — process exit code and file write behaviour. * Spawns the CLI in a temp directory to verify exit codes and manifest mutations. + * All tests use --no-git to avoid requiring a git repository. */ import { describe, test, expect } from 'vitest'; import { spawnSync } from 'child_process'; @@ -19,8 +20,23 @@ const getTmpDir = withTmpDir('pos-cli-version-test-'); const writeManifest = (content) => fs.writeFileSync(path.join(getTmpDir(), 'pos-module.json'), JSON.stringify(content, null, 2)); -const runVersion = (args) => - spawnSync('node', [CLI_PATH, 'modules', 'version', ...args.split(' ').filter(Boolean)], { +const writeTemplateValues = (moduleName, content) => { + const dir = path.join(getTmpDir(), 'modules', moduleName); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'template-values.json'), JSON.stringify(content, null, 2)); +}; + +const readManifest = () => + JSON.parse(fs.readFileSync(path.join(getTmpDir(), 'pos-module.json'), 'utf8')); + +const readTemplateValues = (moduleName) => + JSON.parse(fs.readFileSync(path.join(getTmpDir(), 'modules', moduleName, 'template-values.json'), 'utf8')); + +const templateValuesPath = (moduleName) => + path.join(getTmpDir(), 'modules', moduleName, 'template-values.json'); + +const runVersion = (args = '') => + spawnSync('node', [CLI_PATH, 'modules', 'version', '--no-git', ...args.split(' ').filter(Boolean)], { cwd: getTmpDir(), encoding: 'utf8', stdio: 'pipe' @@ -51,24 +67,10 @@ describe('pos-cli modules version — exit codes', () => { writeManifest({ machine_name: 'user', version: '5.1.2' }); const result = runVersion('5.2.0'); expect(result.status).toBe(0); - const written = JSON.parse(fs.readFileSync(path.join(getTmpDir(), 'pos-module.json'), 'utf8')); - expect(written.version).toBe('5.2.0'); - }); - - test('writes to pos-module.json when it is present (not template-values.json)', () => { - writeManifest({ machine_name: 'user', version: '1.0.0' }); - // Write a template-values.json alongside — version must NOT update it - fs.writeFileSync(path.join(getTmpDir(), 'template-values.json'), JSON.stringify({ machine_name: 'user', version: '1.0.0' }, null, 2)); - runVersion('1.1.0'); - const manifest = JSON.parse(fs.readFileSync(path.join(getTmpDir(), 'pos-module.json'), 'utf8')); - expect(manifest.version).toBe('1.1.0'); - // template-values.json must remain unchanged - const tv = JSON.parse(fs.readFileSync(path.join(getTmpDir(), 'template-values.json'), 'utf8')); - expect(tv.version).toBe('1.0.0'); + expect(readManifest().version).toBe('5.2.0'); }); test('exits with code 1 and shows migration hint when pos-module.json is absent', () => { - // No pos-module.json — should fail with a clear migration hint const result = runVersion('1.1.0'); expect(result.status).toBe(1); expect(result.stderr).toMatch(/pos-module\.json not found|modules migrate/i); @@ -77,10 +79,93 @@ describe('pos-cli modules version — exit codes', () => { test('preserves other fields in pos-module.json when updating version', () => { writeManifest({ machine_name: 'user', name: 'User Module', version: '2.0.0', dependencies: { core: '^1.0.0' } }); runVersion('2.1.0'); - const written = JSON.parse(fs.readFileSync(path.join(getTmpDir(), 'pos-module.json'), 'utf8')); + const written = readManifest(); expect(written.machine_name).toBe('user'); expect(written.name).toBe('User Module'); expect(written.dependencies).toEqual({ core: '^1.0.0' }); expect(written.version).toBe('2.1.0'); }); }); + +describe('pos-cli modules version — semver bump types', () => { + test('defaults to patch bump when no argument is given', () => { + writeManifest({ machine_name: 'user', version: '1.2.3' }); + const result = runVersion(); + expect(result.status).toBe(0); + expect(readManifest().version).toBe('1.2.4'); + }); + + test('bumps patch when "patch" is passed', () => { + writeManifest({ machine_name: 'user', version: '1.2.3' }); + const result = runVersion('patch'); + expect(result.status).toBe(0); + expect(readManifest().version).toBe('1.2.4'); + }); + + test('bumps minor when "minor" is passed', () => { + writeManifest({ machine_name: 'user', version: '1.2.3' }); + const result = runVersion('minor'); + expect(result.status).toBe(0); + expect(readManifest().version).toBe('1.3.0'); + }); + + test('bumps major when "major" is passed', () => { + writeManifest({ machine_name: 'user', version: '1.2.3' }); + const result = runVersion('major'); + expect(result.status).toBe(0); + expect(readManifest().version).toBe('2.0.0'); + }); + + test('still accepts an explicit semver version', () => { + writeManifest({ machine_name: 'user', version: '1.0.0' }); + const result = runVersion('3.0.0'); + expect(result.status).toBe(0); + expect(readManifest().version).toBe('3.0.0'); + }); +}); + +describe('pos-cli modules version — template-values.json sync', () => { + test('updates version in modules//template-values.json when it has a version field', () => { + writeManifest({ machine_name: 'user', version: '5.2.7' }); + writeTemplateValues('user', { + name: 'User', + machine_name: 'user', + type: 'module', + version: '5.2.7', + dependencies: { core: '^2.1.8' } + }); + const result = runVersion('patch'); + expect(result.status).toBe(0); + expect(readManifest().version).toBe('5.2.8'); + const tv = readTemplateValues('user'); + expect(tv.version).toBe('5.2.8'); + expect(tv.name).toBe('User'); + expect(tv.dependencies).toEqual({ core: '^2.1.8' }); + }); + + test('does not create template-values.json when it does not exist', () => { + writeManifest({ machine_name: 'user', version: '1.0.0' }); + const result = runVersion('patch'); + expect(result.status).toBe(0); + expect(readManifest().version).toBe('1.0.1'); + expect(fs.existsSync(templateValuesPath('user'))).toBe(false); + }); + + test('does not modify template-values.json when it has no version field', () => { + writeManifest({ machine_name: 'user', version: '1.0.0' }); + writeTemplateValues('user', { prefix: 'my_prefix' }); + const result = runVersion('major'); + expect(result.status).toBe(0); + expect(readManifest().version).toBe('2.0.0'); + expect(readTemplateValues('user')).toEqual({ prefix: 'my_prefix' }); + }); + + test('updates template-values.json with explicit semver version too', () => { + writeManifest({ machine_name: 'user', version: '1.0.0' }); + writeTemplateValues('user', { version: '1.0.0' }); + const result = runVersion('5.0.0'); + expect(result.status).toBe(0); + expect(readManifest().version).toBe('5.0.0'); + expect(readTemplateValues('user').version).toBe('5.0.0'); + }); +});