Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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: 2 additions & 1 deletion bin/pos-cli-modules-version.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 <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);
Expand Down
71 changes: 70 additions & 1 deletion lib/modules/version.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,19 @@
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';
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') {
Expand All @@ -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');
Expand All @@ -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 };
7 changes: 0 additions & 7 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

121 changes: 103 additions & 18 deletions test/unit/modulesVersion.test.js
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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'
Expand Down Expand Up @@ -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);
Expand All @@ -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/<machine_name>/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');
});
});
Loading